From 6af7a9ed8fe8059f7378c37affbb091140c01165 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:36:06 -0400 Subject: [PATCH 01/94] [qmc5883l] Move per-update log line from DEBUG to VERBOSE (#15781) --- esphome/components/qmc5883l/qmc5883l.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index bc2adb5cfe..d0488d0c9f 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -100,7 +100,7 @@ void QMC5883LComponent::update() { // ROL_PNT in setup and reading 7 bytes starting at the status register. // If status and all three axes are desired, using ROL_PNT saves you 3 bytes. // But simply not reading status saves you 4 bytes always and is much simpler. - if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) { + if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE) { err = this->read_register(QMC5883L_REGISTER_STATUS, &status, 1); if (err != i2c::ERROR_OK) { char buf[32]; @@ -165,7 +165,7 @@ void QMC5883LComponent::update() { temp = int16_t(raw_temp) * 0.01f; } - ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, + ESP_LOGV(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, temp, status); if (this->x_sensor_ != nullptr) From c6ad23fbc05047745522c5ec0e0996e85f958d00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 08:45:33 -1000 Subject: [PATCH 02/94] [bundle] Force-resolve nested IncludeFile during file discovery (#15762) --- esphome/bundle.py | 78 ++++++++- .../fixtures/bundle/bundle_test.yaml | 6 +- .../fixtures/bundle/common/wifi.yaml | 2 + tests/unit_tests/test_bundle.py | 150 +++++++++++++++++- 4 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/fixtures/bundle/common/wifi.yaml diff --git a/esphome/bundle.py b/esphome/bundle.py index b6816c7c95..efa80acc8c 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -151,8 +151,8 @@ class ConfigBundleCreator: def __init__(self, config: dict[str, Any]) -> None: self._config = config - self._config_dir = CORE.config_dir - self._config_path = CORE.config_path + self._config_dir = Path(CORE.config_dir).resolve() + self._config_path = Path(CORE.config_path).resolve() self._files: list[BundleFile] = [] self._seen_paths: set[Path] = set() self._secrets_paths: set[Path] = set() @@ -258,21 +258,36 @@ class ConfigBundleCreator: def _discover_yaml_includes(self) -> None: """Discover YAML files loaded during config parsing. - We track files by wrapping _load_yaml_internal. The config has already - been loaded at this point (bundle is a POST_CONFIG_ACTION), so we - re-load just to discover the file list. + Deliberately uses a fresh re-parse and force-loads every deferred + ``IncludeFile`` to include *all* potentially-reachable includes, + even branches not selected by the local substitutions. Bundles are + meant to be compiled on another system where command-line + substitution overrides may choose a different branch — e.g. + ``!include network/${eth_model}/config.yaml`` must ship every + candidate so the remote build can pick any one. + + Entries with unresolved substitution variables in the filename + path are skipped with a warning (they cannot be resolved without + the substitution pass). Secrets files are tracked separately so we can filter them to only include the keys this config actually references. """ + # Must be a fresh parse: IncludeFile.load() caches its result in + # _content, and we discover files by listening for loader calls. On + # an already-parsed tree the cache is populated, .load() returns + # without calling the loader, the listener never fires, and the + # referenced files would be silently dropped from the bundle. with yaml_util.track_yaml_loads() as loaded_files: try: - yaml_util.load_yaml(self._config_path) + data = yaml_util.load_yaml(self._config_path) except EsphomeError: _LOGGER.debug( "Bundle: re-loading YAML for include discovery failed, " "proceeding with partial file list" ) + else: + _force_load_include_files(data) for fpath in loaded_files: if fpath == self._config_path.resolve(): @@ -608,6 +623,57 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: tar.addfile(info, io.BytesIO(data)) +def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None: + """Recursively resolve any ``IncludeFile`` instances in a YAML tree. + + Nested ``!include`` returns a deferred ``IncludeFile`` that is only + resolved during the substitution pass. During bundle discovery we need + the referenced files to actually load so the ``track_yaml_loads`` + listener fires for them. + + ``IncludeFile`` instances with unresolved substitution variables in the + filename cannot be loaded — we skip and warn about those. + """ + if _seen is None: + _seen = set() + + if isinstance(obj, yaml_util.IncludeFile): + if id(obj) in _seen: + return + _seen.add(id(obj)) + if obj.has_unresolved_expressions(): + _LOGGER.warning( + "Bundle: cannot resolve !include %s (referenced from %s) " + "with substitutions in path", + obj.file, + obj.parent_file, + ) + return + try: + loaded = obj.load() + except EsphomeError as err: + _LOGGER.warning( + "Bundle: failed to load !include %s (referenced from %s): %s", + obj.file, + obj.parent_file, + err, + ) + return + _force_load_include_files(loaded, _seen) + elif isinstance(obj, dict): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for value in obj.values(): + _force_load_include_files(value, _seen) + elif isinstance(obj, (list, tuple)): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for item in obj: + _force_load_include_files(item, _seen) + + def _resolve_include_path(include_path: Any) -> Path | None: """Resolve an include path to absolute, skipping system includes.""" if isinstance(include_path, str) and include_path.startswith("<"): diff --git a/tests/unit_tests/fixtures/bundle/bundle_test.yaml b/tests/unit_tests/fixtures/bundle/bundle_test.yaml index f834a8d867..247f5cc8bb 100644 --- a/tests/unit_tests/fixtures/bundle/bundle_test.yaml +++ b/tests/unit_tests/fixtures/bundle/bundle_test.yaml @@ -11,9 +11,9 @@ esp32: logger: <<: !include common/base.yaml -wifi: - ssid: !secret wifi_ssid - password: !secret wifi_password +# Plain nested !include — deferred as an IncludeFile until the substitution +# pass. The bundle must force-resolve it to pick up common/wifi.yaml. +wifi: !include common/wifi.yaml api: diff --git a/tests/unit_tests/fixtures/bundle/common/wifi.yaml b/tests/unit_tests/fixtures/bundle/common/wifi.yaml new file mode 100644 index 0000000000..d7e7b3cd45 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/common/wifi.yaml @@ -0,0 +1,2 @@ +ssid: !secret wifi_ssid +password: !secret wifi_password diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index b8b2d0ffd1..89bf1a33b3 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -5,8 +5,10 @@ from __future__ import annotations import io import json from pathlib import Path +import shutil import tarfile from typing import Any +from unittest.mock import patch import pytest @@ -20,6 +22,7 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, + _force_load_include_files, extract_bundle, is_bundle_path, prepare_bundle_for_compile, @@ -485,7 +488,7 @@ def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: result = read_bundle_manifest(bundle_path) assert result.esphome_version == "unknown" - assert result.files == [] + assert not result.files assert result.has_secrets is False @@ -862,6 +865,117 @@ def test_discover_files_skips_missing_directory(tmp_path: Path) -> None: assert len(files) == 1 +def test_discover_files_nested_include(tmp_path: Path) -> None: + """Nested !include files (e.g. wifi: !include wifi.yaml) are bundled.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include wifi.yaml\n" + ) + (config_dir / "wifi.yaml").write_text('ssid: "a"\npassword: "b"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert "wifi.yaml" in paths + + +def test_discover_files_deeply_nested_include(tmp_path: Path) -> None: + """Chains of !include (a includes b includes c) are fully resolved.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include level1.yaml\n" + ) + (config_dir / "level1.yaml").write_text("nested: !include level2.yaml\n") + (config_dir / "level2.yaml").write_text('value: "leaf"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "level1.yaml" in paths + assert "level2.yaml" in paths + + +def test_discover_files_nested_include_unresolved_substitution( + tmp_path: Path, +) -> None: + """!include with substitution vars in path cannot be resolved; skipped gracefully.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include ${platform}.yaml\n" + ) + + creator = ConfigBundleCreator({}) + # Should not raise + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + + +def test_discover_files_nested_include_load_failure( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A nested !include pointing at a missing file is logged and skipped.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include missing.yaml\n" + ) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert any( + "failed to load !include" in r.message and "missing.yaml" in r.message + for r in caplog.records + ) + + +def test_force_load_skips_duplicate_include_file() -> None: + """The same IncludeFile referenced twice is only loaded once.""" + + class _StubInclude: + """Mimics yaml_util.IncludeFile minimally for _force_load testing.""" + + def __init__(self) -> None: + self.file = Path("dup.yaml") + self.parent_file = Path("root.yaml") + self.load_calls = 0 + + def has_unresolved_expressions(self) -> bool: + return False + + def load(self) -> dict[str, Any]: + self.load_calls += 1 + return {} + + stub = _StubInclude() + # Same instance appears twice — second visit must hit the _seen guard. + tree = {"a": stub, "b": [stub]} + + with patch("esphome.bundle.yaml_util.IncludeFile", _StubInclude): + _force_load_include_files(tree) + + assert stub.load_calls == 1 + + +def test_force_load_handles_cyclic_containers() -> None: + """Cyclic dict/list references don't cause infinite recursion.""" + cyclic_dict: dict[str, Any] = {} + cyclic_dict["self"] = cyclic_dict + + cyclic_list: list[Any] = [] + cyclic_list.append(cyclic_list) + + # Should return without recursing forever + _force_load_include_files(cyclic_dict) + _force_load_include_files(cyclic_list) + + def test_discover_files_yaml_reload_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1008,6 +1122,40 @@ def test_discover_files_walk_tuple_values(tmp_path: Path) -> None: assert "a.pem" in paths +# --------------------------------------------------------------------------- +# ConfigBundleCreator - fixture-based end-to-end +# --------------------------------------------------------------------------- + + +def test_discover_files_fixture_config(fixture_path: Path, tmp_path: Path) -> None: + """Use the real ``fixtures/bundle/`` tree as an end-to-end reproducer. + + The fixture config uses ``wifi: !include common/wifi.yaml`` — a plain + nested !include that is returned as a deferred ``IncludeFile`` and only + resolved during the substitution pass. Before this fix, bundle discovery + never ran substitutions, so ``common/wifi.yaml`` was silently missing + from the bundle. + """ + # Copy the fixture tree into a tmp dir so the test doesn't rely on the + # source repo being writable and so we can set CORE.config_path freely. + src = fixture_path / "bundle" + dst = tmp_path / "bundle" + shutil.copytree(src, dst) + + CORE.config_path = dst / "bundle_test.yaml" + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + paths = {f.path for f in files} + + # Root and top-level !secret-referenced files + assert "bundle_test.yaml" in paths + assert "secrets.yaml" in paths + # The nested !include — this is what regressed when IncludeFile became + # deferred (PR #12213). + assert "common/wifi.yaml" in paths + + # --------------------------------------------------------------------------- # ConfigBundleCreator - create_bundle # --------------------------------------------------------------------------- From 90943928702b23b5e0d452eb7d7de1b84a6c70c3 Mon Sep 17 00:00:00 2001 From: rwalker777 <49888088+rwalker777@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:01:32 -0500 Subject: [PATCH 03/94] [gpio] Keep interrupts enabled for gpio binary_sensor shared with deep_sleep wakeup pin (#15020) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@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> --- .../components/gpio/binary_sensor/__init__.py | 88 +++++++++++++------ .../gpio/binary_sensor/gpio_binary_sensor.cpp | 5 -- 2 files changed, 63 insertions(+), 30 deletions(-) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 3c2021d40e..390b26ba1d 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -60,20 +60,35 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): - var = await binary_sensor.new_binary_sensor(config) - await cg.register_component(var, config) +def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool: + """Check if pin is shared exclusively with deep_sleep (wakeup pin).""" + pin_key = (CORE.target_platform, CORE.target_platform, pin_num) + pin_users = pins.PIN_SCHEMA_REGISTRY.pins_used.get(pin_key, []) + if len(pin_users) != 2: + return False + return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) - # Check for ESP8266 GPIO16 interrupt limitation - # GPIO16 on ESP8266 is a special pin that doesn't support interrupts through - # the Arduino attachInterrupt() function. This is the only known GPIO pin - # across all supported platforms that has this limitation, so we handle it - # here instead of in the platform-specific code. +def _final_validate(config): use_interrupt = config[CONF_USE_INTERRUPT] - if use_interrupt and CORE.is_esp8266 and config[CONF_PIN][CONF_NUMBER] == 16: + if not use_interrupt: + return config + + pin_num = config[CONF_PIN][CONF_NUMBER] + + # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt + # attachment — only internal/native GPIO pins do. + if pins.PIN_SCHEMA_REGISTRY.get_key(config[CONF_PIN]) != CORE.target_platform: + _LOGGER.info( + "GPIO binary_sensor '%s': Pin is not an internal GPIO, " + "falling back to polling mode.", + config.get(CONF_NAME, config[CONF_ID]), + ) + config[CONF_USE_INTERRUPT] = False + return config + + # GPIO16 on ESP8266 doesn't support interrupts through attachInterrupt(). + if CORE.is_esp8266 and pin_num == 16: _LOGGER.warning( "GPIO binary_sensor '%s': GPIO16 on ESP8266 doesn't support interrupts. " "Falling back to polling mode (same as in ESPHome <2025.7). " @@ -81,22 +96,45 @@ async def to_code(config): "performance with interrupts.", config.get(CONF_NAME, config[CONF_ID]), ) - use_interrupt = False + config[CONF_USE_INTERRUPT] = False + return config - # Check if pin is shared with other components (allow_other_uses) # When a pin is shared, interrupts can interfere with other components - # (e.g., duty_cycle sensor) that need to monitor the pin's state changes - if use_interrupt and config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False): - _LOGGER.info( - "GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared with other components. " - "The sensor will use polling mode for compatibility with other pin uses.", - config.get(CONF_NAME, config[CONF_ID]), - config[CONF_PIN][CONF_NUMBER], - ) - use_interrupt = False + # (e.g., duty_cycle sensor) that need to monitor the pin's state changes. + # Exception: deep_sleep wakeup pins are compatible with interrupts when + # the pin is only shared between this sensor and deep_sleep (count == 2). + if config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False): + if not _pin_shared_only_with_deep_sleep(pin_num): + _LOGGER.info( + "GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared " + "with other components. The sensor will use polling mode for " + "compatibility with other pin uses.", + config.get(CONF_NAME, config[CONF_ID]), + pin_num, + ) + config[CONF_USE_INTERRUPT] = False + else: + _LOGGER.debug( + "GPIO binary_sensor '%s': Pin %s is shared with deep_sleep, " + "keeping interrupts enabled.", + config.get(CONF_NAME, config[CONF_ID]), + pin_num, + ) - if use_interrupt: + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + + pin = await cg.gpio_pin_expression(config[CONF_PIN]) + cg.add(var.set_pin(pin)) + + if config[CONF_USE_INTERRUPT]: cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE])) else: - # Only generate call when disabling interrupts (default is true) - cg.add(var.set_use_interrupt(use_interrupt)) + cg.add(var.set_use_interrupt(False)) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index 39b1a2f713..1f0154c70b 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -46,11 +46,6 @@ void GPIOBinarySensorStore::setup(InternalGPIOPin *pin, Component *component) { } void GPIOBinarySensor::setup() { - if (this->store_.use_interrupt_ && !this->pin_->is_internal()) { - ESP_LOGD(TAG, "GPIO is not internal, falling back to polling mode"); - this->store_.use_interrupt_ = false; - } - if (this->store_.use_interrupt_) { auto *internal_pin = static_cast(this->pin_); this->store_.setup(internal_pin, this); From 7d8add70a7b86afbbf93e9f65e34d9f72532f1eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 09:01:55 -1000 Subject: [PATCH 04/94] [ili9xxx] Guard against null buffer in display_() when allocation fails (#15786) --- esphome/components/ili9xxx/ili9xxx_defines.h | 2 ++ esphome/components/ili9xxx/ili9xxx_display.cpp | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/esphome/components/ili9xxx/ili9xxx_defines.h b/esphome/components/ili9xxx/ili9xxx_defines.h index f4c5aad957..70e0937f79 100644 --- a/esphome/components/ili9xxx/ili9xxx_defines.h +++ b/esphome/components/ili9xxx/ili9xxx_defines.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace esphome { namespace ili9xxx { diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index a3eff901d3..11acb8a73a 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -229,6 +229,10 @@ void ILI9XXXDisplay::update() { } void ILI9XXXDisplay::display_() { + // buffer may be null if allocation failed + if (this->buffer_ == nullptr) { + return; + } // check if something was displayed if ((this->x_high_ < this->x_low_) || (this->y_high_ < this->y_low_)) { return; From 6bb90a126838e8ce5703421e18037e93a2335218 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:07:04 -0400 Subject: [PATCH 05/94] [esp32] Accept unquoted minimum_chip_revision values (#15785) --- esphome/components/esp32/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7b3f9da3da..a68614cb43 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1222,7 +1222,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean, cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean, cv.Optional(CONF_MINIMUM_CHIP_REVISION): cv.one_of( - *ESP32_CHIP_REVISIONS + *ESP32_CHIP_REVISIONS, string=True ), cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean, # DHCP server is needed for WiFi AP mode. When WiFi component is used, From 627e440bd60212b065ab24cc2fbae0ece8d2abda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 09:38:49 -1000 Subject: [PATCH 06/94] [libretiny] Make IRAM_ATTR functional on RTL87xx and LN882H (#15766) --- esphome/components/bk72xx/__init__.py | 5 + esphome/components/libretiny/__init__.py | 17 ++ .../libretiny/generate_components.py | 5 + .../libretiny/patch_linker.py.script | 171 ++++++++++++++++++ esphome/components/ln882x/__init__.py | 5 + esphome/components/rtl87xx/__init__.py | 5 + esphome/core/application.h | 4 +- esphome/core/hal.h | 72 ++++++++ esphome/core/main_task.h | 19 +- esphome/core/wake.cpp | 6 +- esphome/core/wake.h | 28 ++- 11 files changed, 308 insertions(+), 29 deletions(-) create mode 100644 esphome/components/libretiny/patch_linker.py.script diff --git a/esphome/components/bk72xx/__init__.py b/esphome/components/bk72xx/__init__.py index 7fed742d2e..3ffab0f3a5 100644 --- a/esphome/components/bk72xx/__init__.py +++ b/esphome/components/bk72xx/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("bk72xx", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 656eee6d7b..4f42f40478 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -1,5 +1,6 @@ import json import logging +from pathlib import Path import esphome.codegen as cg import esphome.config_validation as cv @@ -24,6 +25,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.core.config import BOARD_MAX_LENGTH +from esphome.helpers import copy_file_if_changed from esphome.storage_json import StorageJSON from . import gpio # noqa @@ -465,6 +467,11 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) + # IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops). + # On other families, patch_linker.py routes .sram.text into the right + # RAM-executable output section and prints a post-link placement summary. + if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX: + cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) # decrease web server stack size (16k words -> 4k words) @@ -549,3 +556,13 @@ async def component_to_code(config): _configure_lwip(config) await cg.register_component(var, config) + + +# Called by writer.py +def copy_files() -> None: + script_dir = Path(__file__).parent + patch_linker_file = script_dir / "patch_linker.py.script" + copy_file_if_changed( + patch_linker_file, + CORE.relative_build_path("patch_linker.py"), + ) diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index 41b4389446..d5437895a6 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -79,6 +79,11 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("{COMPONENT_LOWER}", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() ''' BASE_CODE_BOARDS = ''' diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script new file mode 100644 index 0000000000..282a31d3f2 --- /dev/null +++ b/esphome/components/libretiny/patch_linker.py.script @@ -0,0 +1,171 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import os +import re +import subprocess + +# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family +# section routed into RAM-executable memory (see esphome/core/hal.h). +# +# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK +# masks FIQ+IRQ around flash writes). On the remaining families: +# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. +# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. +# - LN882H: stock linker has no glob for ".sram.text", so we inject +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). +# +# All families also get a post-link summary showing where IRAM_ATTR landed. + + +_MARKER = "/* esphome .sram.text */" +# Strong assignments (not PROVIDE) so the symbols are always emitted in the +# ELF; PROVIDE symbols with no references can be garbage-collected. +_KEEP_LINE = ( + " __esphome_sram_text_start = .; " + "KEEP(*(.sram.text*)) " + "__esphome_sram_text_end = .; " + + _MARKER + "\n" +) +_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") + + +def _detect(env): + prefix = "USE_LIBRETINY_VARIANT_" + # CPPDEFINES may hold strings or (name, value) tuples; BUILD_FLAGS holds + # the raw "-DNAME" strings. PlatformIO populates both, but the exact order + # vs. extra_scripts varies, so check both to be robust. + for token in env.get("CPPDEFINES", []): + if isinstance(token, (list, tuple)): + token = token[0] + if isinstance(token, str) and token.startswith(prefix): + return token[len(prefix):] + for flag in env.get("BUILD_FLAGS", []): + if isinstance(flag, str) and "-D" + prefix in flag: + name = flag.split("-D", 1)[1].split("=", 1)[0].strip() + if name.startswith(prefix): + return name[len(prefix):] + return None + + +KNOWN_VARIANTS = frozenset({ + "LN882H", + "RTL8710B", + "RTL8720C", +}) + + +def _inject_keep(host_section): + """Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" + def patch(content): + if _MARKER in content: + return content + return host_section.sub(r"\1" + _KEEP_LINE, content, count=1) + return patch + + +# Variants not listed here intentionally have no .ld patcher: +# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker +# already routes into .ram_image2.text (> BD_RAM). +# - RTL8720C: stock linker already consumes *(.sram.text*). +# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. +_PATCHERS_BY_VARIANT = { + "LN882H": (_inject_keep(_LN_COPY),), +} + + +def _patchers_for(variant): + return _PATCHERS_BY_VARIANT.get(variant, ()) + + +def _pre_link(target, source, env): + build_dir = env.subst("$BUILD_DIR") + ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")] + patched = 0 + for name in ld_files: + path = os.path.join(build_dir, name) + with open(path, "r", encoding="utf-8") as fh: + original = fh.read() + if _MARKER in original: + patched += 1 + continue + content = original + for fn in _patchers: + content = fn(content) + if content != original: + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) + patched += 1 + if not patched: + raise RuntimeError( + "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " + "regex in patch_linker.py.script (_PATCHERS_BY_VARIANT).".format( + build_dir + ) + ) + + +# Substrings matched against demangled names as a fallback on RTL8720C, +# where we cannot inject __esphome_sram_text_start/end markers. +_FALLBACK_SUBSTRINGS = ("wake_loop_any_context", "wake_loop_isrsafe", + "enable_loop_soon_any_context") + + +def _post_link(target, source, env): + """Print where IRAM_ATTR ended up so users can confirm at a glance.""" + elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") + if not os.path.isfile(elf): + return + nm = env.subst("$NM") + try: + out = subprocess.check_output( + [nm, "--defined-only", "--demangle", elf], text=True + ) + except (OSError, subprocess.CalledProcessError) as exc: + print("ESPHome: IRAM_ATTR summary unavailable (nm failed: {})".format(exc)) + return + start = end = None + fallback = [] + for line in out.splitlines(): + parts = line.split(maxsplit=2) + if len(parts) != 3: + continue + addr_str, _kind, name = parts + if name == "__esphome_sram_text_start": + start = int(addr_str, 16) + elif name == "__esphome_sram_text_end": + end = int(addr_str, 16) + elif "veneer" not in name and any(s in name for s in _FALLBACK_SUBSTRINGS): + fallback.append(int(addr_str, 16)) + print("ESPHome: IRAM_ATTR placement summary ({}):".format(_variant)) + if start is not None and end is not None: + print(" .sram.text: {} bytes at 0x{:08x} - 0x{:08x}".format(end - start, start, end)) + elif fallback: + lo, hi = min(fallback), max(fallback) + print(" IRAM symbols at 0x{:08x} - 0x{:08x} (approx {} bytes)".format(lo, hi, hi - lo)) + else: + print(" no IRAM_ATTR symbols found") + + +if (_variant := _detect(env)) is None: + raise RuntimeError( + "ESPHome: could not determine LibreTiny variant from build flags. " + "patch_linker.py needs USE_LIBRETINY_VARIANT_* to route IRAM_ATTR " + "into SRAM; without it, ISR handlers would silently end up in flash." + ) +if _variant not in KNOWN_VARIANTS: + raise RuntimeError( + "ESPHome: unknown LibreTiny variant {!r}; patch_linker.py does not " + "know how to route IRAM_ATTR into SRAM for this family. Update " + "patch_linker.py.script before shipping firmware.".format(_variant) + ) + +if _patchers := _patchers_for(_variant): + # LibreTiny writes the processed .ld templates into $BUILD_DIR during its + # own builder setup, which may run after this script. Register the patch + # as a pre-link action so it executes once the linker scripts exist. + env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) + +# Post-link summary for every family that reaches this script. +env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py index 5c637bdf62..9c91827522 100644 --- a/esphome/components/ln882x/__init__.py +++ b/esphome/components/ln882x/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("ln882x", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index 6fd750d51e..a3b1dba4f2 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("rtl87xx", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/core/application.h b/esphome/core/application.h index b4bb8a1eec..7356263c55 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -337,8 +337,8 @@ class Application { /// @see esphome::wake_loop_threadsafe() in wake.h for platform details. void wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); } -#ifdef USE_ESP32 - /// Wake from ISR (ESP32 only). +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + /// Wake from ISR (ESP32 and LibreTiny). static void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px) { esphome::wake_loop_isrsafe(px); } #endif diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 03a30b7459..e4083622b9 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -21,6 +21,35 @@ #define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) #define PROGMEM +#elif defined(USE_LIBRETINY) + +// IRAM_ATTR places a function in executable RAM so it is callable from an +// ISR even while flash is busy (XIP stall, OTA, logger flash write). +// Each family uses a section its stock linker already routes to RAM: +// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the +// exception: its stock linker has no matching glob, so patch_linker.py +// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. +// +// BK72xx (all variants) are left as a no-op: their SDK wraps flash +// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for +// the duration of every write, so no ISR fires while flash is stalled and +// the race IRAM_ATTR guards against cannot occur. The trade-off is that +// interrupts are delayed (not dropped) by up to ~20 ms during a sector +// erase, but that is an SDK-level choice and cannot be changed from this +// layer. +#if defined(USE_BK72XX) +#define IRAM_ATTR +#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) +// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). +#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) +#else +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// LN882H: patch_linker.py.script injects *(.sram.text*) into +// .flash_copysection (> RAM0 AT> FLASH). +#define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) +#endif +#define PROGMEM + #else #define IRAM_ATTR @@ -28,8 +57,51 @@ #endif +#ifdef USE_ESP32 +#include +#include +#endif + +#ifdef USE_BK72XX +// Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so +// it is callable from Thumb code via interworking. The MRS CPSR instruction +// is ARM-only and user code here may be built in Thumb, so in_isr_context() +// defers to this port helper on BK72xx instead of reading CPSR inline. +extern "C" uint32_t platform_is_in_interrupt_context(void); +#endif + namespace esphome { +/// Returns true when executing inside an interrupt handler. +/// always_inline so callers placed in IRAM keep the detection in IRAM. +__attribute__((always_inline)) inline bool in_isr_context() { +#if defined(USE_ESP32) + return xPortInIsrContext() != 0; +#elif defined(USE_ESP8266) + // ESP8266 has no reliable single-register ISR detection: PS.INTLEVEL is + // non-zero both in a real ISR and when user code masks interrupts. The + // ESP8266 wake path is context-agnostic (wake_loop_impl uses esp_schedule + // which is ISR-safe) so this helper is unused on this platform. + return false; +#elif defined(USE_RP2040) + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#elif defined(USE_BK72XX) + // BK72xx is ARM968E-S (ARM9); see extern declaration above. + return platform_is_in_interrupt_context() != 0; +#elif defined(USE_LIBRETINY) + // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; + // non-zero means we're in a handler. + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#else + // Host and any future platform without an ISR concept. + return false; +#endif +} + void yield(); uint32_t millis(); uint64_t millis_64(); diff --git a/esphome/core/main_task.h b/esphome/core/main_task.h index ed2885d2e2..3aa8669e44 100644 --- a/esphome/core/main_task.h +++ b/esphome/core/main_task.h @@ -20,7 +20,8 @@ extern "C" { extern TaskHandle_t esphome_main_task_handle; /// Wake the main loop task from another FreeRTOS task. NOT ISR-safe. -static inline void esphome_main_task_notify() { +/// always_inline so callers placed in IRAM do not reference a flash-resident copy. +__attribute__((always_inline)) static inline void esphome_main_task_notify() { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); @@ -28,26 +29,14 @@ static inline void esphome_main_task_notify() { } /// Wake the main loop task from an ISR. ISR-safe. -static inline void esphome_main_task_notify_from_isr(BaseType_t *px_higher_priority_task_woken) { +__attribute__((always_inline)) static inline void esphome_main_task_notify_from_isr( + BaseType_t *px_higher_priority_task_woken) { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { vTaskNotifyGiveFromISR(task, px_higher_priority_task_woken); } } -#ifdef USE_ESP32 -/// Wake the main loop from any context (ISR or task). ESP32-only (needs xPortInIsrContext). -static inline void esphome_main_task_notify_any_context() { - if (xPortInIsrContext()) { - int px_higher_priority_task_woken = 0; - esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); - portYIELD_FROM_ISR(px_higher_priority_task_woken); - } else { - esphome_main_task_notify(); - } -} -#endif - #ifdef __cplusplus } #endif diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index b6b59b5990..3709fa88ac 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -12,12 +12,12 @@ namespace esphome { -// === ESP32 — IRAM_ATTR entry points === -#ifdef USE_ESP32 +// === ESP32 / LibreTiny — IRAM_ATTR entry points === +#if defined(USE_ESP32) || defined(USE_LIBRETINY) void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { esphome_main_task_notify_from_isr(px_higher_priority_task_woken); } -void IRAM_ATTR wake_loop_any_context() { esphome_main_task_notify_any_context(); } +void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } #endif // === ESP8266 / RP2040 === diff --git a/esphome/core/wake.h b/esphome/core/wake.h index a8c9b7ad08..5733ee65f6 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -28,17 +28,27 @@ extern volatile bool g_main_loop_woke; // === ESP32 / LibreTiny (FreeRTOS) === #if defined(USE_ESP32) || defined(USE_LIBRETINY) -#ifdef USE_ESP32 -/// IRAM_ATTR entry point — defined in wake.cpp. -void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -/// IRAM_ATTR entry point — defined in wake.cpp. -void wake_loop_any_context(); +/// Wake the main loop from any context (ISR or task). +/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. +__attribute__((always_inline)) inline void wake_main_task_any_context() { + if (in_isr_context()) { + BaseType_t px_higher_priority_task_woken = pdFALSE; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); +#ifdef portYIELD_FROM_ISR + portYIELD_FROM_ISR(px_higher_priority_task_woken); #else -/// LibreTiny: IRAM_ATTR is not functional and the FreeRTOS port does not -/// provide vTaskNotifyGiveFromISR/portYIELD_FROM_ISR, so ISR-safe wake -/// is not possible. xTaskNotifyGive is used as the best available option. -inline void wake_loop_any_context() { esphome_main_task_notify(); } + // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ + // exit sequence performs the context switch if one was requested. + (void) px_higher_priority_task_woken; #endif + } else { + esphome_main_task_notify(); + } +} + +/// IRAM_ATTR entry points — defined in wake.cpp. +void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); +void wake_loop_any_context(); inline void wake_loop_threadsafe() { esphome_main_task_notify(); } From ff52bb30299c9edd0a36fbd69b3446ef309c7611 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:16:58 -0400 Subject: [PATCH 07/94] [lvgl] Guard lv_image_set_src wrapper with LV_USE_IMAGE (#15789) --- esphome/components/lvgl/lvgl_esphome.h | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3ba258b1a2..3ec1d247d8 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -76,16 +76,17 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { } #endif #if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -// Shortcut / overload, so that the source of an image can easily be updated -// from within a lambda. -inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { lv_image_set_src(obj, image->get_lv_image_dsc()); } +#if LV_USE_IMAGE +// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. +inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } +#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); } inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); } #endif // USE_LVGL_IMAGE #ifdef USE_LVGL_ANIMIMG From ac50f333887bb1d54cf87d5c43a8fbad02f38a07 Mon Sep 17 00:00:00 2001 From: Yves Fischer Date: Fri, 17 Apr 2026 00:27:50 +0200 Subject: [PATCH 08/94] Fix typo in devcontainer.json (#15791) --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5a7a02a266..29f63b54b5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,7 @@ "--privileged", "-e", "GIT_EDITOR=code --wait" - // uncomment and edit the path in order to pass though local USB serial to the conatiner + // uncomment and edit the path in order to pass through local USB serial to the container // , "--device=/dev/ttyACM0" ], "appPort": 6052, From b232fc91aba620736e8678f825f7150369c1c4f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 14:07:26 -1000 Subject: [PATCH 09/94] [runtime_stats] Track main loop active time and report overhead (#15743) --- .../runtime_stats/runtime_stats.cpp | 99 +++++++++++++++---- .../components/runtime_stats/runtime_stats.h | 41 ++++++++ esphome/core/application.h | 27 +++++ esphome/core/component.cpp | 4 + esphome/core/component.h | 8 ++ tests/integration/test_runtime_stats.py | 33 +++++++ 6 files changed, 193 insertions(+), 19 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 06714b5a44..9ed141155a 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -32,40 +32,101 @@ void RuntimeStatsCollector::log_stats_() { " Period stats (last %" PRIu32 "ms): %zu active components", this->log_interval_, count); - if (count == 0) { - return; + // Sum component time so we can derive main-loop overhead + // (active loop time minus time attributable to component loop()s). + // Period sum iterates the active-in-period subset; total sum must iterate + // all components since total_active_time_us_ includes iterations where + // currently-idle components previously ran. + uint64_t period_component_sum_us = 0; + for (size_t i = 0; i < count; i++) { + period_component_sum_us += sorted[i]->runtime_stats_.period_time_us; + } + uint64_t total_component_sum_us = 0; + for (auto *component : components) { + total_component_sum_us += component->runtime_stats_.total_time_us; } - // Sort by period runtime (descending) - std::sort(sorted, sorted + count, compare_period_time); + if (count > 0) { + // Sort by period runtime (descending) + std::sort(sorted, sorted + count, compare_period_time); - // Log top components by period runtime - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, - stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, - stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + // Log top components by period runtime + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, + stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, + stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + } + } + + // Main-loop overhead for the period: active wall time minus component time. + // active = sum of per-iteration loop time excluding yield/sleep. + if (this->period_active_count_ > 0) { + uint64_t active = this->period_active_time_us_; + uint64_t overhead = active > period_component_sum_us ? active - period_component_sum_us : 0; + // Use double for µs→ms conversion so multi-day uptimes (where total + // microsecond counters exceed float's ~7-digit mantissa) keep resolution. + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->period_active_count_, + static_cast(active) / static_cast(this->period_active_count_) / 1000.0, + static_cast(this->period_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); + uint64_t before = this->period_before_time_us_; + uint64_t tail = this->period_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Log total stats since boot (only for active components - idle ones haven't changed) ESP_LOGI(TAG, " Total stats (since boot): %zu active components", count); - // Re-sort by total runtime for all-time stats - std::sort(sorted, sorted + count, compare_total_time); + if (count > 0) { + // Re-sort by total runtime for all-time stats + std::sort(sorted, sorted + count, compare_total_time); - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, - stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, - stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, + stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + } + } + + if (this->total_active_count_ > 0) { + uint64_t active = this->total_active_time_us_; + uint64_t overhead = active > total_component_sum_us ? active - total_component_sum_us : 0; + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->total_active_count_, + static_cast(active) / static_cast(this->total_active_count_) / 1000.0, + static_cast(this->total_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); + uint64_t before = this->total_before_time_us_; + uint64_t tail = this->total_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Reset period stats for (auto *component : components) { component->runtime_stats_.reset_period(); } + this->period_active_count_ = 0; + this->period_active_time_us_ = 0; + this->period_active_max_us_ = 0; + this->period_before_time_us_ = 0; + this->period_tail_time_us_ = 0; } bool RuntimeStatsCollector::compare_period_time(Component *a, Component *b) { diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 3c2c9f78ad..82e0fb7c61 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -29,6 +29,31 @@ class RuntimeStatsCollector { // Process any pending stats printing (should be called after component loop) void process_pending_stats(uint32_t current_time); + // Record the wall time of one main loop iteration excluding the yield/sleep. + // Called once per loop from Application::loop(). + // active_us = total time between loop start and just before yield. + // before_us = time spent in before_loop_tasks_ (scheduler + ISR enable_loop). + // tail_us = time spent in after_loop_tasks_ + the trailing record/stats prefix. + // Residual overhead at log time = active − Σ(component) − before − tail, + // which captures per-iteration inter-component bookkeeping (set_current_component, + // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // the for-loop itself). + void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { + this->period_active_count_++; + this->period_active_time_us_ += active_us; + if (active_us > this->period_active_max_us_) + this->period_active_max_us_ = active_us; + this->total_active_count_++; + this->total_active_time_us_ += active_us; + if (active_us > this->total_active_max_us_) + this->total_active_max_us_ = active_us; + + this->period_before_time_us_ += before_us; + this->total_before_time_us_ += before_us; + this->period_tail_time_us_ += tail_us; + this->total_tail_time_us_ += tail_us; + } + protected: void log_stats_(); // Static comparators — member functions have friend access, lambdas do not @@ -37,6 +62,22 @@ class RuntimeStatsCollector { uint32_t log_interval_; uint32_t next_log_time_{0}; + + // Main loop active-time stats (wall time per iteration, excluding yield/sleep). + // Counters are uint64_t — at sub-millisecond loop times a uint32_t can wrap in + // a few weeks of uptime, which is well within ESPHome device lifetimes. + uint64_t period_active_count_{0}; + uint64_t period_active_time_us_{0}; + uint32_t period_active_max_us_{0}; + uint64_t total_active_count_{0}; + uint64_t total_active_time_us_{0}; + uint32_t total_active_max_us_{0}; + + // Split of overhead sections — accumulated per iteration. + uint64_t period_before_time_us_{0}; + uint64_t total_before_time_us_{0}; + uint64_t period_tail_time_us_{0}; + uint64_t total_tail_time_us_{0}; }; } // namespace runtime_stats diff --git a/esphome/core/application.h b/esphome/core/application.h index 7356263c55..bc40fe0c7e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -575,10 +575,25 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ } inline void ESPHOME_ALWAYS_INLINE Application::loop() { +#ifdef USE_RUNTIME_STATS + // Capture the start of the active (non-sleeping) portion of this iteration. + // Used to derive main-loop overhead = active time − Σ(component time) − + // before/tail splits recorded below. + uint32_t loop_active_start_us = micros(); + // Snapshot the cumulative component-recorded time so we can subtract the + // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // (scheduler.cpp) — that time is already counted in per-component stats, + // so charging it again to "before" would double-count. + uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; +#endif // Get the initial loop time at the start uint32_t last_op_end_time = millis(); this->before_loop_tasks_(last_op_end_time); +#ifdef USE_RUNTIME_STATS + uint32_t loop_before_end_us = micros(); + uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; +#endif for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; this->current_loop_index_++) { @@ -597,12 +612,24 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->feed_wdt_with_time(last_op_end_time); } +#ifdef USE_RUNTIME_STATS + uint32_t loop_tail_start_us = micros(); +#endif this->after_loop_tasks_(); #ifdef USE_RUNTIME_STATS // Process any pending runtime stats printing after all components have run // This ensures stats printing doesn't affect component timing measurements if (global_runtime_stats != nullptr) { + uint32_t loop_now_us = micros(); + // Subtract scheduled-component time from the "before" bucket so it is + // not double-counted (it is already attributed to per-component stats). + uint32_t loop_before_wall_us = loop_before_end_us - loop_active_start_us; + uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us + ? loop_before_wall_us - static_cast(loop_before_scheduled_us) + : 0; + global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, + loop_now_us - loop_tail_start_us); global_runtime_stats->process_pending_stats(last_op_end_time); } #endif diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 8949b4b76d..e33652482e 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -506,6 +506,10 @@ void PollingComponent::stop_poller() { uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } +#ifdef USE_RUNTIME_STATS +uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + void __attribute__((noinline, cold)) WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { bool should_warn; diff --git a/esphome/core/component.h b/esphome/core/component.h index 3307c5ae76..6fbb0d5c06 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -116,6 +116,13 @@ struct ComponentRuntimeStats { uint64_t total_time_us{0}; uint32_t total_max_time_us{0}; + // Cumulative sum of every record_time() duration since boot, across all + // components. Used by Application::loop() to snapshot time spent inside + // WarnIfComponentBlockingGuard (including guards constructed by the + // scheduler at scheduler.cpp) so main-loop overhead accounting can + // subtract scheduled-callback time from the before_loop_tasks_ wall time. + static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void record_time(uint32_t duration_us) { this->period_count++; this->period_time_us += duration_us; @@ -125,6 +132,7 @@ struct ComponentRuntimeStats { this->total_time_us += duration_us; if (duration_us > this->total_max_time_us) this->total_max_time_us = duration_us; + global_recorded_us += duration_us; } void reset_period() { this->period_count = 0; diff --git a/tests/integration/test_runtime_stats.py b/tests/integration/test_runtime_stats.py index 9e93035d83..bd7f36341d 100644 --- a/tests/integration/test_runtime_stats.py +++ b/tests/integration/test_runtime_stats.py @@ -26,6 +26,7 @@ async def test_runtime_stats( # Track component stats component_stats_found = set() + main_loop_lines: list[dict[str, str]] = [] # Patterns to match - need to handle ANSI color codes and timestamps # The log format is: [HH:MM:SS][color codes][I][tag]: message @@ -34,6 +35,14 @@ async def test_runtime_stats( component_pattern = re.compile( r"^\[[^\]]+\].*?\s+([\w.]+):\s+count=(\d+),\s+avg=([\d.]+)ms" ) + # Main loop overhead line emitted by runtime_stats + main_loop_pattern = re.compile( + r"main_loop:\s+iters=(?P\d+),\s+" + r"active_avg=(?P[\d.]+)ms,\s+" + r"active_max=(?P[\d.]+)ms,\s+" + r"active_total=(?P[\d.]+)ms,\s+" + r"overhead_total=(?P[\d.]+)ms" + ) def check_output(line: str) -> None: """Check log output for runtime stats messages.""" @@ -54,6 +63,11 @@ async def test_runtime_stats( component_name = match.group(1) component_stats_found.add(component_name) + # Check for main_loop overhead line + ml_match = main_loop_pattern.search(line) + if ml_match: + main_loop_lines.append(ml_match.groupdict()) + async with ( run_compiled(yaml_config, line_callback=check_output), api_client_connected() as client, @@ -86,3 +100,22 @@ async def test_runtime_stats( assert "template.switch" in component_stats_found, ( f"Expected template.switch stats, found: {component_stats_found}" ) + + # Verify the main_loop overhead line is emitted (at least once for + # the period section and once for the total section, per log cycle). + assert len(main_loop_lines) >= 2, ( + f"Expected at least 2 main_loop lines, got {len(main_loop_lines)}" + ) + for fields in main_loop_lines: + assert int(fields["iters"]) > 0, f"iters should be > 0: {fields}" + assert float(fields["active_total"]) > 0.0, ( + f"active_total should be > 0: {fields}" + ) + assert float(fields["active_avg"]) >= 0.0, ( + f"active_avg should be >= 0: {fields}" + ) + # overhead_total is derived and may be 0 if components dominate, + # but the field must still be present and parseable as a float. + assert float(fields["overhead_total"]) >= 0.0, ( + f"overhead_total should be >= 0: {fields}" + ) From cfe8c0eeee0fa61fa40c1d47a89203b4bdee0f1a Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Fri, 17 Apr 2026 12:20:55 +0200 Subject: [PATCH 10/94] [wireguard] Bump esp_wireguard to 0.4.5 for ESP-IDF v6 (#15804) --- .clang-tidy.hash | 2 +- esphome/components/wireguard/__init__.py | 2 +- platformio.ini | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index ab526134f8..72a9967590 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -dc8ad5472d9fb44ce1ca29a0601afd65705642799a2819704dfc8459fbaf9815 +075ed2142432dc59883bb52db8ac11270f952851d6400deae080f5468c7cb592 diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 4fdb256d0c..e128b8476d 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -137,7 +137,7 @@ async def to_code(config): # the '+1' modifier is relative to the device's own address that will # be automatically added to the provided list. cg.add_build_flag(f"-DCONFIG_WIREGUARD_MAX_SRC_IPS={len(allowed_ips) + 1}") - cg.add_library("droscy/esp_wireguard", "0.4.4") + cg.add_library("droscy/esp_wireguard", "0.4.5") await cg.register_component(var, config) diff --git a/platformio.ini b/platformio.ini index 3897db83e1..e2c7e2b097 100644 --- a/platformio.ini +++ b/platformio.ini @@ -118,7 +118,7 @@ lib_deps = ESP8266HTTPClient ; http_request (Arduino built-in) ESP8266mDNS ; mdns (Arduino built-in) DNSServer ; captive_portal (Arduino built-in) - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard lvgl/lvgl@9.5.0 ; lvgl build_flags = @@ -154,7 +154,7 @@ lib_deps = DNSServer ; captive_portal (Arduino built-in) makuna/NeoPixelBus@2.8.0 ; neopixelbus esphome/ESP32-audioI2S@2.3.0 ; i2s_audio - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word build_flags = @@ -176,7 +176,7 @@ platform_packages = framework = espidf lib_deps = ${common:idf.lib_deps} - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = @@ -221,7 +221,7 @@ lib_compat_mode = soft lib_deps = bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} From 6a46437a5f4f4a74c61943c02759d0209e43f9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 17 Apr 2026 13:58:39 +0300 Subject: [PATCH 11/94] [wifi] Guard retry_phase_to_log_string with log level check to fix warning (#15801) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/wifi/wifi_component.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 7b31a22ed5..598aee8f66 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -308,6 +308,7 @@ bool CompactString::operator==(const StringRef &other) const { /// │ - Roaming fail (RECONNECTING on other AP): counter preserved │ /// └──────────────────────────────────────────────────────────────────────┘ +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO // Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266) static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { if (phase == WiFiRetryPhase::INITIAL_CONNECT) @@ -326,6 +327,7 @@ static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { return LOG_STR("RESTARTING"); return LOG_STR("UNKNOWN"); } +#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO bool WiFiComponent::went_through_explicit_hidden_phase_() const { // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase From 1a529a62aad1bafd3e658cddf980376b3123dfa1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:17:16 +1000 Subject: [PATCH 12/94] [mipi_spi] Drawing fixes for native display (#15802) --- esphome/components/mipi_spi/display.py | 6 +- esphome/components/mipi_spi/mipi_spi.h | 14 +- .../mipi_spi/test_final_validate.py | 185 ++++++++++++++++++ 3 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_final_validate.py diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 42c7ec2224..364ada9046 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -195,7 +195,7 @@ def model_schema(config): "big_endian", "little_endian", lower=True ), model.option(CONF_COLOR_DEPTH, 16): cv.one_of(*color_depth, lower=True), - model.option(CONF_DRAW_ROUNDING, 2): power_of_two, + model.option(CONF_DRAW_ROUNDING, 1): power_of_two, model.option(CONF_PIXEL_MODE, DISPLAY_16BIT): cv.one_of( *pixel_modes, lower=True ), @@ -297,9 +297,9 @@ def _final_validate(config): buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here - fraction = min(20000.0, buffer_size // 16) / buffer_size + fraction = min(20000.0, buffer_size // 4) / buffer_size config[CONF_BUFFER_SIZE] = 1.0 / next( - x for x in range(2, 17) if fraction >= 1 / x + (x for x in range(2, 8) if fraction >= 1 / x), 8 ) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2242be6c17..f292345893 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -546,13 +546,12 @@ class MipiSpiBuffer : public MipiSpistart_line_ = 0; this->start_line_ < this->get_height_internal(); - this->start_line_ += this->get_height_internal() / FRACTION) { + auto increment = (this->get_height_internal() / FRACTION / ROUNDING) * ROUNDING; + for (this->start_line_ = 0; this->start_line_ < this->get_height_internal(); this->start_line_ = this->end_line_) { #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE auto lap = millis(); #endif - this->end_line_ = - clamp_at_most(this->start_line_ + this->get_height_internal() / FRACTION, this->get_height_internal()); + this->end_line_ = clamp_at_most(this->start_line_ + increment, this->get_height_internal()); if (this->auto_clear_enabled_) { this->clear(); } @@ -574,12 +573,13 @@ class MipiSpiBuffer : public MipiSpix_low_ = this->x_low_ / ROUNDING * ROUNDING; this->y_low_ = this->y_low_ / ROUNDING * ROUNDING; - this->x_high_ = (this->x_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; - this->y_high_ = (this->y_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; + this->x_high_ = round_buffer(this->x_high_ + 1) - 1; + this->y_high_ = clamp_at_most(round_buffer(this->y_high_ + 1) - 1, this->end_line_ - 1); int w = this->x_high_ - this->x_low_ + 1; int h = this->y_high_ - this->y_low_ + 1; this->write_to_display_(this->x_low_, this->y_low_, w, h, this->buffer_, this->x_low_, - this->y_low_ - this->start_line_, round_buffer(this->get_width_internal()) - w); + this->y_low_ - this->start_line_, + round_buffer(this->get_width_internal()) - w - this->x_low_); // invalidate watermarks this->x_low_ = this->get_width_internal(); this->y_low_ = this->get_height_internal(); diff --git a/tests/component_tests/mipi_spi/test_final_validate.py b/tests/component_tests/mipi_spi/test_final_validate.py new file mode 100644 index 0000000000..8c45b47752 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_final_validate.py @@ -0,0 +1,185 @@ +"""Tests for the _final_validate buffer size calculation in mipi_spi.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.display import CONF_SHOW_TEST_CARD +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.const import CONF_BUFFER_SIZE, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validated(config: ConfigType) -> ConfigType: + """Run the component config schema followed by the final validation.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def _custom_config( + width: int, + height: int, + color_depth: str | int | None = None, + **extra: Any, +) -> ConfigType: + """Build a minimal valid custom-model config with the given dimensions.""" + config: ConfigType = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": width, "height": height}, + "init_sequence": [[0xA0, 0x01]], + } + if color_depth is not None: + config["color_depth"] = color_depth + config.update(extra) + return config + + +# The auto buffer-size selection inside _final_validate targets ~20 kB of +# pixel buffer. For a buffer of ``depth_bytes * width * height``, it picks the +# smallest integer ``x`` in range(2, 8) such that +# ``min(20000, buffer // 4) / buffer >= 1 / x`` (falling back to ``x = 8``). +# The test cases below cover the full range of possible outcomes (1/4 .. 1/8). +@pytest.mark.parametrize( + ("width", "height", "color_depth", "expected"), + [ + # 16-bit color depth -- buffer = 2 * width * height + # 128*160*2 = 40960 B -> fraction = 10240/40960 = 0.25 -> x = 4 + pytest.param(128, 160, "16bit", 1.0 / 4, id="16bit_tiny"), + # 200*224*2 = 89600 B -> fraction = 20000/89600 ≈ 0.2232 -> x = 5 + pytest.param(200, 224, "16bit", 1.0 / 5, id="16bit_small"), + # 240*224*2 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(240, 224, "16bit", 1.0 / 6, id="16bit_medium"), + # 200*320*2 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(200, 320, "16bit", 1.0 / 7, id="16bit_large"), + # 240*320*2 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(240, 320, "16bit", 1.0 / 8, id="16bit_xlarge"), + # 320*480*2 = 307200 B -> fraction ≈ 0.0651 -> default x = 8 + pytest.param(320, 480, "16bit", 1.0 / 8, id="16bit_huge"), + # 8-bit color depth -- buffer = width * height + # 320*240 = 76800 B -> fraction = 19200/76800 = 0.25 -> x = 4 + pytest.param(320, 240, "8bit", 1.0 / 4, id="8bit_tiny"), + # 400*224 = 89600 B -> fraction ≈ 0.2232 -> x = 5 + pytest.param(400, 224, "8bit", 1.0 / 5, id="8bit_small"), + # 480*224 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(480, 224, "8bit", 1.0 / 6, id="8bit_medium"), + # 400*320 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(400, 320, "8bit", 1.0 / 7, id="8bit_large"), + # 480*320 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(480, 320, "8bit", 1.0 / 8, id="8bit_xlarge"), + ], +) +def test_buffer_size_auto_selected( + width: int, + height: int, + color_depth: str, + expected: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """Without PSRAM or an explicit buffer_size, a fraction is chosen from the display size. + + Without any drawing method and without LVGL, final validation also auto-enables + ``show_test_card``, which in turn makes the component require a buffer and therefore + triggers the buffer-size selection path. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated(_custom_config(width, height, color_depth)) + + # Sanity check: final validation should have enabled the test card for us, + # which is what causes the buffer-size calculation to actually run. + assert config.get(CONF_SHOW_TEST_CARD) is True + assert config[CONF_BUFFER_SIZE] == pytest.approx(expected) + + +@pytest.mark.parametrize( + "buffer_size", + [0.125, 0.25, 0.5, 1.0], + ids=["one_eighth", "one_quarter", "half", "full"], +) +def test_explicit_buffer_size_is_preserved( + buffer_size: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """An explicitly configured buffer_size is never overridden by final validation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated( + _custom_config(240, 320, "16bit", buffer_size=buffer_size), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(buffer_size) + + +def test_buffer_size_not_set_when_psram_enabled( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """When PSRAM is enabled the auto buffer-size selection is skipped.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + # Presence of the psram domain in the full config is what _final_validate checks. + set_component_config("psram", True) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + + +def test_buffer_size_not_set_when_buffer_not_required( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """With LVGL present and no drawing methods, no buffer fraction is chosen. + + LVGL suppresses the automatic show_test_card injection, which means + ``requires_buffer`` is False and the early-return branch fires. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + # And no test card should have been auto-enabled either. + assert not config.get(CONF_SHOW_TEST_CARD) + + +def test_buffer_size_selected_when_lvgl_with_test_card( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """LVGL present + an explicit drawing method still triggers buffer sizing. + + When LVGL is enabled, ``show_test_card`` is not injected automatically, + but users can still request it explicitly -- in that case ``requires_buffer`` + is True and the buffer-size heuristic still runs. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + # 128x160 @ 16bit -> expected 1/4 (see test_buffer_size_auto_selected). + config = _validated( + _custom_config(128, 160, "16bit", show_test_card=True), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) From b018ac67bcfdf8e8f826af385c8dd65aed9d41b0 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:11:05 +1000 Subject: [PATCH 13/94] [image] Fix byte order handling (#15800) --- esphome/components/image/__init__.py | 17 ++++++++++------- esphome/components/image/image.cpp | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 4a5fcc385e..7db50597e6 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -28,7 +28,6 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.final_validate import full_config _LOGGER = logging.getLogger(__name__) @@ -676,12 +675,16 @@ def _final_validate(config): :param config: :return: """ - fv = full_config.get() - if "lvgl" in fv and not all(CONF_BYTE_ORDER in x for x in config): - config = config.copy() - for c in config: - if not c.get(CONF_BYTE_ORDER): - c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" + config = config.copy() + for c in config: + if byte_order := c.get(CONF_BYTE_ORDER): + if byte_order == "BIG_ENDIAN": + _LOGGER.warning( + "The image '%s' is configured with big-endian byte order, little-endian is expected", + c.get(CONF_FILE), + ) + else: + c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" return config diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index a6f9e35e2e..5b4ed6968c 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -189,7 +189,7 @@ Color Image::get_rgb_pixel_(int x, int y) const { } Color Image::get_rgb565_pixel_(int x, int y) const { const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8; - uint16_t rgb565 = encode_uint16(progmem_read_byte(pos), progmem_read_byte(pos + 1)); + uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + 1), progmem_read_byte(pos)); auto r = (rgb565 & 0xF800) >> 11; auto g = (rgb565 & 0x07E0) >> 5; auto b = rgb565 & 0x001F; From 523c6f2376e491fe5f4391041ff9e0039ab3eb7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 02:45:50 -1000 Subject: [PATCH 14/94] [core] coerce set_interval(0) / update_interval: 0ms to 1ms (#15799) --- esphome/config_validation.py | 21 +++++- esphome/core/scheduler.cpp | 13 ++++ .../scheduler_interval_zero_coerced.yaml | 27 ++++++++ .../test_scheduler_interval_zero_coerced.py | 67 +++++++++++++++++++ tests/unit_tests/test_config_validation.py | 28 ++++++++ 5 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/scheduler_interval_zero_coerced.yaml create mode 100644 tests/integration/test_scheduler_interval_zero_coerced.py diff --git a/esphome/config_validation.py b/esphome/config_validation.py index e6b0cb7ee2..fbafc5cb07 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -943,7 +943,26 @@ def time_period_in_minutes_(value): def update_interval(value): if value == "never": return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN) - return positive_time_period_milliseconds(value) + result = positive_time_period_milliseconds(value) + # 0ms was historically (mis)used as a pseudo-loop() mechanism for + # PollingComponents. Under the hood it calls set_interval(0), which + # causes Scheduler::call() to spin (WDT reset in the field). Coerce + # to 1ms so existing configs keep working at ~1kHz instead of + # spinning. Don't hard-fail so configs don't break on upgrade; + # authors should migrate to HighFrequencyLoopRequester (C++) for + # true run-every-loop behaviour. + if result.total_milliseconds == 0: + _LOGGER.warning( + "update_interval of 0ms is not supported - coercing to 1ms. " + "A literal 0ms schedule would spin the main loop (the scheduled " + "item would always be due, so the scheduler would never yield " + "back) and trigger a watchdog reset. Set update_interval to a " + "non-zero value such as 1ms or higher. (Custom C++ components " + "that need true run-every-loop behaviour should override loop() " + "with HighFrequencyLoopRequester instead.)" + ) + return TimePeriodMilliseconds(milliseconds=1) + return result time_period = Any(time_period_str_unit, time_period_str_colon, time_period_dict) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 3e75a68064..7e6ad19ac7 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -144,6 +144,19 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } + // An interval of 0 means "fire every tick forever," which is misuse: the + // item would always be due, causing Scheduler::call() to spin and starve + // the main loop (WDT reset in the field). Coerce to 1ms so existing code + // using update_interval=0ms as a pseudo-loop() continues to work at ~1kHz, + // and warn so authors can migrate to HighFrequencyLoopRequester which is + // the intended mechanism for running fast in the main loop. Zero-delay + // timeouts (defer) remain legitimate one-shots and are not affected. + if (type == SchedulerItem::INTERVAL && delay == 0) [[unlikely]] { + ESP_LOGE(TAG, "[%s] set_interval(0) would spin main loop - coercing to 1ms (use HighFrequencyLoopRequester)", + component ? LOG_STR_ARG(component->get_component_log_str()) : LOG_STR_LITERAL("?")); + delay = 1; + } + // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check LockGuard guard{this->lock_}; diff --git a/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml new file mode 100644 index 0000000000..13be55d617 --- /dev/null +++ b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml @@ -0,0 +1,27 @@ +esphome: + name: sched-interval-zero + +host: +api: +logger: + level: DEBUG + +globals: + - id: fire_count + type: int + initial_value: "0" + +interval: + # Deliberately configure 0ms — this path goes through the C++ + # Scheduler::set_timer_common_ coercion (not the Python cv.update_interval + # path, since interval: doesn't call cv.update_interval — it's an intervals + # component schema, not a PollingComponent's update_interval). + # Expected: scheduler coerces to 1ms at registration, emits ESP_LOGE, + # fires at ~1kHz instead of spinning. + - interval: 0ms + then: + - lambda: |- + id(fire_count) += 1; + if (id(fire_count) == 50) { + ESP_LOGI("test", "ZERO_INTERVAL_50_FIRES_REACHED"); + } diff --git a/tests/integration/test_scheduler_interval_zero_coerced.py b/tests/integration/test_scheduler_interval_zero_coerced.py new file mode 100644 index 0000000000..f71c0f7281 --- /dev/null +++ b/tests/integration/test_scheduler_interval_zero_coerced.py @@ -0,0 +1,67 @@ +"""Test that Scheduler::set_timer_common_ coerces interval=0 to 1ms. + +Regression test for the scheduler busy-loop when interval=0 was passed +literally. Without the coercion, Scheduler::call() would spin forever +because the item's next_execution == now_64 after re-scheduling, failing +the loop's `> now_64` break condition. The device would fail to yield +back to the main loop and trigger a WDT reset. + +With the coercion, interval=0 becomes interval=1 and the scheduler +fires at ~1kHz (bounded by the loop), the main loop continues to run, +and the device stays responsive to API calls. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_interval_zero_coerced( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """interval=0ms must be coerced to 1ms and not starve the main loop.""" + loop = asyncio.get_running_loop() + reached_50: asyncio.Future[None] = loop.create_future() + coerce_warning: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "ZERO_INTERVAL_50_FIRES_REACHED" in line and not reached_50.done(): + reached_50.set_result(None) + if "would spin main loop" in line and not coerce_warning.done(): + coerce_warning.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + # The API-client connection itself is evidence that the main loop + # is not starved — if set_interval(0) were spinning we could not + # get here at all. + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "sched-interval-zero" + + # Coerce warning must fire at registration + try: + await asyncio.wait_for(coerce_warning, timeout=5.0) + except TimeoutError: + pytest.fail("Expected coerce warning 'would spin main loop' not seen") + + # The coerced 1ms interval should fire 50 times quickly — this + # confirms the callback actually runs (not just registered) and the + # scheduler yields back to the main loop each time. + try: + await asyncio.wait_for(reached_50, timeout=5.0) + except TimeoutError: + pytest.fail( + "Coerced interval=0→1ms did not reach 50 fires within 5s, " + "which would indicate either the coercion failed or the " + "main loop is still being starved." + ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ac84ce7cc8..f038272d8b 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -24,6 +24,7 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, + SCHEDULER_DONT_RUN, ) from esphome.core import CORE, HexInt, Lambda @@ -765,3 +766,30 @@ def test_percentage_validators__raw_number_above_one_without_percent_sign( config_validation.unbounded_percentage(value) with pytest.raises(Invalid, match="percent sign"): config_validation.unbounded_possibly_negative_percentage(value) + + +def test_update_interval__coerces_zero_to_one_ms( + caplog: pytest.LogCaptureFixture, +) -> None: + """update_interval: 0ms must be coerced to 1ms (not rejected) because a + literal 0ms schedule causes Scheduler::call() to spin. Coercion keeps + existing configs compiling on upgrade while emitting a user-facing + warning that directs them to set a non-zero value.""" + with caplog.at_level("WARNING"): + result = config_validation.update_interval("0ms") + assert result.total_milliseconds == 1 + assert "update_interval of 0ms is not supported" in caplog.text + assert "1ms" in caplog.text + + +def test_update_interval__preserves_nonzero_values() -> None: + """Non-zero update_interval values must pass through unchanged.""" + assert config_validation.update_interval("1ms").total_milliseconds == 1 + assert config_validation.update_interval("50ms").total_milliseconds == 50 + assert config_validation.update_interval("60s").total_milliseconds == 60000 + + +def test_update_interval__never_passes_through() -> None: + """update_interval: never must still map to SCHEDULER_DONT_RUN.""" + result = config_validation.update_interval("never") + assert result.total_milliseconds == SCHEDULER_DONT_RUN From d4fe46bb24be368e882aa3b78655edc95d82ed6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 02:46:12 -1000 Subject: [PATCH 15/94] [core] Expose App.wake_loop_isrsafe() on ESP8266 (#15797) --- esphome/core/application.h | 3 +++ esphome/core/wake.h | 3 +++ 2 files changed, 6 insertions(+) diff --git a/esphome/core/application.h b/esphome/core/application.h index bc40fe0c7e..82f399b2d6 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -340,6 +340,9 @@ class Application { #if defined(USE_ESP32) || defined(USE_LIBRETINY) /// Wake from ISR (ESP32 and LibreTiny). static void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px) { esphome::wake_loop_isrsafe(px); } +#elif defined(USE_ESP8266) + /// Wake from ISR (ESP8266). No task_woken arg — no FreeRTOS. Caller must be IRAM_ATTR. + static void IRAM_ATTR ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { esphome::wake_loop_isrsafe(); } #endif /// Wake from any context (ISR, thread, callback). diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 5733ee65f6..77a38d429e 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -77,6 +77,9 @@ void wake_loop_any_context(); /// Non-ISR: always inline. inline void wake_loop_threadsafe() { wake_loop_impl(); } +/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. +inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } + namespace internal { inline void wakeable_delay(uint32_t ms) { if (ms == 0) { From bcbfc843ae77ec72af2bc2d80b070c07923d8096 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:05:30 -0400 Subject: [PATCH 16/94] [ethernet] Fix SPI3_HOST default breaking compile on variants without SPI3 (#15809) Co-authored-by: J. Nick Koston --- .../components/ethernet/ethernet_component.h | 2 +- .../ethernet/test.esp32-c3-idf.yaml | 19 +++++++++++++++++++ ...720.esp32-idf.yaml => test.esp32-idf.yaml} | 0 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 tests/components/ethernet/test.esp32-c3-idf.yaml rename tests/components/ethernet/{test-lan8720.esp32-idf.yaml => test.esp32-idf.yaml} (100%) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 3a87842315..17c84ee954 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -221,7 +221,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; - spi_host_device_t interface_{SPI3_HOST}; + spi_host_device_t interface_{SPI2_HOST}; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT uint32_t polling_interval_{0}; #endif diff --git a/tests/components/ethernet/test.esp32-c3-idf.yaml b/tests/components/ethernet/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..b7b95875c6 --- /dev/null +++ b/tests/components/ethernet/test.esp32-c3-idf.yaml @@ -0,0 +1,19 @@ +ethernet: + type: W5500 + clk_pin: 6 + mosi_pin: 7 + miso_pin: 2 + cs_pin: 10 + interrupt_pin: 3 + reset_pin: 4 + clock_speed: 10Mhz + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-lan8720.esp32-idf.yaml b/tests/components/ethernet/test.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-lan8720.esp32-idf.yaml rename to tests/components/ethernet/test.esp32-idf.yaml From 34c35c84d5c0109cb45583a10c0ed9664462013e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 09:31:31 -0500 Subject: [PATCH 17/94] [core] Fix DelayAction compile error with non-const reference args (#15814) --- esphome/core/base_automation.h | 4 +++- tests/components/http_request/http_request.yaml | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 11133d3973..17f937d10d 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -205,7 +205,9 @@ template class DelayAction : public Action, public Compon } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay - auto f = [this, x...]() { this->play_next_(x...); }; + // `mutable` is required so captured copies of non-const reference args (e.g. std::string&) + // are passed as non-const lvalues to play_next_(const Ts&...) where Ts may be `T&` + auto f = [this, x...]() mutable { this->play_next_(x...); }; App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(x...), std::move(f), diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 13ca5ceba0..ef67671c91 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -45,6 +45,11 @@ esphome: args: - response->status_code - body.c_str() + - delay: 1s + - logger.log: + format: "After delay, body still: %s" + args: + - body.c_str() http_request: useragent: esphome/tagreader From 70ea52716172444dda1d4102d0857a20370c445d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:17:51 -0500 Subject: [PATCH 18/94] Bump ruff from 0.15.10 to 0.15.11 (#15790) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston 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 ac4f0049f8..e492d35595 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.15.10 + rev: v0.15.11 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 18d0461e83..bb98375cb6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.10 # also change in .pre-commit-config.yaml when updating +ruff==0.15.11 # 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 b1b0005574b3a54f8aeead1d9eee96f586d72aa6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:14:54 -0400 Subject: [PATCH 19/94] [esp32] Downgrade unneeded `ignore_pin_validation_error` to a warning (#15811) --- esphome/components/esp32/gpio.py | 12 +++- tests/component_tests/esp32/test_esp32.py | 79 ++++++++++++++++++++++- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index a7180cbcd7..36dd44155a 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -172,10 +172,16 @@ def validate_gpio_pin(pin): exc, ) else: - # Throw an exception if used for a pin that would not have resulted - # in a validation error anyway! + # `ignore_pin_validation_error` only suppresses an error raised by the + # variant's pin_validation above (e.g. SPI flash/PSRAM pins, invalid pin + # numbers). If that didn't raise, the option is a no-op -- warn so the + # user can clean it up, but don't block the build. if ignore_pin_validation_warning: - raise cv.Invalid(f"GPIO{pin[CONF_NUMBER]} is not a reserved pin") + _LOGGER.warning( + "GPIO%d has no validation errors to ignore; " + "remove `ignore_pin_validation_error: true` from this pin.", + pin[CONF_NUMBER], + ) return pin diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bd4f9828ce..ac492e2752 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -8,10 +8,16 @@ from typing import Any import pytest -from esphome.components.esp32 import VARIANTS -from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS +from esphome.components.esp32 import VARIANT_ESP32, VARIANTS +from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT +from esphome.components.esp32.gpio import validate_gpio_pin import esphome.config_validation as cv -from esphome.const import CONF_ESPHOME, PlatformFramework +from esphome.const import ( + CONF_ESPHOME, + CONF_IGNORE_PIN_VALIDATION_ERROR, + CONF_NUMBER, + PlatformFramework, +) from esphome.core import CORE from tests.component_tests.types import SetCoreConfigCallable @@ -149,6 +155,73 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +def test_ignore_pin_validation_error_on_clean_pin_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that passes validation but sets `ignore_pin_validation_error: true` + should log a warning nudging the user to remove the flag, and not raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "GPIO4 has no validation errors to ignore" in caplog.text + + +def test_ignore_pin_validation_error_on_dirty_pin_suppresses( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that fails validation with `ignore_pin_validation_error: true` should + log the suppression warning and not raise (existing behavior).""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + # GPIO6 is a flash pin on ESP32 -> pin_validation raises cv.Invalid + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 6 + assert "Ignoring validation error on pin 6" in caplog.text + + +def test_dirty_pin_without_ignore_flag_raises( + set_core_config: SetCoreConfigCallable, +) -> None: + """A pin that fails validation without the ignore flag should still raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with pytest.raises(cv.Invalid, match="flash interface"): + validate_gpio_pin(pin) + + +def test_clean_pin_without_ignore_flag_does_not_warn( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A clean pin without the ignore flag should pass silently.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "has no validation errors to ignore" not in caplog.text + + def test_execute_from_psram_disabled_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From 290e213cd088c34f6156b6d99dc41eaa858e093f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 18 Apr 2026 06:41:33 +1000 Subject: [PATCH 20/94] [mipi_spi] Add Sunton ESP32-2424S012 (#15812) --- esphome/components/mipi_spi/models/cyd.py | 11 ++++++++++- esphome/components/mipi_spi/models/ili.py | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi_spi/models/cyd.py b/esphome/components/mipi_spi/models/cyd.py index 7229412f18..0a35cb4fee 100644 --- a/esphome/components/mipi_spi/models/cyd.py +++ b/esphome/components/mipi_spi/models/cyd.py @@ -1,4 +1,6 @@ -from .ili import ILI9341, ILI9342, ST7789V +from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER + +from .ili import GC9A01A, ILI9341, ILI9342, ST7789V ILI9341.extend( # ESP32-2432S028 CYD board with Micro USB, has ILI9341 controller @@ -43,3 +45,10 @@ ILI9342.extend( (0xE1, 0x00, 0x0B, 0x11, 0x05, 0x13, 0x09, 0x33, 0x67, 0x48, 0x07, 0x0E, 0x0B, 0x23, 0x33, 0x0F), # Negative Gamma Correction ) ) + +GC9A01A.extend( + "ESP32-2424S012", + invert_colors=True, + cs_pin=10, + dc_pin={CONF_NUMBER: 2, CONF_IGNORE_STRAPPING_WARNING: True}, +) diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 6b672b0859..ae6accb907 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -555,7 +555,7 @@ ST7789V = DriverChip( ), ), ) -DriverChip( +GC9A01A = DriverChip( "GC9A01A", mirror_x=True, width=240, From 1bf455cfbb0308e3d86ae3d7f9e662f27f32cda1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 18 Apr 2026 06:42:45 +1000 Subject: [PATCH 21/94] [runtime_image] Fix RGB order (#15813) --- esphome/components/runtime_image/runtime_image.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index fa42b53496..4c7f1bfb6f 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -127,9 +127,9 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { uint32_t pos = this->get_position_(x, y); Color mapped_color = color; this->map_chroma_key(mapped_color); - this->buffer_[pos + 0] = mapped_color.r; + this->buffer_[pos + 0] = mapped_color.b; this->buffer_[pos + 1] = mapped_color.g; - this->buffer_[pos + 2] = mapped_color.b; + this->buffer_[pos + 2] = mapped_color.r; if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { this->buffer_[pos + 3] = color.w; } From 6ebe1e92eb64cf099eb4f1ae97dba5fc1471905a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 17:54:12 -0500 Subject: [PATCH 22/94] [ci] Scope local pylint pre-commit hook to esphome/ (#15818) --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e492d35595..d9b7df6ec5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,6 +58,7 @@ repos: entry: python3 script/run-in-env.py pylint language: system types: [python] + files: ^esphome/.+\.py$ - id: clang-tidy-hash name: Update clang-tidy hash entry: python script/clang_tidy_hash.py --update-if-changed From 562ce541a097c3ab79723553a091db07e1b847ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 17:54:24 -0500 Subject: [PATCH 23/94] [bme680_bsec] [bme68x_bsec2] Mark the two BSEC variants as mutually exclusive (#15826) --- esphome/components/bme680_bsec/__init__.py | 1 + esphome/components/bme68x_bsec2/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index a86e061cd4..2365f8d107 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Fr CODEOWNERS = ["@trvrnrth"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensor", "text_sensor"] +CONFLICTS_WITH = ["bme68x_bsec2"] MULTI_CONF = True CONF_BME680_BSEC_ID = "bme680_bsec_id" diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index b56217fac1..5083d283ef 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( ) CODEOWNERS = ["@neffs", "@kbx81"] +CONFLICTS_WITH = ["bme680_bsec"] DOMAIN = "bme68x_bsec2" From d3691c7ca5d06beb1ba63be18dee66a6e3c7a47d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:17:28 +1000 Subject: [PATCH 24/94] [lvgl] Fix crash with snow on rotated display (#15822) --- esphome/components/lvgl/lvgl_esphome.cpp | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index ce9b013dcf..d8248e4aa4 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -642,26 +642,28 @@ void LvglComponent::write_random_() { int iterations = 6 - lv_display_get_inactive_time(this->disp_) / 60000; if (iterations <= 0) iterations = 1; + int16_t width = lv_display_get_horizontal_resolution(this->disp_); + int16_t height = lv_display_get_vertical_resolution(this->disp_); while (iterations-- != 0) { - int32_t col = random_uint32() % this->width_; + int32_t col = random_uint32() % width; col = col / this->draw_rounding * this->draw_rounding; - int32_t row = random_uint32() % this->height_; + int32_t row = random_uint32() % height; row = row / this->draw_rounding * this->draw_rounding; // size will be between 8 and 32, and a multiple of draw_rounding int32_t size = (random_uint32() % 25 + 8) / this->draw_rounding * this->draw_rounding; - lv_area_t area{col, row, col + size - 1, row + size - 1}; + lv_area_t area{.x1 = col, .y1 = row, .x2 = col + size - 1, .y2 = row + size - 1}; // clip to display bounds just in case - if (area.x2 >= this->width_) - area.x2 = this->width_ - 1; - if (area.y2 >= this->height_) - area.y2 = this->height_ - 1; + if (area.x2 >= width) + area.x2 = width - 1; + if (area.y2 >= height) + area.y2 = height - 1; // line_len can't exceed 1024, and minimum buffer size is 2048, so this won't overflow the buffer size_t line_len = lv_area_get_width(&area) * lv_area_get_height(&area) / 2; for (size_t i = 0; i != line_len; i++) { - ((uint32_t *) (this->draw_buf_))[i] = random_uint32(); + reinterpret_cast(this->draw_buf_)[i] = random_uint32(); } - this->draw_buffer_(&area, (lv_color_data *) this->draw_buf_); + this->draw_buffer_(&area, reinterpret_cast(this->draw_buf_)); } } From df72aa26c0a11a00b581ef65f5c528c840a20f70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 06:58:54 -0500 Subject: [PATCH 25/94] [core] Feed WDT unconditionally in main loop to fix empty-config panic (#15830) --- esphome/core/application.h | 22 +++++++++++++--------- esphome/core/scheduler.cpp | 5 ++++- esphome/core/scheduler.h | 3 ++- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 82f399b2d6..9252a47446 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -402,7 +402,7 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline void ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); + inline uint32_t ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; } /// Process dump_config output one component per loop iteration. @@ -546,18 +546,15 @@ inline void Application::drain_wake_notifications_() { } #endif // USE_HOST -inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { +inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif - // Process scheduled tasks. Scheduler::call now feeds the watchdog itself - // after each scheduled item that actually runs, so we no longer need an - // unconditional feed here — when Scheduler::call has no work to do, the - // only elapsed time is a sleep wake + a few instructions, and when it does - // have work, it fed the wdt as it went. - this->scheduler.call(loop_start_time); + // Scheduler::call feeds the WDT per item and returns the timestamp of the + // last fired item, or the input unchanged when nothing ran. + uint32_t last_op_end_time = this->scheduler.call(loop_start_time); // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions @@ -575,6 +572,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ // Mark that we're in the loop for safe reentrant modifications this->in_loop_ = true; + return last_op_end_time; } inline void ESPHOME_ALWAYS_INLINE Application::loop() { @@ -592,7 +590,13 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Get the initial loop time at the start uint32_t last_op_end_time = millis(); - this->before_loop_tasks_(last_op_end_time); + // Returned timestamp keeps us monotonic with last_wdt_feed_ (advanced by + // the scheduler's per-item feeds) without an extra millis() call. + last_op_end_time = this->before_loop_tasks_(last_op_end_time); + // Guarantee a WDT touch every tick — covers configs with no looping + // components and no scheduler work, where the per-item / per-component + // feeds never fire. Rate-limited inline fast path, ~free when unneeded. + this->feed_wdt_with_time(last_op_end_time); #ifdef USE_RUNTIME_STATS uint32_t loop_before_end_us = micros(); uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 7e6ad19ac7..b0eaa670ac 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -533,7 +533,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -void HOT Scheduler::call(uint32_t now) { +uint32_t HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -703,6 +703,9 @@ void HOT Scheduler::call(uint32_t now) { this->debug_verify_no_leak_(); } #endif + // execute_item_() advances `now` as items fire; return it so the caller + // stays monotonic with last_wdt_feed_. + return now; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7634b3bd08..b0ce365a6f 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -129,7 +129,8 @@ class Scheduler { // Execute all scheduled items that are ready // @param now Fresh timestamp from millis() - must not be stale/cached - void call(uint32_t now); + // @return Timestamp of the last item that ran, or `now` unchanged if none ran. + uint32_t call(uint32_t now); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). From ec9d59f3dc61535c069b8bd769b205d2b9b308d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:32:36 +0000 Subject: [PATCH 26/94] Bump aioesphomeapi from 44.16.0 to 44.16.1 (#15836) 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 36c81e25bf..1623876cb5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.16.0 +aioesphomeapi==44.16.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From ccb53e34ca677659e78abdfe0d72ac95be179372 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:04:51 -0500 Subject: [PATCH 27/94] [core] Default PollingComponent() to 1ms when codegen is bypassed (#15831) --- esphome/core/component.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 6fbb0d5c06..717ca36257 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -601,7 +601,7 @@ class Component { */ class PollingComponent : public Component { public: - PollingComponent() : PollingComponent(0) {} + PollingComponent() : PollingComponent(1) {} /** Initialize this polling component with the given update interval in ms. * From b293be23b050a1da4efc5a2652b70c6168477b5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:11:38 -0500 Subject: [PATCH 28/94] [ci] Honor CONFLICTS_WITH when grouping component tests (#15834) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- script/helpers.py | 105 ++++++++++++++++++++ script/split_components_for_ci.py | 7 +- script/test_build_components.py | 9 +- tests/script/test_helpers.py | 156 ++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 2 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index 9f5ea7894c..7a6d7ecef6 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -1,6 +1,8 @@ from __future__ import annotations +import ast from collections.abc import Callable +from dataclasses import dataclass, field from functools import cache import hashlib import json @@ -139,6 +141,109 @@ def get_component_test_files( return list(tests_dir.glob("test.*.yaml")) +@dataclass(frozen=True) +class ComponentMetadata: + """Statically-parsed AUTO_LOAD and CONFLICTS_WITH declarations.""" + + auto_load: frozenset[str] = field(default_factory=frozenset) + conflicts_with: frozenset[str] = field(default_factory=frozenset) + + +@cache +def parse_component_metadata(name: str) -> ComponentMetadata: + """Return the AUTO_LOAD / CONFLICTS_WITH declarations for a component. + + Parses the component's ``esphome/components//__init__.py`` statically. + Callable forms (``def AUTO_LOAD():``) require runtime imports and are + reported as empty -- safe for conflict detection since they cannot be + evaluated without executing the module. + """ + init_file = Path(root_path) / ESPHOME_COMPONENTS_PATH / name / "__init__.py" + if not init_file.exists(): + return ComponentMetadata() + try: + tree = ast.parse(init_file.read_text(encoding="utf-8")) + except (OSError, SyntaxError, UnicodeError): + return ComponentMetadata() + fields: dict[str, frozenset[str]] = { + "AUTO_LOAD": frozenset(), + "CONFLICTS_WITH": frozenset(), + } + for node in tree.body: + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.List): + continue + for target in node.targets: + if not isinstance(target, ast.Name) or target.id not in fields: + continue + fields[target.id] = frozenset( + e.value + for e in node.value.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str) + ) + return ComponentMetadata( + auto_load=fields["AUTO_LOAD"], + conflicts_with=fields["CONFLICTS_WITH"], + ) + + +@dataclass +class _ConflictWalk: + loaded: set[str] + rejects: set[str] + + +def split_conflicting_groups( + grouped_components: dict[tuple[str, str], list[str]], +) -> dict[tuple[str, str], list[str]]: + """Split groups so components declaring mutual CONFLICTS_WITH end up in separate builds. + + A conflict propagates through AUTO_LOAD: if X declares CONFLICTS_WITH=[Y] + and Z auto-loads Y, then X and Z conflict (e.g. bme680_bsec vs. + bme68x_bsec2_i2c which auto-loads bme68x_bsec2). Only components that + appear in the batch (and their AUTO_LOAD closures) are parsed. The + conflict relation is treated as symmetric even when only one side + declares it (e.g. ethernet rejects wifi but wifi does not declare the + reverse). + """ + batch = {c for comps in grouped_components.values() for c in comps} + + walks: dict[str, _ConflictWalk] = {} + for comp in batch: + walk = _ConflictWalk(loaded={comp}, rejects=set()) + stack = [comp] + while stack: + metadata = parse_component_metadata(stack.pop()) + walk.rejects |= metadata.conflicts_with + new = metadata.auto_load - walk.loaded + walk.loaded |= new + stack.extend(new) + walks[comp] = walk + + def conflicts(a: str, b: str) -> bool: + wa, wb = walks[a], walks[b] + return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint( + wa.loaded + ) + + result: dict[tuple[str, str], list[str]] = {} + for (platform, signature), components in grouped_components.items(): + buckets: list[list[str]] = [] + for comp in components: + for bucket in buckets: + if not any(conflicts(comp, other) for other in bucket): + bucket.append(comp) + break + else: + buckets.append([comp]) + if len(buckets) == 1: + result[(platform, signature)] = buckets[0] + continue + for index, bucket in enumerate(buckets): + key = signature if index == 0 else f"{signature}__conflict{index}" + result[(platform, key)] = bucket + return result + + def styled(color: str | tuple[str, ...], msg: str, reset: bool = True) -> str: prefix = "".join(color) if isinstance(color, tuple) else color suffix = colorama.Style.RESET_ALL if reset else "" diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 65d09efb9b..d95cdcbe81 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -28,7 +28,7 @@ from script.analyze_component_buses import ( create_grouping_signature, merge_compatible_bus_groups, ) -from script.helpers import get_component_test_files +from script.helpers import get_component_test_files, split_conflicting_groups # Weighting for batch creation # Isolated components can't be grouped/merged, so they count as 10x @@ -145,6 +145,11 @@ def create_intelligent_batches( # improving the efficiency of test_build_components.py grouping signature_groups = merge_compatible_bus_groups(signature_groups) + # Split groups containing mutually-incompatible components (CONFLICTS_WITH). + # Without this, batch weighting assumes the group is one build when it will + # actually be split into two at build time -- throwing off CI distribution. + signature_groups = split_conflicting_groups(signature_groups) + # Create batches by keeping signature groups together # Components with the same signature stay in the same batches batches = [] diff --git a/script/test_build_components.py b/script/test_build_components.py index e369b0364e..82d05f78b2 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -39,7 +39,7 @@ from script.analyze_component_buses import ( merge_compatible_bus_groups, uses_local_file_references, ) -from script.helpers import get_component_test_files +from script.helpers import get_component_test_files, split_conflicting_groups from script.merge_component_configs import merge_component_configs @@ -675,6 +675,13 @@ def run_grouped_component_tests( # as long as they don't have conflicting configurations for the same bus type grouped_components = merge_compatible_bus_groups(grouped_components) + # Split groups that contain components declaring CONFLICTS_WITH each other. + # The bus-level merge above only considers shared bus configs; components + # with the same bus signature (e.g. both I2C) can still be mutually + # incompatible (e.g. bme680_bsec vs. bme68x_bsec2_i2c which auto-loads + # bme68x_bsec2). Those must end up in separate builds. + grouped_components = split_conflicting_groups(grouped_components) + # Print detailed grouping plan print("\nGrouping Plan:") print("-" * 80) diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 948aabaa66..db0d2908f4 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -1468,3 +1468,159 @@ def test_cache_miss_corrupted_json( result = helpers.create_components_graph() # Should handle corruption gracefully and rebuild assert result == {} + + +# --------------------------------------------------------------------------- +# parse_component_metadata / split_conflicting_groups +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_components(tmp_path: Path) -> Path: + """Create a fake esphome/components/ tree and return the repo root. + + Component layout (tested against split_conflicting_groups): + + alpha -- CONFLICTS_WITH=["beta"] + beta -- CONFLICTS_WITH=["alpha"] + beta_variant -- AUTO_LOAD=["beta"] + gamma -- (no metadata) + one_sided -- CONFLICTS_WITH=["plain"] (plain does not reject back) + plain -- no CONFLICTS_WITH + callable_auto -- AUTO_LOAD is a function (not a list literal) -> ignored + broken -- __init__.py has a SyntaxError + """ + components = tmp_path / "esphome" / "components" + components.mkdir(parents=True) + + def write(name: str, body: str) -> None: + (components / name).mkdir() + (components / name / "__init__.py").write_text(body) + + write("alpha", 'CONFLICTS_WITH = ["beta"]\n') + write("beta", 'CONFLICTS_WITH = ["alpha"]\n') + write("beta_variant", 'AUTO_LOAD = ["beta"]\n') + write("gamma", "") + write("one_sided", 'CONFLICTS_WITH = ["plain"]\n') + write("plain", "") + write("callable_auto", "def AUTO_LOAD():\n return ['beta']\n") + write("broken", "this is not valid python !!!") + helpers.parse_component_metadata.cache_clear() + return tmp_path + + +def test_parse_component_metadata_list_literals( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + meta = helpers.parse_component_metadata("alpha") + assert meta.conflicts_with == frozenset({"beta"}) + assert meta.auto_load == frozenset() + + variant = helpers.parse_component_metadata("beta_variant") + assert variant.auto_load == frozenset({"beta"}) + assert variant.conflicts_with == frozenset() + + +def test_parse_component_metadata_missing_empty_and_callable( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + # Unknown component -> empty metadata, not an error. + unknown = helpers.parse_component_metadata("does_not_exist") + assert unknown == helpers.ComponentMetadata() + + # Empty __init__.py -> empty metadata. + assert helpers.parse_component_metadata("gamma") == helpers.ComponentMetadata() + + # Callable AUTO_LOAD cannot be statically evaluated -> empty. + callable_meta = helpers.parse_component_metadata("callable_auto") + assert callable_meta.auto_load == frozenset() + + # SyntaxError in __init__.py must not raise. + assert helpers.parse_component_metadata("broken") == helpers.ComponentMetadata() + + +def test_split_conflicting_groups_splits_direct_conflict( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["alpha", "beta", "gamma"]} + ) + # alpha and beta must end up in different buckets; gamma has no conflicts. + buckets = list(result.values()) + assert any("alpha" in b for b in buckets) + assert any("beta" in b for b in buckets) + for bucket in buckets: + assert not ({"alpha", "beta"} <= set(bucket)) + # Gamma sticks with whichever bucket it landed in first (alpha's). + all_members = {c for b in buckets for c in b} + assert all_members == {"alpha", "beta", "gamma"} + + +def test_split_conflicting_groups_propagates_through_auto_load( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """A component that AUTO_LOADs a conflicting one must also be split out.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["alpha", "beta_variant"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"alpha", "beta_variant"} <= set(bucket)) + assert sum(len(b) for b in buckets) == 2 + + +def test_split_conflicting_groups_symmetric_one_sided_declaration( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """If only one side declares CONFLICTS_WITH, the pair must still be split.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["one_sided", "plain"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"one_sided", "plain"} <= set(bucket)) + + +def test_split_conflicting_groups_preserves_non_conflicting_group( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + original = {("esp32", "i2c"): ["alpha", "gamma", "plain"]} + result = helpers.split_conflicting_groups(original) + # All three are mutually compatible -- the group must not be split. + assert result == original + + +def test_split_conflicting_groups_preserves_original_signature_for_first_bucket( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """When a group is split, the first bucket keeps the original signature key.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups({("esp32", "i2c"): ["alpha", "beta"]}) + keys = set(result.keys()) + assert ("esp32", "i2c") in keys + # One additional bucket with a disambiguated signature. + extra = keys - {("esp32", "i2c")} + assert len(extra) == 1 + platform, signature = next(iter(extra)) + assert platform == "esp32" + assert signature.startswith("i2c__conflict") From 38d894dfe7f012fca0545a8fe1356b4b1f4b0325 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:17:22 -0500 Subject: [PATCH 29/94] [ld2412] Fix flaky integration test race condition (#15833) --- tests/integration/test_uart_mock_ld2412.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_uart_mock_ld2412.py b/tests/integration/test_uart_mock_ld2412.py index 12aa3f8397..ea2ec38b2a 100644 --- a/tests/integration/test_uart_mock_ld2412.py +++ b/tests/integration/test_uart_mock_ld2412.py @@ -325,9 +325,13 @@ async def test_uart_mock_ld2412_engineering_truncated( ], ) - # Signal when we see Phase 3 recovery values (gate_0_move=50) + # Signal when we see ALL Phase 3 recovery values to avoid race where some + # arrive after the waiter fires but before we index into the lists recovery_received = collector.add_waiter( - lambda: pytest.approx(50.0) in collector.sensor_states["gate_0_move_energy"] + lambda: ( + pytest.approx(50.0) in collector.sensor_states["gate_0_move_energy"] + and pytest.approx(42.0) in collector.sensor_states["light"] + ) ) async with ( From 7a23a339e9a6f8234f2e8ad3339f047e3b18e27f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Apr 2026 16:00:31 -0500 Subject: [PATCH 30/94] [substitutions] Fix `substitutions: !include file.yaml` regression (#15850) --- esphome/components/packages/__init__.py | 8 ++- esphome/components/substitutions/__init__.py | 35 +++++++++++-- .../15-substitutions_as_include.approved.yaml | 5 ++ .../15-substitutions_as_include.input.yaml | 5 ++ .../substitutions/15-substitutions_inc.yaml | 1 + ...ons_as_include_with_packages.approved.yaml | 5 ++ ...utions_as_include_with_packages.input.yaml | 9 ++++ ...ubstitutions_include_cli_var.approved.yaml | 6 +++ ...7-substitutions_include_cli_var.input.yaml | 8 +++ tests/unit_tests/test_substitutions.py | 51 +++++++++++++++++++ 10 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 3f3df75351..252a24061a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -10,6 +10,7 @@ from esphome.components.substitutions import ( ContextVars, push_context, resolve_include, + resolve_substitutions_block, substitute, ) from esphome.components.substitutions.jinja import has_jinja @@ -516,7 +517,12 @@ def do_packages_pass( if CONF_PACKAGES not in config: return config - substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {})) + with cv.prepend_path(CONF_SUBSTITUTIONS): + substitutions = UserDict( + resolve_substitutions_block( + config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions + ) + ) processor = _PackageProcessor( substitutions, command_line_substitutions, skip_update ) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 94aebbbfe3..e451ad5db8 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -414,6 +414,34 @@ def _warn_unresolved_variables(errors: ErrList) -> None: ) +def resolve_substitutions_block( + substitutions: Any, + command_line_substitutions: dict[str, Any] | None, +) -> dict[str, Any]: + """Resolve a deferred ``substitutions: !include file.yaml`` and validate the shape. + + The caller is responsible for wrapping the call in + ``cv.prepend_path(CONF_SUBSTITUTIONS)`` for error reporting. + ``command_line_substitutions`` seeds the filename context so + ``substitutions: !include ${var}.yaml`` can reference CLI-provided vars. + """ + if isinstance(substitutions, IncludeFile): + # Single-shot resolution — matches ``_walk_packages`` for the + # ``packages: !include`` entry point. Chained includes (an include that + # itself loads another ``!include`` at the top level) are not supported. + substitutions, _ = resolve_include( + substitutions, + [], + ContextVars(command_line_substitutions or {}), + strict_undefined=False, + ) + if not isinstance(substitutions, dict): + raise cv.Invalid( + f"Substitutions must be a key to value mapping, got {type(substitutions)}" + ) + return substitutions + + def do_substitution_pass( config: OrderedDict, command_line_substitutions: dict[str, Any] | None = None ) -> OrderedDict: @@ -429,10 +457,9 @@ def do_substitution_pass( # Use merge_dicts_ordered to preserve OrderedDict type for move_to_end() substitutions = config.pop(CONF_SUBSTITUTIONS, {}) with cv.prepend_path(CONF_SUBSTITUTIONS): - if not isinstance(substitutions, dict): - raise cv.Invalid( - f"Substitutions must be a key to value mapping, got {type(substitutions)}" - ) + substitutions = resolve_substitutions_block( + substitutions, command_line_substitutions + ) substitutions = merge_dicts_ordered( substitutions, command_line_substitutions or {} ) diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml new file mode 100644 index 0000000000..14aa707def --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml new file mode 100644 index 0000000000..5909e7bf4f --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml @@ -0,0 +1,5 @@ +substitutions: !include 15-substitutions_inc.yaml + +wifi: + ssid: main_ssid + password: $wifi_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml new file mode 100644 index 0000000000..44d9a4b9ef --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml @@ -0,0 +1 @@ +wifi_password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml new file mode 100644 index 0000000000..14aa707def --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml new file mode 100644 index 0000000000..a2e72f33a2 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml @@ -0,0 +1,9 @@ +substitutions: !include 15-substitutions_inc.yaml + +packages: + wifi_pkg: + wifi: + password: $wifi_password + +wifi: + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml new file mode 100644 index 0000000000..f1fd5fb078 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml @@ -0,0 +1,6 @@ +substitutions: + subs_file: 15-substitutions_inc + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml new file mode 100644 index 0000000000..3248504b46 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml @@ -0,0 +1,8 @@ +command_line_substitutions: + subs_file: 15-substitutions_inc + +substitutions: !include ${subs_file}.yaml + +wifi: + ssid: main_ssid + password: $wifi_password diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 01c669e542..71bbd9db86 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -675,6 +675,57 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_do_substitution_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` where the file holds a list raises cv.Invalid. + + Locks in the shape check that runs after the deferred IncludeFile has been + resolved. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict({CONF_SUBSTITUTIONS: include}) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + substitutions.do_substitution_pass(config) + + +def test_do_packages_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` alongside `packages:` raises cv.Invalid. + + Without the shape check, ``UserDict(...)`` would surface a low-level + ``TypeError``; the explicit ``cv.Invalid`` points at the substitutions path. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict( + { + CONF_SUBSTITUTIONS: include, + "packages": {"noop": {"wifi": {"ssid": "main"}}}, + } + ) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + do_packages_pass(config) + + def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> None: """An undefined substitution in a package include filename raises cv.Invalid. From aad1318b4a8eceec8c8e16dd3718d26f55790697 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 19 Apr 2026 23:09:14 +0200 Subject: [PATCH 31/94] [packages] Improve error messages with include stack and fix missing path propagation (#15844) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/packages/__init__.py | 9 ++ esphome/components/substitutions/__init__.py | 50 +++++++++++ .../component_tests/packages/test_packages.py | 88 ++++++++++++++++++- tests/unit_tests/test_substitutions.py | 34 +++++++ 4 files changed, 179 insertions(+), 2 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 252a24061a..97a5309480 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -8,7 +8,9 @@ from typing import Any from esphome import git, yaml_util from esphome.components.substitutions import ( ContextVars, + ErrList, push_context, + raise_first_undefined, resolve_include, resolve_substitutions_block, substitute, @@ -360,12 +362,19 @@ def _substitute_package_definition( if isinstance(package_config, str) or ( isinstance(package_config, dict) and is_remote_package(package_config) ): + # Collect undefined-variable errors (rather than raising strict) so the + # path walked through a remote-package dict is preserved and the user + # sees which field (url / path / ref / ...) referenced the undefined + # variable. + errors: ErrList = [] package_config = substitute( item=package_config, path=[], parent_context=context_vars or ContextVars(), strict_undefined=False, + errors=errors, ) + raise_first_undefined(errors, package_config, "package definition") return package_config diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index e451ad5db8..8bbccffca1 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -30,6 +30,56 @@ ErrList = list[tuple[UndefinedError, SubstitutionPath, Any]] jinja = Jinja() +def raise_first_undefined( + errors: ErrList, + source: Any, + context_label: str, +) -> None: + """If *errors* is non-empty, raise ``cv.Invalid`` for the first undefined variable. + + The raised error names the missing variable, the path walked into *source* + (for nested dicts, e.g. ``url`` or ``ref``), and the YAML source location + when *source* carries one. Only the first error is surfaced; the user will + re-run after fixing it and any remaining undefined variables will be + reported then. + + ``context_label`` is the noun describing where the undefined variable + appeared (e.g. ``"package definition"``). + """ + if not errors: + return + err, err_path, err_value = errors[0] + if len(errors) > 1: + # Log any further undefined variables so debug-level output covers + # the full set, even though only the first is surfaced to the user. + extras = ", ".join( + f"{e.message} at '{'->'.join(str(p) for p in p_path)}'" + for e, p_path, _ in errors[1:] + ) + _LOGGER.debug("Additional undefined variables in %s: %s", context_label, extras) + # Prefer the location of the offending scalar (e.g. the `url:` value) over + # the enclosing package-definition dict so the message points at the exact + # line/column that carries the undefined variable. + location_node = ( + err_value + if isinstance(err_value, ESPHomeDataBase) and err_value.esp_range is not None + else source + ) + location = "" + if ( + isinstance(location_node, ESPHomeDataBase) + and location_node.esp_range is not None + ): + mark = location_node.esp_range.start_mark + # DocumentLocation.line/column are 0-based (from the YAML Mark). Render + # as 1-based to match config.line_info() and editor line numbering. + location = f" (in {mark.document} {mark.line + 1}:{mark.column + 1})" + field = f" at '{'->'.join(str(p) for p in err_path)}'" if err_path else "" + raise cv.Invalid( + f"Undefined variable in {context_label}{field}: {err.message}{location}" + ) + + def validate_substitution_key(value: Any) -> str: """Validate and normalize a substitution key, stripping a leading ``$`` if present.""" value = cv.string(value) diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index cd91c4d8cb..0bd339efa9 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -2,18 +2,20 @@ import logging from pathlib import Path +import re from unittest.mock import MagicMock, patch import pytest from esphome.components.packages import ( CONFIG_SCHEMA, + _substitute_package_definition, _walk_packages, do_packages_pass, is_package_definition, merge_packages, ) -from esphome.components.substitutions import do_substitution_pass +from esphome.components.substitutions import ContextVars, do_substitution_pass import esphome.config as config_module from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove @@ -44,7 +46,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.util import OrderedDict -from esphome.yaml_util import IncludeFile, add_context +from esphome.yaml_util import IncludeFile, add_context, load_yaml # Test strings TEST_DEVICE_NAME = "test_device_name" @@ -1399,3 +1401,85 @@ def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None: "CORE.raw_config should contain esphome section after package merge" ) assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME + + +# --------------------------------------------------------------------------- +# _substitute_package_definition +# --------------------------------------------------------------------------- + + +def test_substitute_package_definition_local_dict_returned_unchanged() -> None: + """A plain local config dict is not substituted and is returned as-is.""" + pkg = {CONF_WIFI: {CONF_SSID: "test"}} + result = _substitute_package_definition(pkg, ContextVars()) + assert result is pkg + + +def test_substitute_package_definition_string_resolved_with_context() -> None: + """A string package definition has its variables substituted.""" + ctx = ContextVars({"variant": "esp32"}) + result = _substitute_package_definition("device-${variant}.yaml", ctx) + assert result == "device-esp32.yaml" + + +def test_substitute_package_definition_undefined_in_string() -> None: + """An undefined variable in a package URL string raises cv.Invalid.""" + with pytest.raises(cv.Invalid, match="Undefined variable in package definition"): + _substitute_package_definition( + "github://org/repo/${undefined_var}/pkg.yaml", ContextVars() + ) + + +def test_substitute_package_definition_undefined_in_remote_dict_field() -> None: + """An undefined variable inside a remote-dict field names the offending field.""" + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition( + {CONF_URL: "github://${typo}/repo"}, ContextVars() + ) + err = str(exc_info.value) + assert "'typo' is undefined" in err + assert CONF_URL in err + + +def test_substitute_package_definition_undefined_in_remote_dict_non_first_field() -> ( + None +): + """The field path joins correctly for non-first dict fields (e.g. ``ref``).""" + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition( + { + CONF_URL: "github://org/repo", + CONF_REF: "branch-${branch_typo}", + }, + ContextVars(), + ) + err = str(exc_info.value) + assert "'branch_typo' is undefined" in err + assert CONF_REF in err + + +def test_substitute_package_definition_includes_source_location(tmp_path: Path) -> None: + """A package loaded from YAML surfaces file/line/col in the cv.Invalid message. + + Line/column are rendered 1-based (matching config.line_info() and editor + line numbering) and point at the offending scalar, not the enclosing dict. + """ + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "packages:\n broken: github://org/repo/${undefined_var}/pkg.yaml\n" + ) + config = load_yaml(yaml_file) + package_config = config[CONF_PACKAGES]["broken"] + + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition(package_config, ContextVars()) + + err = str(exc_info.value) + assert "main.yaml" in err + # The offending value lives on line 2 (1-based). Column depends on the YAML + # loader, so we only pin line and check that a 1-based column is present. + match = re.search(r"main\.yaml (\d+):(\d+)", err) + assert match, err + line, col = int(match.group(1)), int(match.group(2)) + assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})" + assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})" diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 71bbd9db86..3599e703d9 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -14,6 +14,7 @@ from esphome.components.packages import ( do_packages_pass, merge_packages, ) +from esphome.components.substitutions.jinja import UndefinedError from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, merge_config import esphome.config_validation as cv @@ -675,6 +676,39 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_raise_first_undefined_logs_extras_at_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """Only the first undefined error is raised; extras are logged at debug.""" + errors: substitutions.ErrList = [ + (UndefinedError("'a' is undefined"), ["url"], None), + (UndefinedError("'b' is undefined"), ["ref"], None), + (UndefinedError("'c' is undefined"), ["path"], None), + ] + + with ( + caplog.at_level(logging.DEBUG, logger="esphome.components.substitutions"), + pytest.raises(cv.Invalid) as exc_info, + ): + substitutions.raise_first_undefined(errors, None, "package definition") + + # First error is surfaced as the cv.Invalid message. + raised = str(exc_info.value) + assert "'a' is undefined" in raised + assert "'b' is undefined" not in raised + assert "'c' is undefined" not in raised + + # Remaining errors are captured via debug logging for troubleshooting. + assert "Additional undefined variables in package definition" in caplog.text + assert "'b' is undefined at 'ref'" in caplog.text + assert "'c' is undefined at 'path'" in caplog.text + + +def test_raise_first_undefined_noop_on_empty() -> None: + """An empty errors list is a no-op — no exception, no log.""" + substitutions.raise_first_undefined([], None, "package definition") + + def test_do_substitution_pass_included_substitutions_must_be_mapping( tmp_path: Path, ) -> None: From 1847666e75950c50eb4be05564e0ca262ff852af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Apr 2026 17:05:27 -0500 Subject: [PATCH 32/94] [core] Default PollingComponent() to not run when codegen is bypassed (#15832) --- esphome/core/component.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 717ca36257..67db5423af 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -601,7 +601,7 @@ class Component { */ class PollingComponent : public Component { public: - PollingComponent() : PollingComponent(1) {} + PollingComponent() : PollingComponent(SCHEDULER_DONT_RUN) {} /** Initialize this polling component with the given update interval in ms. * From cb90ac45c35975a882795013a69a6ac613185a58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 07:50:11 -1000 Subject: [PATCH 33/94] [core] Fix app_state_ status bits clobbered for non-looping components (#15658) --- esphome/core/application.cpp | 34 ++- esphome/core/application.h | 24 ++- esphome/core/component.cpp | 13 ++ esphome/core/component.h | 5 + tests/integration/fixtures/status_flags.yaml | 141 +++++++++++++ tests/integration/test_status_flags.py | 209 +++++++++++++++++++ 6 files changed, 416 insertions(+), 10 deletions(-) create mode 100644 tests/integration/fixtures/status_flags.yaml create mode 100644 tests/integration/test_status_flags.py diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd75859880..0c17c70161 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -85,8 +85,12 @@ void Application::setup() { if (component->can_proceed()) continue; + // Force the status LED to blink WARNING while we wait for a slow + // component to come up. Cleared after setup() finishes if no real + // component has warning set. + this->app_state_ |= STATUS_LED_WARNING; + do { - uint8_t new_app_state = STATUS_LED_WARNING; uint32_t now = millis(); // Process pending loop enables to handle GPIO interrupts during setup @@ -96,17 +100,26 @@ void Application::setup() { // Update loop_component_start_time_ right before calling each component this->loop_component_start_time_ = millis(); this->components_[j]->call(); - new_app_state |= this->components_[j]->get_component_state(); - this->app_state_ |= new_app_state; this->feed_wdt(); } this->after_loop_tasks_(); - this->app_state_ = new_app_state; yield(); } while (!component->can_proceed() && !component->is_failed()); } + // Setup is complete. Reconcile STATUS_LED_WARNING: the slow-setup path + // above may have forced it on, and any status_clear_warning() calls + // from components during setup were intentional no-ops (gated by + // APP_STATE_SETUP_COMPLETE). Walk components once here to pick up the + // real state. STATUS_LED_ERROR is never artificially forced, so its + // clear path always works and needs no reconciliation. Finally, set + // APP_STATE_SETUP_COMPLETE so subsequent warning clears go through + // the normal walk-and-clear path. + if (!this->any_component_has_status_flag_(STATUS_LED_WARNING)) + this->app_state_ &= ~STATUS_LED_WARNING; + this->app_state_ |= APP_STATE_SETUP_COMPLETE; + ESP_LOGI(TAG, "setup() finished successfully!"); #ifdef USE_SETUP_PRIORITY_OVERRIDE @@ -211,6 +224,19 @@ void HOT Application::feed_wdt(uint32_t time) { #endif } } +bool Application::any_component_has_status_flag_(uint8_t flag) const { + // Walk all components (not just looping ones) so non-looping components' + // status bits are respected. Only called from the slow-path clear helpers + // (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an + // actual set→clear transition, so walking O(N) here is paid once per + // transition — not once per loop iteration. + for (auto *component : this->components_) { + if ((component->get_component_state() & flag) != 0) + return true; + } + return false; +} + void Application::reboot() { ESP_LOGI(TAG, "Forcing a reboot"); for (auto &component : std::ranges::reverse_view(this->components_)) { diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b490..0150bb6646 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -401,7 +401,18 @@ class Application { */ void teardown_components(uint32_t timeout_ms); - uint8_t get_app_state() const { return this->app_state_; } + /// Return the public app state status bits (STATUS_LED_* only). + /// Internal bookkeeping bits like APP_STATE_SETUP_COMPLETE are masked + /// out so external readers (status_led components, etc.) never see them. + uint8_t get_app_state() const { return this->app_state_ & ~APP_STATE_SETUP_COMPLETE; } + + /// True once Application::setup() has finished walking all components + /// and finalized the initial status flags. Before this point, the + /// slow-setup busy-wait may be forcing STATUS_LED_WARNING on, and + /// status_clear_* intentionally skips its walk-and-clear step so the + /// forced bit doesn't get wiped. Stored as a free bit on app_state_ + /// (bit 6) to avoid costing additional RAM. + bool is_setup_complete() const { return (this->app_state_ & APP_STATE_SETUP_COMPLETE) != 0; } // Helper macro for entity getter method declarations #ifdef USE_DEVICES @@ -577,6 +588,12 @@ class Application { bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } #endif + /// Walk all registered components looking for any whose component_state_ + /// has the given flag set. Used by Component::status_clear_*_slow_path_() + /// (which is a friend) to decide whether to clear the corresponding bit on + /// this->app_state_ (the app-wide "any component has this status" indicator). + bool any_component_has_status_flag_(uint8_t flag) const; + /// Register a component, detecting loop() override at compile time. /// Uses HasLoopOverride which handles ambiguous &T::loop from multiple inheritance. template void register_component_(T *comp) { @@ -838,8 +855,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ } inline void ESPHOME_ALWAYS_INLINE Application::loop() { - uint8_t new_app_state = 0; - // Get the initial loop time at the start uint32_t last_op_end_time = millis(); @@ -859,13 +874,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); } - new_app_state |= component->get_component_state(); - this->app_state_ |= new_app_state; this->feed_wdt(last_op_end_time); } this->after_loop_tasks_(); - this->app_state_ = new_app_state; #ifdef USE_RUNTIME_STATS // Process any pending runtime stats printing after all components have run diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index deda42b0a7..8949b4b76d 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -411,10 +411,23 @@ void Component::status_set_error(const LogString *message) { } void Component::status_clear_warning_slow_path_() { this->component_state_ &= ~STATUS_LED_WARNING; + // Clear the app-wide STATUS_LED_WARNING bit only if setup has finished + // AND no other component still has it set. During setup the forced + // STATUS_LED_WARNING (from the slow-setup busy-wait) must not be wiped + // by a transient component clear — Application::setup() reconciles + // the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE. + // The set path is unchanged (set_status_flag_ still writes directly). + if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING)) + App.app_state_ &= ~STATUS_LED_WARNING; ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_clear_error_slow_path_() { this->component_state_ &= ~STATUS_LED_ERROR; + // STATUS_LED_ERROR is never artificially forced — it only ever lands + // in app_state_ via a real set_status_flag_ call. So the walk-and-clear + // path is always safe, including during setup. + if (!App.any_component_has_status_flag_(STATUS_LED_ERROR)) + App.app_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 char *name, uint32_t length) { diff --git a/esphome/core/component.h b/esphome/core/component.h index e2b7aa85d3..3307c5ae76 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -89,6 +89,11 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08; inline constexpr uint8_t STATUS_LED_ERROR = 0x10; // Component loop override flag uses bit 5 (set at registration time) inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20; +// Bit 6 on Application::app_state_ (ONLY) — set at the end of +// Application::setup(). Component::status_clear_*_slow_path_() uses this to +// decide whether to propagate clears to App.app_state_. Never set on a +// Component's component_state_. +inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40; // Remove before 2026.8.0 enum class RetryResult { DONE, RETRY }; diff --git a/tests/integration/fixtures/status_flags.yaml b/tests/integration/fixtures/status_flags.yaml new file mode 100644 index 0000000000..cb118dcc84 --- /dev/null +++ b/tests/integration/fixtures/status_flags.yaml @@ -0,0 +1,141 @@ +esphome: + name: status-flags-test + +host: +api: + actions: + # Warning flag services for sensor_a + - action: set_warning_a + then: + - lambda: "id(sensor_a)->status_set_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_warning_a + then: + - lambda: "id(sensor_a)->status_clear_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Warning flag services for sensor_b + - action: set_warning_b + then: + - lambda: "id(sensor_b)->status_set_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_warning_b + then: + - lambda: "id(sensor_b)->status_clear_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Error flag services for sensor_a + - action: set_error_a + then: + - lambda: "id(sensor_a)->status_set_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_error_a + then: + - lambda: "id(sensor_a)->status_clear_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Error flag services for sensor_b + - action: set_error_b + then: + - lambda: "id(sensor_b)->status_set_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_error_b + then: + - lambda: "id(sensor_b)->status_clear_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Snapshot of the status_led_light's output state for observation. + - action: snapshot_led + then: + - component.update: status_led_writes + - component.update: status_led_last_state + +logger: + +# Tracks each write to the fake status_led output. +globals: + - id: status_led_write_count + type: uint32_t + restore_value: no + initial_value: "0" + - id: status_led_last_write + type: bool + restore_value: no + initial_value: "false" + +# Fake binary output — status_led_light writes to this instead of a pin. +# Every write bumps a counter and records the last value, both of which +# are exposed below so the test can verify status_led_light's loop is +# actually reading App.get_app_state() and responding. +output: + - platform: template + id: fake_status_led + type: binary + write_action: + - globals.set: + id: status_led_write_count + value: !lambda "return id(status_led_write_count) + 1;" + - globals.set: + id: status_led_last_write + value: !lambda "return state;" + +# Actual status_led_light component under test. +light: + - platform: status_led + name: Status LED + id: status_led_light_id + output: fake_status_led + +sensor: + # Two components that the test will toggle warning/error flags on. + - platform: template + name: Sensor A + id: sensor_a + update_interval: 24h + lambda: return 1.0; + - platform: template + name: Sensor B + id: sensor_b + update_interval: 24h + lambda: return 2.0; + + # Expose App.app_state_'s STATUS_LED_WARNING / STATUS_LED_ERROR bits + # as 0.0 / 1.0. force_update ensures every manual component.update + # publishes even if the value is unchanged. + - platform: template + name: App Warning Bit + id: app_warning_bit + update_interval: 24h + force_update: true + lambda: |- + return (App.get_app_state() & STATUS_LED_WARNING) != 0 ? 1.0 : 0.0; + - platform: template + name: App Error Bit + id: app_error_bit + update_interval: 24h + force_update: true + lambda: |- + return (App.get_app_state() & STATUS_LED_ERROR) != 0 ? 1.0 : 0.0; + + # Observables for the fake status_led output. + - platform: template + name: Status LED Writes + id: status_led_writes + update_interval: 24h + force_update: true + lambda: return id(status_led_write_count); + - platform: template + name: Status LED Last State + id: status_led_last_state + update_interval: 24h + force_update: true + lambda: |- + return id(status_led_last_write) ? 1.0 : 0.0; diff --git a/tests/integration/test_status_flags.py b/tests/integration/test_status_flags.py new file mode 100644 index 0000000000..ffbc7c7f63 --- /dev/null +++ b/tests/integration/test_status_flags.py @@ -0,0 +1,209 @@ +"""Integration tests for Component::status_set/clear_warning/error propagation. + +Verifies that toggling STATUS_LED_WARNING / STATUS_LED_ERROR on individual +components correctly updates the app-wide bits on Application::app_state_, +AND that the status_led_light component actually responds to those bits +by writing to its output (the full chain from component.status_set_warning +→ App.app_state_ → status_led_light.loop() reading get_app_state()). + +Exercises the multi-component OR semantics (the app bit stays set while +any component still has the flag, and only clears when the last component +clears its bit), the independence of warning and error, and the actual +status_led_light read of the bits via a fake template output that counts +writes. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .state_utils import InitialStateHelper, SensorTracker, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Time to let the host-mode main loop run so status_led_light.loop() can +# execute enough iterations to produce measurable write-count changes on +# the fake template output. 300 ms is well above the minimum needed. +STATUS_LED_SETTLE_S = 0.3 + + +@pytest.mark.asyncio +async def test_status_flags( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + async with run_compiled(yaml_config), api_client_connected() as client: + entities, services = await client.list_entities_services() + + # Map every custom API service by name for the test to execute. + svc = {s.name: s for s in services} + for name in ( + "set_warning_a", + "clear_warning_a", + "set_warning_b", + "clear_warning_b", + "set_error_a", + "clear_error_a", + "set_error_b", + "clear_error_b", + "snapshot_led", + ): + assert name in svc, f"service {name} not registered" + + # Track every sensor we care about. SensorTracker gives us + # expect(value) / expect_any() futures that resolve when a + # matching state arrives; much simpler than manual bookkeeping. + tracker = SensorTracker( + [ + "app_warning_bit", + "app_error_bit", + "status_led_writes", + "status_led_last_state", + ] + ) + tracker.key_to_sensor.update( + build_key_to_entity_mapping(entities, list(tracker.sensor_states.keys())) + ) + + # Swallow initial state broadcasts so the test only reacts to + # state changes triggered by our service calls. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(tracker.on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + async def call(name: str) -> None: + await client.execute_service(svc[name], {}) + + async def call_and_expect_bits( + service_name: str, *, warning: float, error: float + ) -> None: + """Execute a service and wait for both app bit sensors to match. + + Each bit-toggling service calls component.update on both + app_warning_bit and app_error_bit, so both sensors publish. + """ + futures = tracker.expect_all( + {"app_warning_bit": warning, "app_error_bit": error} + ) + await call(service_name) + await tracker.await_all(futures) + + async def snapshot_led_writes() -> int: + """Trigger a publish of the fake status_led output counter and return it.""" + future = tracker.expect_any("status_led_writes") + await call("snapshot_led") + await tracker.await_change(future, "status_led_writes") + return int(tracker.sensor_states["status_led_writes"][-1]) + + # ---- Baseline: everything clean ---- + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # ================================================================ + # Part 1 — STATUS_LED_WARNING propagation to App.app_state_ + # ================================================================ + + # Single component set/clear + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # Multi-component OR: both set, clear A, bit stays (B still has it), clear B, gone + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_b", warning=0.0, error=0.0) + + # Opposite clear order + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # ================================================================ + # Part 2 — STATUS_LED_ERROR propagation (same scenarios) + # ================================================================ + + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0) + + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("set_error_b", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0) + + # ================================================================ + # Part 3 — warning and error are independent + # ================================================================ + + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_error_b", warning=1.0, error=1.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0) + + # ================================================================ + # Part 4 — status_led_light actually reads App.app_state_ + # ================================================================ + # The fake status_led_light output increments status_led_write_count + # on every write. status_led_light::loop() writes its output on every + # iteration while an error/warning bit is set, so after holding a + # warning for ~300 ms we should see the counter move significantly. + # This is the end-to-end proof that the bits we set above actually + # reach status_led_light and drive its behavior. + + count_before_warning = await snapshot_led_writes() + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + # Let status_led_light's loop run long enough to toggle the pin + # several times (it reads get_app_state() every main loop iteration). + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_warning = await snapshot_led_writes() + assert count_after_warning > count_before_warning, ( + "status_led_light did not respond to STATUS_LED_WARNING being set: " + f"write count stayed at {count_before_warning} → {count_after_warning}. " + "The full chain Component::status_set_warning → App.app_state_ → " + "status_led_light::loop reading get_app_state() is broken." + ) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # Same check for ERROR + count_before_error = await snapshot_led_writes() + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_error = await snapshot_led_writes() + assert count_after_error > count_before_error, ( + "status_led_light did not respond to STATUS_LED_ERROR being set: " + f"write count stayed at {count_before_error} → {count_after_error}. " + ) + await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0) + + # ---- Set → clear → re-set round-trip ---- + # After clearing, status_led_light stops writing (steady state). + # Re-setting the flag must make it resume. This guards against a + # future idle optimization (e.g. #15642) where status_led disables + # its own loop when idle: if the re-enable path were broken, the + # second set would not produce writes. + # + # Snapshot AFTER the clear to avoid counting writes that were still + # in-flight from the error-set phase. + count_after_clear = await snapshot_led_writes() + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_idle = await snapshot_led_writes() + assert count_after_idle - count_after_clear <= 5, ( + "status_led_light kept writing after warning/error was cleared: " + f"count grew from {count_after_clear} to {count_after_idle}. " + "Expected it to stop writing once all status bits were clear." + ) + # Re-set warning — writes must resume. + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_reset = await snapshot_led_writes() + assert count_after_reset > count_after_idle + 5, ( + "status_led_light did not resume writing after re-setting " + f"STATUS_LED_WARNING: count went from {count_after_idle} to " + f"{count_after_reset}. If an idle optimization disabled the " + "loop, the re-enable path may be broken." + ) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) From 4479212008f551f38885c3f4a278befb9a400b67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 09:08:30 -1000 Subject: [PATCH 34/94] [core] Inline feed_wdt hot path with out-of-line slow path (#15656) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/application.cpp | 34 ++++++++++++++++++++-------------- esphome/core/application.h | 36 +++++++++++++++++++++++++++++------- esphome/core/scheduler.cpp | 8 +++++++- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 0c17c70161..1c73230705 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -209,21 +209,27 @@ void Application::process_dump_config_() { this->dump_config_at_++; } -void HOT Application::feed_wdt(uint32_t time) { - static uint32_t last_feed = 0; - // Use provided time if available, otherwise get current time - uint32_t now = time ? time : millis(); - // Compare in milliseconds (3ms threshold) - if (now - last_feed > 3) { - arch_feed_wdt(); - last_feed = now; -#ifdef USE_STATUS_LED - if (status_led::global_status_led != nullptr) { - status_led::global_status_led->call(); - } -#endif +void Application::feed_wdt() { + // Cold entry: callers without a millis() timestamp in hand. Fetches the + // time and takes the same rate-limit path as feed_wdt_with_time(). + uint32_t now = millis(); + if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) { + this->feed_wdt_slow_(now); } } + +void HOT Application::feed_wdt_slow_(uint32_t time) { + // Callers (both feed_wdt() and feed_wdt_with_time()) have already + // confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded. + arch_feed_wdt(); + this->last_wdt_feed_ = time; +#ifdef USE_STATUS_LED + if (status_led::global_status_led != nullptr) { + status_led::global_status_led->call(); + } +#endif +} + bool Application::any_component_has_status_flag_(uint8_t flag) const { // Walk all components (not just looping ones) so non-looping components' // status bits are respected. Only called from the slow-path clear helpers @@ -325,7 +331,7 @@ void Application::teardown_components(uint32_t timeout_ms) { while (pending_count > 0 && (now - start_time) < timeout_ms) { // Feed watchdog during teardown to prevent triggering - this->feed_wdt(now); + this->feed_wdt_with_time(now); // Process components and compact the array, keeping only those still pending size_t still_pending = 0; diff --git a/esphome/core/application.h b/esphome/core/application.h index 0150bb6646..60087d527d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -385,7 +385,24 @@ class Application { void schedule_dump_config() { this->dump_config_at_ = 0; } - void feed_wdt(uint32_t time = 0); + /// Minimum interval between real arch_feed_wdt() calls. Chosen to keep the + /// rate of HAL pokes low while still being small enough that any plausible + /// watchdog timeout (seconds) has orders of magnitude of safety margin. + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 3; + + /// Feed the task watchdog. Cold entry — callers without a millis() + /// timestamp in hand. Out of line to keep call sites tiny. + void feed_wdt(); + + /// Feed the task watchdog, hot entry. Callers that already have a + /// millis() timestamp pay only a load + sub + branch on the common + /// (no-op) path. The actual arch feed + status LED update live in + /// feed_wdt_slow_. + void ESPHOME_ALWAYS_INLINE feed_wdt_with_time(uint32_t time) { + if (static_cast(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) [[unlikely]] { + this->feed_wdt_slow_(time); + } + } void reboot(); @@ -632,7 +649,10 @@ class Application { /// Caller must ensure dump_config_at_ < components_.size(). void __attribute__((noinline)) process_dump_config_(); - void feed_wdt_arch_(); + /// Slow path for feed_wdt(): actually calls arch_feed_wdt(), updates + /// last_wdt_feed_, and re-dispatches the status LED. Out of line so the + /// inline wrapper stays tiny. + void feed_wdt_slow_(uint32_t time); /// Perform a delay while also monitoring socket file descriptors for readiness #ifdef USE_HOST @@ -686,6 +706,7 @@ class Application { // 4-byte members uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; + uint32_t last_wdt_feed_{0}; // millis() of most recent arch_feed_wdt(); rate-limits feed_wdt() hot path #ifdef USE_HOST int max_fd_{-1}; // Highest file descriptor number for select() @@ -830,12 +851,13 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ this->drain_wake_notifications_(); #endif - // Process scheduled tasks + // Process scheduled tasks. Scheduler::call now feeds the watchdog itself + // after each scheduled item that actually runs, so we no longer need an + // unconditional feed here — when Scheduler::call has no work to do, the + // only elapsed time is a sleep wake + a few instructions, and when it does + // have work, it fed the wdt as it went. this->scheduler.call(loop_start_time); - // Feed the watchdog timer - this->feed_wdt(loop_start_time); - // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions if (this->has_pending_enable_loop_requests_) { @@ -874,7 +896,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); } - this->feed_wdt(last_op_end_time); + this->feed_wdt_with_time(last_op_end_time); } this->after_loop_tasks_(); diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index dff50b03ef..3e75a68064 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -739,7 +739,13 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); - return guard.finish(); + uint32_t end = guard.finish(); + // Feed the watchdog after each scheduled item (both main heap and defer + // queue paths go through here). A run of back-to-back callbacks cannot + // starve the wdt. The inline fast path is a load + sub + branch — nearly + // free when the 3 ms rate limit hasn't elapsed. + App.feed_wdt_with_time(end); + return end; } // Common implementation for cancel operations - handles locking From 6857e1ceb4533f20a2e07d7cd08aa6e768c030ee Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:32:11 -0400 Subject: [PATCH 35/94] [st7789v] Fix swapped offset_width/offset_height in model presets (#15755) --- esphome/components/st7789v/display.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index 85414237cf..745c37f47d 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -45,8 +45,8 @@ MODELS = { presets={ CONF_HEIGHT: 240, CONF_WIDTH: 135, - CONF_OFFSET_HEIGHT: 52, - CONF_OFFSET_WIDTH: 40, + CONF_OFFSET_HEIGHT: 40, + CONF_OFFSET_WIDTH: 52, CONF_CS_PIN: "GPIO5", CONF_DC_PIN: "GPIO16", CONF_RESET_PIN: "GPIO23", @@ -68,8 +68,8 @@ MODELS = { presets={ CONF_HEIGHT: 280, CONF_WIDTH: 240, - CONF_OFFSET_HEIGHT: 0, - CONF_OFFSET_WIDTH: 20, + CONF_OFFSET_HEIGHT: 20, + CONF_OFFSET_WIDTH: 0, } ), "ADAFRUIT_S2_TFT_FEATHER_240X135": model_spec( @@ -77,8 +77,8 @@ MODELS = { presets={ CONF_HEIGHT: 240, CONF_WIDTH: 135, - CONF_OFFSET_HEIGHT: 52, - CONF_OFFSET_WIDTH: 40, + CONF_OFFSET_HEIGHT: 40, + CONF_OFFSET_WIDTH: 52, CONF_CS_PIN: "GPIO7", CONF_DC_PIN: "GPIO39", CONF_RESET_PIN: "GPIO40", @@ -89,8 +89,8 @@ MODELS = { presets={ CONF_HEIGHT: 320, CONF_WIDTH: 170, - CONF_OFFSET_HEIGHT: 35, - CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + CONF_OFFSET_WIDTH: 35, CONF_ROTATION: 270, CONF_CS_PIN: "GPIO10", CONF_DC_PIN: "GPIO13", @@ -102,8 +102,8 @@ MODELS = { presets={ CONF_HEIGHT: 320, CONF_WIDTH: 172, - CONF_OFFSET_HEIGHT: 34, - CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + CONF_OFFSET_WIDTH: 34, CONF_ROTATION: 90, CONF_CS_PIN: "GPIO21", CONF_DC_PIN: "GPIO22", From 4047d5af5fab0956c9ef782947b99c8c30accd6d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:32:33 -0400 Subject: [PATCH 36/94] [sx126x][sx127x] Fix frequency precision loss from float32 codegen (#15753) --- esphome/components/sx126x/__init__.py | 4 ++-- esphome/components/sx127x/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 08f4c0fb88..b8696158fe 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -200,11 +200,11 @@ CONFIG_SCHEMA = ( cv.hex_int, cv.Range(min=0, max=0xFFFF) ), cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( - cv.frequency, cv.float_range(min=0, max=100000) + cv.frequency, cv.int_range(min=0, max=100000) ), cv.Required(CONF_DIO1_PIN): pins.gpio_input_pin_schema, cv.Required(CONF_FREQUENCY): cv.All( - cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + cv.frequency, cv.int_range(min=int(137e6), max=int(1020e6)) ), cv.Required(CONF_HW_VERSION): cv.one_of( "sx1261", "sx1262", "sx1268", "llcc68", lower=True diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 7f554fbf84..8fa7247192 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -197,11 +197,11 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_CODING_RATE, default="CR_4_5"): cv.enum(CODING_RATE), cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( - cv.frequency, cv.float_range(min=0, max=100000) + cv.frequency, cv.int_range(min=0, max=100000) ), cv.Optional(CONF_DIO0_PIN): pins.internal_gpio_input_pin_schema, cv.Required(CONF_FREQUENCY): cv.All( - cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + cv.frequency, cv.int_range(min=int(137e6), max=int(1020e6)) ), cv.Required(CONF_MODULATION): cv.enum(MOD), cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), From 5a2b7546f666abc106d22860c3f2bea5ad1b1a64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:07:49 -1000 Subject: [PATCH 37/94] Bump aioesphomeapi from 44.15.0 to 44.16.0 (#15757) 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 726a0a221a..b14d5f5f5a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.15.0 +aioesphomeapi==44.16.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From af1aaba547d28639521b07d2bbe763f8c8b9e963 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:19:42 +1000 Subject: [PATCH 38/94] [lvgl] Clean the build if lv_conf.h changes (#15777) --- esphome/components/lvgl/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index b6421dc43d..ac0363ca69 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -44,6 +44,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.writer import clean_build from esphome.yaml_util import load_yaml from . import defines as df, helpers, lv_validation as lvalid, widgets @@ -451,7 +452,8 @@ async def to_code(configs): df.add_define(f"LV_DRAW_SW_SUPPORT_{fmt}", "1") lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME) - write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()) + if write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()): + clean_build(clear_pio_cache=False) cg.add_build_flag("-DLV_CONF_H=1") # handle windows paths in a way that doesn't break the generated C++ lv_conf_h_path = Path(lv_conf_h_file).as_posix() From 92c99a7d41bfd69389519f8628feaf63d95ec957 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Thu, 16 Apr 2026 15:35:24 +0200 Subject: [PATCH 39/94] [mitsubishi_cn105] use HEAT_COOL mode to enable temperature slider (#15748) --- .../mitsubishi_cn105_climate.cpp | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 40ddb88a79..284339e57f 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -7,7 +7,7 @@ namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.climate"; static constexpr std::array MODE_MAP{ - std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_AUTO}, + std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_HEAT_COOL}, std::pair{MitsubishiCN105::Mode::HEAT, climate::CLIMATE_MODE_HEAT}, std::pair{MitsubishiCN105::Mode::DRY, climate::CLIMATE_MODE_DRY}, std::pair{MitsubishiCN105::Mode::COOL, climate::CLIMATE_MODE_COOL}, @@ -76,23 +76,13 @@ void MitsubishiCN105Climate::loop() { climate::ClimateTraits MitsubishiCN105Climate::traits() { climate::ClimateTraits traits; - traits.set_supported_modes({ - climate::CLIMATE_MODE_OFF, - climate::CLIMATE_MODE_COOL, - climate::CLIMATE_MODE_HEAT, - climate::CLIMATE_MODE_DRY, - climate::CLIMATE_MODE_FAN_ONLY, - climate::CLIMATE_MODE_AUTO, - }); + for (const auto &p : MODE_MAP) { + traits.add_supported_mode(p.second); + } - traits.set_supported_fan_modes({ - climate::CLIMATE_FAN_AUTO, - climate::CLIMATE_FAN_QUIET, - climate::CLIMATE_FAN_LOW, - climate::CLIMATE_FAN_MEDIUM, - climate::CLIMATE_FAN_MIDDLE, - climate::CLIMATE_FAN_HIGH, - }); + for (const auto &p : FAN_MODE_MAP) { + traits.add_supported_fan_mode(p.second); + } traits.set_visual_min_temperature(16.0f); traits.set_visual_max_temperature(31.0f); From 914ed10bccee4e21b19916c7b10736124337a457 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 03:36:55 -1000 Subject: [PATCH 40/94] [core] Diagnose missing cg.templatable in codegen for TEMPLATABLE_VALUE fields (#15758) --- esphome/core/automation.h | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index eb270bfee2..468ea3b382 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -62,6 +62,18 @@ template class TemplatableFn { !std::convertible_to, T> || !std::default_initializable) = delete; + // Reject raw (non-callable) values with a helpful diagnostic pointing at the Python-side fix. + // TemplatableFn stores only a function pointer (4 bytes), so constants must be wrapped in a + // stateless lambda by codegen. External components hitting this error should use + // `cg.templatable(value, args, type)` in their Python __init__.py before passing to the setter. + template TemplatableFn(V) requires(!std::invocable) && (!std::convertible_to) { + static_assert(sizeof(V) == 0, "Missing cg.templatable(...) in Python codegen for this TEMPLATABLE_VALUE " + "field. The wrapper was always required; it worked by accident because the old " + "TemplatableValue implicitly converted raw constants. TemplatableFn cannot. See " + "https://developers.esphome.io/blog/2026/04/09/" + "templatablefn-4-byte-templatable-storage-for-trivially-copyable-types/"); + } + bool has_value() const { return this->f_ != nullptr; } T value(X... x) const { return this->f_ ? this->f_(x...) : T{}; } From aa80bdbbc68ab65c19c05002b57ac630f393892a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 03:40:22 -1000 Subject: [PATCH 41/94] [time] Fix RTC is_valid() rejecting valid times after day_of_year cleanup (#15763) --- esphome/components/bm8563/bm8563.cpp | 2 +- esphome/components/ds1307/ds1307.cpp | 2 +- esphome/components/pcf85063/pcf85063.cpp | 2 +- esphome/components/pcf8563/pcf8563.cpp | 2 +- esphome/components/rx8130/rx8130.cpp | 2 +- esphome/core/time.h | 8 ++- tests/components/time/is_valid.cpp | 72 ++++++++++++++++++++++++ 7 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 tests/components/time/is_valid.cpp diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index 062094c036..d911301c9d 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -63,7 +63,7 @@ void BM8563::read_time() { 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()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/ds1307/ds1307.cpp b/esphome/components/ds1307/ds1307.cpp index 8fff4213b4..ba2ad6032f 100644 --- a/esphome/components/ds1307/ds1307.cpp +++ b/esphome/components/ds1307/ds1307.cpp @@ -44,7 +44,7 @@ void DS1307Component::read_time() { .year = uint16_t(ds1307_.reg.year + 10u * ds1307_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index 1cf28a4955..000de1433c 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -44,7 +44,7 @@ void PCF85063Component::read_time() { .year = uint16_t(pcf85063_.reg.year + 10u * pcf85063_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/pcf8563/pcf8563.cpp b/esphome/components/pcf8563/pcf8563.cpp index b748f0156a..50003ca378 100644 --- a/esphome/components/pcf8563/pcf8563.cpp +++ b/esphome/components/pcf8563/pcf8563.cpp @@ -44,7 +44,7 @@ void PCF8563Component::read_time() { .year = uint16_t(pcf8563_.reg.year + 10u * pcf8563_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 3b704d2551..0aa6e86d31 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -81,7 +81,7 @@ void RX8130Component::read_time() { .year = static_cast(bcd2dec(date[6]) + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/core/time.h b/esphome/core/time.h index ed47432038..0b67b7b3fc 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -76,8 +76,12 @@ struct ESPTime { /// @copydoc strftime(const std::string &format) std::string strftime(const char *format); - /// Check if this ESPTime is valid (all fields in range and year is greater than or equal to 2019) - bool is_valid() const { return this->year >= 2019 && this->fields_in_range(); } + /// Check if this ESPTime is valid (year >= 2019 and the requested fields are in range). + /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) + /// @param check_day_of_year validate day_of_year (not always available when constructing from date/time fields) + bool is_valid(bool check_day_of_week = true, bool check_day_of_year = true) const { + return this->year >= 2019 && this->fields_in_range(check_day_of_week, check_day_of_year); + } /// Check if time fields are in range. /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) diff --git a/tests/components/time/is_valid.cpp b/tests/components/time/is_valid.cpp new file mode 100644 index 0000000000..9148c0e8d6 --- /dev/null +++ b/tests/components/time/is_valid.cpp @@ -0,0 +1,72 @@ +// Regression tests for ESPTime::is_valid() optional checks. +// +// The RTC components (ds1307, bm8563, pcf85063, pcf8563, rx8130) read date/time +// fields from hardware but do not populate day_of_year. They call +// recalc_timestamp_utc(false) -- which skips day_of_year -- and then is_valid(). +// These tests ensure the is_valid() overload can skip day_of_year validation so +// RTCs don't log "Invalid RTC time, not syncing to system clock." for valid times. + +#include +#include "esphome/core/time.h" + +namespace esphome::testing { + +// Build an ESPTime that mirrors what the RTC components construct: all fields +// populated from hardware except day_of_year (left zero-initialized). +static ESPTime make_rtc_like_time() { + ESPTime t{}; + t.second = 30; + t.minute = 15; + t.hour = 12; + t.day_of_week = 4; // thursday + t.day_of_month = 15; + t.month = 4; + t.year = 2026; + // day_of_year intentionally left at 0 -- RTCs don't compute it. + return t; +} + +TEST(ESPTimeIsValid, DefaultRejectsZeroDayOfYear) { + // Default is_valid() checks day_of_year; zero-init is out of range. + ESPTime t = make_rtc_like_time(); + EXPECT_FALSE(t.is_valid()); +} + +TEST(ESPTimeIsValid, SkipDayOfYearAcceptsRTCLikeTime) { + // RTC code path: skip day_of_year validation. + ESPTime t = make_rtc_like_time(); + EXPECT_TRUE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipDayOfYearStillRejectsOutOfRangeFields) { + ESPTime t = make_rtc_like_time(); + t.hour = 25; + EXPECT_FALSE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipDayOfYearStillRejectsYearBefore2019) { + ESPTime t = make_rtc_like_time(); + t.year = 2000; + EXPECT_FALSE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipBothDayChecksAcceptsGPSLikeTime) { + // GPS path (gps_time.cpp) populates neither day_of_week nor day_of_year. + ESPTime t{}; + t.second = 30; + t.minute = 15; + t.hour = 12; + t.day_of_month = 15; + t.month = 4; + t.year = 2026; + EXPECT_TRUE(t.is_valid(/*check_day_of_week=*/false, /*check_day_of_year=*/false)); + EXPECT_FALSE(t.is_valid()); // default still rejects +} + +TEST(ESPTimeIsValid, FullyPopulatedAcceptsWithDefaults) { + ESPTime t = make_rtc_like_time(); + t.day_of_year = 105; + EXPECT_TRUE(t.is_valid()); +} + +} // namespace esphome::testing From 227dfa3730692c2ecf1931cdee1131bb08b4893f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:36:06 -0400 Subject: [PATCH 42/94] [qmc5883l] Move per-update log line from DEBUG to VERBOSE (#15781) --- esphome/components/qmc5883l/qmc5883l.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index bc2adb5cfe..d0488d0c9f 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -100,7 +100,7 @@ void QMC5883LComponent::update() { // ROL_PNT in setup and reading 7 bytes starting at the status register. // If status and all three axes are desired, using ROL_PNT saves you 3 bytes. // But simply not reading status saves you 4 bytes always and is much simpler. - if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) { + if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE) { err = this->read_register(QMC5883L_REGISTER_STATUS, &status, 1); if (err != i2c::ERROR_OK) { char buf[32]; @@ -165,7 +165,7 @@ void QMC5883LComponent::update() { temp = int16_t(raw_temp) * 0.01f; } - ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, + ESP_LOGV(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, temp, status); if (this->x_sensor_ != nullptr) From 81fb6712fef424a0273237786b2a4e4898f57742 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 08:45:33 -1000 Subject: [PATCH 43/94] [bundle] Force-resolve nested IncludeFile during file discovery (#15762) --- esphome/bundle.py | 78 ++++++++- .../fixtures/bundle/bundle_test.yaml | 6 +- .../fixtures/bundle/common/wifi.yaml | 2 + tests/unit_tests/test_bundle.py | 150 +++++++++++++++++- 4 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/fixtures/bundle/common/wifi.yaml diff --git a/esphome/bundle.py b/esphome/bundle.py index b6816c7c95..efa80acc8c 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -151,8 +151,8 @@ class ConfigBundleCreator: def __init__(self, config: dict[str, Any]) -> None: self._config = config - self._config_dir = CORE.config_dir - self._config_path = CORE.config_path + self._config_dir = Path(CORE.config_dir).resolve() + self._config_path = Path(CORE.config_path).resolve() self._files: list[BundleFile] = [] self._seen_paths: set[Path] = set() self._secrets_paths: set[Path] = set() @@ -258,21 +258,36 @@ class ConfigBundleCreator: def _discover_yaml_includes(self) -> None: """Discover YAML files loaded during config parsing. - We track files by wrapping _load_yaml_internal. The config has already - been loaded at this point (bundle is a POST_CONFIG_ACTION), so we - re-load just to discover the file list. + Deliberately uses a fresh re-parse and force-loads every deferred + ``IncludeFile`` to include *all* potentially-reachable includes, + even branches not selected by the local substitutions. Bundles are + meant to be compiled on another system where command-line + substitution overrides may choose a different branch — e.g. + ``!include network/${eth_model}/config.yaml`` must ship every + candidate so the remote build can pick any one. + + Entries with unresolved substitution variables in the filename + path are skipped with a warning (they cannot be resolved without + the substitution pass). Secrets files are tracked separately so we can filter them to only include the keys this config actually references. """ + # Must be a fresh parse: IncludeFile.load() caches its result in + # _content, and we discover files by listening for loader calls. On + # an already-parsed tree the cache is populated, .load() returns + # without calling the loader, the listener never fires, and the + # referenced files would be silently dropped from the bundle. with yaml_util.track_yaml_loads() as loaded_files: try: - yaml_util.load_yaml(self._config_path) + data = yaml_util.load_yaml(self._config_path) except EsphomeError: _LOGGER.debug( "Bundle: re-loading YAML for include discovery failed, " "proceeding with partial file list" ) + else: + _force_load_include_files(data) for fpath in loaded_files: if fpath == self._config_path.resolve(): @@ -608,6 +623,57 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: tar.addfile(info, io.BytesIO(data)) +def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None: + """Recursively resolve any ``IncludeFile`` instances in a YAML tree. + + Nested ``!include`` returns a deferred ``IncludeFile`` that is only + resolved during the substitution pass. During bundle discovery we need + the referenced files to actually load so the ``track_yaml_loads`` + listener fires for them. + + ``IncludeFile`` instances with unresolved substitution variables in the + filename cannot be loaded — we skip and warn about those. + """ + if _seen is None: + _seen = set() + + if isinstance(obj, yaml_util.IncludeFile): + if id(obj) in _seen: + return + _seen.add(id(obj)) + if obj.has_unresolved_expressions(): + _LOGGER.warning( + "Bundle: cannot resolve !include %s (referenced from %s) " + "with substitutions in path", + obj.file, + obj.parent_file, + ) + return + try: + loaded = obj.load() + except EsphomeError as err: + _LOGGER.warning( + "Bundle: failed to load !include %s (referenced from %s): %s", + obj.file, + obj.parent_file, + err, + ) + return + _force_load_include_files(loaded, _seen) + elif isinstance(obj, dict): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for value in obj.values(): + _force_load_include_files(value, _seen) + elif isinstance(obj, (list, tuple)): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for item in obj: + _force_load_include_files(item, _seen) + + def _resolve_include_path(include_path: Any) -> Path | None: """Resolve an include path to absolute, skipping system includes.""" if isinstance(include_path, str) and include_path.startswith("<"): diff --git a/tests/unit_tests/fixtures/bundle/bundle_test.yaml b/tests/unit_tests/fixtures/bundle/bundle_test.yaml index f834a8d867..247f5cc8bb 100644 --- a/tests/unit_tests/fixtures/bundle/bundle_test.yaml +++ b/tests/unit_tests/fixtures/bundle/bundle_test.yaml @@ -11,9 +11,9 @@ esp32: logger: <<: !include common/base.yaml -wifi: - ssid: !secret wifi_ssid - password: !secret wifi_password +# Plain nested !include — deferred as an IncludeFile until the substitution +# pass. The bundle must force-resolve it to pick up common/wifi.yaml. +wifi: !include common/wifi.yaml api: diff --git a/tests/unit_tests/fixtures/bundle/common/wifi.yaml b/tests/unit_tests/fixtures/bundle/common/wifi.yaml new file mode 100644 index 0000000000..d7e7b3cd45 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/common/wifi.yaml @@ -0,0 +1,2 @@ +ssid: !secret wifi_ssid +password: !secret wifi_password diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index b8b2d0ffd1..89bf1a33b3 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -5,8 +5,10 @@ from __future__ import annotations import io import json from pathlib import Path +import shutil import tarfile from typing import Any +from unittest.mock import patch import pytest @@ -20,6 +22,7 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, + _force_load_include_files, extract_bundle, is_bundle_path, prepare_bundle_for_compile, @@ -485,7 +488,7 @@ def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: result = read_bundle_manifest(bundle_path) assert result.esphome_version == "unknown" - assert result.files == [] + assert not result.files assert result.has_secrets is False @@ -862,6 +865,117 @@ def test_discover_files_skips_missing_directory(tmp_path: Path) -> None: assert len(files) == 1 +def test_discover_files_nested_include(tmp_path: Path) -> None: + """Nested !include files (e.g. wifi: !include wifi.yaml) are bundled.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include wifi.yaml\n" + ) + (config_dir / "wifi.yaml").write_text('ssid: "a"\npassword: "b"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert "wifi.yaml" in paths + + +def test_discover_files_deeply_nested_include(tmp_path: Path) -> None: + """Chains of !include (a includes b includes c) are fully resolved.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include level1.yaml\n" + ) + (config_dir / "level1.yaml").write_text("nested: !include level2.yaml\n") + (config_dir / "level2.yaml").write_text('value: "leaf"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "level1.yaml" in paths + assert "level2.yaml" in paths + + +def test_discover_files_nested_include_unresolved_substitution( + tmp_path: Path, +) -> None: + """!include with substitution vars in path cannot be resolved; skipped gracefully.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include ${platform}.yaml\n" + ) + + creator = ConfigBundleCreator({}) + # Should not raise + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + + +def test_discover_files_nested_include_load_failure( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A nested !include pointing at a missing file is logged and skipped.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include missing.yaml\n" + ) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert any( + "failed to load !include" in r.message and "missing.yaml" in r.message + for r in caplog.records + ) + + +def test_force_load_skips_duplicate_include_file() -> None: + """The same IncludeFile referenced twice is only loaded once.""" + + class _StubInclude: + """Mimics yaml_util.IncludeFile minimally for _force_load testing.""" + + def __init__(self) -> None: + self.file = Path("dup.yaml") + self.parent_file = Path("root.yaml") + self.load_calls = 0 + + def has_unresolved_expressions(self) -> bool: + return False + + def load(self) -> dict[str, Any]: + self.load_calls += 1 + return {} + + stub = _StubInclude() + # Same instance appears twice — second visit must hit the _seen guard. + tree = {"a": stub, "b": [stub]} + + with patch("esphome.bundle.yaml_util.IncludeFile", _StubInclude): + _force_load_include_files(tree) + + assert stub.load_calls == 1 + + +def test_force_load_handles_cyclic_containers() -> None: + """Cyclic dict/list references don't cause infinite recursion.""" + cyclic_dict: dict[str, Any] = {} + cyclic_dict["self"] = cyclic_dict + + cyclic_list: list[Any] = [] + cyclic_list.append(cyclic_list) + + # Should return without recursing forever + _force_load_include_files(cyclic_dict) + _force_load_include_files(cyclic_list) + + def test_discover_files_yaml_reload_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1008,6 +1122,40 @@ def test_discover_files_walk_tuple_values(tmp_path: Path) -> None: assert "a.pem" in paths +# --------------------------------------------------------------------------- +# ConfigBundleCreator - fixture-based end-to-end +# --------------------------------------------------------------------------- + + +def test_discover_files_fixture_config(fixture_path: Path, tmp_path: Path) -> None: + """Use the real ``fixtures/bundle/`` tree as an end-to-end reproducer. + + The fixture config uses ``wifi: !include common/wifi.yaml`` — a plain + nested !include that is returned as a deferred ``IncludeFile`` and only + resolved during the substitution pass. Before this fix, bundle discovery + never ran substitutions, so ``common/wifi.yaml`` was silently missing + from the bundle. + """ + # Copy the fixture tree into a tmp dir so the test doesn't rely on the + # source repo being writable and so we can set CORE.config_path freely. + src = fixture_path / "bundle" + dst = tmp_path / "bundle" + shutil.copytree(src, dst) + + CORE.config_path = dst / "bundle_test.yaml" + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + paths = {f.path for f in files} + + # Root and top-level !secret-referenced files + assert "bundle_test.yaml" in paths + assert "secrets.yaml" in paths + # The nested !include — this is what regressed when IncludeFile became + # deferred (PR #12213). + assert "common/wifi.yaml" in paths + + # --------------------------------------------------------------------------- # ConfigBundleCreator - create_bundle # --------------------------------------------------------------------------- From 9cb2b562b9e6706dc00117a7ac6299730eebc100 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 09:01:55 -1000 Subject: [PATCH 44/94] [ili9xxx] Guard against null buffer in display_() when allocation fails (#15786) --- esphome/components/ili9xxx/ili9xxx_defines.h | 2 ++ esphome/components/ili9xxx/ili9xxx_display.cpp | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/esphome/components/ili9xxx/ili9xxx_defines.h b/esphome/components/ili9xxx/ili9xxx_defines.h index f4c5aad957..70e0937f79 100644 --- a/esphome/components/ili9xxx/ili9xxx_defines.h +++ b/esphome/components/ili9xxx/ili9xxx_defines.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace esphome { namespace ili9xxx { diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index a3eff901d3..11acb8a73a 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -229,6 +229,10 @@ void ILI9XXXDisplay::update() { } void ILI9XXXDisplay::display_() { + // buffer may be null if allocation failed + if (this->buffer_ == nullptr) { + return; + } // check if something was displayed if ((this->x_high_ < this->x_low_) || (this->y_high_ < this->y_low_)) { return; From 722cfae04c4ce7745701f783f2f9c75fed2243f7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:07:04 -0400 Subject: [PATCH 45/94] [esp32] Accept unquoted minimum_chip_revision values (#15785) --- esphome/components/esp32/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7b3f9da3da..a68614cb43 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1222,7 +1222,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean, cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean, cv.Optional(CONF_MINIMUM_CHIP_REVISION): cv.one_of( - *ESP32_CHIP_REVISIONS + *ESP32_CHIP_REVISIONS, string=True ), cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean, # DHCP server is needed for WiFi AP mode. When WiFi component is used, From b167b64f06ea724824ea5601ab024bac42c235a8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:16:58 -0400 Subject: [PATCH 46/94] [lvgl] Guard lv_image_set_src wrapper with LV_USE_IMAGE (#15789) --- esphome/components/lvgl/lvgl_esphome.h | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3ba258b1a2..3ec1d247d8 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -76,16 +76,17 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { } #endif #if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -// Shortcut / overload, so that the source of an image can easily be updated -// from within a lambda. -inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { lv_image_set_src(obj, image->get_lv_image_dsc()); } +#if LV_USE_IMAGE +// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. +inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } +#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); } inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); } #endif // USE_LVGL_IMAGE #ifdef USE_LVGL_ANIMIMG From c3e739eba9c73c34ce843c7b6cc1bdcee11355c4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:17:16 +1000 Subject: [PATCH 47/94] [mipi_spi] Drawing fixes for native display (#15802) --- esphome/components/mipi_spi/display.py | 6 +- esphome/components/mipi_spi/mipi_spi.h | 14 +- .../mipi_spi/test_final_validate.py | 185 ++++++++++++++++++ 3 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_final_validate.py diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 42c7ec2224..364ada9046 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -195,7 +195,7 @@ def model_schema(config): "big_endian", "little_endian", lower=True ), model.option(CONF_COLOR_DEPTH, 16): cv.one_of(*color_depth, lower=True), - model.option(CONF_DRAW_ROUNDING, 2): power_of_two, + model.option(CONF_DRAW_ROUNDING, 1): power_of_two, model.option(CONF_PIXEL_MODE, DISPLAY_16BIT): cv.one_of( *pixel_modes, lower=True ), @@ -297,9 +297,9 @@ def _final_validate(config): buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here - fraction = min(20000.0, buffer_size // 16) / buffer_size + fraction = min(20000.0, buffer_size // 4) / buffer_size config[CONF_BUFFER_SIZE] = 1.0 / next( - x for x in range(2, 17) if fraction >= 1 / x + (x for x in range(2, 8) if fraction >= 1 / x), 8 ) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2242be6c17..f292345893 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -546,13 +546,12 @@ class MipiSpiBuffer : public MipiSpistart_line_ = 0; this->start_line_ < this->get_height_internal(); - this->start_line_ += this->get_height_internal() / FRACTION) { + auto increment = (this->get_height_internal() / FRACTION / ROUNDING) * ROUNDING; + for (this->start_line_ = 0; this->start_line_ < this->get_height_internal(); this->start_line_ = this->end_line_) { #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE auto lap = millis(); #endif - this->end_line_ = - clamp_at_most(this->start_line_ + this->get_height_internal() / FRACTION, this->get_height_internal()); + this->end_line_ = clamp_at_most(this->start_line_ + increment, this->get_height_internal()); if (this->auto_clear_enabled_) { this->clear(); } @@ -574,12 +573,13 @@ class MipiSpiBuffer : public MipiSpix_low_ = this->x_low_ / ROUNDING * ROUNDING; this->y_low_ = this->y_low_ / ROUNDING * ROUNDING; - this->x_high_ = (this->x_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; - this->y_high_ = (this->y_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; + this->x_high_ = round_buffer(this->x_high_ + 1) - 1; + this->y_high_ = clamp_at_most(round_buffer(this->y_high_ + 1) - 1, this->end_line_ - 1); int w = this->x_high_ - this->x_low_ + 1; int h = this->y_high_ - this->y_low_ + 1; this->write_to_display_(this->x_low_, this->y_low_, w, h, this->buffer_, this->x_low_, - this->y_low_ - this->start_line_, round_buffer(this->get_width_internal()) - w); + this->y_low_ - this->start_line_, + round_buffer(this->get_width_internal()) - w - this->x_low_); // invalidate watermarks this->x_low_ = this->get_width_internal(); this->y_low_ = this->get_height_internal(); diff --git a/tests/component_tests/mipi_spi/test_final_validate.py b/tests/component_tests/mipi_spi/test_final_validate.py new file mode 100644 index 0000000000..8c45b47752 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_final_validate.py @@ -0,0 +1,185 @@ +"""Tests for the _final_validate buffer size calculation in mipi_spi.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.display import CONF_SHOW_TEST_CARD +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.const import CONF_BUFFER_SIZE, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validated(config: ConfigType) -> ConfigType: + """Run the component config schema followed by the final validation.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def _custom_config( + width: int, + height: int, + color_depth: str | int | None = None, + **extra: Any, +) -> ConfigType: + """Build a minimal valid custom-model config with the given dimensions.""" + config: ConfigType = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": width, "height": height}, + "init_sequence": [[0xA0, 0x01]], + } + if color_depth is not None: + config["color_depth"] = color_depth + config.update(extra) + return config + + +# The auto buffer-size selection inside _final_validate targets ~20 kB of +# pixel buffer. For a buffer of ``depth_bytes * width * height``, it picks the +# smallest integer ``x`` in range(2, 8) such that +# ``min(20000, buffer // 4) / buffer >= 1 / x`` (falling back to ``x = 8``). +# The test cases below cover the full range of possible outcomes (1/4 .. 1/8). +@pytest.mark.parametrize( + ("width", "height", "color_depth", "expected"), + [ + # 16-bit color depth -- buffer = 2 * width * height + # 128*160*2 = 40960 B -> fraction = 10240/40960 = 0.25 -> x = 4 + pytest.param(128, 160, "16bit", 1.0 / 4, id="16bit_tiny"), + # 200*224*2 = 89600 B -> fraction = 20000/89600 ≈ 0.2232 -> x = 5 + pytest.param(200, 224, "16bit", 1.0 / 5, id="16bit_small"), + # 240*224*2 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(240, 224, "16bit", 1.0 / 6, id="16bit_medium"), + # 200*320*2 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(200, 320, "16bit", 1.0 / 7, id="16bit_large"), + # 240*320*2 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(240, 320, "16bit", 1.0 / 8, id="16bit_xlarge"), + # 320*480*2 = 307200 B -> fraction ≈ 0.0651 -> default x = 8 + pytest.param(320, 480, "16bit", 1.0 / 8, id="16bit_huge"), + # 8-bit color depth -- buffer = width * height + # 320*240 = 76800 B -> fraction = 19200/76800 = 0.25 -> x = 4 + pytest.param(320, 240, "8bit", 1.0 / 4, id="8bit_tiny"), + # 400*224 = 89600 B -> fraction ≈ 0.2232 -> x = 5 + pytest.param(400, 224, "8bit", 1.0 / 5, id="8bit_small"), + # 480*224 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(480, 224, "8bit", 1.0 / 6, id="8bit_medium"), + # 400*320 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(400, 320, "8bit", 1.0 / 7, id="8bit_large"), + # 480*320 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(480, 320, "8bit", 1.0 / 8, id="8bit_xlarge"), + ], +) +def test_buffer_size_auto_selected( + width: int, + height: int, + color_depth: str, + expected: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """Without PSRAM or an explicit buffer_size, a fraction is chosen from the display size. + + Without any drawing method and without LVGL, final validation also auto-enables + ``show_test_card``, which in turn makes the component require a buffer and therefore + triggers the buffer-size selection path. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated(_custom_config(width, height, color_depth)) + + # Sanity check: final validation should have enabled the test card for us, + # which is what causes the buffer-size calculation to actually run. + assert config.get(CONF_SHOW_TEST_CARD) is True + assert config[CONF_BUFFER_SIZE] == pytest.approx(expected) + + +@pytest.mark.parametrize( + "buffer_size", + [0.125, 0.25, 0.5, 1.0], + ids=["one_eighth", "one_quarter", "half", "full"], +) +def test_explicit_buffer_size_is_preserved( + buffer_size: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """An explicitly configured buffer_size is never overridden by final validation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated( + _custom_config(240, 320, "16bit", buffer_size=buffer_size), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(buffer_size) + + +def test_buffer_size_not_set_when_psram_enabled( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """When PSRAM is enabled the auto buffer-size selection is skipped.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + # Presence of the psram domain in the full config is what _final_validate checks. + set_component_config("psram", True) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + + +def test_buffer_size_not_set_when_buffer_not_required( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """With LVGL present and no drawing methods, no buffer fraction is chosen. + + LVGL suppresses the automatic show_test_card injection, which means + ``requires_buffer`` is False and the early-return branch fires. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + # And no test card should have been auto-enabled either. + assert not config.get(CONF_SHOW_TEST_CARD) + + +def test_buffer_size_selected_when_lvgl_with_test_card( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """LVGL present + an explicit drawing method still triggers buffer sizing. + + When LVGL is enabled, ``show_test_card`` is not injected automatically, + but users can still request it explicitly -- in that case ``requires_buffer`` + is True and the buffer-size heuristic still runs. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + # 128x160 @ 16bit -> expected 1/4 (see test_buffer_size_auto_selected). + config = _validated( + _custom_config(128, 160, "16bit", show_test_card=True), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) From f5806818cd4b087fa282d1c3b98f618f8c8e3c10 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:11:05 +1000 Subject: [PATCH 48/94] [image] Fix byte order handling (#15800) --- esphome/components/image/__init__.py | 17 ++++++++++------- esphome/components/image/image.cpp | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 4a5fcc385e..7db50597e6 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -28,7 +28,6 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.final_validate import full_config _LOGGER = logging.getLogger(__name__) @@ -676,12 +675,16 @@ def _final_validate(config): :param config: :return: """ - fv = full_config.get() - if "lvgl" in fv and not all(CONF_BYTE_ORDER in x for x in config): - config = config.copy() - for c in config: - if not c.get(CONF_BYTE_ORDER): - c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" + config = config.copy() + for c in config: + if byte_order := c.get(CONF_BYTE_ORDER): + if byte_order == "BIG_ENDIAN": + _LOGGER.warning( + "The image '%s' is configured with big-endian byte order, little-endian is expected", + c.get(CONF_FILE), + ) + else: + c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" return config diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index a6f9e35e2e..5b4ed6968c 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -189,7 +189,7 @@ Color Image::get_rgb_pixel_(int x, int y) const { } Color Image::get_rgb565_pixel_(int x, int y) const { const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8; - uint16_t rgb565 = encode_uint16(progmem_read_byte(pos), progmem_read_byte(pos + 1)); + uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + 1), progmem_read_byte(pos)); auto r = (rgb565 & 0xF800) >> 11; auto g = (rgb565 & 0x07E0) >> 5; auto b = rgb565 & 0x001F; From b26601a3dcfc284153b6c5405dcfe91f6356d8c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 02:45:50 -1000 Subject: [PATCH 49/94] [core] coerce set_interval(0) / update_interval: 0ms to 1ms (#15799) --- esphome/config_validation.py | 21 +++++- esphome/core/scheduler.cpp | 13 ++++ .../scheduler_interval_zero_coerced.yaml | 27 ++++++++ .../test_scheduler_interval_zero_coerced.py | 67 +++++++++++++++++++ tests/unit_tests/test_config_validation.py | 28 ++++++++ 5 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/scheduler_interval_zero_coerced.yaml create mode 100644 tests/integration/test_scheduler_interval_zero_coerced.py diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 31cfb41a6d..bf53013d9b 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -943,7 +943,26 @@ def time_period_in_minutes_(value): def update_interval(value): if value == "never": return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN) - return positive_time_period_milliseconds(value) + result = positive_time_period_milliseconds(value) + # 0ms was historically (mis)used as a pseudo-loop() mechanism for + # PollingComponents. Under the hood it calls set_interval(0), which + # causes Scheduler::call() to spin (WDT reset in the field). Coerce + # to 1ms so existing configs keep working at ~1kHz instead of + # spinning. Don't hard-fail so configs don't break on upgrade; + # authors should migrate to HighFrequencyLoopRequester (C++) for + # true run-every-loop behaviour. + if result.total_milliseconds == 0: + _LOGGER.warning( + "update_interval of 0ms is not supported - coercing to 1ms. " + "A literal 0ms schedule would spin the main loop (the scheduled " + "item would always be due, so the scheduler would never yield " + "back) and trigger a watchdog reset. Set update_interval to a " + "non-zero value such as 1ms or higher. (Custom C++ components " + "that need true run-every-loop behaviour should override loop() " + "with HighFrequencyLoopRequester instead.)" + ) + return TimePeriodMilliseconds(milliseconds=1) + return result time_period = Any(time_period_str_unit, time_period_str_colon, time_period_dict) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 3e75a68064..7e6ad19ac7 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -144,6 +144,19 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } + // An interval of 0 means "fire every tick forever," which is misuse: the + // item would always be due, causing Scheduler::call() to spin and starve + // the main loop (WDT reset in the field). Coerce to 1ms so existing code + // using update_interval=0ms as a pseudo-loop() continues to work at ~1kHz, + // and warn so authors can migrate to HighFrequencyLoopRequester which is + // the intended mechanism for running fast in the main loop. Zero-delay + // timeouts (defer) remain legitimate one-shots and are not affected. + if (type == SchedulerItem::INTERVAL && delay == 0) [[unlikely]] { + ESP_LOGE(TAG, "[%s] set_interval(0) would spin main loop - coercing to 1ms (use HighFrequencyLoopRequester)", + component ? LOG_STR_ARG(component->get_component_log_str()) : LOG_STR_LITERAL("?")); + delay = 1; + } + // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check LockGuard guard{this->lock_}; diff --git a/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml new file mode 100644 index 0000000000..13be55d617 --- /dev/null +++ b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml @@ -0,0 +1,27 @@ +esphome: + name: sched-interval-zero + +host: +api: +logger: + level: DEBUG + +globals: + - id: fire_count + type: int + initial_value: "0" + +interval: + # Deliberately configure 0ms — this path goes through the C++ + # Scheduler::set_timer_common_ coercion (not the Python cv.update_interval + # path, since interval: doesn't call cv.update_interval — it's an intervals + # component schema, not a PollingComponent's update_interval). + # Expected: scheduler coerces to 1ms at registration, emits ESP_LOGE, + # fires at ~1kHz instead of spinning. + - interval: 0ms + then: + - lambda: |- + id(fire_count) += 1; + if (id(fire_count) == 50) { + ESP_LOGI("test", "ZERO_INTERVAL_50_FIRES_REACHED"); + } diff --git a/tests/integration/test_scheduler_interval_zero_coerced.py b/tests/integration/test_scheduler_interval_zero_coerced.py new file mode 100644 index 0000000000..f71c0f7281 --- /dev/null +++ b/tests/integration/test_scheduler_interval_zero_coerced.py @@ -0,0 +1,67 @@ +"""Test that Scheduler::set_timer_common_ coerces interval=0 to 1ms. + +Regression test for the scheduler busy-loop when interval=0 was passed +literally. Without the coercion, Scheduler::call() would spin forever +because the item's next_execution == now_64 after re-scheduling, failing +the loop's `> now_64` break condition. The device would fail to yield +back to the main loop and trigger a WDT reset. + +With the coercion, interval=0 becomes interval=1 and the scheduler +fires at ~1kHz (bounded by the loop), the main loop continues to run, +and the device stays responsive to API calls. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_interval_zero_coerced( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """interval=0ms must be coerced to 1ms and not starve the main loop.""" + loop = asyncio.get_running_loop() + reached_50: asyncio.Future[None] = loop.create_future() + coerce_warning: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "ZERO_INTERVAL_50_FIRES_REACHED" in line and not reached_50.done(): + reached_50.set_result(None) + if "would spin main loop" in line and not coerce_warning.done(): + coerce_warning.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + # The API-client connection itself is evidence that the main loop + # is not starved — if set_interval(0) were spinning we could not + # get here at all. + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "sched-interval-zero" + + # Coerce warning must fire at registration + try: + await asyncio.wait_for(coerce_warning, timeout=5.0) + except TimeoutError: + pytest.fail("Expected coerce warning 'would spin main loop' not seen") + + # The coerced 1ms interval should fire 50 times quickly — this + # confirms the callback actually runs (not just registered) and the + # scheduler yields back to the main loop each time. + try: + await asyncio.wait_for(reached_50, timeout=5.0) + except TimeoutError: + pytest.fail( + "Coerced interval=0→1ms did not reach 50 fires within 5s, " + "which would indicate either the coercion failed or the " + "main loop is still being starved." + ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ac84ce7cc8..f038272d8b 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -24,6 +24,7 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, + SCHEDULER_DONT_RUN, ) from esphome.core import CORE, HexInt, Lambda @@ -765,3 +766,30 @@ def test_percentage_validators__raw_number_above_one_without_percent_sign( config_validation.unbounded_percentage(value) with pytest.raises(Invalid, match="percent sign"): config_validation.unbounded_possibly_negative_percentage(value) + + +def test_update_interval__coerces_zero_to_one_ms( + caplog: pytest.LogCaptureFixture, +) -> None: + """update_interval: 0ms must be coerced to 1ms (not rejected) because a + literal 0ms schedule causes Scheduler::call() to spin. Coercion keeps + existing configs compiling on upgrade while emitting a user-facing + warning that directs them to set a non-zero value.""" + with caplog.at_level("WARNING"): + result = config_validation.update_interval("0ms") + assert result.total_milliseconds == 1 + assert "update_interval of 0ms is not supported" in caplog.text + assert "1ms" in caplog.text + + +def test_update_interval__preserves_nonzero_values() -> None: + """Non-zero update_interval values must pass through unchanged.""" + assert config_validation.update_interval("1ms").total_milliseconds == 1 + assert config_validation.update_interval("50ms").total_milliseconds == 50 + assert config_validation.update_interval("60s").total_milliseconds == 60000 + + +def test_update_interval__never_passes_through() -> None: + """update_interval: never must still map to SCHEDULER_DONT_RUN.""" + result = config_validation.update_interval("never") + assert result.total_milliseconds == SCHEDULER_DONT_RUN From ed5852c2d6a64b7618bdabb35c67f650afd59fce Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:05:30 -0400 Subject: [PATCH 50/94] [ethernet] Fix SPI3_HOST default breaking compile on variants without SPI3 (#15809) Co-authored-by: J. Nick Koston --- .../components/ethernet/ethernet_component.h | 2 +- .../ethernet/test.esp32-c3-idf.yaml | 19 +++++++++++++++++++ ...720.esp32-idf.yaml => test.esp32-idf.yaml} | 0 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 tests/components/ethernet/test.esp32-c3-idf.yaml rename tests/components/ethernet/{test-lan8720.esp32-idf.yaml => test.esp32-idf.yaml} (100%) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 3a87842315..17c84ee954 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -221,7 +221,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; - spi_host_device_t interface_{SPI3_HOST}; + spi_host_device_t interface_{SPI2_HOST}; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT uint32_t polling_interval_{0}; #endif diff --git a/tests/components/ethernet/test.esp32-c3-idf.yaml b/tests/components/ethernet/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..b7b95875c6 --- /dev/null +++ b/tests/components/ethernet/test.esp32-c3-idf.yaml @@ -0,0 +1,19 @@ +ethernet: + type: W5500 + clk_pin: 6 + mosi_pin: 7 + miso_pin: 2 + cs_pin: 10 + interrupt_pin: 3 + reset_pin: 4 + clock_speed: 10Mhz + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-lan8720.esp32-idf.yaml b/tests/components/ethernet/test.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-lan8720.esp32-idf.yaml rename to tests/components/ethernet/test.esp32-idf.yaml From 9841deec31cb1d70a19228fb6691ba5b6acfaa16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 09:31:31 -0500 Subject: [PATCH 51/94] [core] Fix DelayAction compile error with non-const reference args (#15814) --- esphome/core/base_automation.h | 4 +++- tests/components/http_request/http_request.yaml | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 11133d3973..17f937d10d 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -205,7 +205,9 @@ template class DelayAction : public Action, public Compon } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay - auto f = [this, x...]() { this->play_next_(x...); }; + // `mutable` is required so captured copies of non-const reference args (e.g. std::string&) + // are passed as non-const lvalues to play_next_(const Ts&...) where Ts may be `T&` + auto f = [this, x...]() mutable { this->play_next_(x...); }; App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(x...), std::move(f), diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 13ca5ceba0..ef67671c91 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -45,6 +45,11 @@ esphome: args: - response->status_code - body.c_str() + - delay: 1s + - logger.log: + format: "After delay, body still: %s" + args: + - body.c_str() http_request: useragent: esphome/tagreader From 1d88027618d06662b8b4df1e1d047922ff1330dc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:14:54 -0400 Subject: [PATCH 52/94] [esp32] Downgrade unneeded `ignore_pin_validation_error` to a warning (#15811) --- esphome/components/esp32/gpio.py | 12 +++- tests/component_tests/esp32/test_esp32.py | 79 ++++++++++++++++++++++- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index a7180cbcd7..36dd44155a 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -172,10 +172,16 @@ def validate_gpio_pin(pin): exc, ) else: - # Throw an exception if used for a pin that would not have resulted - # in a validation error anyway! + # `ignore_pin_validation_error` only suppresses an error raised by the + # variant's pin_validation above (e.g. SPI flash/PSRAM pins, invalid pin + # numbers). If that didn't raise, the option is a no-op -- warn so the + # user can clean it up, but don't block the build. if ignore_pin_validation_warning: - raise cv.Invalid(f"GPIO{pin[CONF_NUMBER]} is not a reserved pin") + _LOGGER.warning( + "GPIO%d has no validation errors to ignore; " + "remove `ignore_pin_validation_error: true` from this pin.", + pin[CONF_NUMBER], + ) return pin diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bd4f9828ce..ac492e2752 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -8,10 +8,16 @@ from typing import Any import pytest -from esphome.components.esp32 import VARIANTS -from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS +from esphome.components.esp32 import VARIANT_ESP32, VARIANTS +from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT +from esphome.components.esp32.gpio import validate_gpio_pin import esphome.config_validation as cv -from esphome.const import CONF_ESPHOME, PlatformFramework +from esphome.const import ( + CONF_ESPHOME, + CONF_IGNORE_PIN_VALIDATION_ERROR, + CONF_NUMBER, + PlatformFramework, +) from esphome.core import CORE from tests.component_tests.types import SetCoreConfigCallable @@ -149,6 +155,73 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +def test_ignore_pin_validation_error_on_clean_pin_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that passes validation but sets `ignore_pin_validation_error: true` + should log a warning nudging the user to remove the flag, and not raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "GPIO4 has no validation errors to ignore" in caplog.text + + +def test_ignore_pin_validation_error_on_dirty_pin_suppresses( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that fails validation with `ignore_pin_validation_error: true` should + log the suppression warning and not raise (existing behavior).""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + # GPIO6 is a flash pin on ESP32 -> pin_validation raises cv.Invalid + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 6 + assert "Ignoring validation error on pin 6" in caplog.text + + +def test_dirty_pin_without_ignore_flag_raises( + set_core_config: SetCoreConfigCallable, +) -> None: + """A pin that fails validation without the ignore flag should still raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with pytest.raises(cv.Invalid, match="flash interface"): + validate_gpio_pin(pin) + + +def test_clean_pin_without_ignore_flag_does_not_warn( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A clean pin without the ignore flag should pass silently.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "has no validation errors to ignore" not in caplog.text + + def test_execute_from_psram_disabled_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From e2dfef5ddce725e7f3323e43749452c4ed93c324 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 18 Apr 2026 06:42:45 +1000 Subject: [PATCH 53/94] [runtime_image] Fix RGB order (#15813) --- esphome/components/runtime_image/runtime_image.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index fa42b53496..4c7f1bfb6f 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -127,9 +127,9 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { uint32_t pos = this->get_position_(x, y); Color mapped_color = color; this->map_chroma_key(mapped_color); - this->buffer_[pos + 0] = mapped_color.r; + this->buffer_[pos + 0] = mapped_color.b; this->buffer_[pos + 1] = mapped_color.g; - this->buffer_[pos + 2] = mapped_color.b; + this->buffer_[pos + 2] = mapped_color.r; if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { this->buffer_[pos + 3] = color.w; } From 6d5340f253009a1421329bcff3c6251ca0e6aa7d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:17:28 +1000 Subject: [PATCH 54/94] [lvgl] Fix crash with snow on rotated display (#15822) --- esphome/components/lvgl/lvgl_esphome.cpp | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index ce9b013dcf..d8248e4aa4 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -642,26 +642,28 @@ void LvglComponent::write_random_() { int iterations = 6 - lv_display_get_inactive_time(this->disp_) / 60000; if (iterations <= 0) iterations = 1; + int16_t width = lv_display_get_horizontal_resolution(this->disp_); + int16_t height = lv_display_get_vertical_resolution(this->disp_); while (iterations-- != 0) { - int32_t col = random_uint32() % this->width_; + int32_t col = random_uint32() % width; col = col / this->draw_rounding * this->draw_rounding; - int32_t row = random_uint32() % this->height_; + int32_t row = random_uint32() % height; row = row / this->draw_rounding * this->draw_rounding; // size will be between 8 and 32, and a multiple of draw_rounding int32_t size = (random_uint32() % 25 + 8) / this->draw_rounding * this->draw_rounding; - lv_area_t area{col, row, col + size - 1, row + size - 1}; + lv_area_t area{.x1 = col, .y1 = row, .x2 = col + size - 1, .y2 = row + size - 1}; // clip to display bounds just in case - if (area.x2 >= this->width_) - area.x2 = this->width_ - 1; - if (area.y2 >= this->height_) - area.y2 = this->height_ - 1; + if (area.x2 >= width) + area.x2 = width - 1; + if (area.y2 >= height) + area.y2 = height - 1; // line_len can't exceed 1024, and minimum buffer size is 2048, so this won't overflow the buffer size_t line_len = lv_area_get_width(&area) * lv_area_get_height(&area) / 2; for (size_t i = 0; i != line_len; i++) { - ((uint32_t *) (this->draw_buf_))[i] = random_uint32(); + reinterpret_cast(this->draw_buf_)[i] = random_uint32(); } - this->draw_buffer_(&area, (lv_color_data *) this->draw_buf_); + this->draw_buffer_(&area, reinterpret_cast(this->draw_buf_)); } } From 08ac61ae9424821ac868d817971313d1e8508394 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 06:58:54 -0500 Subject: [PATCH 55/94] [core] Feed WDT unconditionally in main loop to fix empty-config panic (#15830) --- esphome/core/application.h | 26 +++++++++++++++++--------- esphome/core/scheduler.cpp | 5 ++++- esphome/core/scheduler.h | 3 ++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 60087d527d..db8af735bd 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -641,7 +641,7 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline void ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); + inline uint32_t ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; } /// Process dump_config output one component per loop iteration. @@ -845,18 +845,15 @@ inline void Application::drain_wake_notifications_() { } #endif // USE_HOST -inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { +inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif - // Process scheduled tasks. Scheduler::call now feeds the watchdog itself - // after each scheduled item that actually runs, so we no longer need an - // unconditional feed here — when Scheduler::call has no work to do, the - // only elapsed time is a sleep wake + a few instructions, and when it does - // have work, it fed the wdt as it went. - this->scheduler.call(loop_start_time); + // Scheduler::call feeds the WDT per item and returns the timestamp of the + // last fired item, or the input unchanged when nothing ran. + uint32_t last_op_end_time = this->scheduler.call(loop_start_time); // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions @@ -874,13 +871,24 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ // Mark that we're in the loop for safe reentrant modifications this->in_loop_ = true; + return last_op_end_time; } inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Get the initial loop time at the start uint32_t last_op_end_time = millis(); - this->before_loop_tasks_(last_op_end_time); + // Returned timestamp keeps us monotonic with last_wdt_feed_ (advanced by + // the scheduler's per-item feeds) without an extra millis() call. + last_op_end_time = this->before_loop_tasks_(last_op_end_time); + // Guarantee a WDT touch every tick — covers configs with no looping + // components and no scheduler work, where the per-item / per-component + // feeds never fire. Rate-limited inline fast path, ~free when unneeded. + this->feed_wdt_with_time(last_op_end_time); +#ifdef USE_RUNTIME_STATS + uint32_t loop_before_end_us = micros(); + uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; +#endif for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; this->current_loop_index_++) { diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 7e6ad19ac7..b0eaa670ac 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -533,7 +533,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -void HOT Scheduler::call(uint32_t now) { +uint32_t HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -703,6 +703,9 @@ void HOT Scheduler::call(uint32_t now) { this->debug_verify_no_leak_(); } #endif + // execute_item_() advances `now` as items fire; return it so the caller + // stays monotonic with last_wdt_feed_. + return now; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 43a3ec7049..00a7f26953 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -129,7 +129,8 @@ class Scheduler { // Execute all scheduled items that are ready // @param now Fresh timestamp from millis() - must not be stale/cached - void call(uint32_t now); + // @return Timestamp of the last item that ran, or `now` unchanged if none ran. + uint32_t call(uint32_t now); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). From 78701debec764ae9e0ba6339eb27b8ff96a8ba46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:32:36 +0000 Subject: [PATCH 56/94] Bump aioesphomeapi from 44.16.0 to 44.16.1 (#15836) 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 b14d5f5f5a..25638c0e7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.16.0 +aioesphomeapi==44.16.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 602305b20de2ab6da44baa4e113eb3016115365e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:04:51 -0500 Subject: [PATCH 57/94] [core] Default PollingComponent() to 1ms when codegen is bypassed (#15831) --- esphome/core/component.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 3307c5ae76..9e339dce64 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -593,7 +593,7 @@ class Component { */ class PollingComponent : public Component { public: - PollingComponent() : PollingComponent(0) {} + PollingComponent() : PollingComponent(1) {} /** Initialize this polling component with the given update interval in ms. * From ef780886c35c019fdbdb9c0e4a1fe343958897e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Apr 2026 16:00:31 -0500 Subject: [PATCH 58/94] [substitutions] Fix `substitutions: !include file.yaml` regression (#15850) --- esphome/components/packages/__init__.py | 8 ++- esphome/components/substitutions/__init__.py | 35 +++++++++++-- .../15-substitutions_as_include.approved.yaml | 5 ++ .../15-substitutions_as_include.input.yaml | 5 ++ .../substitutions/15-substitutions_inc.yaml | 1 + ...ons_as_include_with_packages.approved.yaml | 5 ++ ...utions_as_include_with_packages.input.yaml | 9 ++++ ...ubstitutions_include_cli_var.approved.yaml | 6 +++ ...7-substitutions_include_cli_var.input.yaml | 8 +++ tests/unit_tests/test_substitutions.py | 51 +++++++++++++++++++ 10 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 3f3df75351..252a24061a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -10,6 +10,7 @@ from esphome.components.substitutions import ( ContextVars, push_context, resolve_include, + resolve_substitutions_block, substitute, ) from esphome.components.substitutions.jinja import has_jinja @@ -516,7 +517,12 @@ def do_packages_pass( if CONF_PACKAGES not in config: return config - substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {})) + with cv.prepend_path(CONF_SUBSTITUTIONS): + substitutions = UserDict( + resolve_substitutions_block( + config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions + ) + ) processor = _PackageProcessor( substitutions, command_line_substitutions, skip_update ) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index c0bd9d7be9..7aace9bb64 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -414,6 +414,34 @@ def _warn_unresolved_variables(errors: ErrList) -> None: ) +def resolve_substitutions_block( + substitutions: Any, + command_line_substitutions: dict[str, Any] | None, +) -> dict[str, Any]: + """Resolve a deferred ``substitutions: !include file.yaml`` and validate the shape. + + The caller is responsible for wrapping the call in + ``cv.prepend_path(CONF_SUBSTITUTIONS)`` for error reporting. + ``command_line_substitutions`` seeds the filename context so + ``substitutions: !include ${var}.yaml`` can reference CLI-provided vars. + """ + if isinstance(substitutions, IncludeFile): + # Single-shot resolution — matches ``_walk_packages`` for the + # ``packages: !include`` entry point. Chained includes (an include that + # itself loads another ``!include`` at the top level) are not supported. + substitutions, _ = resolve_include( + substitutions, + [], + ContextVars(command_line_substitutions or {}), + strict_undefined=False, + ) + if not isinstance(substitutions, dict): + raise cv.Invalid( + f"Substitutions must be a key to value mapping, got {type(substitutions)}" + ) + return substitutions + + def do_substitution_pass( config: OrderedDict, command_line_substitutions: dict[str, Any] | None = None ) -> OrderedDict: @@ -429,10 +457,9 @@ def do_substitution_pass( # Use merge_dicts_ordered to preserve OrderedDict type for move_to_end() substitutions = config.pop(CONF_SUBSTITUTIONS, {}) with cv.prepend_path(CONF_SUBSTITUTIONS): - if not isinstance(substitutions, dict): - raise cv.Invalid( - f"Substitutions must be a key to value mapping, got {type(substitutions)}" - ) + substitutions = resolve_substitutions_block( + substitutions, command_line_substitutions + ) substitutions = merge_dicts_ordered( substitutions, command_line_substitutions or {} ) diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml new file mode 100644 index 0000000000..14aa707def --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml new file mode 100644 index 0000000000..5909e7bf4f --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml @@ -0,0 +1,5 @@ +substitutions: !include 15-substitutions_inc.yaml + +wifi: + ssid: main_ssid + password: $wifi_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml new file mode 100644 index 0000000000..44d9a4b9ef --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml @@ -0,0 +1 @@ +wifi_password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml new file mode 100644 index 0000000000..14aa707def --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml new file mode 100644 index 0000000000..a2e72f33a2 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml @@ -0,0 +1,9 @@ +substitutions: !include 15-substitutions_inc.yaml + +packages: + wifi_pkg: + wifi: + password: $wifi_password + +wifi: + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml new file mode 100644 index 0000000000..f1fd5fb078 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml @@ -0,0 +1,6 @@ +substitutions: + subs_file: 15-substitutions_inc + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml new file mode 100644 index 0000000000..3248504b46 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml @@ -0,0 +1,8 @@ +command_line_substitutions: + subs_file: 15-substitutions_inc + +substitutions: !include ${subs_file}.yaml + +wifi: + ssid: main_ssid + password: $wifi_password diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 01c669e542..71bbd9db86 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -675,6 +675,57 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_do_substitution_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` where the file holds a list raises cv.Invalid. + + Locks in the shape check that runs after the deferred IncludeFile has been + resolved. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict({CONF_SUBSTITUTIONS: include}) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + substitutions.do_substitution_pass(config) + + +def test_do_packages_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` alongside `packages:` raises cv.Invalid. + + Without the shape check, ``UserDict(...)`` would surface a low-level + ``TypeError``; the explicit ``cv.Invalid`` points at the substitutions path. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict( + { + CONF_SUBSTITUTIONS: include, + "packages": {"noop": {"wifi": {"ssid": "main"}}}, + } + ) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + do_packages_pass(config) + + def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> None: """An undefined substitution in a package include filename raises cv.Invalid. From 1862c6115f51e89587dd8a1dd9462ae7fac18464 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 19 Apr 2026 23:09:14 +0200 Subject: [PATCH 59/94] [packages] Improve error messages with include stack and fix missing path propagation (#15844) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/packages/__init__.py | 9 ++ esphome/components/substitutions/__init__.py | 50 +++++++++++ .../component_tests/packages/test_packages.py | 88 ++++++++++++++++++- tests/unit_tests/test_substitutions.py | 34 +++++++ 4 files changed, 179 insertions(+), 2 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 252a24061a..97a5309480 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -8,7 +8,9 @@ from typing import Any from esphome import git, yaml_util from esphome.components.substitutions import ( ContextVars, + ErrList, push_context, + raise_first_undefined, resolve_include, resolve_substitutions_block, substitute, @@ -360,12 +362,19 @@ def _substitute_package_definition( if isinstance(package_config, str) or ( isinstance(package_config, dict) and is_remote_package(package_config) ): + # Collect undefined-variable errors (rather than raising strict) so the + # path walked through a remote-package dict is preserved and the user + # sees which field (url / path / ref / ...) referenced the undefined + # variable. + errors: ErrList = [] package_config = substitute( item=package_config, path=[], parent_context=context_vars or ContextVars(), strict_undefined=False, + errors=errors, ) + raise_first_undefined(errors, package_config, "package definition") return package_config diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 7aace9bb64..0144c13c01 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -30,6 +30,56 @@ ErrList = list[tuple[UndefinedError, SubstitutionPath, Any]] jinja = Jinja() +def raise_first_undefined( + errors: ErrList, + source: Any, + context_label: str, +) -> None: + """If *errors* is non-empty, raise ``cv.Invalid`` for the first undefined variable. + + The raised error names the missing variable, the path walked into *source* + (for nested dicts, e.g. ``url`` or ``ref``), and the YAML source location + when *source* carries one. Only the first error is surfaced; the user will + re-run after fixing it and any remaining undefined variables will be + reported then. + + ``context_label`` is the noun describing where the undefined variable + appeared (e.g. ``"package definition"``). + """ + if not errors: + return + err, err_path, err_value = errors[0] + if len(errors) > 1: + # Log any further undefined variables so debug-level output covers + # the full set, even though only the first is surfaced to the user. + extras = ", ".join( + f"{e.message} at '{'->'.join(str(p) for p in p_path)}'" + for e, p_path, _ in errors[1:] + ) + _LOGGER.debug("Additional undefined variables in %s: %s", context_label, extras) + # Prefer the location of the offending scalar (e.g. the `url:` value) over + # the enclosing package-definition dict so the message points at the exact + # line/column that carries the undefined variable. + location_node = ( + err_value + if isinstance(err_value, ESPHomeDataBase) and err_value.esp_range is not None + else source + ) + location = "" + if ( + isinstance(location_node, ESPHomeDataBase) + and location_node.esp_range is not None + ): + mark = location_node.esp_range.start_mark + # DocumentLocation.line/column are 0-based (from the YAML Mark). Render + # as 1-based to match config.line_info() and editor line numbering. + location = f" (in {mark.document} {mark.line + 1}:{mark.column + 1})" + field = f" at '{'->'.join(str(p) for p in err_path)}'" if err_path else "" + raise cv.Invalid( + f"Undefined variable in {context_label}{field}: {err.message}{location}" + ) + + def validate_substitution_key(value: Any) -> str: """Validate and normalize a substitution key, stripping a leading ``$`` if present.""" value = cv.string(value) diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index cd91c4d8cb..0bd339efa9 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -2,18 +2,20 @@ import logging from pathlib import Path +import re from unittest.mock import MagicMock, patch import pytest from esphome.components.packages import ( CONFIG_SCHEMA, + _substitute_package_definition, _walk_packages, do_packages_pass, is_package_definition, merge_packages, ) -from esphome.components.substitutions import do_substitution_pass +from esphome.components.substitutions import ContextVars, do_substitution_pass import esphome.config as config_module from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove @@ -44,7 +46,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.util import OrderedDict -from esphome.yaml_util import IncludeFile, add_context +from esphome.yaml_util import IncludeFile, add_context, load_yaml # Test strings TEST_DEVICE_NAME = "test_device_name" @@ -1399,3 +1401,85 @@ def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None: "CORE.raw_config should contain esphome section after package merge" ) assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME + + +# --------------------------------------------------------------------------- +# _substitute_package_definition +# --------------------------------------------------------------------------- + + +def test_substitute_package_definition_local_dict_returned_unchanged() -> None: + """A plain local config dict is not substituted and is returned as-is.""" + pkg = {CONF_WIFI: {CONF_SSID: "test"}} + result = _substitute_package_definition(pkg, ContextVars()) + assert result is pkg + + +def test_substitute_package_definition_string_resolved_with_context() -> None: + """A string package definition has its variables substituted.""" + ctx = ContextVars({"variant": "esp32"}) + result = _substitute_package_definition("device-${variant}.yaml", ctx) + assert result == "device-esp32.yaml" + + +def test_substitute_package_definition_undefined_in_string() -> None: + """An undefined variable in a package URL string raises cv.Invalid.""" + with pytest.raises(cv.Invalid, match="Undefined variable in package definition"): + _substitute_package_definition( + "github://org/repo/${undefined_var}/pkg.yaml", ContextVars() + ) + + +def test_substitute_package_definition_undefined_in_remote_dict_field() -> None: + """An undefined variable inside a remote-dict field names the offending field.""" + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition( + {CONF_URL: "github://${typo}/repo"}, ContextVars() + ) + err = str(exc_info.value) + assert "'typo' is undefined" in err + assert CONF_URL in err + + +def test_substitute_package_definition_undefined_in_remote_dict_non_first_field() -> ( + None +): + """The field path joins correctly for non-first dict fields (e.g. ``ref``).""" + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition( + { + CONF_URL: "github://org/repo", + CONF_REF: "branch-${branch_typo}", + }, + ContextVars(), + ) + err = str(exc_info.value) + assert "'branch_typo' is undefined" in err + assert CONF_REF in err + + +def test_substitute_package_definition_includes_source_location(tmp_path: Path) -> None: + """A package loaded from YAML surfaces file/line/col in the cv.Invalid message. + + Line/column are rendered 1-based (matching config.line_info() and editor + line numbering) and point at the offending scalar, not the enclosing dict. + """ + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "packages:\n broken: github://org/repo/${undefined_var}/pkg.yaml\n" + ) + config = load_yaml(yaml_file) + package_config = config[CONF_PACKAGES]["broken"] + + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition(package_config, ContextVars()) + + err = str(exc_info.value) + assert "main.yaml" in err + # The offending value lives on line 2 (1-based). Column depends on the YAML + # loader, so we only pin line and check that a 1-based column is present. + match = re.search(r"main\.yaml (\d+):(\d+)", err) + assert match, err + line, col = int(match.group(1)), int(match.group(2)) + assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})" + assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})" diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 71bbd9db86..3599e703d9 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -14,6 +14,7 @@ from esphome.components.packages import ( do_packages_pass, merge_packages, ) +from esphome.components.substitutions.jinja import UndefinedError from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, merge_config import esphome.config_validation as cv @@ -675,6 +676,39 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_raise_first_undefined_logs_extras_at_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """Only the first undefined error is raised; extras are logged at debug.""" + errors: substitutions.ErrList = [ + (UndefinedError("'a' is undefined"), ["url"], None), + (UndefinedError("'b' is undefined"), ["ref"], None), + (UndefinedError("'c' is undefined"), ["path"], None), + ] + + with ( + caplog.at_level(logging.DEBUG, logger="esphome.components.substitutions"), + pytest.raises(cv.Invalid) as exc_info, + ): + substitutions.raise_first_undefined(errors, None, "package definition") + + # First error is surfaced as the cv.Invalid message. + raised = str(exc_info.value) + assert "'a' is undefined" in raised + assert "'b' is undefined" not in raised + assert "'c' is undefined" not in raised + + # Remaining errors are captured via debug logging for troubleshooting. + assert "Additional undefined variables in package definition" in caplog.text + assert "'b' is undefined at 'ref'" in caplog.text + assert "'c' is undefined at 'path'" in caplog.text + + +def test_raise_first_undefined_noop_on_empty() -> None: + """An empty errors list is a no-op — no exception, no log.""" + substitutions.raise_first_undefined([], None, "package definition") + + def test_do_substitution_pass_included_substitutions_must_be_mapping( tmp_path: Path, ) -> None: From 36812591ebf2bc00930618ae5eca8a78ab51bb1d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:20:56 +1200 Subject: [PATCH 60/94] Bump version to 2026.4.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 6f1f3d0e7e..deb57df1d3 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 = 2026.4.0 +PROJECT_NUMBER = 2026.4.1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 95fd488dca..1f5b3b6c57 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.4.0" +__version__ = "2026.4.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From bab9cd3e7a9b6ebfd2a0ac1ab0a9dd10371d6529 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Apr 2026 14:07:26 -1000 Subject: [PATCH 61/94] [runtime_stats] Track main loop active time and report overhead (#15743) --- .../runtime_stats/runtime_stats.cpp | 99 +++++++++++++++---- .../components/runtime_stats/runtime_stats.h | 41 ++++++++ esphome/core/application.h | 23 +++++ esphome/core/component.cpp | 4 + esphome/core/component.h | 8 ++ tests/integration/test_runtime_stats.py | 33 +++++++ 6 files changed, 189 insertions(+), 19 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 06714b5a44..9ed141155a 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -32,40 +32,101 @@ void RuntimeStatsCollector::log_stats_() { " Period stats (last %" PRIu32 "ms): %zu active components", this->log_interval_, count); - if (count == 0) { - return; + // Sum component time so we can derive main-loop overhead + // (active loop time minus time attributable to component loop()s). + // Period sum iterates the active-in-period subset; total sum must iterate + // all components since total_active_time_us_ includes iterations where + // currently-idle components previously ran. + uint64_t period_component_sum_us = 0; + for (size_t i = 0; i < count; i++) { + period_component_sum_us += sorted[i]->runtime_stats_.period_time_us; + } + uint64_t total_component_sum_us = 0; + for (auto *component : components) { + total_component_sum_us += component->runtime_stats_.total_time_us; } - // Sort by period runtime (descending) - std::sort(sorted, sorted + count, compare_period_time); + if (count > 0) { + // Sort by period runtime (descending) + std::sort(sorted, sorted + count, compare_period_time); - // Log top components by period runtime - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, - stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, - stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + // Log top components by period runtime + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, + stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, + stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + } + } + + // Main-loop overhead for the period: active wall time minus component time. + // active = sum of per-iteration loop time excluding yield/sleep. + if (this->period_active_count_ > 0) { + uint64_t active = this->period_active_time_us_; + uint64_t overhead = active > period_component_sum_us ? active - period_component_sum_us : 0; + // Use double for µs→ms conversion so multi-day uptimes (where total + // microsecond counters exceed float's ~7-digit mantissa) keep resolution. + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->period_active_count_, + static_cast(active) / static_cast(this->period_active_count_) / 1000.0, + static_cast(this->period_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); + uint64_t before = this->period_before_time_us_; + uint64_t tail = this->period_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Log total stats since boot (only for active components - idle ones haven't changed) ESP_LOGI(TAG, " Total stats (since boot): %zu active components", count); - // Re-sort by total runtime for all-time stats - std::sort(sorted, sorted + count, compare_total_time); + if (count > 0) { + // Re-sort by total runtime for all-time stats + std::sort(sorted, sorted + count, compare_total_time); - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, - stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, - stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, + stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + } + } + + if (this->total_active_count_ > 0) { + uint64_t active = this->total_active_time_us_; + uint64_t overhead = active > total_component_sum_us ? active - total_component_sum_us : 0; + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->total_active_count_, + static_cast(active) / static_cast(this->total_active_count_) / 1000.0, + static_cast(this->total_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); + uint64_t before = this->total_before_time_us_; + uint64_t tail = this->total_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Reset period stats for (auto *component : components) { component->runtime_stats_.reset_period(); } + this->period_active_count_ = 0; + this->period_active_time_us_ = 0; + this->period_active_max_us_ = 0; + this->period_before_time_us_ = 0; + this->period_tail_time_us_ = 0; } bool RuntimeStatsCollector::compare_period_time(Component *a, Component *b) { diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 3c2c9f78ad..82e0fb7c61 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -29,6 +29,31 @@ class RuntimeStatsCollector { // Process any pending stats printing (should be called after component loop) void process_pending_stats(uint32_t current_time); + // Record the wall time of one main loop iteration excluding the yield/sleep. + // Called once per loop from Application::loop(). + // active_us = total time between loop start and just before yield. + // before_us = time spent in before_loop_tasks_ (scheduler + ISR enable_loop). + // tail_us = time spent in after_loop_tasks_ + the trailing record/stats prefix. + // Residual overhead at log time = active − Σ(component) − before − tail, + // which captures per-iteration inter-component bookkeeping (set_current_component, + // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // the for-loop itself). + void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { + this->period_active_count_++; + this->period_active_time_us_ += active_us; + if (active_us > this->period_active_max_us_) + this->period_active_max_us_ = active_us; + this->total_active_count_++; + this->total_active_time_us_ += active_us; + if (active_us > this->total_active_max_us_) + this->total_active_max_us_ = active_us; + + this->period_before_time_us_ += before_us; + this->total_before_time_us_ += before_us; + this->period_tail_time_us_ += tail_us; + this->total_tail_time_us_ += tail_us; + } + protected: void log_stats_(); // Static comparators — member functions have friend access, lambdas do not @@ -37,6 +62,22 @@ class RuntimeStatsCollector { uint32_t log_interval_; uint32_t next_log_time_{0}; + + // Main loop active-time stats (wall time per iteration, excluding yield/sleep). + // Counters are uint64_t — at sub-millisecond loop times a uint32_t can wrap in + // a few weeks of uptime, which is well within ESPHome device lifetimes. + uint64_t period_active_count_{0}; + uint64_t period_active_time_us_{0}; + uint32_t period_active_max_us_{0}; + uint64_t total_active_count_{0}; + uint64_t total_active_time_us_{0}; + uint32_t total_active_max_us_{0}; + + // Split of overhead sections — accumulated per iteration. + uint64_t period_before_time_us_{0}; + uint64_t total_before_time_us_{0}; + uint64_t period_tail_time_us_{0}; + uint64_t total_tail_time_us_{0}; }; } // namespace runtime_stats diff --git a/esphome/core/application.h b/esphome/core/application.h index db8af735bd..19245ab203 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -875,6 +875,17 @@ inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t l } inline void ESPHOME_ALWAYS_INLINE Application::loop() { +#ifdef USE_RUNTIME_STATS + // Capture the start of the active (non-sleeping) portion of this iteration. + // Used to derive main-loop overhead = active time − Σ(component time) − + // before/tail splits recorded below. + uint32_t loop_active_start_us = micros(); + // Snapshot the cumulative component-recorded time so we can subtract the + // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // (scheduler.cpp) — that time is already counted in per-component stats, + // so charging it again to "before" would double-count. + uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; +#endif // Get the initial loop time at the start uint32_t last_op_end_time = millis(); @@ -907,12 +918,24 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->feed_wdt_with_time(last_op_end_time); } +#ifdef USE_RUNTIME_STATS + uint32_t loop_tail_start_us = micros(); +#endif this->after_loop_tasks_(); #ifdef USE_RUNTIME_STATS // Process any pending runtime stats printing after all components have run // This ensures stats printing doesn't affect component timing measurements if (global_runtime_stats != nullptr) { + uint32_t loop_now_us = micros(); + // Subtract scheduled-component time from the "before" bucket so it is + // not double-counted (it is already attributed to per-component stats). + uint32_t loop_before_wall_us = loop_before_end_us - loop_active_start_us; + uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us + ? loop_before_wall_us - static_cast(loop_before_scheduled_us) + : 0; + global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, + loop_now_us - loop_tail_start_us); global_runtime_stats->process_pending_stats(last_op_end_time); } #endif diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 8949b4b76d..e33652482e 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -506,6 +506,10 @@ void PollingComponent::stop_poller() { uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } +#ifdef USE_RUNTIME_STATS +uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + void __attribute__((noinline, cold)) WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { bool should_warn; diff --git a/esphome/core/component.h b/esphome/core/component.h index 9e339dce64..717ca36257 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -116,6 +116,13 @@ struct ComponentRuntimeStats { uint64_t total_time_us{0}; uint32_t total_max_time_us{0}; + // Cumulative sum of every record_time() duration since boot, across all + // components. Used by Application::loop() to snapshot time spent inside + // WarnIfComponentBlockingGuard (including guards constructed by the + // scheduler at scheduler.cpp) so main-loop overhead accounting can + // subtract scheduled-callback time from the before_loop_tasks_ wall time. + static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void record_time(uint32_t duration_us) { this->period_count++; this->period_time_us += duration_us; @@ -125,6 +132,7 @@ struct ComponentRuntimeStats { this->total_time_us += duration_us; if (duration_us > this->total_max_time_us) this->total_max_time_us = duration_us; + global_recorded_us += duration_us; } void reset_period() { this->period_count = 0; diff --git a/tests/integration/test_runtime_stats.py b/tests/integration/test_runtime_stats.py index 9e93035d83..bd7f36341d 100644 --- a/tests/integration/test_runtime_stats.py +++ b/tests/integration/test_runtime_stats.py @@ -26,6 +26,7 @@ async def test_runtime_stats( # Track component stats component_stats_found = set() + main_loop_lines: list[dict[str, str]] = [] # Patterns to match - need to handle ANSI color codes and timestamps # The log format is: [HH:MM:SS][color codes][I][tag]: message @@ -34,6 +35,14 @@ async def test_runtime_stats( component_pattern = re.compile( r"^\[[^\]]+\].*?\s+([\w.]+):\s+count=(\d+),\s+avg=([\d.]+)ms" ) + # Main loop overhead line emitted by runtime_stats + main_loop_pattern = re.compile( + r"main_loop:\s+iters=(?P\d+),\s+" + r"active_avg=(?P[\d.]+)ms,\s+" + r"active_max=(?P[\d.]+)ms,\s+" + r"active_total=(?P[\d.]+)ms,\s+" + r"overhead_total=(?P[\d.]+)ms" + ) def check_output(line: str) -> None: """Check log output for runtime stats messages.""" @@ -54,6 +63,11 @@ async def test_runtime_stats( component_name = match.group(1) component_stats_found.add(component_name) + # Check for main_loop overhead line + ml_match = main_loop_pattern.search(line) + if ml_match: + main_loop_lines.append(ml_match.groupdict()) + async with ( run_compiled(yaml_config, line_callback=check_output), api_client_connected() as client, @@ -86,3 +100,22 @@ async def test_runtime_stats( assert "template.switch" in component_stats_found, ( f"Expected template.switch stats, found: {component_stats_found}" ) + + # Verify the main_loop overhead line is emitted (at least once for + # the period section and once for the total section, per log cycle). + assert len(main_loop_lines) >= 2, ( + f"Expected at least 2 main_loop lines, got {len(main_loop_lines)}" + ) + for fields in main_loop_lines: + assert int(fields["iters"]) > 0, f"iters should be > 0: {fields}" + assert float(fields["active_total"]) > 0.0, ( + f"active_total should be > 0: {fields}" + ) + assert float(fields["active_avg"]) >= 0.0, ( + f"active_avg should be >= 0: {fields}" + ) + # overhead_total is derived and may be 0 if components dominate, + # but the field must still be present and parseable as a float. + assert float(fields["overhead_total"]) >= 0.0, ( + f"overhead_total should be >= 0: {fields}" + ) From e5f6a734ba46884cafeed83e6e735cba0c901a2d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:08:07 +1000 Subject: [PATCH 62/94] [lvgl] Fix angles for arc (#15860) --- esphome/components/lvgl/widgets/arc.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index 9eaf3dadce..ac993cc382 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -77,8 +77,11 @@ class ArcType(NumberType): # start_angle and end_angle are mapped to bg_start_angle and bg_end_angle prop = str(prop) if prop.endswith("_angle"): - prop = "bg_" + prop - await w.set_property(prop, config, processor=validator) + await w.set_property( + "bg_" + prop, await validator.process(config.get(prop)) + ) + else: + await w.set_property(prop, config, processor=validator) if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB) From 0d3db2b6701fb7e87cd41f9f8c60445b06575618 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:08:07 +1000 Subject: [PATCH 63/94] [lvgl] Fix angles for arc (#15860) --- esphome/components/lvgl/widgets/arc.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index 9eaf3dadce..ac993cc382 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -77,8 +77,11 @@ class ArcType(NumberType): # start_angle and end_angle are mapped to bg_start_angle and bg_end_angle prop = str(prop) if prop.endswith("_angle"): - prop = "bg_" + prop - await w.set_property(prop, config, processor=validator) + await w.set_property( + "bg_" + prop, await validator.process(config.get(prop)) + ) + else: + await w.set_property(prop, config, processor=validator) if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB) From f0c21520aa5644370d1ed56e05702c35a95be721 Mon Sep 17 00:00:00 2001 From: guillempages Date: Mon, 20 Apr 2026 13:56:56 +0200 Subject: [PATCH 64/94] [mipi_rgb] Add definitions for sunton displays (#15858) --- esphome/components/mipi_rgb/models/sunton.py | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 esphome/components/mipi_rgb/models/sunton.py diff --git a/esphome/components/mipi_rgb/models/sunton.py b/esphome/components/mipi_rgb/models/sunton.py new file mode 100644 index 0000000000..a33625dfe4 --- /dev/null +++ b/esphome/components/mipi_rgb/models/sunton.py @@ -0,0 +1,51 @@ +from esphome.components.mipi import DriverChip +from esphome.config_validation import UNDEFINED + +# fmt: off +sunton = DriverChip( + "ESP32-8048S070", + swap_xy=UNDEFINED, + initsequence=(), + width=800, + height=480, + pclk_frequency="12.5MHz", + de_pin=41, + hsync_pin=39, + vsync_pin=40, + pclk_pin=42, + hsync_pulse_width=30, + hsync_back_porch=16, + hsync_front_porch=210, + vsync_pulse_width=13, + vsync_back_porch=10, + vsync_front_porch=22, + data_pins={ + "red": [14, 21, 47, 48, 45], + "green": [9, 46, 3, 8, 16, 1], + "blue": [15, 7, 6, 5, 4], + }, +) + +sunton.extend( + "ESP32-8048S050", + swap_xy=UNDEFINED, + initsequence=(), + width=800, + height=480, + pclk_frequency="16MHz", + de_pin=40, + hsync_pin=39, + vsync_pin=41, + pclk_pin=42, + hsync_back_porch=8, + hsync_front_porch=8, + hsync_pulse_width=4, + vsync_back_porch=8, + vsync_front_porch=8, + vsync_pulse_width=4, + data_pins={ + "red": [45, 48, 47, 21, 14], + "green": [5, 6, 7, 15, 16, 4], + "blue": [8, 3, 46, 9, 1], + }, +) From 7321e6e52fc9a19e4c02de406088f04e7ec077ca Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Mon, 20 Apr 2026 15:10:05 +0200 Subject: [PATCH 65/94] [rtttl] allow any control parameters order and default value fallback (#14438) Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/rtttl/rtttl.cpp | 98 +++++++++++++++--------------- tests/components/rtttl/common.yaml | 16 +++++ 2 files changed, 66 insertions(+), 48 deletions(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 01f5aad810..08d902b4be 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -294,57 +294,59 @@ void Rtttl::play(std::string rtttl) { } ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str()); - // Get default duration - this->position_ = this->rtttl_.find("d=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing 'd='"); - return; - } - this->position_ += 2; - num = this->get_integer_(); - if (num == 1 || num == 2 || num == 4 || num == 8 || num == 16 || num == 32) { - this->default_note_denominator_ = num; - } else { - ESP_LOGE(TAG, "Invalid default duration: %d", num); - return; - } - - // Get default octave - this->position_ = this->rtttl_.find("o=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing 'o="); - return; - } - this->position_ += 2; - num = this->get_integer_(); - if (num >= MIN_OCTAVE && num <= MAX_OCTAVE) { - this->default_octave_ = num; - } else { - ESP_LOGE(TAG, "Invalid default octave: %d", num); - return; - } - - // Get BPM - this->position_ = this->rtttl_.find("b=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing b="); - return; - } - this->position_ += 2; - num = this->get_integer_(); - if (num >= 4) { // Below 4 is not realistic and would cause a integer overflow - bpm = num; - } else { - ESP_LOGE(TAG, "Invalid BPM: %d", num); - return; - } - - this->position_ = this->rtttl_.find(':', this->position_); - if (this->position_ == std::string::npos) { + size_t name_end_position = this->position_; + size_t control_end = this->rtttl_.find(':', name_end_position + 1); + if (control_end == std::string::npos) { ESP_LOGE(TAG, "Missing second ':'"); return; } - this->position_++; + + // Get default duration + size_t pos = this->rtttl_.find("d=", name_end_position); + if (pos == std::string::npos || pos >= control_end) { + ESP_LOGW(TAG, "Missing 'd='; use default duration %d", this->default_note_denominator_); + } else { + this->position_ = pos + 2; + num = this->get_integer_(); + if (num == 1 || num == 2 || num == 4 || num == 8 || num == 16 || num == 32) { + this->default_note_denominator_ = num; + } else { + ESP_LOGE(TAG, "Invalid default duration: %d", num); + return; + } + } + + // Get default octave + pos = this->rtttl_.find("o=", name_end_position); + if (pos == std::string::npos || pos >= control_end) { + ESP_LOGW(TAG, "Missing 'o='; use default octave %d", this->default_octave_); + } else { + this->position_ = pos + 2; + num = this->get_integer_(); + if (num >= MIN_OCTAVE && num <= MAX_OCTAVE) { + this->default_octave_ = num; + } else { + ESP_LOGE(TAG, "Invalid default octave: %d", num); + return; + } + } + + // Get BPM + pos = this->rtttl_.find("b=", name_end_position); + if (pos == std::string::npos || pos >= control_end) { + ESP_LOGW(TAG, "Missing 'b='; use default BPM %d", bpm); + } else { + this->position_ = pos + 2; + num = this->get_integer_(); + if (num >= 4) { // Below 4 is not realistic and would cause a integer overflow + bpm = num; + } else { + ESP_LOGE(TAG, "Invalid BPM: %d", num); + return; + } + } + + this->position_ = control_end + 1; // BPM usually expresses the number of quarter notes per minute this->wholenote_duration_ = 60 * 1000L * 4 / bpm; // This is the time for whole note (in milliseconds) diff --git a/tests/components/rtttl/common.yaml b/tests/components/rtttl/common.yaml index 86b52ca3de..529713583b 100644 --- a/tests/components/rtttl/common.yaml +++ b/tests/components/rtttl/common.yaml @@ -3,6 +3,22 @@ esphome: then: - rtttl.play: 'siren:d=8,o=5,b=100:d,e,d,e,d,e,d,e' - rtttl.stop + # Test all note features: all notes, denominators (1,2,4,8,16,32), sharp (#), octaves (4-7), dotted (.), note gap (c5,c5), pause (p) + - rtttl.play: 'special:d=4,o=5,b=120:1c4,2d#5,4e6.,8f#7,16g4,32a5,8a#5,4b6,8h5,c5,c5,8p,2c4' + # Different orders of control parameters + - rtttl.play: 'test_odb:o=5,d=8,b=100:c' + - rtttl.play: 'test_bod:b=100,o=5,d=8:c' + - rtttl.play: 'test_bdo:b=100,d=8,o=5:c' + - rtttl.play: 'test_obd:o=5,b=100,d=8:c' + - rtttl.play: 'test_dbo:d=8,b=100,o=5:c' + # Missing parameters (use defaults) + - rtttl.play: 'test_no_d:o=5,b=100:c' + - rtttl.play: 'test_no_o:d=8,b=100:c' + - rtttl.play: 'test_no_b:d=8,o=5:c' + - rtttl.play: 'test_only_d:d=8:c' + - rtttl.play: 'test_only_o:o=5:c' + - rtttl.play: 'test_only_b:b=100:c' + - rtttl.play: 'test_empty::c' output: - platform: ${output_platform} From 0dae41aa2209031a78c77387d85e27842ff3b9b9 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:13:42 +1000 Subject: [PATCH 66/94] [lvgl] Fix format of hello world page (#15868) --- esphome/components/lvgl/hello_world.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/hello_world.yaml b/esphome/components/lvgl/hello_world.yaml index bbbd34e30a..7bf068cc5d 100644 --- a/esphome/components/lvgl/hello_world.yaml +++ b/esphome/components/lvgl/hello_world.yaml @@ -89,10 +89,12 @@ id: hello_world_label_ text: "Hello World!" align: center - - obj: + - container: id: hello_world_qrcode_ outline_width: 0 border_width: 0 + height: 100 + width: 100 hidden: !lambda |- return lv_obj_get_width(lv_screen_active()) < 300 && lv_obj_get_height(lv_screen_active()) < 400; widgets: From 9459f0426dcec99e412287faab1b612c433e01c4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:14:15 +1000 Subject: [PATCH 67/94] [lvgl] Fix overloads for setting images on styles (#15864) --- esphome/components/lvgl/lvgl_esphome.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3ec1d247d8..146866f5bd 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -88,6 +88,12 @@ inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { ::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); } +inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) { + ::lv_style_set_bg_image_src(style, image->get_lv_image_dsc()); +} +inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) { + ::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc()); +} #endif // USE_LVGL_IMAGE #ifdef USE_LVGL_ANIMIMG inline void lv_animimg_set_src(lv_obj_t *img, std::vector images) { From 73b8e8ac098955c8cdd3bb1f72e4ce7bf7372f4a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:15:51 +1000 Subject: [PATCH 68/94] [lvgl] Fix update of textarea attached to keyboard (#15866) --- esphome/components/lvgl/widgets/keyboard.py | 26 ++++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index 029ca5f684..c5628cee3c 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -52,19 +52,23 @@ class KeyboardType(WidgetType): if mode := config.get(CONF_MODE): await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode)) if textarea := config.get(CONF_TEXTAREA): - # If a textarea is configured, it must be generated before the keyboard can attach it. - # If not yet configured, defer the attachment code. + if not is_widget_completed(textarea): + # Can only happen for an initial config, where the keyboard is configured before the + # textarea, so it's ok to always emit into the global context + async def add_textarea(): + async with LvContext(): + await w.set_property( + CONF_TEXTAREA, + (await get_widgets(config, CONF_TEXTAREA))[0].obj, + ) - async def add_textarea(): - async with LvContext(): - await w.set_property( - CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj - ) - - if is_widget_completed(textarea): - await add_textarea() - else: CORE.add_job(add_textarea) + else: + # Handles updates in automations, and properly ordered initial config. Code is generated + # into the enclosing context (main or lambda) + await w.set_property( + CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj + ) keyboard_spec = KeyboardType() From b72f5447c3fc4a0130ef56a74677b8fea0a25ab8 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Mon, 20 Apr 2026 14:24:07 +0100 Subject: [PATCH 69/94] [modbus] Simplify payload size validation in modbus_helpers (#15838) --- esphome/components/modbus/modbus_helpers.cpp | 114 +++++++++--------- .../components/modbus/modbus_helpers_test.cpp | 22 ++++ 2 files changed, 79 insertions(+), 57 deletions(-) create mode 100644 tests/components/modbus/modbus_helpers_test.cpp diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 77190b2846..89dc3c08bc 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -5,6 +5,29 @@ namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; +static size_t required_payload_size(SensorValueType sensor_value_type) { + switch (sensor_value_type) { + case SensorValueType::U_WORD: + case SensorValueType::S_WORD: + return 2; + case SensorValueType::U_DWORD: + case SensorValueType::FP32: + case SensorValueType::U_DWORD_R: + case SensorValueType::FP32_R: + case SensorValueType::S_DWORD: + case SensorValueType::S_DWORD_R: + return 4; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: + return 8; + case SensorValueType::RAW: + default: + return 0; + } +} + void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { switch (value_type) { case SensorValueType::U_WORD: @@ -47,93 +70,70 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens uint32_t bitmask) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits - if (offset > data.size()) { - ESP_LOGE(TAG, "not enough data for value"); + // Validate offset against the buffer for all types, including RAW/unsupported, so + // a malformed or misconfigured frame still produces an error log. + if (static_cast(offset) > data.size()) { + ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), + static_cast(offset), data.size()); + return value; + } + + const size_t required_size = required_payload_size(sensor_value_type); + if (required_size == 0) { + return value; + } + + if (data.size() - offset < required_size) { + ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", + static_cast(sensor_value_type), static_cast(offset), data.size(), + required_size); return value; } - size_t size = data.size() - offset; - bool error = false; switch (sensor_value_type) { case SensorValueType::U_WORD: - if (size >= 2) { - value = mask_and_shift_by_rightbit(get_data(data, offset), - bitmask); // default is 0xFFFF ; - } else { - error = true; - } + value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; break; case SensorValueType::U_DWORD: case SensorValueType::FP32: - if (size >= 4) { - value = get_data(data, offset); - value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); - } else { - error = true; - } + value = get_data(data, offset); + value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); break; case SensorValueType::U_DWORD_R: case SensorValueType::FP32_R: - if (size >= 4) { - value = get_data(data, offset); - value = static_cast(value & 0xFFFF) << 16 | (value & 0xFFFF0000) >> 16; - value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); - } else { - error = true; - } + value = get_data(data, offset); + value = static_cast(value & 0xFFFF) << 16 | (value & 0xFFFF0000) >> 16; + value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); break; case SensorValueType::S_WORD: - if (size >= 2) { - value = mask_and_shift_by_rightbit(get_data(data, offset), - bitmask); // default is 0xFFFF ; - } else { - error = true; - } + value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; break; case SensorValueType::S_DWORD: - if (size >= 4) { - value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); - } else { - error = true; - } + value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); break; case SensorValueType::S_DWORD_R: { - if (size >= 4) { - value = get_data(data, offset); - // Currently the high word is at the low position - // the sign bit is therefore at low before the switch - uint32_t sign_bit = (value & 0x8000) << 16; - value = mask_and_shift_by_rightbit( - static_cast(((value & 0x7FFF) << 16 | (value & 0xFFFF0000) >> 16) | sign_bit), bitmask); - } else { - error = true; - } + value = get_data(data, offset); + // Currently the high word is at the low position + // the sign bit is therefore at low before the switch + uint32_t sign_bit = (value & 0x8000) << 16; + value = mask_and_shift_by_rightbit( + static_cast(((value & 0x7FFF) << 16 | (value & 0xFFFF0000) >> 16) | sign_bit), bitmask); } break; case SensorValueType::U_QWORD: case SensorValueType::S_QWORD: // Ignore bitmask for QWORD - if (size >= 8) { - value = get_data(data, offset); - } else { - error = true; - } + value = get_data(data, offset); break; case SensorValueType::U_QWORD_R: case SensorValueType::S_QWORD_R: { // Ignore bitmask for QWORD - if (size >= 8) { - uint64_t tmp = get_data(data, offset); - value = (tmp << 48) | (tmp >> 48) | ((tmp & 0xFFFF0000) << 16) | ((tmp >> 16) & 0xFFFF0000); - } else { - error = true; - } + uint64_t tmp = get_data(data, offset); + value = (tmp << 48) | (tmp >> 48) | ((tmp & 0xFFFF0000) << 16) | ((tmp >> 16) & 0xFFFF0000); } break; case SensorValueType::RAW: default: break; } - if (error) - ESP_LOGE(TAG, "not enough data for value"); return value; } } // namespace esphome::modbus::helpers diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp new file mode 100644 index 0000000000..e1b4fb2aa6 --- /dev/null +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -0,0 +1,22 @@ +#include + +#include "esphome/components/modbus/modbus_helpers.h" + +namespace esphome::modbus::helpers { + +TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { + const std::vector data{0x12, 0x34}; + EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); +} + +TEST(ModbusHelpersTest, PayloadToNumberRejectsTruncatedMultiRegisterValue) { + const std::vector data{0x12, 0x34, 0x56}; + EXPECT_EQ(payload_to_number(data, SensorValueType::U_DWORD, 0, 0xFFFFFFFF), 0); +} + +TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { + const std::vector data{0x12, 0x34}; + EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); +} + +} // namespace esphome::modbus::helpers From 82656cb0cf1c051d428438e5f562f8de3541d1ca Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:28:52 +1000 Subject: [PATCH 70/94] [mipi_dsi] Add Seeed reTerminal d1001 display (#15867) --- esphome/components/mipi_dsi/models/seeed.py | 29 +++++++++++++++++++ .../mipi_dsi/test_mipi_dsi_config.py | 5 ++++ 2 files changed, 34 insertions(+) create mode 100644 esphome/components/mipi_dsi/models/seeed.py diff --git a/esphome/components/mipi_dsi/models/seeed.py b/esphome/components/mipi_dsi/models/seeed.py new file mode 100644 index 0000000000..290b0e07ee --- /dev/null +++ b/esphome/components/mipi_dsi/models/seeed.py @@ -0,0 +1,29 @@ +from esphome.components.mipi import DriverChip +import esphome.config_validation as cv + +# Standalone display +# Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html +DriverChip( + "SEEED-RETERMINAL-D1001", + height=1280, + width=800, + hsync_back_porch=20, + hsync_pulse_width=20, + hsync_front_porch=40, + vsync_back_porch=12, + vsync_pulse_width=4, + vsync_front_porch=30, + pclk_frequency="80MHz", + lane_bit_rate="1.5Gbps", + swap_xy=cv.UNDEFINED, + color_order="RGB", + enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}], + reset_pin={"xl9535": None, "number": 2}, + initsequence=( + (0xE0, 0x00), + (0xE1, 0x93), + (0xE2, 0x65), + (0xE3, 0xF8), + (0x80, 0x01), + ), +) diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 119bbf7fea..955e945526 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -7,6 +7,11 @@ import pytest from esphome import config_validation as cv from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4 + +# Importing xl9535 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that +# models (e.g. SEEED-RETERMINAL-D1001) that reference xl9535-backed pins in their +# defaults can be validated by the mipi_dsi CONFIG_SCHEMA in this test. +import esphome.components.xl9535 # noqa: F401 from esphome.const import ( CONF_DIMENSIONS, CONF_HEIGHT, From 6af341bb5be6a3850bf16a9c01f49e24b60f5413 Mon Sep 17 00:00:00 2001 From: Elvin Luff Date: Mon, 20 Apr 2026 14:34:31 +0100 Subject: [PATCH 71/94] [epaper_spi] Support SSD1683 and GDEY042T81 4.2 inch display (#13910) --- .../epaper_spi/epaper_spi_ssd1683.cpp | 97 +++++++++++++++++++ .../epaper_spi/epaper_spi_ssd1683.h | 22 +++++ .../components/epaper_spi/models/ssd1683.py | 27 ++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 16 +++ 4 files changed, 162 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_spi_ssd1683.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_ssd1683.h create mode 100644 esphome/components/epaper_spi/models/ssd1683.py diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp new file mode 100644 index 0000000000..6fb7e1ac1a --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp @@ -0,0 +1,97 @@ +#include "epaper_spi_ssd1683.h" + +#include + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +static constexpr const char *const TAG = "epaper_spi.mono"; + +void EPaperSSD1683::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); + this->cmd_data(0x3C, {partial ? (uint8_t) 0x80 : (uint8_t) 0x01}); + // On partial update, set red RAM to inverse to remove BW ghosting + this->cmd_data(0x21, {partial ? (uint8_t) 0x80 : (uint8_t) 0x40, (uint8_t) 0x00}); + // Set full update to 0xD7 for fast update, 0xF7 for normal + // Fast update flashes less and draws sooner but is in busy state for the same amount of time + // Manufacturer recommends not using fast update all the time, TODO expose this to the user + this->cmd_data(0x22, {partial ? (uint8_t) 0xFC : (uint8_t) 0xF7}); + this->command(0x20); +} + +// Puts the display into deep sleep mode 1, only way to get out is to reset the display +// Mode 1 retains RAM while sleeping, necessary for future partial and window updates +void EPaperSSD1683::deep_sleep() { + if (this->is_using_partial_update_()) { + ESP_LOGV(TAG, "Deep sleep mode 1"); + this->cmd_data(0x10, {0x01}); // deep sleep, retain RAM + } else { + ESP_LOGV(TAG, "Deep sleep mode 2"); + this->cmd_data(0x10, {0x03}); // deep sleep, lose RAM + } +} + +void EPaperSSD1683::set_window() { + // if not using partial update, the display will go into deep sleep mode 2, so must rewrite entire + // buffer since the display RAM will not retain contents + if (!this->is_using_partial_update_()) { + this->x_low_ = 0; + this->x_high_ = this->width_; + this->y_low_ = 0; + this->y_high_ = this->height_; + } + + // round x-coordinates to byte boundaries + this->x_low_ /= 8; + this->x_high_ += 7; + this->x_high_ /= 8; + + this->cmd_data(0x44, {(uint8_t) this->x_low_, (uint8_t) (this->x_high_ - 1)}); + this->cmd_data(0x45, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256), (uint8_t) (this->y_high_ - 1), + (uint8_t) ((this->y_high_ - 1) / 256)}); + this->cmd_data(0x4E, {(uint8_t) this->x_low_}); + this->cmd_data(0x4F, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256)}); +} + +bool HOT EPaperSSD1683::transfer_data() { + auto start_time = millis(); + if (this->current_data_index_ == 0) { + if (this->send_red_) { + // round to byte boundaries + this->set_window(); + } + // for monochrome, we need to send red on every refresh to prevent dirty pixels + // when doing a 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_; + FixedVector bytes_to_send{}; + bytes_to_send.init(row_length); + ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, 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->row_width_ + this->x_low_; + for (size_t i = 0; i != row_length; i++) { + bytes_to_send[i] = 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->disable(); + return false; + } + } + + this->disable(); + this->current_data_index_ = 0; + if (this->send_red_) { + this->send_red_ = false; + return false; + } + this->send_red_ = true; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1683.h b/esphome/components/epaper_spi/epaper_spi_ssd1683.h new file mode 100644 index 0000000000..4532900dd1 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1683.h @@ -0,0 +1,22 @@ +#pragma once + +#include "epaper_spi_mono.h" + +namespace esphome::epaper_spi { +/** + * A class for Solomon SSD1683 epaper displays. + */ +class EPaperSSD1683 : public EPaperMono { + public: + EPaperSSD1683(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperMono(name, width, height, init_sequence, init_sequence_length) {} + + protected: + void refresh_screen(bool partial) override; + void deep_sleep() override; + void set_window() override; + bool transfer_data() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/ssd1683.py b/esphome/components/epaper_spi/models/ssd1683.py new file mode 100644 index 0000000000..983f5bb382 --- /dev/null +++ b/esphome/components/epaper_spi/models/ssd1683.py @@ -0,0 +1,27 @@ +from esphome.const import CONF_DATA_RATE + +from . import EpaperModel + + +class SSD1683(EpaperModel): + def __init__(self, name, class_name="EPaperSSD1683", data_rate="20MHz", **defaults): + defaults[CONF_DATA_RATE] = data_rate + super().__init__(name, class_name, **defaults) + + # fmt: off + def get_init_sequence(self, config: dict): + _width, height = self.get_dimensions(config) + return ( + (0x01, (height - 1) % 256, (height - 1) // 256, 0x00), # Set column gate limit + (0x18, 0x80), # Select internal Temp sensor + (0x11, 0x03), # Set transform + ) + + +ssd1683 = SSD1683("ssd1683") + +goodisplay_gdey042t81 = ssd1683.extend( + "goodisplay-gdey042t81-4.2", + width=400, + height=300, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index bf6053c78b..8a420f299a 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -145,3 +145,19 @@ display: it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0)); + + - platform: epaper_spi + spi_id: spi_bus + model: goodisplay-gdey042t81-4.2 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 From 94f30d5950e3d5aa3e49d31879ec394c30e63d45 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 20 Apr 2026 16:26:47 -0400 Subject: [PATCH 72/94] [micro_wake_word] Use ESPMicroSpeechFeatures from Espressif registry (v1.2.3) (#15879) --- .clang-tidy.hash | 2 +- esphome/components/micro_wake_word/__init__.py | 4 ++-- esphome/idf_component.yml | 2 ++ platformio.ini | 2 -- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 72a9967590..02aa990809 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -075ed2142432dc59883bb52db8ac11270f952851d6400deae080f5468c7cb592 +c65f1a0804a7765462d570c50891ac719260592df2c9cdfe88233fc346ac59e9 diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 5ab1e4bb80..22d2098de0 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -454,12 +454,12 @@ async def to_code(config): # Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn) esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2") + esp32.add_idf_component(name="esphome/esp-micro-speech-features", ref="1.2.3") + cg.add_build_flag("-DTF_LITE_STATIC_MEMORY") cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON") cg.add_build_flag("-DESP_NN") - cg.add_library("kahrendt/ESPMicroSpeechFeatures", "1.1.0") - if vad_model := config.get(CONF_VAD): cg.add_define("USE_MICRO_WAKE_WORD_VAD") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f4e3e751ec..3637481c92 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -3,6 +3,8 @@ dependencies: version: "7.4.2" esphome/esp-audio-libs: version: 2.0.4 + esphome/esp-micro-speech-features: + version: 1.2.3 esphome/micro-decoder: version: 0.1.1 esphome/micro-flac: diff --git a/platformio.ini b/platformio.ini index e2c7e2b097..d7b14944e4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -155,7 +155,6 @@ lib_deps = makuna/NeoPixelBus@2.8.0 ; neopixelbus esphome/ESP32-audioI2S@2.3.0 ; i2s_audio droscy/esp_wireguard@0.4.5 ; wireguard - kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word build_flags = ${common:arduino.build_flags} @@ -177,7 +176,6 @@ framework = espidf lib_deps = ${common:idf.lib_deps} droscy/esp_wireguard@0.4.5 ; wireguard - kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = ${common:idf.build_flags} From 213ab312d2d4f703056393eadc689fcfe0e42165 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:27:34 -0400 Subject: [PATCH 73/94] [image] Fix rodata bloat for multi-frame RGB565+alpha animations (#15873) --- esphome/components/image/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 7db50597e6..8375ab91d3 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -756,7 +756,7 @@ async def write_image(config, all_frames=False): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() - encoder.end_image() + encoder.end_image() rhs = [HexInt(x) for x in encoder.data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) From a43ee15b567896cc932bb45da2d2de3fcdbd48ec Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:33:48 -0400 Subject: [PATCH 74/94] [core] Fix Pvariable placement new losing subclass identity (#15881) --- esphome/cpp_generator.py | 50 ++++++++++++------- tests/component_tests/ili9xxx/__init__.py | 0 .../ili9xxx/config/ili9xxx_test.yaml | 20 ++++++++ tests/component_tests/ili9xxx/test_ili9xxx.py | 31 ++++++++++++ 4 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 tests/component_tests/ili9xxx/__init__.py create mode 100644 tests/component_tests/ili9xxx/config/ili9xxx_test.yaml create mode 100644 tests/component_tests/ili9xxx/test_ili9xxx.py diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index cf90b878e1..c622207dac 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -606,33 +606,43 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": if isinstance(rhs, MockObj) and rhs.is_new_expr: # For 'new' allocations, use placement new into static storage # to avoid heap fragmentation on embedded devices. - the_type = id_.type + # + # Storage must be sized and aligned for the actual instantiated class, + # which may be a subclass of id_.type (e.g. `cv.declare_id(BaseClass)` + # combined with `SubClass.new()` — used by ili9xxx, waveshare_epaper, + # etc. to select a model-specific constructor). Using id_.type would + # run the base-class default constructor instead, silently losing any + # subclass initialization. Template args live on the CallExpression + # and are re-emitted below. + call_expr = rhs.base + assert isinstance(call_expr, CallExpression), ( + f"Expected CallExpression for placement new, got {type(call_expr)}" + ) + actual_type = rhs.new_type if rhs.new_type is not None else id_.type + if call_expr.template_args is not None: + actual_type = f"{actual_type}{call_expr.template_args}" + pointer_type = id_.type # Extract component namespace from type for memory analysis attribution - component_ns = _extract_component_ns(str(the_type)) + component_ns = _extract_component_ns(str(actual_type)) storage_name = f"{component_ns}__{id_.id}__pstorage" # Declare aligned byte array for the object storage CORE.add_global( RawStatement( - f"alignas({the_type}) static unsigned char {storage_name}[sizeof({the_type})];" + f"alignas({actual_type}) static unsigned char {storage_name}[sizeof({actual_type})];" ) ) + # Pointer declaration uses id_.type to preserve the declared base-class + # pointer type for downstream callers (polymorphism through base ptr). CORE.add_global( AssignmentExpression( - f"static {the_type}", + f"static {pointer_type}", "*const ", id_, - MockObj(f"reinterpret_cast<{the_type} *>({storage_name})"), + MockObj(f"reinterpret_cast<{pointer_type} *>({storage_name})"), ) ) - # Extract args from the CallExpression and rebuild as placement new. - # Template args are already encoded in the_type (e.g. GlobalsComponent), - # so we only pass the constructor args, not template_args. - call_expr = rhs.base - assert isinstance(call_expr, CallExpression), ( - f"Expected CallExpression for placement new, got {type(call_expr)}" - ) - placement_new = CallExpression(f"new({id_.id}) {the_type}", *call_expr.args) + placement_new = CallExpression(f"new({id_.id}) {actual_type}", *call_expr.args) CORE.add(ExpressionStatement(placement_new)) else: decl = VariableDeclarationExpression(id_.type, "*", id_, static=True) @@ -869,12 +879,16 @@ class MockObj(Expression): Mostly consists of magic methods that allow ESPHome's codegen syntax. """ - __slots__ = ("base", "op", "is_new_expr") + __slots__ = ("base", "op", "is_new_expr", "new_type") - def __init__(self, base, op=".", is_new_expr=False) -> None: + def __init__(self, base, op=".", is_new_expr=False, new_type=None) -> None: self.base = base self.op = op self.is_new_expr = is_new_expr + # For `is_new_expr=True` objects, `new_type` holds the class name being + # constructed (e.g. "ili9xxx::ILI9XXXST7789V"). Needed by Pvariable so + # placement new uses the actual subclass rather than id_.type. + self.new_type = new_type def __getattr__(self, attr: str) -> "MockObj": # prevent python dunder methods being replaced by mock objects @@ -889,7 +903,9 @@ class MockObj(Expression): def __call__(self, *args: SafeExpType) -> "MockObj": call = CallExpression(self.base, *args) - return MockObj(call, self.op, is_new_expr=self.is_new_expr) + return MockObj( + call, self.op, is_new_expr=self.is_new_expr, new_type=self.new_type + ) def __str__(self): return str(self.base) @@ -903,7 +919,7 @@ class MockObj(Expression): @property def new(self) -> "MockObj": - return MockObj(f"new {self.base}", "->", is_new_expr=True) + return MockObj(f"new {self.base}", "->", is_new_expr=True, new_type=self.base) def template(self, *args: SafeExpType) -> "MockObj": """Apply template parameters to this object.""" diff --git a/tests/component_tests/ili9xxx/__init__.py b/tests/component_tests/ili9xxx/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml b/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml new file mode 100644 index 0000000000..bc6148b8d8 --- /dev/null +++ b/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: arduino + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: ili9xxx + id: tft_display + model: ST7789V + cs_pin: GPIO5 + dc_pin: GPIO17 + reset_pin: GPIO16 + invert_colors: false diff --git a/tests/component_tests/ili9xxx/test_ili9xxx.py b/tests/component_tests/ili9xxx/test_ili9xxx.py new file mode 100644 index 0000000000..3919eb3823 --- /dev/null +++ b/tests/component_tests/ili9xxx/test_ili9xxx.py @@ -0,0 +1,31 @@ +"""Tests for the ili9xxx component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + + +def test_ili9xxx_placement_new_uses_model_subclass( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Regression test for ili9xxx picking the right constructor under placement new. + + ili9xxx declares the ID as the base ``ILI9XXXDisplay`` but constructs a + model-specific subclass (e.g. ``ILI9XXXST7789V``) via ``MODELS[...].new()``. + Pvariable must emit placement new for the subclass — otherwise the base + default constructor runs and the panel is left with a null init sequence + and 0x0 dimensions, producing a silent blank screen. + """ + main_cpp = generate_main(component_config_path("ili9xxx_test.yaml")) + + # Storage is sized for the subclass so the full object fits. + assert "sizeof(ili9xxx::ILI9XXXST7789V)" in main_cpp + assert "alignas(ili9xxx::ILI9XXXST7789V)" in main_cpp + # Pointer is declared as the base type for polymorphism. + assert "static ili9xxx::ILI9XXXDisplay *const tft_display" in main_cpp + # Placement new runs the subclass constructor — this is the actual regression fix. + assert "new(tft_display) ili9xxx::ILI9XXXST7789V()" in main_cpp + # Base-class default constructor must NOT be used. + assert "new(tft_display) ili9xxx::ILI9XXXDisplay()" not in main_cpp From 4cb7ea2584225f5ea919d4a5f3088f075484dc9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:37:56 +0200 Subject: [PATCH 75/94] [light] Force-inline LightCall::set_flag_/clear_flag_ (#15729) --- esphome/components/light/light_call.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 88d29bd349..39953d0d20 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -222,7 +222,7 @@ class LightCall { inline bool get_save_() { return (this->flags_ & FLAG_SAVE) != 0; } // Helper to set flag - defaults to true for common case - void set_flag_(FieldFlags flag, bool value = true) { + void set_flag_(FieldFlags flag, bool value = true) ESPHOME_ALWAYS_INLINE { if (value) { this->flags_ |= flag; } else { @@ -231,7 +231,7 @@ class LightCall { } // Helper to clear flag - reduces code size for common case - void clear_flag_(FieldFlags flag) { this->flags_ &= ~flag; } + void clear_flag_(FieldFlags flag) ESPHOME_ALWAYS_INLINE { this->flags_ &= ~flag; } // Helper to log unsupported feature and clear flag - reduces code duplication void log_and_clear_unsupported_(FieldFlags flag, const LogString *feature, bool use_color_mode_log); From 0a0176d600e1b33159ad737df15640ee43cac525 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:38:12 +0200 Subject: [PATCH 76/94] [core] raise WDT_FEED_INTERVAL_MS from 3 ms to 300 ms (#15846) --- esphome/core/application.cpp | 54 +++++++++++++++++++++------------ esphome/core/application.h | 59 ++++++++++++++++++++++++++++++------ 2 files changed, 84 insertions(+), 29 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 866edebbf6..e5e1b36a65 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -211,11 +211,16 @@ void Application::process_dump_config_() { void Application::feed_wdt() { // Cold entry: callers without a millis() timestamp in hand. Fetches the - // time and takes the same rate-limit path as feed_wdt_with_time(). + // time and takes the same rate-limit paths as feed_wdt_with_time(). uint32_t now = millis(); if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) { this->feed_wdt_slow_(now); } +#ifdef USE_STATUS_LED + if (now - this->last_status_led_service_ > STATUS_LED_DISPATCH_INTERVAL_MS) { + this->service_status_led_slow_(now); + } +#endif } void HOT Application::feed_wdt_slow_(uint32_t time) { @@ -223,27 +228,36 @@ void HOT Application::feed_wdt_slow_(uint32_t time) { // confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded. arch_feed_wdt(); this->last_wdt_feed_ = time; -#ifdef USE_STATUS_LED - if (status_led::global_status_led != nullptr) { - auto *sl = status_led::global_status_led; - uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; - if (sl_state == COMPONENT_STATE_LOOP_DONE) { - // status_led only transitions to LOOP_DONE from inside its own loop() (after the - // first idle-path dispatch), so its pin is already initialized by pre_setup() and - // its setup() has already run. Re-dispatch only if an error or warning bit has been - // set since; otherwise skip entirely. - if ((this->app_state_ & STATUS_LED_MASK) == 0) - return; - sl->enable_loop(); - } else if (sl_state != COMPONENT_STATE_LOOP) { - // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. - return; - } - sl->loop(); - } -#endif } +#ifdef USE_STATUS_LED +void HOT Application::service_status_led_slow_(uint32_t time) { + // Callers (feed_wdt(), feed_wdt_with_time()) have already confirmed the + // STATUS_LED_DISPATCH_INTERVAL_MS rate limit was exceeded. Rate-limited + // separately from arch_feed_wdt() so the LED blink pattern stays readable + // (status_led error blink period is 250 ms) while HAL watchdog pokes can + // still run at the much coarser WDT_FEED_INTERVAL_MS cadence. + this->last_status_led_service_ = time; + if (status_led::global_status_led == nullptr) + return; + auto *sl = status_led::global_status_led; + uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; + if (sl_state == COMPONENT_STATE_LOOP_DONE) { + // status_led only transitions to LOOP_DONE from inside its own loop() (after the + // first idle-path dispatch), so its pin is already initialized by pre_setup() and + // its setup() has already run. Re-dispatch only if an error or warning bit has been + // set since; otherwise skip entirely. + if ((this->app_state_ & STATUS_LED_MASK) == 0) + return; + sl->enable_loop(); + } else if (sl_state != COMPONENT_STATE_LOOP) { + // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. + return; + } + sl->loop(); +} +#endif + bool Application::any_component_has_status_flag_(uint8_t flag) const { // Walk all components (not just looping ones) so non-looping components' // status bits are respected. Only called from the slow-path clear helpers diff --git a/esphome/core/application.h b/esphome/core/application.h index 9252a47446..645caa2404 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -229,23 +229,50 @@ class Application { void schedule_dump_config() { this->dump_config_at_ = 0; } - /// Minimum interval between real arch_feed_wdt() calls. Chosen to keep the - /// rate of HAL pokes low while still being small enough that any plausible - /// watchdog timeout (seconds) has orders of magnitude of safety margin. - static constexpr uint32_t WDT_FEED_INTERVAL_MS = 3; + /// Minimum interval between real arch_feed_wdt() calls. Sized so the outer + /// feed in Application::loop() is effectively rate-limited across both the + /// normal ~62 Hz cadence and worst-case wake-storm scenarios (e.g. external + /// stacks like OpenThread posting frequent wake notifications). Component + /// loops and scheduler items still feed after every op, so any op exceeding + /// this threshold triggers a real feed naturally. + /// Safety margins vs. platform watchdog timeouts: + /// - ESP32 task WDT default (5 s): ~16x + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here + /// - ESP8266 HW WDT (~6 s): ~20x + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; /// Feed the task watchdog. Cold entry — callers without a millis() /// timestamp in hand. Out of line to keep call sites tiny. void feed_wdt(); +#ifdef USE_STATUS_LED + /// Dispatch interval for the status LED update. Deliberately shorter than + /// WDT_FEED_INTERVAL_MS because the status LED error blink has a 250 ms + /// period (status_led.cpp:ERROR_PERIOD_MS) and a 150 ms on-window; the + /// dispatch cadence must be short enough to render that blink without + /// aliasing. Sampling every 100 ms yields an on/off observation inside + /// every error period with headroom for the 250 ms warning on-window. + static constexpr uint32_t STATUS_LED_DISPATCH_INTERVAL_MS = 100; +#endif + /// Feed the task watchdog, hot entry. Callers that already have a /// millis() timestamp pay only a load + sub + branch on the common - /// (no-op) path. The actual arch feed + status LED update live in - /// feed_wdt_slow_. + /// (no-op) path. The actual arch feed lives in feed_wdt_slow_. + /// When USE_STATUS_LED is compiled in, also gates a separate (shorter) + /// interval for dispatching status_led so the LED blink pattern stays + /// readable even though arch_feed_wdt pokes are now rate-limited at + /// WDT_FEED_INTERVAL_MS. The two rate limits are independent so raising + /// WDT_FEED_INTERVAL_MS does not distort the LED cadence. void ESPHOME_ALWAYS_INLINE feed_wdt_with_time(uint32_t time) { if (static_cast(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) [[unlikely]] { this->feed_wdt_slow_(time); } +#ifdef USE_STATUS_LED + if (static_cast(time - this->last_status_led_service_) > STATUS_LED_DISPATCH_INTERVAL_MS) [[unlikely]] { + this->service_status_led_slow_(time); + } +#endif } void reboot(); @@ -410,11 +437,21 @@ class Application { /// Caller must ensure dump_config_at_ < components_.size(). void __attribute__((noinline)) process_dump_config_(); - /// Slow path for feed_wdt(): actually calls arch_feed_wdt(), updates - /// last_wdt_feed_, and re-dispatches the status LED. Out of line so the - /// inline wrapper stays tiny. + /// Slow path for feed_wdt(): actually calls arch_feed_wdt() and updates + /// last_wdt_feed_. Out of line so the inline wrapper stays tiny. Does NOT + /// touch status_led — that's gated separately via service_status_led_slow_ + /// because the two rate limits have very different safe ranges (~ seconds + /// for WDT, < 250 ms for LED blink rendering). void feed_wdt_slow_(uint32_t time); +#ifdef USE_STATUS_LED + /// Slow path for the status_led dispatch rate limit. Runs the status_led + /// component's loop() based on its state (LOOP / LOOP_DONE with status + /// bits set), and updates last_status_led_service_. Out of line to keep + /// the feed_wdt_with_time hot path a couple of load+branch sequences. + void service_status_led_slow_(uint32_t time); +#endif + /// Perform a delay while also monitoring socket file descriptors for readiness #ifdef USE_HOST // select() fallback path is too complex to inline (host platform) @@ -468,6 +505,10 @@ class Application { uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; uint32_t last_wdt_feed_{0}; // millis() of most recent arch_feed_wdt(); rate-limits feed_wdt() hot path +#ifdef USE_STATUS_LED + // millis() of most recent status_led dispatch; rate-limits independently of last_wdt_feed_ + uint32_t last_status_led_service_{0}; +#endif #ifdef USE_HOST int max_fd_{-1}; // Highest file descriptor number for select() From 0d3a3552dadc74d8770fe9dd2f3e41402eac9c31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:39:49 +0200 Subject: [PATCH 77/94] [core] Move heap-allocating helpers to alloc_helpers.h/cpp (#15623) --- esphome/components/anova/anova_base.cpp | 6 +- .../components/http_request/http_request.cpp | 2 +- .../components/http_request/http_request.h | 5 +- .../http_request/http_request_arduino.cpp | 2 +- .../http_request/http_request_host.cpp | 2 +- .../http_request/http_request_idf.cpp | 2 +- esphome/core/alloc_helpers.cpp | 229 +++++++++++++++++ esphome/core/alloc_helpers.h | 128 +++++++++ esphome/core/helpers.cpp | 189 +------------- esphome/core/helpers.h | 242 ++---------------- script/ci-custom.py | 16 +- 11 files changed, 417 insertions(+), 406 deletions(-) create mode 100644 esphome/core/alloc_helpers.cpp create mode 100644 esphome/core/alloc_helpers.h diff --git a/esphome/components/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp index fef4f1d852..a14dd728a8 100644 --- a/esphome/components/anova/anova_base.cpp +++ b/esphome/components/anova/anova_base.cpp @@ -2,6 +2,8 @@ #include #include +#include "esphome/core/alloc_helpers.h" + namespace esphome { namespace anova { @@ -105,14 +107,14 @@ void AnovaCodec::decode(const uint8_t *data, uint16_t length) { } case READ_TARGET_TEMPERATURE: case SET_TARGET_TEMPERATURE: { - this->target_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); + this->target_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); // NOLINT if (this->fahrenheit_) this->target_temp_ = ftoc(this->target_temp_); this->has_target_temp_ = true; break; } case READ_CURRENT_TEMPERATURE: { - this->current_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); + this->current_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); // NOLINT if (this->fahrenheit_) this->current_temp_ = ftoc(this->current_temp_); this->has_current_temp_ = true; diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index 2c74638f12..d45208ed5d 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -22,7 +22,7 @@ void HttpRequestComponent::dump_config() { } std::string HttpContainer::get_response_header(const std::string &header_name) { - auto lower = str_lower_case(header_name); + auto lower = str_lower_case(header_name); // NOLINT for (const auto &entry : this->response_headers_) { if (entry.name == lower) { ESP_LOGD(TAG, "Header with name %s found with value %s", lower.c_str(), entry.value.c_str()); diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index ae73983bab..f37bf77633 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -11,6 +11,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/alloc_helpers.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -400,7 +401,7 @@ class HttpRequestComponent : public Component { std::vector lower; lower.reserve(collect_headers.size()); for (const auto &h : collect_headers) { - lower.push_back(str_lower_case(h)); + lower.push_back(str_lower_case(h)); // NOLINT } return this->perform(url, method, body, request_headers, lower); } @@ -415,7 +416,7 @@ class HttpRequestComponent : public Component { std::vector lower; lower.reserve(collect_headers.size()); for (const auto &h : collect_headers) { - lower.push_back(str_lower_case(h)); + lower.push_back(str_lower_case(h)); // NOLINT } return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), lower); } diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index f0dd649285..05f9db1c06 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -161,7 +161,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->response_headers_.clear(); auto header_count = container->client_.headers(); for (int i = 0; i < header_count; i++) { - const std::string header_name = str_lower_case(container->client_.headerName(i).c_str()); + const std::string header_name = str_lower_case(container->client_.headerName(i).c_str()); // NOLINT if (should_collect_header(lower_case_collect_headers, header_name)) { std::string header_value = container->client_.header(i).c_str(); ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str()); diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 60ab4d68a0..85c6e8b3c7 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -115,7 +115,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, container->content_length = container->response_body_.size(); for (auto header : response.headers) { ESP_LOGD(TAG, "Header: %s: %s", header.first.c_str(), header.second.c_str()); - auto lower_name = str_lower_case(header.first); + auto lower_name = str_lower_case(header.first); // NOLINT if (should_collect_header(lower_case_collect_headers, lower_name)) { container->response_headers_.push_back({lower_name, header.second}); } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 30f53eecdc..3e341395a4 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -38,7 +38,7 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { switch (evt->event_id) { case HTTP_EVENT_ON_HEADER: { - const std::string header_name = str_lower_case(evt->header_key); + const std::string header_name = str_lower_case(evt->header_key); // NOLINT if (should_collect_header(user_data->lower_case_collect_headers, header_name)) { const std::string header_value = evt->header_value; ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str()); diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp new file mode 100644 index 0000000000..11c7abe3f7 --- /dev/null +++ b/esphome/core/alloc_helpers.cpp @@ -0,0 +1,229 @@ +#include "esphome/core/alloc_helpers.h" + +#include "esphome/core/helpers.h" + +#include +#include +#include +#include +#include +#include + +namespace esphome { + +// --- String helpers --- + +std::string str_truncate(const std::string &str, size_t length) { + return str.length() > length ? str.substr(0, length) : str; +} + +std::string str_until(const char *str, char ch) { + const char *pos = strchr(str, ch); + return pos == nullptr ? std::string(str) : std::string(str, pos - str); +} +std::string str_until(const std::string &str, char ch) { return str.substr(0, str.find(ch)); } + +// wrapper around std::transform to run safely on functions from the ctype.h header +// see https://en.cppreference.com/w/cpp/string/byte/toupper#Notes +template std::string str_ctype_transform(const std::string &str) { + std::string result; + result.resize(str.length()); + std::transform(str.begin(), str.end(), result.begin(), [](unsigned char ch) { return fn(ch); }); + return result; +} +std::string str_lower_case(const std::string &str) { return str_ctype_transform(str); } + +std::string str_upper_case(const std::string &str) { + std::string result; + result.resize(str.length()); + std::transform(str.begin(), str.end(), result.begin(), [](unsigned char ch) { return std::toupper(ch); }); + return result; +} + +std::string str_snake_case(const std::string &str) { + std::string result = str; + for (char &c : result) { + c = to_snake_case_char(c); + } + return result; +} + +std::string str_sanitize(const std::string &str) { + std::string result; + result.resize(str.size()); + str_sanitize_to(&result[0], str.size() + 1, str.c_str()); + return result; +} + +std::string str_snprintf(const char *fmt, size_t len, ...) { + std::string str; + va_list args; + + str.resize(len); + va_start(args, len); + size_t out_length = vsnprintf(&str[0], len + 1, fmt, args); + va_end(args); + + if (out_length < len) + str.resize(out_length); + + return str; +} + +std::string str_sprintf(const char *fmt, ...) { + std::string str; + va_list args; + + va_start(args, fmt); + size_t length = vsnprintf(nullptr, 0, fmt, args); + va_end(args); + + str.resize(length); + va_start(args, fmt); + vsnprintf(&str[0], length + 1, fmt, args); + va_end(args); + + return str; +} + +// --- Value formatting helpers --- + +std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { + char buf[VALUE_ACCURACY_MAX_LEN]; + value_accuracy_to_buf(buf, value, accuracy_decimals); + return std::string(buf); +} + +// --- Base64 helpers --- + +static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789+/"; + +// Encode 3 input bytes to 4 base64 characters, append 'count' to ret. +static inline void base64_encode_triple(const char *char_array_3, int count, std::string &ret) { + char char_array_4[4]; + char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; + char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); + char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); + char_array_4[3] = char_array_3[2] & 0x3f; + + for (int j = 0; j < count; j++) + ret += BASE64_CHARS[static_cast(char_array_4[j])]; +} + +std::string base64_encode(const std::vector &buf) { return base64_encode(buf.data(), buf.size()); } + +std::string base64_encode(const uint8_t *buf, size_t buf_len) { + std::string ret; + int i = 0; + char char_array_3[3]; + + while (buf_len--) { + char_array_3[i++] = *(buf++); + if (i == 3) { + base64_encode_triple(char_array_3, 4, ret); + i = 0; + } + } + + if (i) { + for (int j = i; j < 3; j++) + char_array_3[j] = '\0'; + + base64_encode_triple(char_array_3, i + 1, ret); + + while ((i++ < 3)) + ret += '='; + } + + return ret; +} + +std::vector base64_decode(const std::string &encoded_string) { + // Calculate maximum decoded size: every 4 base64 chars = 3 bytes + size_t max_len = ((encoded_string.size() + 3) / 4) * 3; + std::vector ret(max_len); + size_t actual_len = base64_decode(encoded_string, ret.data(), max_len); + ret.resize(actual_len); + return ret; +} + +// --- Hex/binary formatting helpers --- + +std::string format_mac_address_pretty(const uint8_t *mac) { + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); +} + +std::string format_hex(const uint8_t *data, size_t length) { + std::string ret; + ret.resize(length * 2); + format_hex_to(&ret[0], length * 2 + 1, data, length); + return ret; +} + +std::string format_hex(const std::vector &data) { return format_hex(data.data(), data.size()); } + +// Shared implementation for uint8_t and string hex pretty formatting +static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, char separator, bool show_length) { + if (data == nullptr || length == 0) + return ""; + std::string ret; + size_t hex_len = separator ? (length * 3 - 1) : (length * 2); + ret.resize(hex_len); + format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); + if (show_length && length > 4) + return ret + " (" + std::to_string(length) + ")"; + return ret; +} + +std::string format_hex_pretty(const uint8_t *data, size_t length, char separator, bool show_length) { + return format_hex_pretty_uint8(data, length, separator, show_length); +} +std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { + return format_hex_pretty(data.data(), data.size(), separator, show_length); +} + +std::string format_hex_pretty(const uint16_t *data, size_t length, char separator, bool show_length) { + if (data == nullptr || length == 0) + return ""; + std::string ret; + size_t hex_len = separator ? (length * 5 - 1) : (length * 4); + ret.resize(hex_len); + format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); + if (show_length && length > 4) + return ret + " (" + std::to_string(length) + ")"; + return ret; +} +std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { + return format_hex_pretty(data.data(), data.size(), separator, show_length); +} +std::string format_hex_pretty(const std::string &data, char separator, bool show_length) { + return format_hex_pretty_uint8(reinterpret_cast(data.data()), data.length(), separator, show_length); +} + +std::string format_bin(const uint8_t *data, size_t length) { + std::string result; + result.resize(length * 8); + format_bin_to(&result[0], length * 8 + 1, data, length); + return result; +} + +// --- MAC address helpers --- + +std::string get_mac_address() { + uint8_t mac[6]; + get_mac_address_raw(mac); + char buf[13]; + format_mac_addr_lower_no_sep(mac, buf); + return std::string(buf); +} + +std::string get_mac_address_pretty() { + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + return std::string(get_mac_address_pretty_into_buffer(buf)); +} + +} // namespace esphome diff --git a/esphome/core/alloc_helpers.h b/esphome/core/alloc_helpers.h new file mode 100644 index 0000000000..fe350886b7 --- /dev/null +++ b/esphome/core/alloc_helpers.h @@ -0,0 +1,128 @@ +#pragma once + +/// @file alloc_helpers.h +/// @brief Heap-allocating helper functions. +/// +/// These functions return std::string and allocate heap memory on every call. +/// On long-running embedded devices, repeated heap allocations fragment memory +/// over time, eventually causing crashes even with free memory available. +/// +/// Prefer the stack-based alternatives documented on each function instead. +/// New code should avoid using these functions. + +#include +#include +#include +#include +#include + +namespace esphome { + +// --- String helpers (allocating) --- + +/// Truncate a string to a specific length. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_truncate(const std::string &str, size_t length); + +/// Extract the part of the string until either the first occurrence of the specified character, or the end +/// (requires str to be null-terminated). +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_until(const char *str, char ch); +/// Extract the part of the string until either the first occurrence of the specified character, or the end. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_until(const std::string &str, char ch); + +/// Convert the string to lower case. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_lower_case(const std::string &str); + +/// Convert the string to upper case. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_upper_case(const std::string &str); + +/// Convert the string to snake case (lowercase with underscores). +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_snake_case(const std::string &str); + +/// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores. +/// @warning Allocates heap memory. Use str_sanitize_to() with a stack buffer instead. +std::string str_sanitize(const std::string &str); + +/// snprintf-like function returning std::string of maximum length \p len (excluding null terminator). +/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. +std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt, size_t len, ...); + +/// sprintf-like function returning std::string. +/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. +std::string __attribute__((format(printf, 1, 2))) str_sprintf(const char *fmt, ...); + +// --- Hex/binary formatting helpers (allocating) --- + +/// Format the six-byte array \p mac into a MAC address string. +/// @warning Allocates heap memory. Use format_mac_addr_upper() with a stack buffer instead. +std::string format_mac_address_pretty(const uint8_t mac[6]); + +/// Format the byte array \p data of length \p len in lowercased hex. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +std::string format_hex(const uint8_t *data, size_t length); + +/// Format the vector \p data in lowercased hex. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +std::string format_hex(const std::vector &data); + +/// Format a byte array in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const uint8_t *data, size_t length, char separator = '.', bool show_length = true); + +/// Format a 16-bit word array in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const uint16_t *data, size_t length, char separator = '.', bool show_length = true); + +/// Format a byte vector in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); + +/// Format a 16-bit word vector in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); + +/// Format a string's bytes in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const std::string &data, char separator = '.', bool show_length = true); + +/// Format the byte array \p data of length \p len in binary. +/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. +std::string format_bin(const uint8_t *data, size_t length); + +// --- Value formatting helpers (allocating) --- + +/// Format a float value with accuracy decimals to a string. +/// @deprecated Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0. +__attribute__((deprecated("Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0."))) +std::string +value_accuracy_to_string(float value, int8_t accuracy_decimals); + +// --- Base64 helpers (allocating) --- + +/// Encode a byte buffer to base64 string. +/// @warning Allocates heap memory. +std::string base64_encode(const uint8_t *buf, size_t buf_len); +/// Encode a byte vector to base64 string. +/// @warning Allocates heap memory. +std::string base64_encode(const std::vector &buf); + +/// Decode a base64 string to a byte vector. +/// @warning Allocates heap memory. Use base64_decode(data, len, buf, buf_len) with a pre-allocated buffer instead. +std::vector base64_decode(const std::string &encoded_string); + +// --- MAC address helpers (allocating) --- + +/// Get the device MAC address as a string, in lowercase hex notation. +/// @warning Allocates heap memory. Use get_mac_address_into_buffer() instead. +std::string get_mac_address(); + +/// Get the device MAC address as a string, in colon-separated uppercase hex notation. +/// @warning Allocates heap memory. Use get_mac_address_pretty_into_buffer() instead. +std::string get_mac_address_pretty(); + +} // namespace esphome diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 34ecaf137f..1d0efd01ce 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -221,31 +221,7 @@ bool str_endswith_ignore_case(const char *str, size_t str_len, const char *suffi return strncasecmp(str + str_len - suffix_len, suffix, suffix_len) == 0; } -std::string str_truncate(const std::string &str, size_t length) { - return str.length() > length ? str.substr(0, length) : str; -} -std::string str_until(const char *str, char ch) { - const char *pos = strchr(str, ch); - return pos == nullptr ? std::string(str) : std::string(str, pos - str); -} -std::string str_until(const std::string &str, char ch) { return str.substr(0, str.find(ch)); } -// wrapper around std::transform to run safely on functions from the ctype.h header -// see https://en.cppreference.com/w/cpp/string/byte/toupper#Notes -template std::string str_ctype_transform(const std::string &str) { - std::string result; - result.resize(str.length()); - std::transform(str.begin(), str.end(), result.begin(), [](unsigned char ch) { return fn(ch); }); - return result; -} -std::string str_lower_case(const std::string &str) { return str_ctype_transform(str); } -std::string str_upper_case(const std::string &str) { return str_ctype_transform(str); } -std::string str_snake_case(const std::string &str) { - std::string result = str; - for (char &c : result) { - c = to_snake_case_char(c); - } - return result; -} +// str_truncate, str_until, str_lower_case, str_upper_case, str_snake_case moved to alloc_helpers.cpp char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { if (buffer_size == 0) { return buffer; @@ -258,41 +234,7 @@ char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { return buffer; } -std::string str_sanitize(const std::string &str) { - std::string result; - result.resize(str.size()); - str_sanitize_to(&result[0], str.size() + 1, str.c_str()); - return result; -} -std::string str_snprintf(const char *fmt, size_t len, ...) { - std::string str; - va_list args; - - str.resize(len); - va_start(args, len); - size_t out_length = vsnprintf(&str[0], len + 1, fmt, args); - va_end(args); - - if (out_length < len) - str.resize(out_length); - - return str; -} -std::string str_sprintf(const char *fmt, ...) { - std::string str; - va_list args; - - va_start(args, fmt); - size_t length = vsnprintf(nullptr, 0, fmt, args); - va_end(args); - - str.resize(length); - va_start(args, fmt); - vsnprintf(&str[0], length + 1, fmt, args); - va_end(args); - - return str; -} +// str_sanitize, str_snprintf, str_sprintf moved to alloc_helpers.cpp // 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; @@ -341,11 +283,7 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { return chars; } -std::string format_mac_address_pretty(const uint8_t *mac) { - char buf[18]; - format_mac_addr_upper(mac, buf); - return std::string(buf); -} +// format_mac_address_pretty moved to alloc_helpers.cpp // Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase. // When separator is set, it is written unconditionally after each byte and the last @@ -398,13 +336,7 @@ char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_ return format_hex_internal(buffer, buffer_size, data, length, 0, 'a'); } -std::string format_hex(const uint8_t *data, size_t length) { - std::string ret; - ret.resize(length * 2); - format_hex_to(&ret[0], length * 2 + 1, data, length); - return ret; -} -std::string format_hex(const std::vector &data) { return format_hex(data.data(), data.size()); } +// format_hex (std::string returning overloads) moved to alloc_helpers.cpp char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator) { return format_hex_internal(buffer, buffer_size, data, length, separator, 'A'); @@ -441,43 +373,7 @@ char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint16_t *dat return buffer; } -// Shared implementation for uint8_t and string hex formatting -static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, char separator, bool show_length) { - if (data == nullptr || length == 0) - return ""; - std::string ret; - size_t hex_len = separator ? (length * 3 - 1) : (length * 2); - ret.resize(hex_len); - format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); - if (show_length && length > 4) - return ret + " (" + std::to_string(length) + ")"; - return ret; -} - -std::string format_hex_pretty(const uint8_t *data, size_t length, char separator, bool show_length) { - return format_hex_pretty_uint8(data, length, separator, show_length); -} -std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { - return format_hex_pretty(data.data(), data.size(), separator, show_length); -} - -std::string format_hex_pretty(const uint16_t *data, size_t length, char separator, bool show_length) { - if (data == nullptr || length == 0) - return ""; - std::string ret; - size_t hex_len = separator ? (length * 5 - 1) : (length * 4); - ret.resize(hex_len); - format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); - if (show_length && length > 4) - return ret + " (" + std::to_string(length) + ")"; - return ret; -} -std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { - return format_hex_pretty(data.data(), data.size(), separator, show_length); -} -std::string format_hex_pretty(const std::string &data, char separator, bool show_length) { - return format_hex_pretty_uint8(reinterpret_cast(data.data()), data.length(), separator, show_length); -} +// format_hex_pretty (all std::string returning overloads) moved to alloc_helpers.cpp char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) { if (buffer_size == 0) { @@ -500,12 +396,7 @@ char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_ return buffer; } -std::string format_bin(const uint8_t *data, size_t length) { - std::string result; - result.resize(length * 8); - format_bin_to(&result[0], length * 8 + 1, data, length); - return result; -} +// format_bin moved to alloc_helpers.cpp ParseOnOffState parse_on_off(const char *str, const char *on, const char *off) { if (on == nullptr && ESPHOME_strcasecmp_P(str, ESPHOME_PSTR("on")) == 0) @@ -537,11 +428,7 @@ static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_de } } -std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { - char buf[VALUE_ACCURACY_MAX_LEN]; - value_accuracy_to_buf(buf, value, accuracy_decimals); - return std::string(buf); -} +// value_accuracy_to_string moved to alloc_helpers.cpp size_t value_accuracy_to_buf(std::span buf, float value, int8_t accuracy_decimals) { normalize_accuracy_decimals(value, accuracy_decimals); @@ -606,45 +493,7 @@ static inline uint8_t base64_find_char(char c) { // Check if character is valid base64 or base64url static inline bool is_base64(char c) { return (isalnum(c) || (c == '+') || (c == '/') || (c == '-') || (c == '_')); } -std::string base64_encode(const std::vector &buf) { return base64_encode(buf.data(), buf.size()); } - -// Encode 3 input bytes to 4 base64 characters, append 'count' to ret. -static inline void base64_encode_triple(const char *char_array_3, int count, std::string &ret) { - char char_array_4[4]; - char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; - char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); - char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); - char_array_4[3] = char_array_3[2] & 0x3f; - - for (int j = 0; j < count; j++) - ret += BASE64_CHARS[static_cast(char_array_4[j])]; -} - -std::string base64_encode(const uint8_t *buf, size_t buf_len) { - std::string ret; - int i = 0; - char char_array_3[3]; - - while (buf_len--) { - char_array_3[i++] = *(buf++); - if (i == 3) { - base64_encode_triple(char_array_3, 4, ret); - i = 0; - } - } - - if (i) { - for (int j = i; j < 3; j++) - char_array_3[j] = '\0'; - - base64_encode_triple(char_array_3, i + 1, ret); - - while ((i++ < 3)) - ret += '='; - } - - return ret; -} +// base64_encode (both overloads) moved to alloc_helpers.cpp size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { return base64_decode(reinterpret_cast(encoded_string.data()), encoded_string.size(), buf, buf_len); @@ -705,14 +554,7 @@ size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *b return out; } -std::vector base64_decode(const std::string &encoded_string) { - // Calculate maximum decoded size: every 4 base64 chars = 3 bytes - size_t max_len = ((encoded_string.size() + 3) / 4) * 3; - std::vector ret(max_len); - size_t actual_len = base64_decode(encoded_string, ret.data(), max_len); - ret.resize(actual_len); - return ret; -} +// base64_decode (vector-returning overload) moved to alloc_helpers.cpp /// Decode base64/base64url string directly into vector of little-endian int32 values /// @param base64 Base64 or base64url encoded string (both +/ and -_ accepted) @@ -851,18 +693,7 @@ void HighFrequencyLoopRequester::stop() { this->started_ = false; } -std::string get_mac_address() { - uint8_t mac[6]; - get_mac_address_raw(mac); - char buf[13]; - format_mac_addr_lower_no_sep(mac, buf); - return std::string(buf); -} - -std::string get_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(get_mac_address_pretty_into_buffer(buf)); -} +// get_mac_address, get_mac_address_pretty moved to alloc_helpers.cpp void get_mac_address_into_buffer(std::span buf) { uint8_t mac[6]; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 54bc32a5a5..bb164f5034 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -21,6 +21,12 @@ #include "esphome/core/optional.h" +// Backward compatibility re-export of heap-allocating helpers. +// These functions have moved to alloc_helpers.h. External components should +// update their includes to use #include "esphome/core/alloc_helpers.h" directly. +// This re-export will be removed in 2026.11.0. +#include "esphome/core/alloc_helpers.h" + #ifdef USE_ESP8266 #include #include @@ -979,27 +985,13 @@ inline bool str_endswith_ignore_case(const std::string &str, const char *suffix) return str_endswith_ignore_case(str.c_str(), str.size(), suffix, strlen(suffix)); } -/// Truncate a string to a specific length. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -std::string str_truncate(const std::string &str, size_t length); +// str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0 -/// Extract the part of the string until either the first occurrence of the specified character, or the end -/// (requires str to be null-terminated). -std::string str_until(const char *str, char ch); -/// Extract the part of the string until either the first occurrence of the specified character, or the end. -std::string str_until(const std::string &str, char ch); - -/// Convert the string to lower case. -std::string str_lower_case(const std::string &str); -/// Convert the string to upper case. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -std::string str_upper_case(const std::string &str); +// str_until, str_lower_case, str_upper_case moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Convert a single char to snake_case: lowercase and space to underscore. constexpr char to_snake_case_char(char c) { return (c == ' ') ? '_' : (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c; } -/// Convert the string to snake case (lowercase with underscores). -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -std::string str_snake_case(const std::string &str); +// str_snake_case moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Sanitize a single char: keep alphanumerics, dashes, underscores; replace others with underscore. constexpr char to_sanitized_char(char c) { @@ -1022,9 +1014,7 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s return str_sanitize_to(buffer, N, str); } -/// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores. -/// @warning Allocates heap memory. Use str_sanitize_to() with a stack buffer instead. -std::string str_sanitize(const std::string &str); +// str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. /// This computes object_id hashes directly from names without creating an intermediate buffer. @@ -1040,13 +1030,7 @@ inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { return hash; } -/// snprintf-like function returning std::string of maximum length \p len (excluding null terminator). -/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. -std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt, size_t len, ...); - -/// sprintf-like function returning std::string. -/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. -std::string __attribute__((format(printf, 1, 2))) str_sprintf(const char *fmt, ...); +// str_snprintf, str_sprintf moved to alloc_helpers.h - remove this comment before 2026.11.0 #ifdef USE_ESP8266 // ESP8266: Use vsnprintf_P to keep format strings in flash (PROGMEM) @@ -1441,189 +1425,26 @@ inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { format_hex_to(output, MAC_ADDRESS_BUFFER_SIZE, mac, MAC_ADDRESS_SIZE); } -/// Format the six-byte array \p mac into a MAC address. -/// @warning Allocates heap memory. Use format_mac_addr_upper() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_mac_address_pretty(const uint8_t mac[6]); -/// Format the byte array \p data of length \p len in lowercased hex. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_hex(const uint8_t *data, size_t length); -/// Format the vector \p data in lowercased hex. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_hex(const std::vector &data); +// format_mac_address_pretty, format_hex (all overloads) moved to alloc_helpers.h +// Remove this comment and the template overloads below before 2026.11.0 + /// Format an unsigned integer in lowercased hex, starting with the most significant byte. /// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. template::value, int> = 0> std::string format_hex(T val) { val = convert_big_endian(val); return format_hex(reinterpret_cast(&val), sizeof(T)); } /// Format the std::array \p data in lowercased hex. /// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. template std::string format_hex(const std::array &data) { return format_hex(data.data(), data.size()); } -/** Format a byte array in pretty-printed, human-readable hex format. - * - * Converts binary data to a hexadecimal string representation with customizable formatting. - * Each byte is displayed as a two-digit uppercase hex value, separated by the specified separator. - * Optionally includes the total byte count in parentheses at the end. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Pointer to the byte array to format. - * @param length Number of bytes in the array. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string, e.g., "A1.B2.C3.D4.E5 (5)" or "A1:B2:C3" depending on parameters. - * - * @note Returns empty string if data is nullptr or length is 0. - * @note The length will only be appended if show_length is true AND the length is greater than 4. - * - * Example: - * @code - * uint8_t data[] = {0xA1, 0xB2, 0xC3}; - * format_hex_pretty(data, 3); // Returns "A1.B2.C3" (no length shown for <= 4 parts) - * uint8_t data2[] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5}; - * format_hex_pretty(data2, 5); // Returns "A1.B2.C3.D4.E5 (5)" - * format_hex_pretty(data2, 5, ':'); // Returns "A1:B2:C3:D4:E5 (5)" - * format_hex_pretty(data2, 5, '.', false); // Returns "A1.B2.C3.D4.E5" - * @endcode - */ -std::string format_hex_pretty(const uint8_t *data, size_t length, char separator = '.', bool show_length = true); +// format_hex_pretty (all overloads) moved to alloc_helpers.h +// Remove this comment and the template overload below before 2026.11.0 -/** Format a 16-bit word array in pretty-printed, human-readable hex format. - * - * Similar to the byte array version, but formats 16-bit words as 4-digit hex values. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Pointer to the 16-bit word array to format. - * @param length Number of 16-bit words in the array. - * @param separator Character to use between hex words (default: '.'). - * @param show_length Whether to append the word count in parentheses (default: true). - * @return Formatted hex string with 4-digit hex values per word. - * - * @note The length will only be appended if show_length is true AND the length is greater than 4. - * - * Example: - * @code - * uint16_t data[] = {0xA1B2, 0xC3D4}; - * format_hex_pretty(data, 2); // Returns "A1B2.C3D4" (no length shown for <= 4 parts) - * uint16_t data2[] = {0xA1B2, 0xC3D4, 0xE5F6}; - * format_hex_pretty(data2, 3); // Returns "A1B2.C3D4.E5F6 (3)" - * @endcode - */ -std::string format_hex_pretty(const uint16_t *data, size_t length, char separator = '.', bool show_length = true); - -/** Format a byte vector in pretty-printed, human-readable hex format. - * - * Convenience overload for std::vector. Formats each byte as a two-digit - * uppercase hex value with customizable separator. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Vector of bytes to format. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string representation of the vector contents. - * - * @note The length will only be appended if show_length is true AND the vector size is greater than 4. - * - * Example: - * @code - * std::vector data = {0xDE, 0xAD, 0xBE, 0xEF}; - * format_hex_pretty(data); // Returns "DE.AD.BE.EF" (no length shown for <= 4 parts) - * std::vector data2 = {0xDE, 0xAD, 0xBE, 0xEF, 0xCA}; - * format_hex_pretty(data2); // Returns "DE.AD.BE.EF.CA (5)" - * format_hex_pretty(data2, '-'); // Returns "DE-AD-BE-EF-CA (5)" - * @endcode - */ -std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); - -/** Format a 16-bit word vector in pretty-printed, human-readable hex format. - * - * Convenience overload for std::vector. Each 16-bit word is formatted - * as a 4-digit uppercase hex value in big-endian order. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Vector of 16-bit words to format. - * @param separator Character to use between hex words (default: '.'). - * @param show_length Whether to append the word count in parentheses (default: true). - * @return Formatted hex string representation of the vector contents. - * - * @note The length will only be appended if show_length is true AND the vector size is greater than 4. - * - * Example: - * @code - * std::vector data = {0x1234, 0x5678}; - * format_hex_pretty(data); // Returns "1234.5678" (no length shown for <= 4 parts) - * std::vector data2 = {0x1234, 0x5678, 0x9ABC}; - * format_hex_pretty(data2); // Returns "1234.5678.9ABC (3)" - * @endcode - */ -std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); - -/** Format a string's bytes in pretty-printed, human-readable hex format. - * - * Treats each character in the string as a byte and formats it in hex. - * Useful for debugging binary data stored in std::string containers. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data String whose bytes should be formatted as hex. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string representation of the string's byte contents. - * - * @note The length will only be appended if show_length is true AND the string length is greater than 4. - * - * Example: - * @code - * std::string data = "ABC"; // ASCII: 0x41, 0x42, 0x43 - * format_hex_pretty(data); // Returns "41.42.43" (no length shown for <= 4 parts) - * std::string data2 = "ABCDE"; - * format_hex_pretty(data2); // Returns "41.42.43.44.45 (5)" - * @endcode - */ -std::string format_hex_pretty(const std::string &data, char separator = '.', bool show_length = true); - -/** Format an unsigned integer in pretty-printed, human-readable hex format. - * - * Converts the integer to big-endian byte order and formats each byte as hex. - * The most significant byte appears first in the output string. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @tparam T Unsigned integer type (uint8_t, uint16_t, uint32_t, uint64_t, etc.). - * @param val The unsigned integer value to format. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string with most significant byte first. - * - * @note The length will only be appended if show_length is true AND sizeof(T) is greater than 4. - * - * Example: - * @code - * uint32_t value = 0x12345678; - * format_hex_pretty(value); // Returns "12.34.56.78" (no length shown for <= 4 parts) - * uint64_t value2 = 0x123456789ABCDEF0; - * format_hex_pretty(value2); // Returns "12.34.56.78.9A.BC.DE.F0 (8)" - * format_hex_pretty(value2, ':'); // Returns "12:34:56:78:9A:BC:DE:F0 (8)" - * format_hex_pretty(0x1234); // Returns "12.34" - * @endcode - */ +/// Format an unsigned integer in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. template::value, int> = 0> std::string format_hex_pretty(T val, char separator = '.', bool show_length = true) { val = convert_big_endian(val); @@ -1683,13 +1504,10 @@ inline char *format_bin_to(char (&buffer)[N], T val) { return format_bin_to(buffer, reinterpret_cast(&val), sizeof(T)); } -/// Format the byte array \p data of length \p len in binary. -/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_bin(const uint8_t *data, size_t length); +// format_bin moved to alloc_helpers.h - remove this comment and template overload before 2026.11.0 + /// Format an unsigned integer in binary, starting with the most significant byte. /// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. template::value, int> = 0> std::string format_bin(T val) { val = convert_big_endian(val); return format_bin(reinterpret_cast(&val), sizeof(T)); @@ -1705,9 +1523,7 @@ enum ParseOnOffState : uint8_t { /// Parse a string that contains either on, off or toggle. ParseOnOffState parse_on_off(const char *str, const char *on = nullptr, const char *off = nullptr); -/// @deprecated Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0. -ESPDEPRECATED("Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0.", "2026.1.0") -std::string value_accuracy_to_string(float value, int8_t accuracy_decimals); +// value_accuracy_to_string moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Maximum buffer size for value_accuracy formatting (float ~15 chars + space + UOM ~40 chars + null) static constexpr size_t VALUE_ACCURACY_MAX_LEN = 64; @@ -1721,10 +1537,8 @@ size_t value_accuracy_with_uom_to_buf(std::span bu /// Derive accuracy in decimals from an increment step. int8_t step_to_accuracy_decimals(float step); -std::string base64_encode(const uint8_t *buf, size_t buf_len); -std::string base64_encode(const std::vector &buf); - -std::vector base64_decode(const std::string &encoded_string); +// base64_encode (both overloads), base64_decode (vector overload) moved to alloc_helpers.h +// Remove this comment before 2026.11.0 size_t base64_decode(std::string const &encoded_string, uint8_t *buf, size_t buf_len); size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len); @@ -2160,15 +1974,7 @@ class HighFrequencyLoopRequester { /// Get the device MAC address as raw bytes, written into the provided byte array (6 bytes). void get_mac_address_raw(uint8_t *mac); // NOLINT(readability-non-const-parameter) -/// Get the device MAC address as a string, in lowercase hex notation. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -/// Use get_mac_address_into_buffer() instead. -std::string get_mac_address(); - -/// Get the device MAC address as a string, in colon-separated uppercase hex notation. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -/// Use get_mac_address_pretty_into_buffer() instead. -std::string get_mac_address_pretty(); +// get_mac_address, get_mac_address_pretty moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Get the device MAC address into the given buffer, in lowercase hex notation. /// Assumes buffer length is MAC_ADDRESS_BUFFER_SIZE (12 digits for hexadecimal representation followed by null diff --git a/script/ci-custom.py b/script/ci-custom.py index 6dce86924e..02ec08bc31 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -722,18 +722,22 @@ def lint_trailing_whitespace(fname, match): # Heap-allocating helpers that cause fragmentation on long-running embedded devices. # These return std::string and should be replaced with stack-based alternatives. HEAP_ALLOCATING_HELPERS = { + "base64_encode": "base64_encode_to() with a pre-allocated buffer", "format_bin": "format_bin_to() with a stack buffer", "format_hex": "format_hex_to() with a stack buffer", "format_hex_pretty": "format_hex_pretty_to() with a stack buffer", "format_mac_address_pretty": "format_mac_addr_upper() with a stack buffer", "get_mac_address": "get_mac_address_into_buffer() with a stack buffer", "get_mac_address_pretty": "get_mac_address_pretty_into_buffer() with a stack buffer", + "str_lower_case": "manual tolower() with a stack buffer", "str_sanitize": "str_sanitize_to() with a stack buffer", "str_truncate": "removal (function is unused)", + "str_until": "manual strchr()/find() with a StringRef or stack buffer", "str_upper_case": "removal (function is unused)", "str_snake_case": "removal (function is unused)", "str_sprintf": "snprintf() with a stack buffer", "str_snprintf": "snprintf() with a stack buffer", + "value_accuracy_to_string": "value_accuracy_to_buf() with a stack buffer", } @@ -743,24 +747,33 @@ HEAP_ALLOCATING_HELPERS = { # get_mac_address(?!_) ensures we don't match get_mac_address_into_buffer, etc. # CPP_RE_EOL captures rest of line so NOLINT comments are detected r"[^\w](" + r"base64_encode(?!_)|" r"format_bin(?!_)|" r"format_hex(?!_)|" r"format_hex_pretty(?!_)|" r"format_mac_address_pretty|" r"get_mac_address_pretty(?!_)|" r"get_mac_address(?!_)|" + r"str_lower_case|" r"str_sanitize(?!_)|" r"str_truncate|" + r"str_until|" r"str_upper_case|" r"str_snake_case|" r"str_sprintf|" - r"str_snprintf" + r"str_snprintf|" + r"value_accuracy_to_string" r")\s*\(" + CPP_RE_EOL, include=cpp_include, exclude=[ # The definitions themselves + "esphome/core/alloc_helpers.h", + "esphome/core/alloc_helpers.cpp", + # Backward compatibility re-exports (remove before 2026.11.0) "esphome/core/helpers.h", "esphome/core/helpers.cpp", + # Vendored third-party library + "esphome/components/http_request/httplib.h", ], ) def lint_no_heap_allocating_helpers(fname, match): @@ -812,6 +825,7 @@ def lint_no_sprintf(fname, match): "esphome/components/http_request/httplib.h", # Deprecated helpers that return std::string "esphome/core/helpers.cpp", + "esphome/core/alloc_helpers.cpp", # The using declaration itself "esphome/core/helpers.h", # Test fixtures - not production embedded code From a5b1f3eece4d1d64e62daaa902259fd87f61f967 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:40:03 +0200 Subject: [PATCH 78/94] [core] Remove pre-sleep socket scan from fast select path (#15639) --- .../components/socket/bsd_sockets_impl.cpp | 44 +++++++++---------- esphome/components/socket/bsd_sockets_impl.h | 15 +++++-- .../components/socket/lwip_sockets_impl.cpp | 44 +++++++++---------- esphome/components/socket/lwip_sockets_impl.h | 15 +++++-- esphome/components/socket/socket.h | 21 +++++++-- esphome/core/application.cpp | 27 +----------- esphome/core/application.h | 41 ++++++----------- 7 files changed, 96 insertions(+), 111 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index aea7c776c6..92691b17ab 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -14,38 +14,34 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - // Cache lwip_sock pointer and register for monitoring (hooks callback internally) - this->cached_sock_ = esphome_lwip_get_sock(this->fd_); - this->loop_monitored_ = App.register_socket(this->cached_sock_); + this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif } -BSDSocketImpl::~BSDSocketImpl() { - if (!this->closed_) { - this->close(); - } -} +BSDSocketImpl::~BSDSocketImpl() { this->close(); } int BSDSocketImpl::close() { - if (!this->closed_) { - // Unregister before closing to avoid dangling pointer in monitored set -#ifdef USE_LWIP_FAST_SELECT - if (this->loop_monitored_) { - App.unregister_socket(this->cached_sock_); - this->cached_sock_ = nullptr; - } -#else - if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); - } -#endif - int ret = ::close(this->fd_); - this->closed_ = true; - return ret; + if (this->fd_ < 0) { + // Already closed, or never opened. + return 0; } - return 0; +#ifdef USE_LWIP_FAST_SELECT + // Null the cached lwip_sock pointer before closing. The underlying lwip slot can be + // recycled for a new connection as soon as ::close() returns, so anything that might + // dereference cached_sock_ post-close (e.g. setsockopt(TCP_NODELAY)) would otherwise + // touch an unrelated socket's pcb. No per-socket callback unhook is needed — + // all LwIP sockets share the same static event_callback. + this->cached_sock_ = nullptr; +#else + if (this->loop_monitored_) { + App.unregister_socket_fd(this->fd_); + } +#endif + int ret = ::close(this->fd_); + this->fd_ = -1; // Sentinel for "closed" — prevents double-close and makes use-after-close visible. + return ret; } int BSDSocketImpl::setblocking(bool blocking) { diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index e520784702..57c1a430a2 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -119,12 +119,21 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } protected: + // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This + // replaces a separate closed_ flag: close() sets fd_ = -1 after ::close(), and the + // destructor / double-close path just check fd_ < 0. int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT - struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() -#endif - bool closed_{false}; + // Cached lwip_sock pointer used for direct rcvevent reads in ready() on the + // fast-select path. Replaces loop_monitored_: null means this socket is not being + // monitored for read events — either monitoring was not requested, the fd was + // invalid, or esphome_lwip_get_sock() failed. Non-null means the netconn event + // callback was hooked and notifications are flowing. close() nulls this to prevent + // use-after-free via a recycled lwip slot. + struct lwip_sock *cached_sock_{nullptr}; +#else bool loop_monitored_{false}; +#endif }; } // namespace esphome::socket diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index 2fad429e0f..b4eba3febf 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -14,38 +14,34 @@ LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - // Cache lwip_sock pointer and register for monitoring (hooks callback internally) - this->cached_sock_ = esphome_lwip_get_sock(this->fd_); - this->loop_monitored_ = App.register_socket(this->cached_sock_); + this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else this->loop_monitored_ = App.register_socket_fd(this->fd_); #endif } -LwIPSocketImpl::~LwIPSocketImpl() { - if (!this->closed_) { - this->close(); - } -} +LwIPSocketImpl::~LwIPSocketImpl() { this->close(); } int LwIPSocketImpl::close() { - if (!this->closed_) { - // Unregister before closing to avoid dangling pointer in monitored set -#ifdef USE_LWIP_FAST_SELECT - if (this->loop_monitored_) { - App.unregister_socket(this->cached_sock_); - this->cached_sock_ = nullptr; - } -#else - if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); - } -#endif - int ret = lwip_close(this->fd_); - this->closed_ = true; - return ret; + if (this->fd_ < 0) { + // Already closed, or never opened. + return 0; } - return 0; +#ifdef USE_LWIP_FAST_SELECT + // Null the cached lwip_sock pointer before closing. The underlying lwip slot can be + // recycled for a new connection as soon as lwip_close() returns, so anything that + // might dereference cached_sock_ post-close (e.g. setsockopt(TCP_NODELAY)) would + // otherwise touch an unrelated socket's pcb. No per-socket callback unhook is needed — + // all LwIP sockets share the same static event_callback. + this->cached_sock_ = nullptr; +#else + if (this->loop_monitored_) { + App.unregister_socket_fd(this->fd_); + } +#endif + int ret = lwip_close(this->fd_); + this->fd_ = -1; // Sentinel for "closed" — prevents double-close and makes use-after-close visible. + return ret; } int LwIPSocketImpl::setblocking(bool blocking) { diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index 942d0ccf85..7f3b706cd8 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -85,12 +85,21 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } protected: + // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This + // replaces a separate closed_ flag: close() sets fd_ = -1 after lwip_close(), and the + // destructor / double-close path just check fd_ < 0. int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT - struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() -#endif - bool closed_{false}; + // Cached lwip_sock pointer used for direct rcvevent reads in ready() on the + // fast-select path. Replaces loop_monitored_: null means this socket is not being + // monitored for read events — either monitoring was not requested, the fd was + // invalid, or esphome_lwip_get_sock() failed. Non-null means the netconn event + // callback was hooked and notifications are flowing. close() nulls this to prevent + // use-after-free via a recycled lwip slot. + struct lwip_sock *cached_sock_{nullptr}; +#else bool loop_monitored_{false}; +#endif }; } // namespace esphome::socket diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index ad55e889e8..204113e4b2 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -42,8 +42,23 @@ using ListenSocket = LWIPRawListenImpl; #ifdef USE_LWIP_FAST_SELECT /// Shared ready() helper using cached lwip_sock pointer for direct rcvevent read. -inline bool socket_ready(struct lwip_sock *cached_sock, bool loop_monitored) { - return !loop_monitored || (cached_sock != nullptr && esphome_lwip_socket_has_data(cached_sock)); +/// cached_sock == nullptr means the socket is not monitored (monitor_loop was false, fd +/// was invalid, or esphome_lwip_get_sock() failed) — in that case return true so the +/// caller attempts the read and handles blocking itself. +inline bool socket_ready(struct lwip_sock *cached_sock) { + return cached_sock == nullptr || esphome_lwip_socket_has_data(cached_sock); +} + +/// Resolve an fd to its lwip_sock and install the netconn event-callback hook so the +/// main loop is woken by FreeRTOS task notifications when data arrives. Shared between +/// BSD and LwIP socket impls on the fast-select path. Returns the cached lwip_sock +/// pointer (or nullptr if the fd does not map to a valid lwip_sock). +inline struct lwip_sock *hook_fd_for_fast_select(int fd) { + struct lwip_sock *sock = esphome_lwip_get_sock(fd); + if (sock != nullptr) { + esphome_lwip_hook_socket(sock); + } + return sock; } #elif defined(USE_HOST) /// Shared ready() helper for fd-based socket implementations. @@ -69,7 +84,7 @@ bool socket_ready_fd(int fd, bool loop_monitored); #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) inline bool Socket::ready() const { #ifdef USE_LWIP_FAST_SELECT - return socket_ready(this->cached_sock_, this->loop_monitored_); + return socket_ready(this->cached_sock_); #else return socket_ready_fd(this->fd_, this->loop_monitored_); #endif diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index e5e1b36a65..affee20066 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -509,32 +509,7 @@ void Application::enable_pending_loops_() { } } -#ifdef USE_LWIP_FAST_SELECT -bool Application::register_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - if (sock == nullptr) - return false; - esphome_lwip_hook_socket(sock); - this->monitored_sockets_.push_back(sock); - return true; -} - -void Application::unregister_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - for (size_t i = 0; i < this->monitored_sockets_.size(); i++) { - if (this->monitored_sockets_[i] != sock) - continue; - - // Swap with last element and pop - O(1) removal since order doesn't matter. - // No need to unhook the netconn callback — all LwIP sockets share the same - // static event_callback, and the socket will be closed by the caller. - if (i < this->monitored_sockets_.size() - 1) - this->monitored_sockets_[i] = this->monitored_sockets_.back(); - this->monitored_sockets_.pop_back(); - return; - } -} -#elif defined(USE_HOST) +#ifdef USE_HOST bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop // It modifies socket_fds_ and related variables without locking diff --git a/esphome/core/application.h b/esphome/core/application.h index 645caa2404..a512af9c61 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -345,16 +345,13 @@ class Application { Scheduler scheduler; - /// Register/unregister a socket to be monitored for read events. - /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. -#ifdef USE_LWIP_FAST_SELECT - /// Fast select path: hooks netconn callback and registers for monitoring. - /// @return true if registration was successful, false if sock is null - bool register_socket(struct lwip_sock *sock); - void unregister_socket(struct lwip_sock *sock); -#elif defined(USE_HOST) - /// Fallback select() path: monitors file descriptors. +#ifdef USE_HOST + /// Register/unregister a socket file descriptor with the host select() fallback loop. + /// USE_LWIP_FAST_SELECT builds do not use this API — sockets hook the lwIP netconn + /// event_callback directly (see socket.h hook_fd_for_fast_select) and rely on FreeRTOS + /// task notifications for wake-up. /// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error. + /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. /// @return true if registration was successful, false if fd exceeds limits bool register_socket_fd(int fd); void unregister_socket_fd(int fd); @@ -488,9 +485,7 @@ class Application { // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop FixedVector looping_components_{}; -#ifdef USE_LWIP_FAST_SELECT - std::vector monitored_sockets_; // Cached lwip_sock pointers for direct rcvevent read -#elif defined(USE_HOST) +#ifdef USE_HOST std::vector socket_fds_; // Vector of all monitored socket file descriptors #endif #ifdef USE_HOST @@ -705,26 +700,16 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #ifndef USE_HOST inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { #ifdef USE_LWIP_FAST_SELECT - // Fast path (ESP32/LibreTiny): reads rcvevent directly from cached lwip_sock pointers. - // Safe because this runs on the main loop which owns socket lifetime (create, read, close). + // Fast path (ESP32/LibreTiny): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the delay_ms timeout. if (delay_ms == 0) [[unlikely]] { yield(); return; } - - // Check if any socket already has pending data before sleeping. - // If a socket still has unread data (rcvevent > 0) but the task notification was already - // consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency. - // This scan preserves select() semantics: return immediately when any fd is ready. - for (struct lwip_sock *sock : this->monitored_sockets_) { - if (esphome_lwip_socket_has_data(sock)) { - yield(); - return; - } - } - - // Sleep with instant wake via FreeRTOS task notification. - // Woken by: callback wrapper (socket data), wake_loop_threadsafe() (background tasks), or timeout. #endif esphome::internal::wakeable_delay(delay_ms); } From 37608c26560fe0ba7eefad7a0129074e6b7ccc2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:40:24 +0200 Subject: [PATCH 79/94] [ltr390] Reduce data polling delay and timeout (#15507) --- esphome/components/ltr390/ltr390.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index ba4a7ea5cb..033f31a3d1 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -45,6 +45,7 @@ optional LTR390Component::read_sensor_data_(LTR390MODE mode) { uint8_t buffer[num_bytes]; // Wait until data available + constexpr uint32_t max_wait_ms = 25; const uint32_t now = millis(); while (true) { std::bitset<8> status = this->reg(LTR390_MAIN_STATUS).get(); @@ -52,12 +53,12 @@ optional LTR390Component::read_sensor_data_(LTR390MODE mode) { if (available) break; - if (millis() - now > 100) { + if (millis() - now > max_wait_ms) { ESP_LOGW(TAG, "Sensor didn't return any data, aborting"); return {}; } - ESP_LOGD(TAG, "Waiting for data"); - delay(2); + ESP_LOGV(TAG, "Waiting for data"); + delay(1); } if (!this->read_bytes(MODEADDRESSES[mode], buffer, num_bytes)) { From 78875abee4a396b73b36557cbfa58ff5b51b3c1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:40:40 +0200 Subject: [PATCH 80/94] [core] Make buf_append_str PROGMEM-aware on ESP8266 (#15738) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/helpers.h | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index bb164f5034..78476bf596 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1079,7 +1079,33 @@ __attribute__((format(printf, 4, 5))) inline size_t buf_append_printf(char *buf, } #endif -/// Safely append a string to buffer without format parsing, returning new position (capped at size). +#ifdef USE_ESP8266 +/// Safely append a PROGMEM string to buffer, returning new position (capped at size). +/// ESP8266 internal implementation — prefer the `buf_append_str` macro which wraps +/// literals with `PSTR()` automatically so they stay in flash instead of eating RAM. +/// @param buf Output buffer +/// @param size Total buffer size +/// @param pos Current position in buffer +/// @param str PROGMEM-resident string to append (must not be null) +/// @return New position after appending; returns `size` if `pos >= size`, otherwise +/// returns at most `size - 1` because one byte is reserved for the null terminator +inline size_t buf_append_str_p(char *buf, size_t size, size_t pos, PGM_P str) { + if (pos >= size) { + return size; + } + size_t remaining = size - pos - 1; // reserve space for null terminator + size_t len = strnlen_P(str, remaining); + memcpy_P(buf + pos, str, len); + pos += len; + buf[pos] = '\0'; + return pos; +} +/// Safely append a string to buffer, returning new position (capped at size). +/// More efficient than buf_append_printf for plain string literals. +/// On ESP8266 the literal is wrapped with PSTR() so it stays in flash. +#define buf_append_str(buf, size, pos, str) buf_append_str_p(buf, size, pos, PSTR(str)) +#else +/// Safely append a string to buffer, returning new position (capped at size). /// More efficient than buf_append_printf for plain string literals. /// @param buf Output buffer /// @param size Total buffer size @@ -1091,15 +1117,13 @@ inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str return size; } size_t remaining = size - pos - 1; // reserve space for null terminator - size_t len = strlen(str); - if (len > remaining) { - len = remaining; - } + size_t len = strnlen(str, remaining); memcpy(buf + pos, str, len); pos += len; buf[pos] = '\0'; return pos; } +#endif /// Concatenate a name with a separator and suffix using an efficient stack-based approach. /// This avoids multiple heap allocations during string construction. From f05fa45747f2ae8f71928f99b161a8b7f0c5fd0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 04:41:13 +0200 Subject: [PATCH 81/94] [sensor] Specialize `throttle_with_priority` NaN-only case (#15823) --- esphome/components/sensor/__init__.py | 18 +++++++++++++++--- esphome/components/sensor/filter.cpp | 12 ++++++++++++ esphome/components/sensor/filter.h | 13 +++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index b658ff7056..8dcb7165e3 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -275,6 +275,9 @@ ThrottleFilter = sensor_ns.class_("ThrottleFilter", Filter) ThrottleWithPriorityFilter = sensor_ns.class_( "ThrottleWithPriorityFilter", ValueListFilter ) +ThrottleWithPriorityNanFilter = sensor_ns.class_( + "ThrottleWithPriorityNanFilter", Filter +) TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) @@ -656,9 +659,18 @@ THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( THROTTLE_WITH_PRIORITY_SCHEMA, ) async def throttle_with_priority_filter_to_code(config, filter_id): - if not isinstance(config[CONF_VALUE], list): - config[CONF_VALUE] = [config[CONF_VALUE]] - template_ = [await cg.templatable(x, [], cg.float_) for x in config[CONF_VALUE]] + values = config[CONF_VALUE] + if not isinstance(values, list): + values = [values] + # Specialize the common "NaN-only" case (the schema default when the user + # omits `value:`) to avoid the TemplatableFn array + NaN lambda the + # generic ValueListFilter path requires. Behavior is identical: NaN sensor + # readings always bypass the throttle. + if values and all(isinstance(v, float) and math.isnan(v) for v in values): + filter_id = filter_id.copy() + filter_id.type = ThrottleWithPriorityNanFilter + return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) + template_ = [await cg.templatable(x, [], cg.float_) for x in values] return cg.new_Pvariable( filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ ) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index fbac7d3535..4896757d3f 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -269,6 +269,18 @@ optional throttle_with_priority_new_value(Sensor *parent, float value, co return {}; } +// ThrottleWithPriorityNanFilter +ThrottleWithPriorityNanFilter::ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs) + : min_time_between_inputs_(min_time_between_inputs) {} +optional ThrottleWithPriorityNanFilter::new_value(float value) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || std::isnan(value)) { + this->last_input_ = now; + return value; + } + return {}; +} + // DeltaFilter DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) : min_a0_(min_a0), min_a1_(min_a1), max_a0_(max_a0), max_a1_(max_a1) {} diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 0dbbc33ab3..a91d66a8fb 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -399,6 +399,19 @@ template class ThrottleWithPriorityFilter : public ValueListFilter uint32_t min_time_between_inputs_; }; +/// Specialization of ThrottleWithPriorityFilter for the common "prioritize NaN" +/// case: skips the TemplatableFn array + lambda and inlines the check. +class ThrottleWithPriorityNanFilter : public Filter { + public: + explicit ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs); + + optional new_value(float value) override; + + protected: + uint32_t last_input_{0}; + uint32_t min_time_between_inputs_; +}; + // Base class for timeout filters - contains common loop logic class TimeoutFilterBase : public Filter, public Component { public: From a8bd035b62b7ab9712e394fafdbdef9cd772bcde Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 04:44:25 +0200 Subject: [PATCH 82/94] Bump CodSpeedHQ/action from 4.13.1 to 4.14.0 (#15880) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 6aa5b2a547..20c349ac00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -339,7 +339,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4 + uses: CodSpeedHQ/action@658a901452bb54c799643e060733b7afe9121b8d # v4.14.0 with: run: ${{ steps.build.outputs.binary }} mode: simulation From 26a656af296172ef140d7632e19bfe25f6d1873b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 21 Apr 2026 07:47:07 -0400 Subject: [PATCH 83/94] [ld2412] Fix null deref in set_basic_config when entities unconfigured (#15893) --- esphome/components/ld2412/ld2412.cpp | 42 ++++++++++++++++------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index a502ae3c10..093e8c72dc 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -766,32 +766,38 @@ void LD2412Component::get_distance_resolution_() { this->send_command_(CMD_QUERY void LD2412Component::query_light_control_() { this->send_command_(CMD_QUERY_LIGHT_CONTROL, nullptr, 0); } void LD2412Component::set_basic_config() { + uint8_t min_gate = 1; + uint8_t max_gate = TOTAL_GATES; + uint16_t timeout = DEFAULT_PRESENCE_TIMEOUT; + uint8_t out_pin_level = 0x01; + #ifdef USE_NUMBER - if (!this->min_distance_gate_number_->has_state() || !this->max_distance_gate_number_->has_state() || - !this->timeout_number_->has_state()) { - return; + if (this->min_distance_gate_number_ != nullptr) { + if (!this->min_distance_gate_number_->has_state()) + return; + min_gate = static_cast(this->min_distance_gate_number_->state); + } + if (this->max_distance_gate_number_ != nullptr) { + if (!this->max_distance_gate_number_->has_state()) + return; + max_gate = static_cast(this->max_distance_gate_number_->state) + 1; + } + if (this->timeout_number_ != nullptr) { + if (!this->timeout_number_->has_state()) + return; + timeout = static_cast(this->timeout_number_->state); } #endif #ifdef USE_SELECT - if (!this->out_pin_level_select_->has_state()) { - return; + if (this->out_pin_level_select_ != nullptr) { + if (!this->out_pin_level_select_->has_state()) + return; + out_pin_level = find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str()); } #endif uint8_t value[5] = { -#ifdef USE_NUMBER - lowbyte(static_cast(this->min_distance_gate_number_->state)), - lowbyte(static_cast(this->max_distance_gate_number_->state) + 1), - lowbyte(static_cast(this->timeout_number_->state)), - highbyte(static_cast(this->timeout_number_->state)), -#else - 1, TOTAL_GATES, DEFAULT_PRESENCE_TIMEOUT, 0, -#endif -#ifdef USE_SELECT - find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str()), -#else - 0x01, // Default value if not using select -#endif + lowbyte(min_gate), lowbyte(max_gate), lowbyte(timeout), highbyte(timeout), out_pin_level, }; this->set_config_mode_(true); this->send_command_(CMD_BASIC_CONF, value, sizeof(value)); From cb56f9a9bfd3705ab350e2c19d9e5f387b35ca09 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 21 Apr 2026 07:47:16 -0400 Subject: [PATCH 84/94] [qmc5883l] Use GPIO interrupt when DRDY pin is configured (#15876) --- esphome/components/qmc5883l/qmc5883l.cpp | 36 ++++++++++++++++++++---- esphome/components/qmc5883l/qmc5883l.h | 5 ++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index d0488d0c9f..44bd006c1a 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -24,6 +24,8 @@ static const uint8_t QMC5883L_REGISTER_CONTROL_1 = 0x09; static const uint8_t QMC5883L_REGISTER_CONTROL_2 = 0x0A; static const uint8_t QMC5883L_REGISTER_PERIOD = 0x0B; +void IRAM_ATTR QMC5883LComponent::gpio_intr(QMC5883LComponent *arg) { arg->enable_loop_soon_any_context(); } + void QMC5883LComponent::setup() { // Soft Reset if (!this->write_byte(QMC5883L_REGISTER_CONTROL_2, 1 << 7)) { @@ -35,6 +37,12 @@ void QMC5883LComponent::setup() { if (this->drdy_pin_) { this->drdy_pin_->setup(); + if (this->drdy_pin_->is_internal()) { + static_cast(this->drdy_pin_) + ->attach_interrupt(&QMC5883LComponent::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + this->drdy_use_isr_ = true; + this->stop_poller(); + } } uint8_t control_1 = 0; @@ -65,8 +73,8 @@ void QMC5883LComponent::setup() { return; } - if (this->get_update_interval() < App.get_loop_interval()) { - high_freq_.start(); + if (!this->drdy_use_isr_ && this->get_update_interval() < App.get_loop_interval()) { + this->high_freq_.start(); } } @@ -84,16 +92,32 @@ void QMC5883LComponent::dump_config() { LOG_SENSOR(" ", "Heading", this->heading_sensor_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_PIN(" DRDY Pin: ", this->drdy_pin_); + if (this->drdy_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " DRDY mode: %s", + this->drdy_use_isr_ ? LOG_STR_LITERAL("interrupt") : LOG_STR_LITERAL("polling")); + } } void QMC5883LComponent::update() { - i2c::ErrorCode err; - uint8_t status = false; - - // If DRDY pin is configured and the data is not ready return. + // If DRDY is on an external expander we keep the polling path and early-return + // if data is not ready yet. Internal DRDY pins take the ISR path via loop(). if (this->drdy_pin_ && !this->drdy_pin_->digital_read()) { return; } + this->read_sensor_(); +} + +void QMC5883LComponent::loop() { + this->disable_loop(); + if (!this->drdy_use_isr_ || !this->drdy_pin_->digital_read()) { + return; + } + this->read_sensor_(); +} + +void QMC5883LComponent::read_sensor_() { + i2c::ErrorCode err; + uint8_t status = false; // Status byte gets cleared when data is read, so we have to read this first. // If status and two axes are desired, it's possible to save one byte of traffic by enabling diff --git a/esphome/components/qmc5883l/qmc5883l.h b/esphome/components/qmc5883l/qmc5883l.h index 21ef9c2a17..2ab6aa3e9f 100644 --- a/esphome/components/qmc5883l/qmc5883l.h +++ b/esphome/components/qmc5883l/qmc5883l.h @@ -32,6 +32,7 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { void setup() override; void dump_config() override; void update() override; + void loop() override; void set_drdy_pin(GPIOPin *pin) { drdy_pin_ = pin; } void set_datarate(QMC5883LDatarate datarate) { datarate_ = datarate; } @@ -44,6 +45,9 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } protected: + static void IRAM_ATTR gpio_intr(QMC5883LComponent *arg); + void read_sensor_(); + QMC5883LDatarate datarate_{QMC5883L_DATARATE_10_HZ}; QMC5883LRange range_{QMC5883L_RANGE_200_UT}; QMC5883LOversampling oversampling_{QMC5883L_SAMPLING_512}; @@ -53,6 +57,7 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *heading_sensor_{nullptr}; sensor::Sensor *temperature_sensor_{nullptr}; GPIOPin *drdy_pin_{nullptr}; + bool drdy_use_isr_{false}; enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, From f50409948551875d9626e1aa14a092d3a2738bf8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 13:47:37 +0200 Subject: [PATCH 85/94] [api] Replace clients_ std::vector with compile-time std::array + uint8_t count (#15889) --- esphome/components/api/__init__.py | 11 +++-- esphome/components/api/api_server.cpp | 61 ++++++++++++++------------- esphome/components/api/api_server.h | 33 ++++++++++++--- esphome/core/defines.h | 1 + 4 files changed, 64 insertions(+), 42 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 84589d540d..ad778f20ad 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -291,12 +291,12 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault( CONF_MAX_CONNECTIONS, esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes - esp32=8, # 520KB RAM available + esp32=5, # 520KB RAM available rp2040=4, # 264KB RAM but LWIP constraints - bk72xx=8, # Moderate RAM - rtl87xx=8, # Moderate RAM + bk72xx=5, # Moderate RAM + rtl87xx=5, # Moderate RAM host=8, # Abundant resources - ln882x=8, # Moderate RAM + ln882x=5, # Moderate RAM ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size @@ -336,8 +336,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) if CONF_LISTEN_BACKLOG in config: cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) - if CONF_MAX_CONNECTIONS in config: - cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) + cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) # Set USE_API_USER_DEFINED_ACTIONS if any services are enabled diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index d9c3cc6846..4559168ece 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -118,7 +118,7 @@ void APIServer::loop() { this->accept_new_connections_(); } - if (this->clients_.empty()) { + if (this->api_connection_count_ == 0) { // 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) { @@ -135,15 +135,15 @@ void APIServer::loop() { // Check network connectivity once for all clients if (!network::is_connected()) { // Network is down - disconnect all clients - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { client->on_fatal_error(); client->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Network down; disconnect")); } // Continue to process and clean up the clients below } - size_t client_index = 0; - while (client_index < this->clients_.size()) { + uint8_t client_index = 0; + while (client_index < this->api_connection_count_) { auto &client = this->clients_[client_index]; // Common case: process active client @@ -161,7 +161,7 @@ void APIServer::loop() { } } -void APIServer::remove_client_(size_t client_index) { +void APIServer::remove_client_(uint8_t client_index) { auto &client = this->clients_[client_index]; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -179,14 +179,17 @@ void APIServer::remove_client_(size_t client_index) { // Close socket now (was deferred from on_fatal_error to allow getpeername) client->helper_->close(); - // Swap with the last element and pop (avoids expensive vector shifts) - if (client_index < this->clients_.size() - 1) { - std::swap(this->clients_[client_index], this->clients_.back()); + // Swap-and-reset: move the removed client to the trailing slot and null it out so slots + // [api_connection_count_, N) remain nullptr. + const uint8_t last_index = this->api_connection_count_ - 1; + if (client_index < last_index) { + std::swap(this->clients_[client_index], this->clients_[last_index]); } - this->clients_.pop_back(); + this->clients_[last_index].reset(); + this->api_connection_count_--; // Last client disconnected - set warning and start tracking for reboot timeout - if (this->clients_.empty() && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) { this->status_set_warning(LOG_STR("waiting for client connection")); this->last_connected_ = App.get_loop_component_start_time(); } @@ -210,8 +213,8 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { sock->getpeername_to(peername); // Check if we're at the connection limit - if (this->clients_.size() >= this->max_connections_) { - ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, peername); + if (this->api_connection_count_ >= MAX_API_CONNECTIONS) { + ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername); // Immediately close - socket destructor will handle cleanup sock.reset(); continue; @@ -220,11 +223,11 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { ESP_LOGD(TAG, "Accept %s", peername); auto *conn = new APIConnection(std::move(sock), this); - this->clients_.emplace_back(conn); + this->clients_[this->api_connection_count_++].reset(conn); conn->start(); // First client connected - clear warning and update timestamp - if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) { this->status_clear_warning(); this->last_connected_ = App.get_loop_component_start_time(); } @@ -237,7 +240,7 @@ void APIServer::dump_config() { " Address: %s:%u\n" " Listen backlog: %u\n" " Max connections: %u", - network::get_use_address(), this->port_, this->listen_backlog_, this->max_connections_); + network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk())); if (!this->noise_ctx_.has_psk()) { @@ -255,7 +258,7 @@ void APIServer::handle_disconnect(APIConnection *conn) {} void APIServer::on_##entity_name##_update(entity_type *obj) { /* NOLINT(bugprone-macro-parentheses) */ \ if (obj->is_internal()) \ return; \ - for (auto &c : this->clients_) { \ + for (auto &c : this->active_clients()) { \ if (c->flags_.state_subscription) \ c->send_##entity_name##_state(obj); \ } \ @@ -337,7 +340,7 @@ API_DISPATCH_UPDATE(water_heater::WaterHeater, water_heater) void APIServer::on_event(event::Event *obj) { if (obj->is_internal()) return; - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (c->flags_.state_subscription) c->send_event(obj); } @@ -349,7 +352,7 @@ void APIServer::on_event(event::Event *obj) { void APIServer::on_update(update::UpdateEntity *obj) { if (obj->is_internal()) return; - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (c->flags_.state_subscription) c->send_update_state(obj); } @@ -360,7 +363,7 @@ void APIServer::on_update(update::UpdateEntity *obj) { void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { // We could add code to manage a second subscription type, but, since this message type is // very infrequent and small, we simply send it to all clients - for (auto &c : this->clients_) + for (auto &c : this->active_clients()) c->send_message(msg); } #endif @@ -375,7 +378,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ resp.key = key; resp.timings = timings; - for (auto &c : this->clients_) + for (auto &c : this->active_clients()) c->send_infrared_rf_receive_event(resp); } #endif @@ -392,7 +395,7 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { client->send_homeassistant_action(call); } } @@ -532,7 +535,7 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString return; } ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { DisconnectRequest req; c->send_message(req); } @@ -583,7 +586,7 @@ bool APIServer::clear_noise_psk(bool make_active) { #ifdef USE_HOMEASSISTANT_TIME void APIServer::request_time() { - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { if (!client->flags_.remove && client->is_authenticated()) { client->send_time_request(); return; // Only request from one client to avoid clock conflicts @@ -593,8 +596,8 @@ void APIServer::request_time() { #endif bool APIServer::is_connected_with_state_subscription() const { - for (const auto &client : this->clients_) { - if (client->flags_.state_subscription) { + for (uint8_t i = 0; i < this->api_connection_count_; i++) { + if (this->clients_[i]->flags_.state_subscription) { return true; } } @@ -609,7 +612,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size // we would be filling a buffer we are trying to clear return; } - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (!c->flags_.remove && c->get_log_subscription_level() >= level) c->try_send_log_message(level, tag, message, message_len); } @@ -618,7 +621,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size #ifdef USE_CAMERA void APIServer::on_camera_image(const std::shared_ptr &image) { - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (!c->flags_.remove) c->set_camera_state(image); } @@ -635,7 +638,7 @@ void APIServer::on_shutdown() { this->batch_delay_ = 5; // Send disconnect requests to all connected clients - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { DisconnectRequest req; if (!c->send_message(req)) { // If we can't send the disconnect request directly (tx_buffer full), @@ -653,7 +656,7 @@ bool APIServer::teardown() { this->loop(); // Return true only when all clients have been torn down - return this->clients_.empty(); + return this->api_connection_count_ == 0; } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 65076879a2..d6ac1a6d5d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -21,6 +21,8 @@ #include "esphome/components/camera/camera.h" #endif +#include +#include #include namespace esphome::api { @@ -63,7 +65,6 @@ class APIServer final : public Component, void set_batch_delay(uint16_t batch_delay); uint16_t get_batch_delay() const { return batch_delay_; } void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } - void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; } // Get reference to shared buffer for API connections APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; } @@ -186,9 +187,26 @@ class APIServer final : public Component, void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif - bool is_connected() const { return !this->clients_.empty(); } + bool is_connected() const { return this->api_connection_count_ != 0; } bool is_connected_with_state_subscription() const; + // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect + // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the + // APIConnection but cannot reset/move the slot and break the count invariant. + using APIConnectionPtr = std::unique_ptr; + class ActiveClientsView { + const APIConnectionPtr *begin_; + const APIConnectionPtr *end_; + + public: + ActiveClientsView(const APIConnectionPtr *b, const APIConnectionPtr *e) : begin_(b), end_(e) {} + const APIConnectionPtr *begin() const { return this->begin_; } + const APIConnectionPtr *end() const { return this->end_; } + }; + ActiveClientsView active_clients() const { + return {this->clients_.data(), this->clients_.data() + this->api_connection_count_}; + } + #ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { const char *entity_id; // Pointer to flash (internal) or heap (external) @@ -234,8 +252,8 @@ class APIServer final : public Component, protected: // Accept incoming socket connections. Only called when socket has pending connections. void __attribute__((noinline)) accept_new_connections_(); - // Remove a disconnected client by index. Swaps with last element and pops. - void __attribute__((noinline)) remove_client_(size_t client_index); + // Remove a disconnected client by index. Swaps with the last populated slot and resets it. + void __attribute__((noinline)) remove_client_(uint8_t client_index); #ifdef USE_API_NOISE bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, @@ -273,8 +291,9 @@ class APIServer final : public Component, uint32_t reboot_timeout_{300000}; uint32_t last_connected_{0}; + // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. + std::array, MAX_API_CONNECTIONS> clients_{}; // Vectors and strings (12 bytes each on 32-bit) - std::vector> clients_; // Shared proto write buffer for all connections. // Not pre-allocated: all send paths call prepare_first_message_buffer() which // reserves the exact needed size. Pre-allocating here would cause heap fragmentation @@ -309,10 +328,10 @@ class APIServer final : public Component, uint16_t port_{6053}; uint16_t batch_delay_{100}; // Connection limits - these defaults will be overridden by config values - // from cv.SplitDefault in __init__.py which sets platform-specific defaults + // from cv.SplitDefault in __init__.py which sets platform-specific defaults. uint8_t listen_backlog_{4}; - uint8_t max_connections_{8}; bool shutting_down_ = false; + uint8_t api_connection_count_{0}; // 7 bytes used, 1 byte padding #ifdef USE_API_NOISE diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d8b4faced9..0fb7221571 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -177,6 +177,7 @@ #define USE_API_USER_DEFINED_ACTION_RESPONSES #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 +#define MAX_API_CONNECTIONS 6 #define USE_MD5 #define USE_SHA256 #define USE_MQTT From e4d5886383eff13b0c86139102f8e04d49e2f993 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 13:48:16 +0200 Subject: [PATCH 86/94] [zwave_proxy] Inline loop() hot-path fast-paths for response_handler_ and process_uart_ (#15887) --- .../components/zwave_proxy/zwave_proxy.cpp | 10 +++--- esphome/components/zwave_proxy/zwave_proxy.h | 34 +++++++++++++++++-- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index ecb38b25e7..8a24bd57d6 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -101,8 +101,10 @@ void ZWaveProxy::loop() { this->status_clear_warning(); } -void ZWaveProxy::process_uart_() { - while (this->available()) { +void ZWaveProxy::process_uart_slow_() { + // Caller (inline process_uart_) has already confirmed available() > 0, so use do/while to + // drain bytes — available() is still checked at the tail, but not redundantly on entry. + do { uint8_t byte; if (!this->read_byte(&byte)) { this->status_set_warning(LOG_STR("UART read failed")); @@ -137,7 +139,7 @@ void ZWaveProxy::process_uart_() { this->api_connection_->send_message(this->outgoing_proto_msg_); } } - } + } while (this->available()); } void ZWaveProxy::dump_config() { @@ -414,7 +416,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { } } -bool ZWaveProxy::response_handler_() { +bool ZWaveProxy::response_handler_slow_() { switch (this->parsing_state_) { case ZWAVE_PARSING_STATE_SEND_ACK: this->last_response_ = ZWAVE_FRAME_TYPE_ACK; diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 0b810de29f..dc5dc46abc 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -38,6 +38,13 @@ enum ZWaveParsingState : uint8_t { ZWAVE_PARSING_STATE_READ_BL_MENU, }; +// response_handler_()'s inline fast-path relies on SEND_ACK/CAN/NAK being contiguous in this +// enum so a single range check (state - SEND_ACK < 3) is equivalent to three equality checks. +static_assert(ZWAVE_PARSING_STATE_SEND_CAN == ZWAVE_PARSING_STATE_SEND_ACK + 1, + "SEND_CAN must immediately follow SEND_ACK for response_handler_ fast-path"); +static_assert(ZWAVE_PARSING_STATE_SEND_NAK == ZWAVE_PARSING_STATE_SEND_ACK + 2, + "SEND_NAK must immediately follow SEND_CAN for response_handler_ fast-path"); + enum ZWaveProxyFeature : uint32_t { FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0, }; @@ -72,8 +79,31 @@ class ZWaveProxy : public uart::UARTDevice, public Component { void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) void parse_start_(uint8_t byte); - bool response_handler_(); - void process_uart_(); // Process all available UART data + // Inline fast-path: most calls happen with parsing_state_ outside the SEND_* range, so skip the + // out-of-line call entirely in the hot path (e.g. every loop() tick) and only pay for the real + // work when a response is actually pending. ESPHOME_ALWAYS_INLINE is required because with -Os + // gcc otherwise clones the wrapper into a shared $isra$ outline and keeps the call8. + ESPHOME_ALWAYS_INLINE bool response_handler_() { + if (this->parsing_state_ < ZWAVE_PARSING_STATE_SEND_ACK || this->parsing_state_ > ZWAVE_PARSING_STATE_SEND_NAK) { + return false; + } + return this->response_handler_slow_(); + } + bool response_handler_slow_(); + // Inline fast-path: UART::available() is cheap (ring-buffer head/tail compare on most backends). + // On an idle loop tick we want to skip the call to process_uart_ entirely. When bytes are + // pending we fall into the slow path, which drains the UART with a do/while so available() is + // only checked once per byte — no redundant re-check on entry. + ESPHOME_ALWAYS_INLINE void process_uart_() { + if (!this->available()) { + return; + } + this->process_uart_slow_(); + } + // Precondition: caller must guarantee available() > 0 before invoking (see inline + // process_uart_ above). The slow path uses do/while and would otherwise set a spurious UART + // warning on entry if called with no bytes pending. + void process_uart_slow_(); // Pre-allocated message - always ready to send api::ZWaveProxyFrame outgoing_proto_msg_; From 947c714f8936ffad5355a3bf9c72a2fbff3a5be2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 13:48:33 +0200 Subject: [PATCH 87/94] [core] Inline api_is_connected() for hot-path callers (#15888) --- esphome/core/util.cpp | 14 -------------- esphome/core/util.h | 22 ++++++++++++++++++++-- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/esphome/core/util.cpp b/esphome/core/util.cpp index 996cf8e310..54a7956163 100644 --- a/esphome/core/util.cpp +++ b/esphome/core/util.cpp @@ -1,28 +1,14 @@ #include "esphome/core/util.h" -#include "esphome/core/defines.h" #include "esphome/core/application.h" #include "esphome/core/version.h" #include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/api_server.h" -#endif - #ifdef USE_MQTT #include "esphome/components/mqtt/mqtt_client.h" #endif namespace esphome { -bool api_is_connected() { -#ifdef USE_API - if (api::global_api_server != nullptr) { - return api::global_api_server->is_connected(); - } -#endif - return false; -} - bool mqtt_is_connected() { #ifdef USE_MQTT if (mqtt::global_mqtt_client != nullptr) { diff --git a/esphome/core/util.h b/esphome/core/util.h index 1ca0173eab..8f90aa3411 100644 --- a/esphome/core/util.h +++ b/esphome/core/util.h @@ -1,10 +1,28 @@ #pragma once #include + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +#ifdef USE_API +#include "esphome/components/api/api_server.h" +#endif + namespace esphome { -/// Return whether the node has at least one client connected to the native API -bool api_is_connected(); +/// Return whether the node has at least one client connected to the native API. +/// +/// Inline so that hot-path callers (e.g. component loop() ticks that check connectivity every +/// iteration) can skip the call8/return pair. With USE_API disabled this trivially returns false +/// and collapses at compile time. +#ifdef USE_API +ESPHOME_ALWAYS_INLINE inline bool api_is_connected() { + return api::global_api_server != nullptr && api::global_api_server->is_connected(); +} +#else +ESPHOME_ALWAYS_INLINE inline bool api_is_connected() { return false; } +#endif /// Return whether the node has an active connection to an MQTT broker bool mqtt_is_connected(); From 1504ac3d19e2225f7713eabc4c20c8d543a91bfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 14:32:29 +0200 Subject: [PATCH 88/94] [core] Replace strnlen in buf_append_str for Zephyr compatibility (#15892) --- esphome/core/helpers.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 78476bf596..6b71916cd2 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1117,7 +1117,10 @@ inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str return size; } size_t remaining = size - pos - 1; // reserve space for null terminator - size_t len = strnlen(str, remaining); + size_t len = 0; + while (len < remaining && str[len] != '\0') { + len++; + } memcpy(buf + pos, str, len); pos += len; buf[pos] = '\0'; From e4f413adad74735d7dc61332c06376dd6904e9ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 14:48:21 +0200 Subject: [PATCH 89/94] [core] decouple main loop cadence from scheduler wake timing (#15792) --- .../runtime_stats/runtime_stats.cpp | 11 +- .../components/runtime_stats/runtime_stats.h | 20 ++- esphome/core/application.cpp | 9 +- esphome/core/application.h | 153 ++++++++++++------ esphome/core/wake.cpp | 16 ++ esphome/core/wake.h | 51 +++++- .../wake_test_component/__init__.py | 19 +++ .../wake_test_component.cpp | 19 +++ .../wake_test_component/wake_test_component.h | 27 ++++ .../fixtures/loop_interval_decoupling.yaml | 60 +++++++ ...p_interval_default_not_pulled_forward.yaml | 51 ++++++ .../fixtures/wake_loop_forces_phase_b.yaml | 52 ++++++ .../test_loop_interval_decoupling.py | 75 +++++++++ ...oop_interval_default_not_pulled_forward.py | 67 ++++++++ .../test_wake_loop_forces_phase_b.py | 76 +++++++++ 15 files changed, 644 insertions(+), 62 deletions(-) create mode 100644 tests/integration/fixtures/external_components/wake_test_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp create mode 100644 tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h create mode 100644 tests/integration/fixtures/loop_interval_decoupling.yaml create mode 100644 tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml create mode 100644 tests/integration/fixtures/wake_loop_forces_phase_b.yaml create mode 100644 tests/integration/test_loop_interval_decoupling.py create mode 100644 tests/integration/test_loop_interval_default_not_pulled_forward.py create mode 100644 tests/integration/test_wake_loop_forces_phase_b.py diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 9ed141155a..d733394b78 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -137,11 +137,12 @@ bool RuntimeStatsCollector::compare_total_time(Component *a, Component *b) { return a->runtime_stats_.total_time_us > b->runtime_stats_.total_time_us; } -void RuntimeStatsCollector::process_pending_stats(uint32_t current_time) { - if ((int32_t) (current_time - this->next_log_time_) >= 0) { - this->log_stats_(); - this->next_log_time_ = current_time + this->log_interval_; - } +// Slow path for process_pending_stats — gate already checked by the inline +// wrapper in runtime_stats.h. Out-of-line keeps the log_stats_ machinery out +// of Application::loop(). +void RuntimeStatsCollector::process_pending_stats_slow_(uint32_t current_time) { + this->log_stats_(); + this->next_log_time_ = current_time + this->log_interval_; } } // namespace runtime_stats diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 82e0fb7c61..888d48e672 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -6,6 +6,7 @@ #include #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -26,14 +27,24 @@ class RuntimeStatsCollector { } uint32_t get_log_interval() const { return this->log_interval_; } - // Process any pending stats printing (should be called after component loop) - void process_pending_stats(uint32_t current_time); + // Process any pending stats printing. Called on every Application::loop() + // tick, so the common "not yet time to log" path must be cheap — inline + // the gate check and keep the actual logging work out-of-line. + void ESPHOME_ALWAYS_INLINE process_pending_stats(uint32_t current_time) { + if ((int32_t) (current_time - this->next_log_time_) >= 0) [[unlikely]] { + this->process_pending_stats_slow_(current_time); + } + } // Record the wall time of one main loop iteration excluding the yield/sleep. // Called once per loop from Application::loop(). // active_us = total time between loop start and just before yield. - // before_us = time spent in before_loop_tasks_ (scheduler + ISR enable_loop). - // tail_us = time spent in after_loop_tasks_ + the trailing record/stats prefix. + // before_us = time spent in Phase A (scheduler tick) excluding time + // already attributed to per-component stats. + // tail_us = time spent in after_component_phase_ + the trailing record/stats + // prefix. Only meaningful on component-phase ticks; reported + // as 0 on Phase A-only ticks (no component phase ran, so any + // overhead between Phase A and stats belongs to "residual"). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, @@ -55,6 +66,7 @@ class RuntimeStatsCollector { } protected: + void process_pending_stats_slow_(uint32_t current_time); void log_stats_(); // Static comparators — member functions have friend access, lambdas do not static bool compare_period_time(Component *a, Component *b); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index affee20066..b626eb1de6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -93,8 +93,11 @@ void Application::setup() { do { uint32_t now = millis(); - // Process pending loop enables to handle GPIO interrupts during setup - this->before_loop_tasks_(now); + // Service scheduler and process pending loop enables to handle GPIO + // interrupts during setup. During setup we always run the component + // phase (no loop_interval_ gate), so call both helpers unconditionally. + this->scheduler_tick_(now); + this->before_component_phase_(); for (uint32_t j = 0; j <= i; j++) { // Update loop_component_start_time_ right before calling each component @@ -103,7 +106,7 @@ void Application::setup() { this->feed_wdt(); } - this->after_loop_tasks_(); + this->after_component_phase_(); yield(); } while (!component->can_proceed() && !component->is_failed()); } diff --git a/esphome/core/application.h b/esphome/core/application.h index a512af9c61..d3851a32da 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -426,8 +426,9 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline uint32_t ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); - inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; } + inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); + inline void ESPHOME_ALWAYS_INLINE before_component_phase_(); + inline void ESPHOME_ALWAYS_INLINE after_component_phase_() { this->in_loop_ = false; } /// Process dump_config output one component per loop iteration. /// Extracted from loop() to keep cold startup/reconnect logging out of the hot path. @@ -582,16 +583,25 @@ inline void Application::drain_wake_notifications_() { } #endif // USE_HOST -inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { +// Phase A: drain wake notifications and run the scheduler. Invoked on every +// Application::loop() tick regardless of whether a component phase runs, so +// scheduler items fire at their requested cadence even when the caller has +// raised loop_interval_ for power savings (see Application::loop()). +// Returns the timestamp of the last scheduler item that ran (or `now` +// unchanged if none ran), so the caller's WDT feed stays monotonic with the +// per-item feeds inside scheduler.call() without an extra millis(). +inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif + return this->scheduler.call(now); +} - // Scheduler::call feeds the WDT per item and returns the timestamp of the - // last fired item, or the input unchanged when nothing ran. - uint32_t last_op_end_time = this->scheduler.call(loop_start_time); - +// Phase B entry: only invoked when a component loop phase is about to run. +// Processes pending enable_loop requests from ISRs and marks in_loop_ so +// reentrant modifications during component.loop() are safe. +inline void ESPHOME_ALWAYS_INLINE Application::before_component_phase_() { // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions if (this->has_pending_enable_loop_requests_) { @@ -608,7 +618,6 @@ inline uint32_t ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t l // Mark that we're in the loop for safe reentrant modifications this->in_loop_ = true; - return last_op_end_time; } inline void ESPHOME_ALWAYS_INLINE Application::loop() { @@ -623,46 +632,77 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; #endif - // Get the initial loop time at the start - uint32_t last_op_end_time = millis(); + // Phase A: always service the scheduler. Decouples scheduler cadence from + // loop_interval_ so raised intervals (for power savings) don't drag scheduled + // items forward. A tick that only runs the scheduler is cheap. + // scheduler_tick_ returns the timestamp of the last scheduler item that ran + // (advanced by its per-item feeds) or `now` unchanged. We adopt it as `now` + // so the gate check and WDT feed both reflect actual elapsed time after + // scheduler dispatch, without an extra millis() call. + uint32_t now = this->scheduler_tick_(millis()); + // Guarantee one WDT feed per tick even when the scheduler had nothing to + // dispatch and the component phase is gated out — covers configs with no + // looping components and no scheduler work (setup() has its own + // per-component feed_wdt calls, so only do this here, not in scheduler_tick_). + this->feed_wdt_with_time(now); - // Returned timestamp keeps us monotonic with last_wdt_feed_ (advanced by - // the scheduler's per-item feeds) without an extra millis() call. - last_op_end_time = this->before_loop_tasks_(last_op_end_time); - // Guarantee a WDT touch every tick — covers configs with no looping - // components and no scheduler work, where the per-item / per-component - // feeds never fire. Rate-limited inline fast path, ~free when unneeded. - this->feed_wdt_with_time(last_op_end_time); #ifdef USE_RUNTIME_STATS uint32_t loop_before_end_us = micros(); uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; + // Only meaningful when do_component_phase is true; initialized to 0 so the + // tail bucket receives 0 on Phase A-only ticks (no component tail happened, + // the gate-check / stats-prefix overhead belongs to "residual", not "tail"). + uint32_t loop_tail_start_us = 0; #endif - for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; - this->current_loop_index_++) { - Component *component = this->looping_components_[this->current_loop_index_]; + // Gate the component phase on loop_interval_, an active high-frequency + // request, or an explicit wake from a background producer. A scheduler-only + // wake (e.g. set_interval firing under a raised loop_interval_) leaves the + // component phase gated; an external producer that called wake_loop_* + // (MQTT RX, USB RX, BLE event, etc.) needs the component phase to actually + // run so its component's loop() can drain the queued work — that is the + // long-standing semantic of wake_loop_threadsafe(), and the wake_request + // flag preserves it. wake_request_take() exchange-clears the flag; wakes + // that arrive during Phase B re-set it and run Phase B again on the next + // iteration. + const bool high_frequency = HighFrequencyLoopRequester::is_high_frequency(); + const uint32_t elapsed = now - this->last_loop_; + const bool woke = esphome::wake_request_take(); + const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_); - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; + if (do_component_phase) { + this->before_component_phase_(); - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; - component->loop(); - // Use the finish method to get the current time as the end time - last_op_end_time = guard.finish(); + uint32_t last_op_end_time = now; + for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; + this->current_loop_index_++) { + Component *component = this->looping_components_[this->current_loop_index_]; + + // Update the cached time before each component runs + this->loop_component_start_time_ = last_op_end_time; + + { + this->set_current_component(component); + WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + component->loop(); + // Use the finish method to get the current time as the end time + last_op_end_time = guard.finish(); + } + this->feed_wdt_with_time(last_op_end_time); } - this->feed_wdt_with_time(last_op_end_time); + +#ifdef USE_RUNTIME_STATS + loop_tail_start_us = micros(); +#endif + this->last_loop_ = last_op_end_time; + now = last_op_end_time; + this->after_component_phase_(); } #ifdef USE_RUNTIME_STATS - uint32_t loop_tail_start_us = micros(); -#endif - this->after_loop_tasks_(); - -#ifdef USE_RUNTIME_STATS - // Process any pending runtime stats printing after all components have run - // This ensures stats printing doesn't affect component timing measurements + // Record per-tick timing on every loop, not just component-phase ticks. + // record_loop_active is a small accumulator; process_pending_stats is an + // inline gate check that early-outs unless now >= next_log_time_. if (global_runtime_stats != nullptr) { uint32_t loop_now_us = micros(); // Subtract scheduled-component time from the "before" bucket so it is @@ -671,25 +711,40 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us ? loop_before_wall_us - static_cast(loop_before_scheduled_us) : 0; - global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, - loop_now_us - loop_tail_start_us); - global_runtime_stats->process_pending_stats(last_op_end_time); + // tail_us is only defined when Phase B ran; 0 on Phase A-only ticks so the + // stats bucket keeps its "component-phase trailing overhead" meaning. + uint32_t loop_tail_us = do_component_phase ? (loop_now_us - loop_tail_start_us) : 0; + global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, loop_tail_us); + global_runtime_stats->process_pending_stats(now); } #endif - // Use the last component's end time instead of calling millis() again + // Compute sleep: bounded by time-until-next-component-phase and the + // scheduler's next deadline. When a scheduler timer fires it re-enters + // loop(), Phase A services it, and the component phase stays gated by + // loop_interval_. When a background producer calls wake_loop_threadsafe() + // it sets the wake_request flag and wakes select() / the task notification; + // the gate above sees the flag and runs Phase B too so the producer's + // component can drain its queued work without waiting up to loop_interval_. + // + // Re-read HighFrequencyLoopRequester::is_high_frequency() here instead of + // reusing the cached `high_frequency` captured above: a component calling + // HighFrequencyLoopRequester::start() from within its loop() would + // otherwise sit under the stale value and sleep for up to loop_interval_ + // before the request took effect. That was fine pre-decoupling (the old + // main loop also called the function fresh at the sleep point) but now + // matters much more — loop_interval_ is a power-saving knob documented + // to accept multi-second values, so the stale path could add seconds of + // latency on an HF request. The call is a trivial atomic read. uint32_t delay_time = 0; - auto elapsed = last_op_end_time - this->last_loop_; - if (elapsed < this->loop_interval_ && !HighFrequencyLoopRequester::is_high_frequency()) { - delay_time = this->loop_interval_ - elapsed; - uint32_t next_schedule = this->scheduler.next_schedule_in(last_op_end_time).value_or(delay_time); - // next_schedule is max 0.5*delay_time - // otherwise interval=0 schedules result in constant looping with almost no sleep - next_schedule = std::max(next_schedule, delay_time / 2); - delay_time = std::min(next_schedule, delay_time); + if (!HighFrequencyLoopRequester::is_high_frequency()) { + const uint32_t elapsed_since_phase = now - this->last_loop_; + const uint32_t until_phase = + (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); + const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); + delay_time = std::min(until_phase, until_sched); } this->yield_with_select_(delay_time); - this->last_loop_ = last_op_end_time; if (this->dump_config_at_ < this->components_.size()) { this->process_dump_config_(); diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index 3709fa88ac..cebc4d04b7 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -12,9 +12,22 @@ namespace esphome { +// === Wake-requested flag storage === +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic g_wake_requested{0}; +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +#endif + // === ESP32 / LibreTiny — IRAM_ATTR entry points === #if defined(USE_ESP32) || defined(USE_LIBRETINY) void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { + // ISR-safe: set flag before notify so the wake is visible on the next gate + // check. wake_request_set() is just an aligned 8-bit store / atomic store + // and is safe from IRAM. + wake_request_set(); esphome_main_task_notify_from_isr(px_higher_priority_task_woken); } void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } @@ -72,6 +85,9 @@ void wakeable_delay(uint32_t ms) { // === Host (UDP loopback socket) === #ifdef USE_HOST void wake_loop_threadsafe() { + // Set flag before sending so the consumer's gate check on the next loop() + // entry observes the wake regardless of select() scheduling. + wake_request_set(); if (App.wake_socket_fd_ >= 0) { const char dummy = 1; ::send(App.wake_socket_fd_, &dummy, 1, 0); diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 77a38d429e..41b7ab33b5 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -7,6 +7,10 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#include +#endif + #if defined(USE_ESP32) || defined(USE_LIBRETINY) #include "esphome/core/main_task.h" #endif @@ -25,12 +29,48 @@ namespace esphome { extern volatile bool g_main_loop_woke; #endif +// === wake_request flag — signals Application::loop() that a producer queued +// work for some component's loop() to drain (MQTT RX, USB RX, BLE event, etc.) +// and the component phase should run this tick instead of being held off by +// the loop_interval_ gate. Set by every wake_loop_* entry point; consumed +// (via exchange-and-clear) at the gate in Application::loop(). === +// +// std::atomic rather than std::atomic because GCC on Xtensa +// generates an indirect function call for atomic ops instead of inlining +// them — same workaround applied in scheduler.h for the SchedulerItem::remove +// flag. On non-atomic platforms a volatile uint8_t suffices: 8-bit aligned +// loads/stores are atomic on every supported MCU, and the platform signal +// that follows wake_request_set() (FreeRTOS task-notify, esp_schedule, socket +// send) provides the cross-thread/cross-core memory barrier. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern std::atomic g_wake_requested; + +__attribute__((always_inline)) inline void wake_request_set() { g_wake_requested.store(1, std::memory_order_release); } +__attribute__((always_inline)) inline bool wake_request_take() { + return g_wake_requested.exchange(0, std::memory_order_acquire) != 0; +} +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern volatile uint8_t g_wake_requested; + +__attribute__((always_inline)) inline void wake_request_set() { g_wake_requested = 1; } +__attribute__((always_inline)) inline bool wake_request_take() { + uint8_t v = g_wake_requested; + g_wake_requested = 0; + return v != 0; +} +#endif + // === ESP32 / LibreTiny (FreeRTOS) === #if defined(USE_ESP32) || defined(USE_LIBRETINY) /// Wake the main loop from any context (ISR or task). /// always_inline so callers placed in IRAM keep the whole wake path in IRAM. __attribute__((always_inline)) inline void wake_main_task_any_context() { + // Set the wake-requested flag BEFORE the task notification so the consumer + // (Application::loop() gate) is guaranteed to see it on its next gate check. + wake_request_set(); if (in_isr_context()) { BaseType_t px_higher_priority_task_woken = pdFALSE; esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); @@ -50,7 +90,10 @@ __attribute__((always_inline)) inline void wake_main_task_any_context() { void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); void wake_loop_any_context(); -inline void wake_loop_threadsafe() { esphome_main_task_notify(); } +inline void wake_loop_threadsafe() { + wake_request_set(); + esphome_main_task_notify(); +} namespace internal { inline void wakeable_delay(uint32_t ms) { @@ -67,6 +110,9 @@ inline void wakeable_delay(uint32_t ms) { /// Inline implementation — IRAM callers inline this directly. inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { + // Set the wake-requested flag BEFORE esp_schedule so the consumer is + // guaranteed to see it on its next gate check. + wake_request_set(); g_main_loop_woke = true; esp_schedule(); } @@ -98,6 +144,9 @@ inline void wakeable_delay(uint32_t ms) { #elif defined(USE_RP2040) inline void wake_loop_any_context() { + // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed + // to see it on its next gate check. + wake_request_set(); g_main_loop_woke = true; __sev(); } diff --git a/tests/integration/fixtures/external_components/wake_test_component/__init__.py b/tests/integration/fixtures/external_components/wake_test_component/__init__.py new file mode 100644 index 0000000000..ce24167889 --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/__init__.py @@ -0,0 +1,19 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@esphome/tests"] + +wake_test_component_ns = cg.esphome_ns.namespace("wake_test_component") +WakeTestComponent = wake_test_component_ns.class_("WakeTestComponent", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(WakeTestComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp new file mode 100644 index 0000000000..b58f1c9adc --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp @@ -0,0 +1,19 @@ +#include "wake_test_component.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include +#include + +namespace esphome::wake_test_component { + +static const char *const TAG = "wake_test_component"; + +void WakeTestComponent::start_async_wake() { + ESP_LOGI(TAG, "Spawning async wake thread (50ms delay)"); + std::thread([] { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + App.wake_loop_threadsafe(); + }).detach(); +} + +} // namespace esphome::wake_test_component diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h new file mode 100644 index 0000000000..c8e4e0a89f --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esphome/core/component.h" +#include + +namespace esphome::wake_test_component { + +class WakeTestComponent : public Component { + public: + void setup() override {} + void loop() override { this->loop_count_.fetch_add(1, std::memory_order_relaxed); } + + int get_loop_count() const { return this->loop_count_.load(std::memory_order_relaxed); } + + // Spawn a detached thread that sleeps briefly then calls + // App.wake_loop_threadsafe(). Used by the integration test to verify a + // cross-thread wake forces a component-phase iteration even when + // loop_interval_ has been raised high enough to gate it off otherwise. + void start_async_wake(); + + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + std::atomic loop_count_{0}; +}; + +} // namespace esphome::wake_test_component diff --git a/tests/integration/fixtures/loop_interval_decoupling.yaml b/tests/integration/fixtures/loop_interval_decoupling.yaml new file mode 100644 index 0000000000..5aedd9aba5 --- /dev/null +++ b/tests/integration/fixtures/loop_interval_decoupling.yaml @@ -0,0 +1,60 @@ +esphome: + name: loop-interval-decouple + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 500ms. With the decoupling fix the + // component phase should run ~twice per second while the 50ms + // scheduler interval below still fires at its requested cadence. + App.set_loop_interval(500); + # Start measurement after 1s so boot transients settle. + - delay: 1000ms + - lambda: |- + id(loop_at_start) = id(loop_counter)->get_loop_count(); + id(sched_at_start) = id(sched_count); + ESP_LOGI("test", "MEASUREMENT_STARTED loop=%d sched=%d", + id(loop_at_start), id(sched_at_start)); + # Observe for 2s. + - delay: 2000ms + - lambda: |- + int loop_delta = id(loop_counter)->get_loop_count() - id(loop_at_start); + int sched_delta = id(sched_count) - id(sched_at_start); + ESP_LOGI("test", "MEASUREMENT_DONE loop_delta=%d sched_delta=%d", + loop_delta, sched_delta); + +host: +api: +logger: + level: INFO + logs: + loop_test_component: WARN # Silence per-loop log spam + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +globals: + - id: sched_count + type: int + initial_value: "0" + - id: loop_at_start + type: int + initial_value: "0" + - id: sched_at_start + type: int + initial_value: "0" + +loop_test_component: + components: + - id: loop_counter + name: loop_counter + +interval: + # Fast scheduler interval — with the decoupling fix this should fire at + # its requested 50ms cadence regardless of loop_interval_. + - interval: 50ms + then: + - lambda: |- + id(sched_count) += 1; diff --git a/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml b/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml new file mode 100644 index 0000000000..fec83865b9 --- /dev/null +++ b/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml @@ -0,0 +1,51 @@ +esphome: + name: loop-default-not-pulled + on_boot: + priority: -100 + then: + # Leave loop_interval_ at its default (16 ms → ~62 Hz). Do NOT call + # set_loop_interval here. The fast scheduler interval below used to + # pull the component phase forward to ~128 Hz via the old + # std::max(next_schedule, delay_time / 2) floor. + # Start measurement after 1s so boot transients settle. + - delay: 1000ms + - lambda: |- + id(loop_at_start) = id(loop_counter)->get_loop_count(); + ESP_LOGI("test", "MEASUREMENT_STARTED loop=%d", id(loop_at_start)); + # Observe for 2s. + - delay: 2000ms + - lambda: |- + int loop_delta = id(loop_counter)->get_loop_count() - id(loop_at_start); + ESP_LOGI("test", "MEASUREMENT_DONE loop_delta=%d", loop_delta); + +host: +api: +logger: + level: INFO + logs: + loop_test_component: WARN # Silence per-loop log spam + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +globals: + - id: loop_at_start + type: int + initial_value: "0" + +loop_test_component: + components: + - id: loop_counter + name: loop_counter + +interval: + # Fast scheduler interval (well under loop_interval_/2 = 8ms). In the + # pre-decoupling code this would have pulled the component phase forward + # to ~128 Hz. After the decoupling fix the component phase stays at + # ~62 Hz regardless. + - interval: 5ms + then: + - lambda: |- + // No-op; the presence of a due scheduler item is what matters. diff --git a/tests/integration/fixtures/wake_loop_forces_phase_b.yaml b/tests/integration/fixtures/wake_loop_forces_phase_b.yaml new file mode 100644 index 0000000000..d97ab8514f --- /dev/null +++ b/tests/integration/fixtures/wake_loop_forces_phase_b.yaml @@ -0,0 +1,52 @@ +esphome: + name: wake-loop-phase-b + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 2000ms. Without the wake-request flag, + // a wake_loop_threadsafe() call would only run Phase A (scheduler) + // and leave the component phase gated for ~2s. + App.set_loop_interval(2000); + # Let boot transients settle. + - delay: 1000ms + - lambda: |- + // Snapshot the loop counter, then ask the component to spawn a + // background thread that calls App.wake_loop_threadsafe() after + // ~50ms. With the fix, that wake forces Phase B on the next tick + // and the counter increments well within the 500ms observation + // window below. + id(count_at_start) = id(wake_counter)->get_loop_count(); + id(start_time) = millis(); + id(wake_counter)->start_async_wake(); + ESP_LOGI("test", "WAKE_STARTED count=%d", id(count_at_start)); + # Observation window must be much shorter than loop_interval_ (2000ms) + # so a "false pass" isn't possible by simply waiting out the gate. + - delay: 500ms + - lambda: |- + int count_now = id(wake_counter)->get_loop_count(); + int delta = count_now - id(count_at_start); + uint32_t elapsed = millis() - id(start_time); + ESP_LOGI("test", "WAKE_RESULT delta=%d elapsed=%u", delta, elapsed); + +host: +api: +logger: + level: INFO + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [wake_test_component] + +globals: + - id: count_at_start + type: int + initial_value: "0" + - id: start_time + type: uint32_t + initial_value: "0" + +wake_test_component: + id: wake_counter diff --git a/tests/integration/test_loop_interval_decoupling.py b/tests/integration/test_loop_interval_decoupling.py new file mode 100644 index 0000000000..6c34aed458 --- /dev/null +++ b/tests/integration/test_loop_interval_decoupling.py @@ -0,0 +1,75 @@ +"""Test that loop_interval_ no longer clamps scheduler cadence. + +Regression test for the decoupling of Application::loop() component-phase +cadence from scheduler wake timing. + +Setup: +- App.set_loop_interval(500) — raised for power-savings style cadence +- Scheduler interval at 50ms — should fire at 50ms regardless of loop_interval_ +- Component loop (LoopTestComponent) — should run at 500ms cadence + +Before the decoupling fix the old `std::max(next_schedule, delay_time / 2)` +floor clamped the sleep to ~250ms, so the 50ms scheduler only fired ~8 times +per 2s (vs the ~40 expected). After the fix the scheduler fires close to its +requested cadence while the component phase stays gated at loop_interval_. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_loop_interval_decoupling( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Raised loop_interval_ must not clamp scheduler item cadence.""" + loop = asyncio.get_running_loop() + measurement_done: asyncio.Future[tuple[int, int]] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)", line) + if match and not measurement_done.done(): + measurement_done.set_result((int(match.group(1)), int(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "loop-interval-decouple" + + try: + loop_delta, sched_delta = await asyncio.wait_for( + measurement_done, timeout=10.0 + ) + except TimeoutError: + pytest.fail("MEASUREMENT_DONE marker never appeared") + + # Observation window = 2s, loop_interval_ = 500ms. + # Component phase should fire ~4 times in 2s. The upper bound must be + # less than 8: the pre-decoupling behavior clamped to ~250ms cadence + # giving ~8 loops/2s, so allowing 8 would let the old behavior pass. + # Lower bound 3 (not 2) keeps the test honest: a >30% slowdown from + # the ~4 nominal is not normal CI jitter and should fail. + assert 3 <= loop_delta <= 6, ( + f"Component loop should fire ~4 times in 2s at loop_interval=500ms, " + f"got {loop_delta}" + ) + + # Scheduler interval = 50ms → ~40 fires in 2s. Before the decoupling + # fix this clamped to ~8 fires. Assert >= 20 to catch the old clamped + # behavior with comfortable jitter headroom for slow CI hosts. + assert sched_delta >= 20, ( + f"50ms scheduler interval should fire ~40 times in 2s but only " + f"fired {sched_delta}. This indicates loop_interval_ is still " + f"clamping scheduler cadence." + ) diff --git a/tests/integration/test_loop_interval_default_not_pulled_forward.py b/tests/integration/test_loop_interval_default_not_pulled_forward.py new file mode 100644 index 0000000000..17a7070436 --- /dev/null +++ b/tests/integration/test_loop_interval_default_not_pulled_forward.py @@ -0,0 +1,67 @@ +"""Test that a fast scheduler item does not pull the component phase forward. + +Regression test for the original ~128 Hz → ~62 Hz bug fixed by decoupling +Application::loop() component-phase cadence from scheduler wake timing. + +Setup: +- loop_interval_ left at its default (16 ms → ~62 Hz component phase). +- Scheduler interval at 5 ms (well under the old loop_interval_/2 = 8 ms floor). + +Before the decoupling fix the ``std::max(next_schedule, delay_time / 2)`` floor +clamped the sleep to ~8 ms whenever any scheduler item was due sooner than +loop_interval_/2. That pulled the component phase forward to ~128 Hz — twice +what the documented ~62 Hz default promised. After the fix the component +phase stays at ~62 Hz regardless of scheduler activity. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_loop_interval_default_not_pulled_forward( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Fast scheduler item must not pull component phase past default ~62 Hz.""" + loop = asyncio.get_running_loop() + measurement_done: asyncio.Future[int] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+)", line) + if match and not measurement_done.done(): + measurement_done.set_result(int(match.group(1))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "loop-default-not-pulled" + + try: + loop_delta = await asyncio.wait_for(measurement_done, timeout=10.0) + except TimeoutError: + pytest.fail("MEASUREMENT_DONE marker never appeared") + + # Observation window = 2s, loop_interval_ default = 16ms → ~62 Hz → + # ~125 component-phase iterations expected. + # Pre-fix behavior: the 5 ms scheduler interval tripped the old + # delay_time/2 = 8 ms floor, pulling the phase to ~128 Hz → ~256. + # Upper bound 180 is comfortably below the ~256 pre-fix rate but + # above the ~125 nominal with CI jitter. + # Lower bound 80 covers very slow CI hosts without permitting a + # complete regression. + assert 80 <= loop_delta <= 180, ( + f"Component loop at default loop_interval_ should fire ~125 times " + f"in 2s (≈62 Hz × 2s); got {loop_delta}. Values >200 indicate the " + f"scheduler is again pulling the component phase forward." + ) diff --git a/tests/integration/test_wake_loop_forces_phase_b.py b/tests/integration/test_wake_loop_forces_phase_b.py new file mode 100644 index 0000000000..5f05f07dd8 --- /dev/null +++ b/tests/integration/test_wake_loop_forces_phase_b.py @@ -0,0 +1,76 @@ +"""Test that wake_loop_threadsafe() forces a component-phase iteration. + +Regression test for the wake-request flag added to Application::loop()'s +Phase A / Phase B gate. Background producers (MQTT RX, USB RX, BLE event, +etc.) call App.wake_loop_threadsafe() expecting their component's loop() +to drain queued work; if the component phase stays gated by loop_interval_, +the work waits up to loop_interval_ ms instead of running on the next tick. + +Setup: +- App.set_loop_interval(2000) — a wide gate that would clearly mask the bug. +- A test component spawns a detached std::thread that sleeps 50 ms and then + calls App.wake_loop_threadsafe() from a non-main thread. +- The on_boot block snapshots the component's loop counter before/after a + 500 ms observation window. + +Without the fix, delta=0 (the gate holds Phase B for ~2 s). +With the fix, delta>=1 (the wake forces Phase B within one tick of the wake). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wake_loop_forces_phase_b( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A wake_loop_threadsafe() call from a background thread must trigger the + component phase within the next tick, even when loop_interval_ is raised + well above the observation window.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + result: asyncio.Future[tuple[int, int]] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"WAKE_RESULT delta=(\d+) elapsed=(\d+)", line) + if match and not result.done(): + result.set_result((int(match.group(1)), int(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "wake-loop-phase-b" + + try: + delta, elapsed = await asyncio.wait_for(result, timeout=15.0) + except TimeoutError: + pytest.fail("WAKE_RESULT marker never appeared") + + # Without the fix, delta would be 0 — loop_interval_=2000ms held + # Phase B off for the full 500ms observation window. With the fix + # the wake from the background thread (~50ms after start) forces + # Phase B on the next tick, so the counter increments at least once. + assert delta >= 1, ( + f"wake_loop_threadsafe() from a background thread should force " + f"Phase B within the next tick; observed delta={delta} after " + f"{elapsed}ms with loop_interval_=2000ms" + ) From 7bd36e0c8dc6c2b968e858087e77bacb376b84bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 14:51:45 +0200 Subject: [PATCH 90/94] [debug] Migrate trivial buf_append_printf sites to buf_append_str (#15885) --- esphome/components/debug/debug_component.cpp | 2 +- esphome/components/debug/debug_esp32.cpp | 23 +++++++++++------ esphome/components/debug/debug_libretiny.cpp | 9 ++++--- esphome/components/debug/debug_zephyr.cpp | 26 ++++++++++++++------ 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 15f68c3a3b..d97a4aa135 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -30,7 +30,7 @@ void DebugComponent::dump_config() { char device_info_buffer[DEVICE_INFO_BUFFER_SIZE]; ESP_LOGD(TAG, "ESPHome version %s", ESPHOME_VERSION); - size_t pos = buf_append_printf(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, "%s", ESPHOME_VERSION); + size_t pos = buf_append_str(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, ESPHOME_VERSION); this->free_heap_ = get_free_heap_(); ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 2e04090749..ea0c635207 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -224,17 +224,21 @@ size_t DebugComponent::get_device_info_(std::span const char *model = ESPHOME_VARIANT; // Build features string - pos = buf_append_printf(buf, size, pos, "|Chip: %s Features:", model); + pos = buf_append_str(buf, size, pos, "|Chip: "); + pos = buf_append_str(buf, size, pos, model); + pos = buf_append_str(buf, size, pos, " Features:"); bool first_feature = true; for (const auto &feature : CHIP_FEATURES) { if (info.features & feature.bit) { - pos = buf_append_printf(buf, size, pos, "%s%s", first_feature ? "" : ", ", feature.name); + pos = buf_append_str(buf, size, pos, first_feature ? "" : ", "); + pos = buf_append_str(buf, size, pos, feature.name); first_feature = false; info.features &= ~feature.bit; } } if (info.features != 0) { - pos = buf_append_printf(buf, size, pos, "%sOther:0x%" PRIx32, first_feature ? "" : ", ", info.features); + pos = buf_append_str(buf, size, pos, first_feature ? "" : ", "); + pos = buf_append_printf(buf, size, pos, "Other:0x%" PRIx32, info.features); } pos = buf_append_printf(buf, size, pos, " Cores:%u Revision:%u", info.cores, info.revision); @@ -267,17 +271,20 @@ size_t DebugComponent::get_device_info_(std::span // Framework detection #ifdef USE_ARDUINO ESP_LOGD(TAG, " Framework: Arduino"); - pos = buf_append_printf(buf, size, pos, "|Framework: Arduino"); + pos = buf_append_str(buf, size, pos, "|Framework: Arduino"); #else ESP_LOGD(TAG, " Framework: ESP-IDF"); - pos = buf_append_printf(buf, size, pos, "|Framework: ESP-IDF"); + pos = buf_append_str(buf, size, pos, "|Framework: ESP-IDF"); #endif - pos = buf_append_printf(buf, size, pos, "|ESP-IDF: %s", esp_get_idf_version()); + pos = buf_append_str(buf, size, pos, "|ESP-IDF: "); + pos = buf_append_str(buf, size, pos, esp_get_idf_version()); pos = buf_append_printf(buf, size, pos, "|EFuse MAC: %02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); - pos = buf_append_printf(buf, size, pos, "|Reset: %s", reset_reason); - pos = buf_append_printf(buf, size, pos, "|Wakeup: %s", wakeup_cause); + pos = buf_append_str(buf, size, pos, "|Reset: "); + pos = buf_append_str(buf, size, pos, reset_reason); + pos = buf_append_str(buf, size, pos, "|Wakeup: "); + pos = buf_append_str(buf, size, pos, wakeup_cause); return pos; } diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 1d458c602a..6f36debb95 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -38,9 +38,12 @@ size_t DebugComponent::get_device_info_(std::span lt_get_version(), lt_cpu_get_model_name(), lt_cpu_get_model(), lt_cpu_get_freq_mhz(), mac_id, lt_get_board_code(), flash_kib, ram_kib, reset_reason); - pos = buf_append_printf(buf, size, pos, "|Version: %s", LT_BANNER_STR + 10); - pos = buf_append_printf(buf, size, pos, "|Reset Reason: %s", reset_reason); - pos = buf_append_printf(buf, size, pos, "|Chip Name: %s", lt_cpu_get_model_name()); + pos = buf_append_str(buf, size, pos, "|Version: "); + pos = buf_append_str(buf, size, pos, LT_BANNER_STR + 10); + pos = buf_append_str(buf, size, pos, "|Reset Reason: "); + pos = buf_append_str(buf, size, pos, reset_reason); + pos = buf_append_str(buf, size, pos, "|Chip Name: "); + pos = buf_append_str(buf, size, pos, lt_cpu_get_model_name()); pos = buf_append_printf(buf, size, pos, "|Chip ID: 0x%06" PRIX32, mac_id); pos = buf_append_printf(buf, size, pos, "|Flash: %" PRIu32 " KiB", flash_kib); pos = buf_append_printf(buf, size, pos, "|RAM: %" PRIu32 " KiB", ram_kib); diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index d1580dae80..49790b5b9a 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -162,14 +162,18 @@ size_t DebugComponent::get_device_info_(std::span const char *supply_status = (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_NORMAL) ? "Normal voltage." : "High voltage."; ESP_LOGD(TAG, "Main supply status: %s", supply_status); - pos = buf_append_printf(buf, size, pos, "|Main supply status: %s", supply_status); + pos = buf_append_str(buf, size, pos, "|Main supply status: "); + pos = buf_append_str(buf, size, pos, supply_status); // Regulator stage 0 if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) { const char *reg0_type = nrf_power_dcdcen_vddh_get(NRF_POWER) ? "DC/DC" : "LDO"; const char *reg0_voltage = regout0_to_str((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos); ESP_LOGD(TAG, "Regulator stage 0: %s, %s", reg0_type, reg0_voltage); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: %s, %s", reg0_type, reg0_voltage); + pos = buf_append_str(buf, size, pos, "|Regulator stage 0: "); + pos = buf_append_str(buf, size, pos, reg0_type); + pos = buf_append_str(buf, size, pos, ", "); + pos = buf_append_str(buf, size, pos, reg0_voltage); #ifdef USE_NRF52_REG0_VOUT if ((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos != USE_NRF52_REG0_VOUT) { ESP_LOGE(TAG, "Regulator stage 0: expected %s", regout0_to_str(USE_NRF52_REG0_VOUT)); @@ -177,13 +181,14 @@ size_t DebugComponent::get_device_info_(std::span #endif } else { ESP_LOGD(TAG, "Regulator stage 0: disabled"); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: disabled"); + pos = buf_append_str(buf, size, pos, "|Regulator stage 0: disabled"); } // Regulator stage 1 const char *reg1_type = nrf_power_dcdcen_get(NRF_POWER) ? "DC/DC" : "LDO"; ESP_LOGD(TAG, "Regulator stage 1: %s", reg1_type); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 1: %s", reg1_type); + pos = buf_append_str(buf, size, pos, "|Regulator stage 1: "); + pos = buf_append_str(buf, size, pos, reg1_type); // USB power state const char *usb_state; @@ -197,7 +202,8 @@ size_t DebugComponent::get_device_info_(std::span usb_state = "disconnected"; } ESP_LOGD(TAG, "USB power state: %s", usb_state); - pos = buf_append_printf(buf, size, pos, "|USB power state: %s", usb_state); + pos = buf_append_str(buf, size, pos, "|USB power state: "); + pos = buf_append_str(buf, size, pos, usb_state); // Power-fail comparator bool enabled; @@ -302,14 +308,18 @@ size_t DebugComponent::get_device_info_(std::span break; } ESP_LOGD(TAG, "Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: "); + pos = buf_append_str(buf, size, pos, pof_voltage); + pos = buf_append_str(buf, size, pos, ", VDDH: "); + pos = buf_append_str(buf, size, pos, vddh_voltage); } else { ESP_LOGD(TAG, "Power-fail comparator: %s", pof_voltage); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s", pof_voltage); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: "); + pos = buf_append_str(buf, size, pos, pof_voltage); } } else { ESP_LOGD(TAG, "Power-fail comparator: disabled"); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: disabled"); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: disabled"); } auto package = [](uint32_t value) { From 3a6f3dfb9468d1de3be68616a5bef95feba215dc Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Tue, 21 Apr 2026 16:03:07 +0300 Subject: [PATCH 91/94] [lock] Implemented open states support (#15120) 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/lock/__init__.py | 2 ++ esphome/components/lock/lock.cpp | 11 ++++++++--- esphome/components/lock/lock.h | 4 +++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 1a45896ac1..a36d52a5d8 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -35,9 +35,11 @@ LockStateForwarder = lock_ns.class_("LockStateForwarder") LockState = lock_ns.enum("LockState") LOCK_STATES = { + "OPEN": LockState.LOCK_STATE_OPEN, "LOCKED": LockState.LOCK_STATE_LOCKED, "UNLOCKED": LockState.LOCK_STATE_UNLOCKED, "JAMMED": LockState.LOCK_STATE_JAMMED, + "OPENING": LockState.LOCK_STATE_OPENING, "LOCKING": LockState.LOCK_STATE_LOCKING, "UNLOCKING": LockState.LOCK_STATE_UNLOCKING, } diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp index 3ff131af3d..66eb692bd5 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -8,9 +8,10 @@ namespace esphome::lock { static const char *const TAG = "lock"; -// Lock state strings indexed by LockState enum (0-5): NONE(UNKNOWN), LOCKED, UNLOCKED, JAMMED, LOCKING, UNLOCKING +// Lock state strings indexed by LockState enum. // Index 0 is UNKNOWN (for LOCK_STATE_NONE), also used as fallback for out-of-range -PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING"); +PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING", "OPENING", + "OPEN"); const LogString *lock_state_to_string(LockState state) { return LockStateStrings::get_log_str(static_cast(state), 0); @@ -74,12 +75,16 @@ LockCall &LockCall::set_state(optional state) { return *this; } LockCall &LockCall::set_state(const char *state) { - if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKED")) == 0) { + if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("OPEN")) == 0) { + this->set_state(LOCK_STATE_OPEN); + } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKED")) == 0) { this->set_state(LOCK_STATE_LOCKED); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("UNLOCKED")) == 0) { this->set_state(LOCK_STATE_UNLOCKED); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("JAMMED")) == 0) { this->set_state(LOCK_STATE_JAMMED); + } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("OPENING")) == 0) { + this->set_state(LOCK_STATE_OPENING); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKING")) == 0) { this->set_state(LOCK_STATE_LOCKING); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("UNLOCKING")) == 0) { diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 543a4b51a8..86a9cdd3fb 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -26,7 +26,9 @@ enum LockState : uint8_t { LOCK_STATE_UNLOCKED = 2, LOCK_STATE_JAMMED = 3, LOCK_STATE_LOCKING = 4, - LOCK_STATE_UNLOCKING = 5 + LOCK_STATE_UNLOCKING = 5, + LOCK_STATE_OPENING = 6, + LOCK_STATE_OPEN = 7, }; const LogString *lock_state_to_string(LockState state); From 14defb69b64f3fb365a4b9a68ef5e791963c5add Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Apr 2026 15:04:13 +0200 Subject: [PATCH 92/94] [template] Use placement new for template text restore saver (#15883) --- esphome/components/template/text/__init__.py | 13 +++++- tests/component_tests/template/__init__.py | 0 .../config/template_text_restore.yaml | 14 ++++++ .../template/test_template_text.py | 44 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/template/__init__.py create mode 100644 tests/component_tests/template/config/template_text_restore.yaml create mode 100644 tests/component_tests/template/test_template_text.py diff --git a/esphome/components/template/text/__init__.py b/esphome/components/template/text/__init__.py index 572b5ba0f4..1266370cb2 100644 --- a/esphome/components/template/text/__init__.py +++ b/esphome/components/template/text/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import text import esphome.config_validation as cv from esphome.const import ( + CONF_ID, CONF_INITIAL_VALUE, CONF_LAMBDA, CONF_MAX_LENGTH, @@ -12,6 +13,7 @@ from esphome.const import ( CONF_RESTORE_VALUE, CONF_SET_ACTION, ) +from esphome.core import ID from .. import template_ns @@ -84,8 +86,15 @@ async def to_code(config): if initial_value_config := config.get(CONF_INITIAL_VALUE): cg.add(var.set_initial_value(initial_value_config)) if config[CONF_RESTORE_VALUE]: - args = cg.TemplateArguments(config[CONF_MAX_LENGTH]) - saver = TextSaverTemplate.template(args).new() + saver_id = ID( + f"{config[CONF_ID].id}_value_saver", + is_declaration=True, + type=TextSaverBase, + ) + saver_type = TextSaverTemplate.template( + cg.TemplateArguments(config[CONF_MAX_LENGTH]) + ) + saver = cg.Pvariable(saver_id, saver_type.new()) cg.add(var.set_value_saver(saver)) if CONF_SET_ACTION in config: diff --git a/tests/component_tests/template/__init__.py b/tests/component_tests/template/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/template/config/template_text_restore.yaml b/tests/component_tests/template/config/template_text_restore.yaml new file mode 100644 index 0000000000..4574470eab --- /dev/null +++ b/tests/component_tests/template/config/template_text_restore.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +host: + +text: + - platform: template + name: "Test Text Restore" + id: test_text_restore + optimistic: true + max_length: 10 + mode: text + initial_value: "hello" + restore_value: true diff --git a/tests/component_tests/template/test_template_text.py b/tests/component_tests/template/test_template_text.py new file mode 100644 index 0000000000..2ce9a88d67 --- /dev/null +++ b/tests/component_tests/template/test_template_text.py @@ -0,0 +1,44 @@ +"""Tests for the template text component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + + +def test_template_text_saver_uses_placement_new_with_templated_subclass( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Regression test for template text restore saver using placement new. + + When ``restore_value: true``, the saver is its own Pvariable with + placement new: storage is sized for ``TextSaver``, the + declared pointer stays at ``TemplateTextSaverBase *`` for polymorphism, + and the templated subclass constructor runs. A regression would either + reintroduce the heap ``new TextSaver<...>()`` expression or size the + storage for the base class and silently skip the subclass ctor. + """ + main_cpp = generate_main(component_config_path("template_text_restore.yaml")) + + # Storage is sized and aligned for the templated subclass. + assert "sizeof(template_::TextSaver<10>)" in main_cpp + assert "alignas(template_::TextSaver<10>)" in main_cpp + # Pointer declared as base type for polymorphism. + assert ( + "static template_::TemplateTextSaverBase *const test_text_restore_value_saver" + in main_cpp + ) + # Placement new runs the templated subclass constructor. + assert "new(test_text_restore_value_saver) template_::TextSaver<10>()" in main_cpp + # Base-class default ctor must NOT be used. + assert ( + "new(test_text_restore_value_saver) template_::TemplateTextSaverBase()" + not in main_cpp + ) + # No heap `new TextSaver<...>()` left over — the pre-fix pattern. + assert "new template_::TextSaver<" not in main_cpp + # Saver is wired into the text component. + assert ( + "test_text_restore->set_value_saver(test_text_restore_value_saver)" in main_cpp + ) From 0c9d443a5c041910f4df5cbba3a136d151c92c8e Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:18:46 +0200 Subject: [PATCH 93/94] [esp32_ble] Add `use_psram` option to offload BT memory allocation to SPIRAM (#15644) --- esphome/components/esp32_ble/__init__.py | 20 +++++++++++++++++++ esphome/components/esp32_ble/ble.cpp | 3 +++ esphome/core/defines.h | 1 + .../esp32_ble/common_use_psram.yaml | 4 ++++ .../components/esp32_ble/test.esp32-ard.yaml | 1 + .../components/esp32_ble/test.esp32-idf.yaml | 1 + .../esp32_ble/test.esp32-p4-idf.yaml | 1 + 7 files changed, 31 insertions(+) create mode 100644 tests/components/esp32_ble/common_use_psram.yaml diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 79d05049bf..c7b6b40394 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -7,6 +7,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_USE_PSRAM from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant from esphome.components.esp32.const import VARIANT_ESP32C2 import esphome.config_validation as cv @@ -342,6 +343,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_MAX_CONNECTIONS, default=DEFAULT_MAX_CONNECTIONS): cv.All( cv.positive_int, cv.Range(min=1, max=IDF_MAX_CONNECTIONS) ), + cv.Optional(CONF_USE_PSRAM): cv.All( + cv.only_on_esp32, cv.requires_component("psram"), cv.boolean + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -598,6 +602,22 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for + # heap allocations and use dynamic (heap-based) environment memory tables + # instead of large static DRAM arrays. This frees ~40 kB of internal RAM. + # Reference: Espressif ADF Design Considerations + # https://espressif-docs.readthedocs-hosted.com/projects/esp-adf/en/latest/ + # design-guide/design-considerations.html + if config.get(CONF_USE_PSRAM, False): + cg.add_define("USE_ESP32_BLE_PSRAM") + # CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST is only available on ESP32 + # (BTDM dual-mode controller). BLE-only SoCs (C3, S3, C2, H2) do not + # expose this Kconfig symbol; applying it there would cause a build error. + if get_esp32_variant() == const.VARIANT_ESP32: + add_idf_sdkconfig_option("CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST", True) + # CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY applies to all Bluedroid-enabled variants. + add_idf_sdkconfig_option("CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY", True) + # Register the core BLE loggers that are always needed register_bt_logger(BTLoggers.GAP, BTLoggers.BTM, BTLoggers.HCI) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 0280439731..ebb44c7d91 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -667,6 +667,9 @@ void ESP32BLE::dump_config() { " MAC address: %s\n" " IO Capability: %s", mac_s, io_capability_s); +#ifdef USE_ESP32_BLE_PSRAM + ESP_LOGCONFIG(TAG, " PSRAM BLE allocation: enabled"); +#endif #ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS const char *auth_req_mode_s = ""; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0fb7221571..07cac97e17 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -48,6 +48,7 @@ #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT +#define USE_ESP32_BLE_PSRAM #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED #define USE_ESP32_IMPROV_STATE_CALLBACK diff --git a/tests/components/esp32_ble/common_use_psram.yaml b/tests/components/esp32_ble/common_use_psram.yaml new file mode 100644 index 0000000000..cce6cf547f --- /dev/null +++ b/tests/components/esp32_ble/common_use_psram.yaml @@ -0,0 +1,4 @@ +esp32_ble: + use_psram: true + +psram: diff --git a/tests/components/esp32_ble/test.esp32-ard.yaml b/tests/components/esp32_ble/test.esp32-ard.yaml index dade44d145..fa7b9befc7 100644 --- a/tests/components/esp32_ble/test.esp32-ard.yaml +++ b/tests/components/esp32_ble/test.esp32-ard.yaml @@ -1 +1,2 @@ <<: !include common.yaml +<<: !include common_use_psram.yaml diff --git a/tests/components/esp32_ble/test.esp32-idf.yaml b/tests/components/esp32_ble/test.esp32-idf.yaml index f8defaf28f..0b2a920c60 100644 --- a/tests/components/esp32_ble/test.esp32-idf.yaml +++ b/tests/components/esp32_ble/test.esp32-idf.yaml @@ -1,4 +1,5 @@ <<: !include common.yaml +<<: !include common_use_psram.yaml esp32_ble: io_capability: keyboard_only diff --git a/tests/components/esp32_ble/test.esp32-p4-idf.yaml b/tests/components/esp32_ble/test.esp32-p4-idf.yaml index 4eeb7c2f18..170220bf48 100644 --- a/tests/components/esp32_ble/test.esp32-p4-idf.yaml +++ b/tests/components/esp32_ble/test.esp32-p4-idf.yaml @@ -2,6 +2,7 @@ packages: ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml <<: !include common.yaml +<<: !include common_use_psram.yaml esp32_ble: io_capability: keyboard_only From 43c6b839cd70e3c2430964ec441c3815ac203d91 Mon Sep 17 00:00:00 2001 From: Geoff Date: Tue, 21 Apr 2026 07:00:03 -0700 Subject: [PATCH 94/94] [sensor] Filter to round to significant digits (#11157) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sensor/__init__.py | 14 ++++++ esphome/components/sensor/filter.h | 13 +++++ esphome/core/helpers.cpp | 17 +++++++ esphome/core/helpers.h | 5 ++ tests/components/core/helpers_test.cpp | 58 ++++++++++++++++++++++ tests/components/template/common-base.yaml | 1 + 6 files changed, 108 insertions(+) create mode 100644 tests/components/core/helpers_test.cpp diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 8dcb7165e3..43fbc98953 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -118,6 +118,7 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.util import Registry CODEOWNERS = ["@esphome/core"] + DEVICE_CLASSES = [ DEVICE_CLASS_ABSOLUTE_HUMIDITY, DEVICE_CLASS_APPARENT_POWER, @@ -293,6 +294,7 @@ SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter) ClampFilter = sensor_ns.class_("ClampFilter", Filter) RoundFilter = sensor_ns.class_("RoundFilter", Filter) RoundMultipleFilter = sensor_ns.class_("RoundMultipleFilter", Filter) +RoundSignificantDigitsFilter = sensor_ns.class_("RoundSignificantDigitsFilter", Filter) validate_unit_of_measurement = cv.All( cv.string_strict, @@ -900,6 +902,18 @@ async def round_multiple_filter_to_code(config, filter_id): ) +@FILTER_REGISTRY.register( + "round_to_significant_digits", + RoundSignificantDigitsFilter, + cv.int_range(min=1, max=6), +) +async def round_significant_digits_filter_to_code(config, filter_id): + return cg.new_Pvariable( + filter_id, + cg.TemplateArguments(config), + ) + + async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index a91d66a8fb..917a1ce7d5 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -604,6 +604,19 @@ class RoundMultipleFilter : public Filter { float multiple_; }; +template class RoundSignificantDigitsFilter : public Filter { + public: + optional new_value(float value) override { + if (std::isfinite(value)) { + if (value == 0.0f) + return 0.0f; + float factor = pow10_int(Digits - 1 - ilog10(value)); + return roundf(value * factor) / factor; + } + return value; + } +}; + class ToNTCResistanceFilter : public Filter { public: ToNTCResistanceFilter(double a, double b, double c) : a_(a), b_(b), c_(c) {} diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1d0efd01ce..113b6f6187 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -413,6 +413,23 @@ ParseOnOffState parse_on_off(const char *str, const char *on, const char *off) { return PARSE_NONE; } +int8_t ilog10(float value) { + float abs_val = fabsf(value); + int8_t exp = 0; + if (abs_val >= 10.0f) { + while (abs_val >= 10.0f) { + abs_val /= 10.0f; + exp++; + } + } else if (abs_val < 1.0f) { + while (abs_val < 1.0f) { + abs_val *= 10.0f; + exp--; + } + } + return exp; +} + static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_decimals) { if (accuracy_decimals < 0) { float divisor; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 6b71916cd2..939852bfcb 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -740,6 +740,11 @@ template class SmallBufferWithHeapFallb /// @name Mathematics ///@{ +/// Compute floor(log10(fabs(value))) using iterative comparison. +/// Avoids pulling in __ieee754_logf/log10f (~1KB flash). +/// Only valid for finite, non-zero values. +int8_t ilog10(float value); + /// Compute 10^exp using iterative multiplication/division. /// Avoids pulling in powf/__ieee754_powf (~2.3KB flash) for small integer exponents. // NOLINT /// Matches powf(10, exp) for the int8_t exponent range used by sensor accuracy_decimals. // NOLINT diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp new file mode 100644 index 0000000000..468185787f --- /dev/null +++ b/tests/components/core/helpers_test.cpp @@ -0,0 +1,58 @@ +#include +#include +#include "esphome/core/helpers.h" + +namespace esphome { + +TEST(HelpersTest, Ilog10PowersOfTen) { + EXPECT_EQ(ilog10(1.0f), 0); + EXPECT_EQ(ilog10(10.0f), 1); + EXPECT_EQ(ilog10(100.0f), 2); + EXPECT_EQ(ilog10(1000.0f), 3); + EXPECT_EQ(ilog10(10000.0f), 4); + EXPECT_EQ(ilog10(100000.0f), 5); + EXPECT_EQ(ilog10(0.1f), -1); + EXPECT_EQ(ilog10(0.001f), -3); +} + +TEST(HelpersTest, Ilog10General) { + EXPECT_EQ(ilog10(5.0f), 0); + EXPECT_EQ(ilog10(9.99f), 0); + EXPECT_EQ(ilog10(50.0f), 1); + EXPECT_EQ(ilog10(99.0f), 1); + EXPECT_EQ(ilog10(999.0f), 2); + EXPECT_EQ(ilog10(0.5f), -1); + EXPECT_EQ(ilog10(0.0072f), -3); + EXPECT_EQ(ilog10(120000.0f), 5); + EXPECT_EQ(ilog10(123456.789f), 5); +} + +TEST(HelpersTest, Ilog10Negative) { + EXPECT_EQ(ilog10(-1.0f), 0); + EXPECT_EQ(ilog10(-10.0f), 1); + EXPECT_EQ(ilog10(-0.1f), -1); + EXPECT_EQ(ilog10(-123.456f), 2); +} + +// Verify that ilog10 + pow10_int produces the same rounding result as log10/pow. +// ilog10 may differ from floor(log10f()) for values not exactly representable in float +// (e.g. 0.01f is 0.00999...), but the full round-trip must match. +TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { + float values[] = {0.0072f, 0.05f, 0.1f, 0.5f, 1.0f, 3.14f, 9.99f, 10.0f, 42.0f, 100.0f, + 1234.5f, 9999.0f, 10000.0f, 99999.0f, 120000.0f, 999999.0f, -1.0f, -0.1f, -123.456f, -10000.0f}; + for (uint8_t digits = 1; digits <= 6; digits++) { + for (float v : values) { + // New implementation using ilog10 + pow10_int + float factor_new = pow10_int(digits - 1 - ilog10(v)); + float result_new = roundf(v * factor_new) / factor_new; + + // Reference using log10/pow + double factor_ref = pow(10.0, digits - std::ceil(std::log10(std::fabs(v)))); + float result_ref = static_cast(round(v * factor_ref) / factor_ref); + + EXPECT_FLOAT_EQ(result_new, result_ref) << "mismatch for value=" << v << " digits=" << (int) digits; + } + } +} + +} // namespace esphome diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index ed398b0abd..ecc65de66c 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -171,6 +171,7 @@ sensor: quantile: .9 - round: 1 - round_to_multiple_of: 0.25 + - round_to_significant_digits: 3 - skip_initial: 3 - sliding_window_moving_average: window_size: 15