From 226908a64a1b8f7908983b41ccdfb431d07805bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 14:44:10 -1000 Subject: [PATCH 01/21] reduce review load --- esphome/core/automation.h | 46 +++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 7a054f9899..268aa4af49 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -222,7 +222,7 @@ template class TemplatableValue { template TemplatableValue(F f) requires std::invocable && std::convertible_to : type_(STATELESS_LAMBDA) { - this->stateless_f_ = f; + this->stateless_f_ = f; // Implicit conversion to function pointer } // For stateful lambdas (not convertible to function pointer): use std::function @@ -232,6 +232,7 @@ template class TemplatableValue { this->f_ = new std::function(std::move(f)); } + // Copy constructor TemplatableValue(const TemplatableValue &other) : type_(other.type_) { if (this->type_ == VALUE) { this->value_ = new std::string(*other.value_); @@ -244,6 +245,7 @@ template class TemplatableValue { } } + // Move constructor TemplatableValue(TemplatableValue &&other) noexcept : type_(other.type_) { if (this->type_ == VALUE) { this->value_ = other.value_; @@ -259,6 +261,7 @@ template class TemplatableValue { other.type_ = NONE; } + // Assignment operators TemplatableValue &operator=(const TemplatableValue &other) { if (this != &other) { this->~TemplatableValue(); @@ -281,6 +284,7 @@ template class TemplatableValue { } else if (this->type_ == LAMBDA) { delete this->f_; } + // STATELESS_LAMBDA/STATIC_STRING/FLASH_STRING/NONE: no cleanup needed (pointers, not heap-allocated) } bool has_value() const { return this->type_ != NONE; } @@ -288,15 +292,16 @@ template class TemplatableValue { std::string value(X... x) const { switch (this->type_) { case STATELESS_LAMBDA: - return this->stateless_f_(x...); + return this->stateless_f_(x...); // Direct function pointer call case LAMBDA: - return (*this->f_)(x...); + return (*this->f_)(x...); // std::function call case VALUE: return *this->value_; case STATIC_STRING: return std::string(this->static_str_); #ifdef USE_ESP8266 case FLASH_STRING: { + // PROGMEM pointer — must use _P functions to access on ESP8266 size_t len = strlen_P(this->static_str_); std::string result(len, '\0'); memcpy_P(result.data(), this->static_str_, len); @@ -321,13 +326,18 @@ template class TemplatableValue { return this->value(x...); } - /// Check if this holds a static string (const char* stored without allocation). + /// Check if this holds a static string (const char* stored without allocation) + /// The pointer is always directly readable (RAM or flash-mapped). + /// Returns false for FLASH_STRING (PROGMEM on ESP8266, requires _P functions). bool is_static_string() const { return this->type_ == STATIC_STRING; } - /// Get the static string pointer (only valid if is_static_string() returns true). + /// Get the static string pointer (only valid if is_static_string() returns true) + /// The pointer is always directly readable — FLASH_STRING uses a separate type. const char *get_static_string() const { return this->static_str_; } /// Check if the string value is empty without allocating. + /// For NONE, returns true. For STATIC_STRING/VALUE, checks without allocation. + /// For LAMBDA/STATELESS_LAMBDA, must call value() which may allocate. bool is_empty() const { switch (this->type_) { case NONE: @@ -336,17 +346,24 @@ template class TemplatableValue { return this->static_str_ == nullptr || this->static_str_[0] == '\0'; #ifdef USE_ESP8266 case FLASH_STRING: + // PROGMEM pointer — must use progmem_read_byte on ESP8266 return this->static_str_ == nullptr || progmem_read_byte(reinterpret_cast(this->static_str_)) == '\0'; #endif case VALUE: return this->value_->empty(); - default: + default: // LAMBDA/STATELESS_LAMBDA - must call value() return this->value().empty(); } } - /// Get a StringRef without heap allocation when possible. + /// Get a StringRef to the string value without heap allocation when possible. + /// For STATIC_STRING/VALUE, returns reference to existing data (no allocation). + /// For FLASH_STRING (ESP8266 PROGMEM), copies to provided buffer via _P functions. + /// For LAMBDA/STATELESS_LAMBDA, calls value(), copies to provided buffer, returns ref to buffer. + /// @param lambda_buf Buffer used only for copy cases (must remain valid while StringRef is used). + /// @param lambda_buf_size Size of the buffer. + /// @return StringRef pointing to the string data. StringRef ref_or_copy_to(char *lambda_buf, size_t lambda_buf_size) const { switch (this->type_) { case NONE: @@ -360,6 +377,7 @@ template class TemplatableValue { if (this->static_str_ == nullptr) return StringRef(); { + // PROGMEM pointer — copy to buffer via _P functions size_t len = strlen_P(this->static_str_); size_t copy_len = std::min(len, lambda_buf_size - 1); memcpy_P(lambda_buf, this->static_str_, copy_len); @@ -369,7 +387,7 @@ template class TemplatableValue { #endif case VALUE: return StringRef(this->value_->data(), this->value_->size()); - default: { + default: { // LAMBDA/STATELESS_LAMBDA - must call value() and copy std::string result = this->value(); size_t copy_len = std::min(result.size(), lambda_buf_size - 1); memcpy(lambda_buf, result.data(), copy_len); @@ -385,14 +403,14 @@ template class TemplatableValue { VALUE, LAMBDA, STATELESS_LAMBDA, - STATIC_STRING, - FLASH_STRING, + STATIC_STRING, // For const char* — avoids heap allocation + FLASH_STRING, // PROGMEM pointer on ESP8266; never set on other platforms } type_; union { - std::string *value_; - std::function *f_; - std::string (*stateless_f_)(X...); - const char *static_str_; + std::string *value_; // Heap-allocated string (VALUE) + std::function *f_; // Heap-allocated std::function (LAMBDA) + std::string (*stateless_f_)(X...); // Function pointer (STATELESS_LAMBDA) + const char *static_str_; // For STATIC_STRING and FLASH_STRING types }; }; From 0ec4e9837455436d15f5de2f073ee11ffa100462 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 14:54:41 -1000 Subject: [PATCH 02/21] [core] Expand = delete to catch inconvertible return types, add to_exp test - TemplatableFn = delete now catches both stateful lambdas AND stateless lambdas with inconvertible return types (e.g., string -> int) - Add test for to_exp with non-string output_type (lambda-wraps result) --- esphome/core/automation.h | 8 ++++++-- tests/unit_tests/test_cpp_generator.py | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 268aa4af49..e448faad48 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -54,8 +54,12 @@ template class TemplatableFn { std::invocable &&std::convertible_to, T> &&std::is_empty_v &&std::default_initializable : f_([](X... x) -> T { return static_cast(F{}(x...)); }) {} - // Reject stateful lambdas (non-empty, i.e. capturing) with a clear error - template TemplatableFn(F) requires std::invocable &&(!std::is_empty_v) = delete; + // Reject any callable that didn't match the above (stateful lambdas or inconvertible return types) + template + TemplatableFn(F) requires std::invocable && + (!std::convertible_to) &&(!std::is_empty_v || + !std::convertible_to, T> || + !std::default_initializable) = delete; bool has_value() const { return this->f_ != nullptr; } diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 3c87e311c3..81ae586e23 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -684,6 +684,15 @@ async def test_templatable__with_to_exp_callable() -> None: assert result == 84 +@pytest.mark.asyncio +async def test_templatable__with_to_exp_callable_and_output_type() -> None: + """When to_exp is provided with non-string output_type, result is lambda-wrapped.""" + result = await cg.templatable(42, [], ct.int_, to_exp=lambda x: x * 2) + + assert isinstance(result, cg.LambdaExpression) + assert result.capture == "" + + @pytest.mark.asyncio async def test_templatable__with_to_exp_dict() -> None: """When to_exp is a dict, value is looked up.""" From 801f3fadaa266acac82e29dcc3060d576431f947 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:00:39 +1000 Subject: [PATCH 03/21] [epaper_spi] Fix deep sleep command (#15544) --- esphome/components/epaper_spi/epaper_spi.h | 8 +++++--- esphome/components/epaper_spi/epaper_spi_mono.cpp | 14 +++++++++++++- tests/components/epaper_spi/test.esp32-s3-idf.yaml | 1 + 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 47b4f9f72d..2992ca5afd 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -110,12 +110,14 @@ class EPaperBase : public Display, this->fill(COLOR_ON); } - protected: - int get_height_internal() override { return this->height_; }; - int get_width_internal() override { return this->width_; }; int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } void draw_pixel_at(int x, int y, Color color) override; + + protected: + int get_height_internal() override { return this->height_; }; + int get_width_internal() override { return this->width_; }; + bool is_using_partial_update_() const { return this->full_update_every_ > 1; } void process_state_(); const char *epaper_state_to_string_(); diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index d10022c4ac..ee117304c4 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -15,7 +15,11 @@ void EPaperMono::refresh_screen(bool partial) { void EPaperMono::deep_sleep() { ESP_LOGV(TAG, "Deep sleep"); - this->command(0x10); + if (this->is_using_partial_update_()) { + this->cmd_data(0x10, {0x00}); // sleep in power on mode + } else { + this->cmd_data(0x10, {0x03}); // deep sleep + } } bool EPaperMono::reset() { @@ -27,6 +31,14 @@ bool EPaperMono::reset() { } void EPaperMono::set_window() { + // if not using partial update, the display will go into deep sleep, 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_ &= ~7; this->x_high_ += 7; diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 9593d0f6f0..bf6053c78b 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -78,6 +78,7 @@ display: model: seeed-reterminal-e1002 - platform: epaper_spi model: seeed-ee04-mono-4.26 + full_update_every: 10 # Override pins to avoid conflict with other display configs busy_pin: 43 dc_pin: 42 From d20d613c1da39c5c0bcddb1b426fbac9a52a68fd Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Wed, 8 Apr 2026 03:12:55 +0200 Subject: [PATCH 04/21] [substitutions] `!include ${filename}`, Substitutions in include filename paths (package refactor part 5) (#12213) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/packages/__init__.py | 61 +++++-- esphome/components/substitutions/__init__.py | 58 ++++++ esphome/components/substitutions/jinja.py | 16 +- esphome/config_validation.py | 7 +- esphome/expression.py | 25 +++ esphome/yaml_util.py | 148 ++++++++++++--- .../11-include_path.approved.yaml | 15 ++ .../substitutions/11-include_path.input.yaml | 21 +++ .../substitutions/12-yaml-merge.approved.yaml | 9 + .../substitutions/12-yaml-merge.input.yaml | 10 ++ .../fixtures/substitutions/inc2.yaml | 6 + .../fixtures/substitutions/inc3.yaml | 3 + tests/unit_tests/test_substitutions.py | 68 ++++++- tests/unit_tests/test_yaml_util.py | 168 +++++++++++++++++- 14 files changed, 562 insertions(+), 53 deletions(-) create mode 100644 esphome/expression.py create mode 100644 tests/unit_tests/fixtures/substitutions/11-include_path.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/11-include_path.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/12-yaml-merge.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/12-yaml-merge.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/inc2.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/inc3.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 1a6df84fe0..04db690c6f 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -6,7 +6,12 @@ from pathlib import Path from typing import Any from esphome import git, yaml_util -from esphome.components.substitutions import ContextVars, push_context, substitute +from esphome.components.substitutions import ( + ContextVars, + push_context, + resolve_include, + substitute, +) from esphome.components.substitutions.jinja import has_jinja from esphome.config_helpers import Remove, merge_config import esphome.config_validation as cv @@ -31,6 +36,8 @@ from esphome.core import EsphomeError _LOGGER = logging.getLogger(__name__) DOMAIN = CONF_PACKAGES +# Guard against infinite include chains (e.g. A includes B includes A). +MAX_INCLUDE_DEPTH = 20 def is_remote_package(package_config: dict) -> bool: @@ -59,8 +66,8 @@ def valid_package_contents(package_config: dict) -> dict: for k, v in package_config.items(): if not isinstance(k, str): raise cv.Invalid("Package content keys must be strings") - if isinstance(v, (dict, list, Remove)): - continue # e.g. script: [], psram: !remove, logger: {level: debug} + if isinstance(v, (dict, list, Remove, yaml_util.IncludeFile)): + continue # e.g. script: [], psram: !remove, logger: {level: debug}, switch: !include switches.yaml if v is None: continue # e.g. web_server: if isinstance(v, str) and has_jinja(v): @@ -160,6 +167,7 @@ REMOTE_PACKAGE_SCHEMA = cv.All( PACKAGE_SCHEMA = cv.Any( # A package definition is either: validate_source_shorthand, # A git URL shorthand string that expands to a remote package schema, or REMOTE_PACKAGE_SCHEMA, # a valid remote package schema, or + yaml_util.IncludeFile, # isinstance check — passes IncludeFile objects through unchanged, or: valid_package_contents, # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}} # which will have to be fully validated later as per each component's schema. ) @@ -396,16 +404,49 @@ class _PackageProcessor: self.skip_update = skip_update def resolve_package( - self, package_config: dict | str, context_vars: ContextVars | None + self, + package_config: dict | str | yaml_util.IncludeFile, + context_vars: ContextVars | None, ) -> dict: - """Substitute variables in the definition and fetch remote packages. + """Resolve a package definition to a concrete ``dict`` and fetch remote packages. - The input may be a ``str`` (git shorthand or Jinja expression) or a - ``dict`` (remote or local package). After ``PACKAGE_SCHEMA`` validation - the result is always a ``dict``. + The input may be a ``str`` (git shorthand or Jinja expression), a + ``dict`` (remote or local package), or an ``IncludeFile`` whose filename + may itself contain substitution expressions. + + The loop handles the case where loading an ``IncludeFile`` yields another + ``IncludeFile`` (e.g. a chain of deferred includes). Each iteration: + + 1. If the current value is an ``IncludeFile``, load it — resolving any + substitutions in its filename first. + 2. Substitute variables in the resulting value (for strings and remote + package dicts). + 3. Validate against ``PACKAGE_SCHEMA``. If the result is a ``dict``, + the loop exits; otherwise another iteration is needed. + + Raises ``cv.Invalid`` if the chain has not resolved to a ``dict`` after + ``MAX_INCLUDE_DEPTH`` iterations. """ - package_config = _substitute_package_definition(package_config, context_vars) - package_config = PACKAGE_SCHEMA(package_config) + for _ in range(MAX_INCLUDE_DEPTH): + if isinstance(package_config, yaml_util.IncludeFile): + package_config, _ = resolve_include( + package_config, + [], + context_vars or ContextVars(), + strict_undefined=False, + ) + + package_config = _substitute_package_definition( + package_config, context_vars + ) + package_config = PACKAGE_SCHEMA(package_config) + if isinstance(package_config, dict): + break + else: + raise cv.Invalid( + f"Maximum include nesting depth ({MAX_INCLUDE_DEPTH}) exceeded" + ) + if is_remote_package(package_config): package_config = _process_remote_package(package_config, self.skip_update) return package_config diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index aab1712b65..c0bd9d7be9 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -2,6 +2,7 @@ from collections import ChainMap import logging from typing import Any +import esphome from esphome import core from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered import esphome.config_validation as cv @@ -12,6 +13,7 @@ from esphome.yaml_util import ( ConfigContext, ESPHomeDataBase, ESPLiteralValue, + IncludeFile, make_data_base, ) @@ -291,6 +293,59 @@ def push_context( return parent_context +def resolve_include( + include: IncludeFile, + path: list[int | str], + context_vars: ContextVars, + strict_undefined: bool = True, + errors: ErrList | None = None, +) -> tuple[Any, str]: + """Resolve an include, substituting the filename if needed. + + Returns the loaded content and the resolved filename. + + Note: no path-traversal validation is performed on the resolved filename. + A substitution that resolves to an absolute path will bypass the parent + directory (Path.__truediv__ ignores the left operand for absolute paths). + ESPHome's trust model assumes the config author controls all substitution + values (including command-line substitutions), so path restrictions are + an explicit non-goal here. + """ + original = str(include.file) + filename = str( + _expand_substitutions( + original, path + ["file"], context_vars, strict_undefined, errors + ) + ) + if filename != original: + include = IncludeFile( + include.parent_file, filename, include.vars, include.yaml_loader + ) + try: + return include.load(), filename + except esphome.core.EsphomeError as err: + raise cv.Invalid( + f"Error including file '{filename}': {err}", + path + [f"<{filename}>"], + ) from err + + +def _substitute_include( + include: IncludeFile, + path: list[int | str], + context_vars: ContextVars, + strict_undefined: bool, + errors: ErrList | None, +) -> Any: + """Resolve an include and substitute its content.""" + content, filename = resolve_include( + include, path, context_vars, strict_undefined, errors + ) + return substitute( + content, path + [f"<{filename}>"], context_vars, strict_undefined, errors + ) + + def substitute( item: Any, path: SubstitutionPath, @@ -333,6 +388,9 @@ def substitute( if item.value != value: result = type(item)(value) + elif isinstance(item, IncludeFile): + result = _substitute_include(item, path, context_vars, strict_undefined, errors) + if isinstance(item, ESPHomeDataBase): result = make_data_base(result, item) return result diff --git a/esphome/components/substitutions/jinja.py b/esphome/components/substitutions/jinja.py index 37e9fa4d2d..36a7425a69 100644 --- a/esphome/components/substitutions/jinja.py +++ b/esphome/components/substitutions/jinja.py @@ -2,7 +2,6 @@ from ast import literal_eval from collections.abc import Iterator, Mapping from itertools import chain, islice import math -import re from types import GeneratorType from typing import Any @@ -10,6 +9,9 @@ import jinja2 as jinja from jinja2.nativetypes import NativeCodeGenerator, NativeTemplate from jinja2.runtime import missing as Missing +# Re-exported for backward compatibility — consumers import has_jinja from here +from esphome.expression import has_jinja # noqa: F401 # pylint: disable=unused-import + TemplateError = jinja.TemplateError TemplateSyntaxError = jinja.TemplateSyntaxError TemplateRuntimeError = jinja.TemplateRuntimeError @@ -20,18 +22,6 @@ Undefined = jinja.Undefined Resolver = ".resolver" -DETECT_JINJA = r"(\$\{)" -detect_jinja_re = re.compile( - r"<%.+?%>" # Block form expression: <% ... %> - r"|\$\{[^}]+\}", # Braced form expression: ${ ... } - flags=re.MULTILINE, -) - - -def has_jinja(st: str) -> bool: - return detect_jinja_re.search(st) is not None - - # SAFE_GLOBALS defines a allowlist of built-in functions or modules that are considered safe to expose # in Jinja templates or other sandboxed evaluation contexts. Only functions that do not allow # arbitrary code execution, file access, or other security risks are included. diff --git a/esphome/config_validation.py b/esphome/config_validation.py index b0bd9e6231..31cfb41a6d 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -75,7 +75,6 @@ from esphome.const import ( SCHEDULER_DONT_RUN, TYPE_GIT, TYPE_LOCAL, - VALID_SUBSTITUTIONS_CHARACTERS, Framework, __version__ as ESPHOME_VERSION, ) @@ -90,6 +89,7 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) +from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG from esphome.helpers import add_class_to_obj, docs_url, list_starts_with from esphome.schema_extractors import ( SCHEMA_EXTRACT, @@ -104,11 +104,6 @@ from esphome.yaml_util import make_data_base _LOGGER = logging.getLogger(__name__) -# pylint: disable=consider-using-f-string -VARIABLE_PROG = re.compile( - f"\\$([{VALID_SUBSTITUTIONS_CHARACTERS}]+|\\{{[{VALID_SUBSTITUTIONS_CHARACTERS}]*\\}})" -) - # pylint: disable=invalid-name Schema = _Schema diff --git a/esphome/expression.py b/esphome/expression.py new file mode 100644 index 0000000000..d425d822a4 --- /dev/null +++ b/esphome/expression.py @@ -0,0 +1,25 @@ +"""Helpers for detecting substitution variables and Jinja expressions.""" + +import re + +from esphome.const import VALID_SUBSTITUTIONS_CHARACTERS + +SUBSTITUTION_VARIABLE_PROG = re.compile( + rf"\$([{VALID_SUBSTITUTIONS_CHARACTERS}]+|\{{[{VALID_SUBSTITUTIONS_CHARACTERS}]*\}})" +) + +_JINJA_RE = re.compile( + r"<%.+?%>" # Block: <% ... %> + r"|\$\{[^}]+\}", # Braced: ${ ... } + flags=re.MULTILINE, +) + + +def has_jinja(value: str) -> bool: + """Check if a string contains Jinja expressions.""" + return _JINJA_RE.search(value) is not None + + +def has_substitution_or_expression(value: str) -> bool: + """Check if a string contains substitution variables ($name, ${name}) or Jinja expressions.""" + return SUBSTITUTION_VARIABLE_PROG.search(value) is not None or has_jinja(value) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index a24c1ebccb..c621428196 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -33,6 +33,7 @@ from esphome.core import ( MACAddress, TimePeriod, ) +from esphome.expression import has_substitution_or_expression from esphome.helpers import add_class_to_obj from esphome.util import OrderedDict, filter_yaml_files @@ -110,24 +111,6 @@ def make_data_base( return value -class ConfigContext: - """This is a mixin class that holds substitution vars that should be applied - to the tagged node and its children. During configuration loading, context vars can - be added to nodes using `add_context` function, which applies the mixin storing - the captured values and unevaluated expressions. - The substitution pass then recreates the effective context by merging the context vars - from this node and parent nodes. - """ - - @property - def vars(self) -> dict[str, Any]: - return self._context_vars - - def set_context(self, vars: dict[str, Any]) -> None: - # pylint: disable=attribute-defined-outside-init - self._context_vars = vars - - def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: """Tags a list/string/dict value with context vars that must be applied to it and its children during the substitution pass. If no vars are given, no tagging is done. @@ -151,6 +134,94 @@ def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: return value +class ConfigContext: + """This is a mixin class that holds substitution vars that should be applied + to the tagged node and its children. During configuration loading, context vars can + be added to nodes using `add_context` function, which applies the mixin storing + the captured values and unevaluated expressions. + The substitution pass then recreates the effective context by merging the context vars + from this node and parent nodes. + """ + + @property + def vars(self) -> dict[str, Any]: + return self._context_vars + + def set_context(self, vars: dict[str, Any]) -> None: + # pylint: disable=attribute-defined-outside-init + self._context_vars = vars + + def copy_context_to_children(self) -> None: + """Propagate context to children. + + isinstance(self, dict/list) works because ConfigContext is dynamically + mixed into dict/list subclasses via add_class_to_obj in add_context(). + """ + if isinstance(self, dict): + # pylint: disable=no-member + tagged = { + add_context(k, self.vars): add_context(v, self.vars) + for k, v in self.items() + } + self.clear() + self.update(tagged) + elif isinstance(self, list): + for i, item in enumerate(self): + # pylint: disable=unsupported-assignment-operation + self[i] = add_context(item, self.vars) + + +_UNSET = object() + + +class IncludeFile: + """Deferred !include that is resolved during the substitution pass. + + Created during YAML parsing instead of loading the file immediately, + allowing substitution variables to appear in the filename path + (e.g. ``!include device-${platform}.yaml``). The actual file is + loaded on the first call to ``load()``, and the result is cached. + """ + + def __init__( + self, + parent_file: Path, + file: Path | str, + vars: dict[str, Any] | None, + yaml_loader: Callable[[Path], Any], + ) -> None: + self.parent_file = parent_file + self.file = Path(file) + self.vars = vars + self.yaml_loader = yaml_loader + self._content: Any = _UNSET + + def __repr__(self) -> str: + return f"IncludeFile({self.file.as_posix()})" + + def load(self) -> Any: + """Load and cache the included file content. + + Note: returns the cached mutable object on subsequent calls. + Callers that need to modify the result should copy it first. + """ + if self._content is not _UNSET: + return self._content + if self.has_unresolved_expressions(): + from esphome.config_validation import Invalid + + raise Invalid( + f"Cannot load include with unresolved substitutions: {self.file}" + ) + self._content = self.yaml_loader(Path(self.parent_file.parent / self.file)) + self._content = add_context(self._content, self.vars) + return self._content + + def has_unresolved_expressions(self) -> bool: + """Check if the filename contains substitution variables or Jinja expressions.""" + return has_substitution_or_expression(str(self.file)) + + def _add_data_ref(fn): @functools.wraps(fn) def wrapped(loader, node): @@ -170,6 +241,36 @@ def _add_data_ref(fn): return wrapped +_MAX_MERGE_INCLUDE_DEPTH = 10 + + +def _resolve_merge_include(value: Any, node: yaml.Node, value_node: yaml.Node) -> Any: + """Resolve an IncludeFile (and chains) and propagate context for merge key handling.""" + for _ in range(_MAX_MERGE_INCLUDE_DEPTH): + if not isinstance(value, IncludeFile): + break + if value.has_unresolved_expressions(): + raise yaml.constructor.ConstructorError( + "While constructing a mapping", + node.start_mark, + "Substitution in include filename with merge keys is not supported yet.", + value_node.start_mark, + ) + value = value.load() + else: + raise yaml.constructor.ConstructorError( + "While constructing a mapping", + node.start_mark, + f"Maximum include chain depth ({_MAX_MERGE_INCLUDE_DEPTH}) exceeded in merge key", + value_node.start_mark, + ) + if isinstance(value, ConfigContext): + # Since the parent dict/list will disappear, propagate + # context to children now to retain context vars + value.copy_context_to_children() + return value + + class ESPHomeLoaderMixin: """Loader class that keeps track of line numbers.""" @@ -261,6 +362,9 @@ class ESPHomeLoaderMixin: # This is a merge key, resolve value and add to merge_pairs value = self.construct_object(value_node) + + value = _resolve_merge_include(value, node, value_node) + if isinstance(value, dict): # base case, copy directly to merge_pairs # direct merge, like "<<: {some_key: some_value}" @@ -268,6 +372,7 @@ class ESPHomeLoaderMixin: elif isinstance(value, list): # sequence merge, like "<<: [{some_key: some_value}, {other_key: some_value}]" for item in value: + item = _resolve_merge_include(item, node, value_node) if not isinstance(item, dict): raise yaml.constructor.ConstructorError( "While constructing a mapping", @@ -362,8 +467,11 @@ class ESPHomeLoaderMixin: else: file, vars = node.value, None - result = self.yaml_loader(self._rel_path(file)) - return add_context(result, vars) + return IncludeFile(self.name, file, vars, self.yaml_loader) + + # Directory includes (!include_dir_*) load eagerly during YAML parsing + # because their paths are directory names, not individual files, and + # substitutions in directory paths are not supported. @_add_data_ref def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]: diff --git a/tests/unit_tests/fixtures/substitutions/11-include_path.approved.yaml b/tests/unit_tests/fixtures/substitutions/11-include_path.approved.yaml new file mode 100644 index 0000000000..d758a832a4 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/11-include_path.approved.yaml @@ -0,0 +1,15 @@ +values: + - var1: 4 + - a: 5 + - b: 6 + - c: The value of C is 7 + - This value comes from inc2.yaml. x is 3, y is 4 + - From main config, x is 3, y is 2 + - $a $b $c are out of scope here + - keys_in_inc3: + x: 3 + y: 2 +substitutions: + x: 3 + y: 2 + include_file: inc1 diff --git a/tests/unit_tests/fixtures/substitutions/11-include_path.input.yaml b/tests/unit_tests/fixtures/substitutions/11-include_path.input.yaml new file mode 100644 index 0000000000..78b1cb4fb9 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/11-include_path.input.yaml @@ -0,0 +1,21 @@ +substitutions: + include_file: inc1 + x: 3 # override x from inc2.yaml + +packages: + my_package: !include + file: ${include_file + ".yaml"} # includes inc1.yaml + vars: + var1: 4 + a: ${x+2} + b: ${a+1} + c: 7 + other_package: !include + file: inc${1+1}.yaml # includes inc2.yaml + vars: + y: 4 + +values: + - From main config, x is $x, y is $y + - $a $b $c are out of scope here + - !include ${"inc" + "3.yaml"} # includes inc3.yaml here (not a package) diff --git a/tests/unit_tests/fixtures/substitutions/12-yaml-merge.approved.yaml b/tests/unit_tests/fixtures/substitutions/12-yaml-merge.approved.yaml new file mode 100644 index 0000000000..02d8512498 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/12-yaml-merge.approved.yaml @@ -0,0 +1,9 @@ +substitutions: + x: 7 +test_list: + - content: + before: Content before + after: Content after + keys_in_inc3: + x: 7 + y: 8 diff --git a/tests/unit_tests/fixtures/substitutions/12-yaml-merge.input.yaml b/tests/unit_tests/fixtures/substitutions/12-yaml-merge.input.yaml new file mode 100644 index 0000000000..a03e66e393 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/12-yaml-merge.input.yaml @@ -0,0 +1,10 @@ +substitutions: + x: 7 +test_list: + - content: + before: Content before + <<: !include + file: inc3.yaml + vars: + y: 8 + after: Content after diff --git a/tests/unit_tests/fixtures/substitutions/inc2.yaml b/tests/unit_tests/fixtures/substitutions/inc2.yaml new file mode 100644 index 0000000000..29a1833efc --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/inc2.yaml @@ -0,0 +1,6 @@ +substitutions: + x: 1 + y: 2 + +values: + - This value comes from inc2.yaml. x is $x, y is $y diff --git a/tests/unit_tests/fixtures/substitutions/inc3.yaml b/tests/unit_tests/fixtures/substitutions/inc3.yaml new file mode 100644 index 0000000000..03d459dc97 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/inc3.yaml @@ -0,0 +1,3 @@ +keys_in_inc3: + x: ${x} + y: ${y} diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index c7b0bbcf7c..01c669e542 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -8,12 +8,17 @@ import pytest from esphome import config as config_module, yaml_util from esphome.components import substitutions -from esphome.components.packages import do_packages_pass, merge_packages +from esphome.components.packages import ( + MAX_INCLUDE_DEPTH, + _PackageProcessor, + do_packages_pass, + merge_packages, +) from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, merge_config import esphome.config_validation as cv from esphome.const import CONF_SUBSTITUTIONS -from esphome.core import CORE, Lambda +from esphome.core import CORE, EsphomeError, Lambda from esphome.util import OrderedDict _LOGGER = logging.getLogger(__name__) @@ -630,3 +635,62 @@ def test_do_substitution_pass_substitutions_must_be_mapping_from_config() -> Non cv.Invalid, match="Substitutions must be a key to value mapping" ): substitutions.do_substitution_pass(config) + + +# ── IncludeFile / package loading tests ──────────────────────────────────── + + +def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None: + """A yaml_loader that always returns another IncludeFile triggers the depth guard.""" + parent = tmp_path / "main.yaml" + parent.write_text("") + + # Each call to the loader returns a fresh IncludeFile pointing at itself, + # so PACKAGE_SCHEMA always sees an IncludeFile and never a dict. + def always_returns_include(path: Path) -> yaml_util.IncludeFile: + return yaml_util.IncludeFile(parent, path.name, None, always_returns_include) + + package_config = yaml_util.IncludeFile( + parent, "test.yaml", None, always_returns_include + ) + processor = _PackageProcessor({}, None, False) + with pytest.raises( + cv.Invalid, + match=f"Maximum include nesting depth \\({MAX_INCLUDE_DEPTH}\\) exceeded", + ): + processor.resolve_package(package_config, substitutions.ContextVars()) + + +def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: + """!include with an undefined substitution variable raises cv.Invalid. + + The error message must reference the unresolved filename template so the + user knows which include failed, rather than seeing a bare file-not-found. + """ + main_file = tmp_path / "main.yaml" + main_file.write_text("result: !include ${undefined_var}.yaml\n") + + config = yaml_util.load_yaml(main_file) + with pytest.raises(cv.Invalid, match=r"\$\{undefined_var\}"): + substitutions.do_substitution_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. + + Previously this would raise an unhandled UndefinedError. With + strict_undefined=False, the unresolved filename passes through to + file loading which produces a clean cv.Invalid error. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + raise EsphomeError(f"Error reading file {path}: No such file") + + package_config = yaml_util.IncludeFile( + parent, "${undefined_var}.yaml", None, loader + ) + processor = _PackageProcessor({}, None, False) + with pytest.raises(cv.Invalid, match="unresolved substitutions"): + processor.resolve_package(package_config, substitutions.ContextVars()) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 0342d12540..0bd7c9453b 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1,3 +1,4 @@ +import io from pathlib import Path import shutil from unittest.mock import patch @@ -7,6 +8,7 @@ import pytest from esphome import core, yaml_util from esphome.components import substitutions from esphome.config_helpers import Extend, Remove +import esphome.config_validation as cv from esphome.core import EsphomeError from esphome.util import OrderedDict @@ -74,7 +76,9 @@ def test_parsing_with_custom_loader(fixture_path): loader_calls.append(fname) with yaml_file.open(encoding="utf-8") as f_handle: - yaml_util.parse_yaml(yaml_file, f_handle, custom_loader) + config = yaml_util.parse_yaml(yaml_file, f_handle, custom_loader) + # substitute config to expand includes: + substitutions.substitute(config, [], substitutions.ContextVars(), False) assert len(loader_calls) == 3 assert loader_calls[0].parts[-2:] == ("includes", "included.yaml") @@ -348,7 +352,9 @@ def test_track_yaml_loads_records_includes(tmp_path: Path) -> None: main.write_text("child: !include included.yaml\n") with yaml_util.track_yaml_loads() as loaded: - yaml_util.load_yaml(main) + result = yaml_util.load_yaml(main) + # !include is deferred; resolve it to trigger the nested load + result["child"].load() resolved = [p.name for p in loaded] assert "main.yaml" in resolved @@ -500,3 +506,161 @@ def test_represent_extend() -> None: def test_represent_remove() -> None: """Test that Remove objects are dumped as plain !remove scalars.""" assert yaml_util.dump({"key": Remove("my_id")}) == "key: !remove 'my_id'\n" + + +# ── IncludeFile unit tests ────────────────────────────────────────────────── + + +def test_include_file_repr(tmp_path: Path) -> None: + """repr() includes the filename so it appears usefully in error messages.""" + parent = tmp_path / "main.yaml" + include = yaml_util.IncludeFile(parent, "some/nested.yaml", None, lambda _: {}) + assert repr(include) == "IncludeFile(some/nested.yaml)" + + +def test_include_file_load_caches_result(tmp_path: Path) -> None: + """load() invokes the yaml_loader only once; subsequent calls return the cached object.""" + parent = tmp_path / "main.yaml" + content = {"key": "value"} + call_count = 0 + + def counting_loader(_): + nonlocal call_count + call_count += 1 + return content + + include = yaml_util.IncludeFile(parent, "child.yaml", None, counting_loader) + first = include.load() + second = include.load() + + assert call_count == 1 + assert first is second + + +def test_include_file_load_caches_none_result(tmp_path: Path) -> None: + """load() caches None content (empty YAML files) and does not re-invoke the loader.""" + parent = tmp_path / "main.yaml" + call_count = 0 + + def counting_loader(_): + nonlocal call_count + call_count += 1 + + include = yaml_util.IncludeFile(parent, "empty.yaml", None, counting_loader) + first = include.load() + second = include.load() + + assert call_count == 1 + assert first is None + assert second is None + + +def test_include_file_load_raises_on_unresolved_expressions(tmp_path: Path) -> None: + """load() raises if the filename contains unresolved substitutions or expressions.""" + parent = tmp_path / "main.yaml" + include = yaml_util.IncludeFile(parent, "${undefined_var}.yaml", None, lambda _: {}) + with pytest.raises(cv.Invalid, match="unresolved"): + include.load() + + +@pytest.mark.parametrize( + ("filename", "expected"), + [ + ("device-${platform}.yaml", True), + ("$platform.yaml", True), + ("${a + b}.yaml", True), # Jinja expression + ("device.yaml", False), + ("path/to/device.yaml", False), + ("my$file.yaml", True), # $file is a valid substitution + ("price-100$.yaml", False), # $ at end, not followed by valid substitution + ], +) +def test_include_file_has_unresolved_expressions( + tmp_path: Path, filename: str, expected: bool +) -> None: + """has_unresolved_expressions() detects substitution patterns in the filename.""" + parent = tmp_path / "main.yaml" + include = yaml_util.IncludeFile(parent, filename, None, lambda _: {}) + assert include.has_unresolved_expressions() == expected + + +def test_include_in_list_context() -> None: + """!include of a file returning a list is handled correctly, + including when that list itself contains a nested IncludeFile.""" + parent = Path("/fake/main.yaml") + + # The nested IncludeFile resolves to a plain string value + inner = yaml_util.IncludeFile(parent, "inner.yaml", None, lambda _: "gamma") + + # The outer IncludeFile returns a list whose last element is itself an IncludeFile, + # exercising the substitution pass's ability to recurse into loaded content. + outer = yaml_util.IncludeFile( + parent, "items.yaml", None, lambda _: ["alpha", "beta", inner] + ) + + config = OrderedDict({"values": outer}) + config = substitutions.do_substitution_pass(config) + + assert config["values"] == ["alpha", "beta", "gamma"] + + +def test_include_plain_filename_loads_after_deferred_refactor() -> None: + """!include with a plain filename (no $ expressions) still loads correctly. + + Regression guard: the deferred-loading refactor must not break the simple case. + """ + parent = Path("/fake/main.yaml") + include = yaml_util.IncludeFile( + parent, "child.yaml", None, lambda _: {"answer": 42} + ) + + config = OrderedDict({"result": include}) + config = substitutions.do_substitution_pass(config) + + assert config["result"]["answer"] == 42 + + +def test_yaml_merge_include_with_filename_substitution_raises() -> None: + """<<: !include ${expr} raises a clear error — substitutions in merge-key filenames + are not yet supported, and the error message must say so.""" + yaml_text = "base:\n existing: value\n <<: !include ${filename}.yaml\n" + with pytest.raises(EsphomeError, match="not supported yet"): + yaml_util.parse_yaml( + Path("/fake/main.yaml"), io.StringIO(yaml_text), lambda _: {} + ) + + +def test_yaml_merge_list_include_with_filename_substitution_raises() -> None: + """Substitutions in include filenames within merge-key lists raise a clear error.""" + yaml_text = "base:\n existing: value\n <<:\n - !include ${filename}.yaml\n" + with pytest.raises(EsphomeError, match="not supported yet"): + yaml_util.parse_yaml( + Path("/fake/main.yaml"), io.StringIO(yaml_text), lambda _: {} + ) + + +def test_yaml_merge_chain_include_resolves() -> None: + """Chained includes in merge keys resolve through multiple IncludeFile layers.""" + parent = Path("/fake/main.yaml") + + inner = yaml_util.IncludeFile(parent, "inner.yaml", None, lambda _: {"x": 1}) + outer = yaml_util.IncludeFile(parent, "outer.yaml", None, lambda _: inner) + + yaml_text = "base:\n existing: value\n <<: !include outer.yaml\n" + config = yaml_util.parse_yaml(parent, io.StringIO(yaml_text), lambda _: outer) + config = substitutions.do_substitution_pass(config) + + assert config["base"]["x"] == 1 + assert config["base"]["existing"] == "value" + + +def test_yaml_merge_chain_include_depth_exceeded() -> None: + """Chain includes in merge keys exceeding depth limit raise a clear error.""" + parent = Path("/fake/main.yaml") + + def self_referencing_loader(path: Path) -> yaml_util.IncludeFile: + return yaml_util.IncludeFile(parent, path.name, None, self_referencing_loader) + + yaml_text = "base:\n <<: !include loop.yaml\n" + with pytest.raises(EsphomeError, match="Maximum include chain depth"): + yaml_util.parse_yaml(parent, io.StringIO(yaml_text), self_referencing_loader) From 88f4067dd6bbaae97e679e734626dea5d761d26a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:19:29 +1000 Subject: [PATCH 05/21] [lvgl] Implement rotation with PPA (#15453) --- esphome/components/lvgl/__init__.py | 13 +- esphome/components/lvgl/lvgl_esphome.cpp | 144 ++++++++++++++++--- esphome/components/lvgl/lvgl_esphome.h | 14 ++ tests/components/lvgl/test.esp32-idf.yaml | 4 +- tests/components/lvgl/test.esp32-p4-idf.yaml | 12 ++ 5 files changed, 160 insertions(+), 27 deletions(-) create mode 100644 tests/components/lvgl/test.esp32-p4-idf.yaml diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index b69f8ef57b..f6f6204f4c 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -187,7 +187,6 @@ def final_validation(config_list): for config in config_list: if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages): raise cv.Invalid("At least one page must not be skipped") - uses_rotation = CONF_ROTATION in config for display_id in config[df.CONF_DISPLAYS]: path = global_config.get_path_for_id(display_id)[:-1] display = global_config.get_config_for_path(path) @@ -196,9 +195,9 @@ def final_validation(config_list): "Using lambda: or pages: in display config is not compatible with LVGL" ) # treating 0 as false is intended here. - if uses_rotation and display.get(CONF_ROTATION): - df.LOGGER.warning( - "use of 'rotation' in both LVGL and the display config is not recommended" + if display.get(CONF_ROTATION): + raise cv.Invalid( + "use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead" ) if display.get(CONF_AUTO_CLEAR_ENABLED) is True: raise cv.Invalid( @@ -262,6 +261,7 @@ async def to_code(configs): df.add_define("LV_USE_STDLIB_SPRINTF", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_STRING", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_MALLOC", "LV_STDLIB_CUSTOM") + df.add_define("LV_DEF_REFR_PERIOD", "16") cg.add_define("USE_LVGL") # suppress default enabling of extra widgets # cg.add_define("LV_KCONFIG_PRESENT") @@ -341,7 +341,10 @@ async def to_code(configs): df.LOGGER.info("LVGL will use hardware rotation via display driver") else: rotation_type = RotationType.ROTATION_SOFTWARE - df.LOGGER.info("LVGL will use software rotation") + if get_esp32_variant() == VARIANT_ESP32P4: + df.LOGGER.info("LVGL will use software rotation (PPA accelerated)") + else: + df.LOGGER.info("LVGL will use software rotation") lv_component = cg.new_Pvariable( config[CONF_ID], displays, diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 0ab49d0a10..0c4e7a3425 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -158,8 +158,15 @@ void LvglComponent::dump_config() { " Draw rounding: %d", this->width_, this->height_, 100 / this->buffer_frac_, this->rotation_, (int) this->draw_rounding); if (this->rotation_type_ != ROTATION_UNUSED) { - ESP_LOGCONFIG(TAG, " Rotation type: %s", - this->rotation_type_ == RotationType::ROTATION_SOFTWARE ? "software" : "hardware via display driver"); + const char *rot_type = "hardware via display driver"; + if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) { +#ifdef USE_ESP32_VARIANT_ESP32P4 + rot_type = this->ppa_client_ != nullptr ? "software (PPA accelerated)" : "software"; +#else + rot_type = "software"; +#endif + } + ESP_LOGCONFIG(TAG, " Rotation type: %s", rot_type); } } @@ -252,21 +259,120 @@ void LvglComponent::show_prev_page(lv_screen_load_anim_t anim, uint32_t time) { size_t LvglComponent::get_current_page() const { return this->current_page_; } bool LvPageType::is_showing() const { return this->parent_->get_current_page() == this->index; } +#ifdef USE_ESP32_VARIANT_ESP32P4 +bool LvglComponent::ppa_rotate_(const lv_color_data *src, lv_color_data *dst, uint16_t width, uint16_t height, + uint32_t height_rounded) { + ppa_srm_rotation_angle_t angle; + uint16_t out_w, out_h; + + // Map ESPHome clockwise display rotation to PPA counter-clockwise angles + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + angle = PPA_SRM_ROTATION_ANGLE_270; // 270° CCW = 90° CW + out_w = height_rounded; + out_h = width; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + angle = PPA_SRM_ROTATION_ANGLE_180; + out_w = width; + out_h = height; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + angle = PPA_SRM_ROTATION_ANGLE_90; // 90° CCW = 270° CW + out_w = height_rounded; + out_h = width; + break; + default: + return false; // No rotation needed + } + + // Align buffer size to cache line (LV_DRAW_BUF_ALIGN) as required by PPA DMA + // the underlying buffer will be large enough as the size is also padded when allocating. + size_t out_buf_size = out_w * out_h * sizeof(lv_color_data); + out_buf_size = LV_ROUND_UP(out_buf_size, LV_DRAW_BUF_ALIGN); + + ppa_srm_oper_config_t srm_config{}; + srm_config.in.buffer = src; + srm_config.in.pic_w = width; + srm_config.in.pic_h = height; + srm_config.in.block_w = width; + srm_config.in.block_h = height; +#if LV_COLOR_DEPTH == 16 + srm_config.in.srm_cm = PPA_SRM_COLOR_MODE_RGB565; +#elif LV_COLOR_DEPTH == 32 + srm_config.in.srm_cm = PPA_SRM_COLOR_MODE_ARGB8888; +#endif + srm_config.out.buffer = dst; + srm_config.out.buffer_size = out_buf_size; + srm_config.out.pic_w = out_w; + srm_config.out.pic_h = out_h; +#if LV_COLOR_DEPTH == 16 + srm_config.out.srm_cm = PPA_SRM_COLOR_MODE_RGB565; +#elif LV_COLOR_DEPTH == 32 + srm_config.out.srm_cm = PPA_SRM_COLOR_MODE_ARGB8888; +#endif + srm_config.rotation_angle = angle; + srm_config.scale_x = 1.0f; + srm_config.scale_y = 1.0f; + srm_config.mode = PPA_TRANS_MODE_BLOCKING; + + esp_err_t ret = ppa_do_scale_rotate_mirror(this->ppa_client_, &srm_config); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "PPA rotation failed: %s", esp_err_to_name(ret)); + ESP_LOGW(TAG, "PPA SRM: in=%ux%u src=%p, out=%ux%u dst=%p size=%zu, angle=%d", width, height, src, out_w, out_h, + dst, out_buf_size, (int) angle); + return false; + } + return true; +} +#endif // USE_ESP32_VARIANT_ESP32P4 + void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) { auto width = lv_area_get_width(area); auto height = lv_area_get_height(area); auto height_rounded = (height + this->draw_rounding - 1) / this->draw_rounding * this->draw_rounding; auto x1 = area->x1; auto y1 = area->y1; - if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) { + if (this->rotation_type_ == ROTATION_SOFTWARE) { lv_color_data *dst = reinterpret_cast(this->rotate_buf_); +#ifdef USE_ESP32_VARIANT_ESP32P4 + bool ppa_done = this->ppa_client_ != nullptr && this->ppa_rotate_(ptr, dst, width, height, height_rounded); + if (!ppa_done) +#endif + { + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + for (lv_coord_t x = height; x-- != 0;) { + for (lv_coord_t y = 0; y != width; y++) { + dst[y * height_rounded + x] = *ptr++; + } + } + break; + + case display::DISPLAY_ROTATION_180_DEGREES: + for (lv_coord_t y = height; y-- != 0;) { + for (lv_coord_t x = width; x-- != 0;) { + dst[y * width + x] = *ptr++; + } + } + break; + + case display::DISPLAY_ROTATION_270_DEGREES: + for (lv_coord_t x = 0; x != height; x++) { + for (lv_coord_t y = width; y-- != 0;) { + dst[y * height_rounded + x] = *ptr++; + } + } + break; + + default: + dst = ptr; + break; + } + } + // Coordinate adjustments apply regardless of PPA or SW rotation switch (this->rotation_) { case display::DISPLAY_ROTATION_90_DEGREES: - for (lv_coord_t x = height; x-- != 0;) { - for (lv_coord_t y = 0; y != width; y++) { - dst[y * height_rounded + x] = *ptr++; - } - } y1 = x1; x1 = this->width_ - area->y1 - height; height = width; @@ -274,21 +380,11 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) { break; case display::DISPLAY_ROTATION_180_DEGREES: - for (lv_coord_t y = height; y-- != 0;) { - for (lv_coord_t x = width; x-- != 0;) { - dst[y * width + x] = *ptr++; - } - } x1 = this->width_ - x1 - width; y1 = this->height_ - y1 - height; break; case display::DISPLAY_ROTATION_270_DEGREES: - for (lv_coord_t x = 0; x != height; x++) { - for (lv_coord_t y = width; y-- != 0;) { - dst[y * height_rounded + x] = *ptr++; - } - } x1 = y1; y1 = this->height_ - area->x1 - width; height = width; @@ -296,7 +392,6 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) { break; default: - dst = ptr; break; } ptr = dst; @@ -664,6 +759,15 @@ void LvglComponent::setup() { this->mark_failed(); return; } +#ifdef USE_ESP32_VARIANT_ESP32P4 + ppa_client_config_t ppa_config{}; + ppa_config.oper_type = PPA_OPERATION_SRM; + ppa_config.max_pending_trans_num = 1; + if (ppa_register_client(&ppa_config, &this->ppa_client_) != ESP_OK) { + ESP_LOGW(TAG, "PPA client registration failed, using software rotation"); + this->ppa_client_ = nullptr; + } +#endif } if (this->draw_start_callback_ != nullptr) { lv_display_add_event_cb(this->disp_, render_start_cb, LV_EVENT_RENDER_START, this); @@ -804,7 +908,7 @@ static unsigned cap_bits = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; // NOLINT static void *lv_alloc_draw_buf(size_t size, bool internal) { void *buffer; - size = ((size + LV_DRAW_BUF_ALIGN - 1) / LV_DRAW_BUF_ALIGN) * LV_DRAW_BUF_ALIGN; + size = LV_ROUND_UP(size, LV_DRAW_BUF_ALIGN); buffer = heap_caps_aligned_alloc(LV_DRAW_BUF_ALIGN, size, internal ? MALLOC_CAP_8BIT : cap_bits); // NOLINT if (buffer == nullptr) ESP_LOGW(esphome::lvgl::TAG, "Failed to allocate %zu bytes for %sdraw buffer", size, internal ? "internal " : ""); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 4a4c11d383..3433aaa527 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -26,6 +26,10 @@ #include #include +#ifdef USE_ESP32_VARIANT_ESP32P4 +#include "driver/ppa.h" +#endif + #ifdef USE_FONT #include "esphome/components/font/font.h" #endif // USE_LVGL_FONT @@ -229,6 +233,9 @@ class LvglComponent : public PollingComponent { display::DisplayRotation get_rotation() const { return this->rotation_; } void rotate_coordinates(int32_t &x, int32_t &y) const; + uint16_t get_width() const { return lv_display_get_horizontal_resolution(this->disp_); } + uint16_t get_height() const { return lv_display_get_vertical_resolution(this->disp_); } + protected: void set_resolution_() const; void draw_end_(); @@ -238,6 +245,10 @@ class LvglComponent : public PollingComponent { void write_random_(); void draw_buffer_(const lv_area_t *area, lv_color_data *ptr); +#ifdef USE_ESP32_VARIANT_ESP32P4 + bool ppa_rotate_(const lv_color_data *src, lv_color_data *dst, uint16_t width, uint16_t height, + uint32_t height_rounded); +#endif void flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p); std::vector displays_{}; @@ -266,6 +277,9 @@ class LvglComponent : public PollingComponent { void *rotate_buf_{}; display::DisplayRotation rotation_{display::DISPLAY_ROTATION_0_DEGREES}; RotationType rotation_type_; +#ifdef USE_ESP32_VARIANT_ESP32P4 + ppa_client_handle_t ppa_client_{}; +#endif }; class IdleTrigger : public Trigger<> { diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index e6025e17fc..79ea06f16a 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -21,7 +21,7 @@ binary_sensor: ignore_strapping_warning: true display: - - platform: ili9xxx + - platform: mipi_spi spi_id: spi_bus model: st7789v id: second_display @@ -41,7 +41,7 @@ display: invert_colors: false update_interval: never - - platform: ili9xxx + - platform: mipi_spi spi_id: spi_bus model: st7789v id: tft_display diff --git a/tests/components/lvgl/test.esp32-p4-idf.yaml b/tests/components/lvgl/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..5fd9370255 --- /dev/null +++ b/tests/components/lvgl/test.esp32-p4-idf.yaml @@ -0,0 +1,12 @@ +display: + - platform: mipi_dsi + model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C +lvgl: + byte_order: little_endian + rotation: 90 + +psram: + +esp_ldo: + - channel: 3 + voltage: 2.5V From fc8de545f3d1ea1103bae0e21b071b0790769163 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 15:34:28 -1000 Subject: [PATCH 06/21] [globals] Wrap raw constants in stateless lambda for TemplatableFn globals.set action passed raw values with output_type=None, bypassing the lambda wrapping in cg.templatable(). Now explicitly wraps constants in a stateless lambda with no return type annotation (compiler deduces the correct type from the value). --- esphome/components/globals/__init__.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index fe83b1ea7c..c069409360 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import LambdaExpression from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -108,8 +109,16 @@ async def globals_set_to_code(config, action_id, template_arg, args): full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) template_arg = cg.TemplateArguments(full_id.type, *template_arg) var = cg.new_Pvariable(action_id, template_arg, paren) - templ = await cg.templatable( - config[CONF_VALUE], args, None, to_exp=cg.RawExpression - ) + value = config[CONF_VALUE] + if cg.is_template(value): + templ = await cg.templatable(value, args, None, to_exp=cg.RawExpression) + else: + # Wrap raw constant in a stateless lambda for TemplatableFn storage. + # Use RawExpression for the value since T is a template parameter + # (the C++ compiler handles the type deduction). + raw_value = cg.RawExpression(value) + templ = LambdaExpression( + f"return {cg.safe_exp(raw_value)};", args, capture="", return_type=None + ) cg.add(var.set_value(templ)) return var From 810f22e8c9b908637c03bf5e0026faf04daba647 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 15:39:31 -1000 Subject: [PATCH 07/21] [sprinkler] Use TemplatableValue for valve_to_start (set from C++ with raw value) valve_to_start is set from both codegen (lambdas) and C++ code (raw size_t in sprinkler.cpp:387). Use TemplatableValue directly instead of the TEMPLATABLE_VALUE macro since TemplatableFn doesn't accept raw runtime values. --- esphome/components/sprinkler/automation.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index b3f030805d..ed091ac5d7 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -108,7 +108,9 @@ template class StartSingleValveAction : public Action { public: explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - TEMPLATABLE_VALUE(size_t, valve_to_start) + // valve_to_start uses TemplatableValue (not TemplatableFn) because it is set + // from both codegen (lambdas) and C++ (raw values in sprinkler.cpp). + template void set_valve_to_start(V valve_to_start) { this->valve_to_start_ = valve_to_start; } TEMPLATABLE_VALUE(uint32_t, valve_run_duration) void play(const Ts &...x) override { @@ -118,6 +120,7 @@ template class StartSingleValveAction : public Action { protected: Sprinkler *sprinkler_; + TemplatableValue valve_to_start_{}; }; template class ShutdownAction : public Action { From de7f081799d1788d6403cbc01579c4651bb2be14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 15:52:37 -1000 Subject: [PATCH 08/21] [emontx] Fix uart package name in tests (#15546) --- tests/components/emontx/test.esp32-idf.yaml | 2 +- tests/components/emontx/test.esp8266-ard.yaml | 2 +- tests/components/emontx/test.rp2040-ard.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/emontx/test.esp32-idf.yaml b/tests/components/emontx/test.esp32-idf.yaml index 3a3747f3a5..a0784fcd53 100644 --- a/tests/components/emontx/test.esp32-idf.yaml +++ b/tests/components/emontx/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/emontx/test.esp8266-ard.yaml b/tests/components/emontx/test.esp8266-ard.yaml index 31c5731589..80a2cb2fc0 100644 --- a/tests/components/emontx/test.esp8266-ard.yaml +++ b/tests/components/emontx/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/emontx/test.rp2040-ard.yaml b/tests/components/emontx/test.rp2040-ard.yaml index ff55e8263d..410c579d4b 100644 --- a/tests/components/emontx/test.rp2040-ard.yaml +++ b/tests/components/emontx/test.rp2040-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml <<: !include common.yaml From 67eec208bd962c7ccb3c1163795362cf206c9fcb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 16:01:05 -1000 Subject: [PATCH 09/21] [yaml] Add IncludeFile representer to ESPHomeDumper The deferred IncludeFile objects introduced in #12213 could not be serialized by the YAML dumper, causing test_build_components to fail when merging configs that contain !include package references. Uses add_multi_representer to also match the dynamically-created ESPHomeDataBase subclass produced by add_class_to_obj. --- esphome/yaml_util.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index c621428196..19bbca61f8 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -754,6 +754,9 @@ class ESPHomeDumper(yaml.SafeDumper): def represent_remove(self, value): return self.represent_scalar(tag="!remove", value=value.value) + def represent_include_file(self, value): + return self.represent_scalar(tag="!include", value=value.file.as_posix()) + def represent_id(self, value): if is_secret(value.id): return self.represent_secret(value.id) @@ -785,3 +788,4 @@ ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove) ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id) ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify) ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify) +ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file) From c7513b926219bc668e935829371559c182c9cb5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 16:01:18 -1000 Subject: [PATCH 10/21] [ci] Add lint check for test package key matching bus directory (#15547) --- script/ci-custom.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/script/ci-custom.py b/script/ci-custom.py index ad39f92005..1ec3eab3a9 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1006,6 +1006,38 @@ def lint_log_in_header(fname, line, col, content): ) +PACKAGE_BUS_RE = re.compile( + r"^\s+(\w+):\s*!include\s+\S*test_build_components/common/(\w+)/", + re.MULTILINE, +) + + +@lint_content_check(include=["tests/components/*/test.*.yaml"]) +def lint_test_package_key_matches_bus(fname, content): + """Ensure package keys match the common bus directory name. + + For example, a package using uart_115200 includes must use + 'uart_115200' as the key, not 'uart'. + """ + errs: list[tuple[int, int, str]] = [] + for match in PACKAGE_BUS_RE.finditer(content): + pkg_key = match.group(1) + bus_dir = match.group(2) + if pkg_key != bus_dir: + lineno = content.count("\n", 0, match.start()) + 1 + errs.append( + ( + lineno, + 1, + f"Package key {highlight(pkg_key)} does not match bus directory " + f"{highlight(bus_dir)}. The package key must match the directory " + f"name under tests/test_build_components/common/. " + f"Change {highlight(pkg_key)} to {highlight(bus_dir)}.", + ) + ) + return errs + + @lint_content_find_check( "FINAL_VALIDATE_SCHEMA", include=["esphome/core/*.py"], From 1d982c47cc713a9f15218551cbd65a93f8e4b604 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 16:02:34 -1000 Subject: [PATCH 11/21] [yaml] Add tests for IncludeFile YAML dumper representer --- tests/unit_tests/test_yaml_util.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 0bd7c9453b..a918e521d8 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -508,6 +508,28 @@ def test_represent_remove() -> None: assert yaml_util.dump({"key": Remove("my_id")}) == "key: !remove 'my_id'\n" +def test_represent_include_file() -> None: + """Test that IncludeFile objects are dumped as !include scalars.""" + include = yaml_util.IncludeFile( + Path("/fake/main.yaml"), "path/to/file.yaml", None, lambda _: {} + ) + assert yaml_util.dump({"key": include}) == "key: !include 'path/to/file.yaml'\n" + + +def test_represent_include_file_with_data_base_mixin() -> None: + """Test that IncludeFile wrapped with ESPHomeDataBase mixin is also dumped correctly. + + The YAML loader wraps IncludeFile via add_class_to_obj, creating a dynamic + subclass. add_multi_representer must match this subclass through the MRO. + """ + include = yaml_util.IncludeFile( + Path("/fake/main.yaml"), "common/spi.yaml", None, lambda _: {} + ) + wrapped = yaml_util.make_data_base(include) + assert isinstance(wrapped, yaml_util.ESPHomeDataBase) + assert yaml_util.dump({"pkg": wrapped}) == "pkg: !include 'common/spi.yaml'\n" + + # ── IncludeFile unit tests ────────────────────────────────────────────────── From 8ffe0f5e31d9816eea26046e05f7b7b3b05a4748 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:02:36 -0400 Subject: [PATCH 12/21] [core] Fix ANSI codes for secret text hiding (#15521) --- esphome/__main__.py | 2 +- esphome/core/log.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index a696cceffb..25b404ae45 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1083,7 +1083,7 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: # add the console decoration so the front-end can hide the secrets if not args.show_secrets: output = re.sub( - r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[5m\2\\033[6m", output + r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output ) if not CORE.quiet: safe_print(output) diff --git a/esphome/core/log.h b/esphome/core/log.h index ff39633142..72e06cabac 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -54,8 +54,8 @@ namespace esphome { #define ESPHOME_LOG_COLOR_CYAN "36" // DEBUG #define ESPHOME_LOG_COLOR_GRAY "37" // VERBOSE #define ESPHOME_LOG_COLOR_WHITE "38" -#define ESPHOME_LOG_SECRET_BEGIN "\033[5m" -#define ESPHOME_LOG_SECRET_END "\033[6m" +#define ESPHOME_LOG_SECRET_BEGIN "\033[8m" +#define ESPHOME_LOG_SECRET_END "\033[28m" #define LOG_SECRET(x) ESPHOME_LOG_SECRET_BEGIN x ESPHOME_LOG_SECRET_END #define ESPHOME_LOG_COLOR(COLOR) "\033[0;" COLOR "m" From 8399fb546ea185ef5edc9a9d81ebf30ca10dd3ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 16:06:30 -1000 Subject: [PATCH 13/21] [core] Deduplicate late std_string import in cg.templatable() --- esphome/cpp_generator.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 48b53b197a..f9330508c8 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -835,27 +835,26 @@ async def templatable( """ if is_template(value): return await process_lambda(value, args, return_type=output_type) + # Late import to avoid circular dependency (cpp_generator <-> cpp_types). + from esphome.cpp_types import std_string + if to_exp is not None: value = to_exp[value] if isinstance(to_exp, dict) else to_exp(value) - elif isinstance(value, str) and output_type is not None: + elif ( + isinstance(value, str) and output_type is not None and output_type is std_string + ): # Automatically wrap static strings in ESPHOME_F() for PROGMEM storage on ESP8266. # On other platforms ESPHOME_F() is a no-op returning const char*. - from esphome.cpp_types import std_string - - if output_type is std_string: - return FlashStringLiteral(value) + return FlashStringLiteral(value) # For non-string types, wrap constants in stateless lambdas so that # TemplatableFn (used by TEMPLATABLE_VALUE macro) stores them as function pointers. - if output_type is not None: - from esphome.cpp_types import std_string - - if output_type is not std_string: - return LambdaExpression( - f"return {safe_exp(value)};", - args, - capture="", - return_type=output_type, - ) + if output_type is not None and output_type is not std_string: + return LambdaExpression( + f"return {safe_exp(value)};", + args, + capture="", + return_type=output_type, + ) return value From 32fcbc8823364f1b634fc141f4a7ca8994ad05dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 16:10:40 -1000 Subject: [PATCH 14/21] address bot comments --- esphome/yaml_util.py | 5 +++++ tests/unit_tests/test_yaml_util.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 19bbca61f8..520379e51d 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -755,6 +755,11 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_scalar(tag="!remove", value=value.value) def represent_include_file(self, value): + if value.vars: + mapping = {"file": value.file.as_posix(), "vars": value.vars} + return self.represent_mapping( + tag="!include", mapping=mapping, flow_style=False + ) return self.represent_scalar(tag="!include", value=value.file.as_posix()) def represent_id(self, value): diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index a918e521d8..2c01019abd 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -516,6 +516,20 @@ def test_represent_include_file() -> None: assert yaml_util.dump({"key": include}) == "key: !include 'path/to/file.yaml'\n" +def test_represent_include_file_with_vars() -> None: + """Test that IncludeFile with vars is dumped as !include mapping form.""" + include = yaml_util.IncludeFile( + Path("/fake/main.yaml"), + "path/to/file.yaml", + {"key": "value"}, + lambda _: {}, + ) + result = yaml_util.dump({"key": include}) + assert "!include" in result + assert "file: path/to/file.yaml" in result + assert "key: value" in result + + def test_represent_include_file_with_data_base_mixin() -> None: """Test that IncludeFile wrapped with ESPHomeDataBase mixin is also dumped correctly. From 2e3ff4e215a2cc831ba570d4c9448be39adbcd99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:11:51 +0000 Subject: [PATCH 15/21] Bump cryptography from 46.0.6 to 46.0.7 (#15550) 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 5c798819a8..31f33ce7ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==46.0.6 +cryptography==46.0.7 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From 4db82877af35f9f06d1f7c658e01accd6642b0d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 16:27:11 -1000 Subject: [PATCH 16/21] [yaml] Add IncludeFile representer to ESPHomeDumper (#15549) --- esphome/yaml_util.py | 9 ++++++++ tests/unit_tests/test_yaml_util.py | 36 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index c621428196..520379e51d 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -754,6 +754,14 @@ class ESPHomeDumper(yaml.SafeDumper): def represent_remove(self, value): return self.represent_scalar(tag="!remove", value=value.value) + def represent_include_file(self, value): + if value.vars: + mapping = {"file": value.file.as_posix(), "vars": value.vars} + return self.represent_mapping( + tag="!include", mapping=mapping, flow_style=False + ) + return self.represent_scalar(tag="!include", value=value.file.as_posix()) + def represent_id(self, value): if is_secret(value.id): return self.represent_secret(value.id) @@ -785,3 +793,4 @@ ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove) ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id) ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify) ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify) +ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 0bd7c9453b..2c01019abd 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -508,6 +508,42 @@ def test_represent_remove() -> None: assert yaml_util.dump({"key": Remove("my_id")}) == "key: !remove 'my_id'\n" +def test_represent_include_file() -> None: + """Test that IncludeFile objects are dumped as !include scalars.""" + include = yaml_util.IncludeFile( + Path("/fake/main.yaml"), "path/to/file.yaml", None, lambda _: {} + ) + assert yaml_util.dump({"key": include}) == "key: !include 'path/to/file.yaml'\n" + + +def test_represent_include_file_with_vars() -> None: + """Test that IncludeFile with vars is dumped as !include mapping form.""" + include = yaml_util.IncludeFile( + Path("/fake/main.yaml"), + "path/to/file.yaml", + {"key": "value"}, + lambda _: {}, + ) + result = yaml_util.dump({"key": include}) + assert "!include" in result + assert "file: path/to/file.yaml" in result + assert "key: value" in result + + +def test_represent_include_file_with_data_base_mixin() -> None: + """Test that IncludeFile wrapped with ESPHomeDataBase mixin is also dumped correctly. + + The YAML loader wraps IncludeFile via add_class_to_obj, creating a dynamic + subclass. add_multi_representer must match this subclass through the MRO. + """ + include = yaml_util.IncludeFile( + Path("/fake/main.yaml"), "common/spi.yaml", None, lambda _: {} + ) + wrapped = yaml_util.make_data_base(include) + assert isinstance(wrapped, yaml_util.ESPHomeDataBase) + assert yaml_util.dump({"pkg": wrapped}) == "pkg: !include 'common/spi.yaml'\n" + + # ── IncludeFile unit tests ────────────────────────────────────────────────── From e658a8559ebafc94deface09ea4e14cdc43611be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 16:57:05 -1000 Subject: [PATCH 17/21] [ethernet] Add W6100 and W6300 support for RP2040 (#15543) --- esphome/components/ethernet/__init__.py | 16 +++++++++---- .../components/ethernet/ethernet_component.h | 22 +++++++++++++++++ .../ethernet/ethernet_component_rp2040.cpp | 24 +++++++++++++++++++ esphome/core/defines.h | 2 ++ .../ethernet/common-w6100-rp2040.yaml | 18 ++++++++++++++ .../ethernet/common-w6300-rp2040.yaml | 18 ++++++++++++++ .../ethernet/test-w6100.rp2040-ard.yaml | 1 + .../ethernet/test-w6300.rp2040-ard.yaml | 1 + 8 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 tests/components/ethernet/common-w6100-rp2040.yaml create mode 100644 tests/components/ethernet/common-w6300-rp2040.yaml create mode 100644 tests/components/ethernet/test-w6100.rp2040-ard.yaml create mode 100644 tests/components/ethernet/test-w6300.rp2040-ard.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index d9f51c677e..10f9a73863 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -123,6 +123,8 @@ ETHERNET_TYPES = { "DM9051": EthernetType.ETHERNET_TYPE_DM9051, "LAN8670": EthernetType.ETHERNET_TYPE_LAN8670, "ENC28J60": EthernetType.ETHERNET_TYPE_ENC28J60, + "W6100": EthernetType.ETHERNET_TYPE_W6100, + "W6300": EthernetType.ETHERNET_TYPE_W6300, } # PHY types that need compile-time defines for conditional compilation @@ -140,6 +142,8 @@ _PHY_TYPE_TO_DEFINE = { "DM9051": "USE_ETHERNET_DM9051", "LAN8670": "USE_ETHERNET_LAN8670", "ENC28J60": "USE_ETHERNET_ENC28J60", + "W6100": "USE_ETHERNET_W6100", + "W6300": "USE_ETHERNET_W6300", } @@ -170,12 +174,14 @@ _ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60"} # ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver) SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60"} -# RP2040-supported SPI ethernet types -RP2040_SPI_ETHERNET_TYPES = {"W5100", "W5500", "ENC28J60"} +# RP2040-supported ethernet types (SPI and PIO QSPI) +RP2040_ETHERNET_TYPES = {"W5100", "W5500", "W6100", "W6300", "ENC28J60"} _RP2040_SPI_LIBRARIES = { "W5100": "lwIP_w5100", "W5500": "lwIP_w5500", "ENC28J60": "lwIP_enc28j60", + "W6100": "lwIP_w6100", + "W6300": "lwIP_w6300", } SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) @@ -328,9 +334,9 @@ def _validate(config): f"{config[CONF_TYPE]} PHY requires RMII interface and is only supported " f"on ESP32 classic and ESP32-P4, not {variant}" ) - elif CORE.is_rp2040 and config[CONF_TYPE] not in RP2040_SPI_ETHERNET_TYPES: + elif CORE.is_rp2040 and config[CONF_TYPE] not in RP2040_ETHERNET_TYPES: raise cv.Invalid( - f"Only {', '.join(sorted(RP2040_SPI_ETHERNET_TYPES))} are supported on RP2040, " + f"Only {', '.join(sorted(RP2040_ETHERNET_TYPES))} are supported on RP2040, " f"not {config[CONF_TYPE]}" ) return config @@ -427,6 +433,8 @@ CONFIG_SCHEMA = cv.All( "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, "ENC28J60": SPI_SCHEMA, + "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), + "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), "LAN8670": RMII_SCHEMA, }, upper=True, diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index b760ba2af7..3a87842315 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -30,6 +30,20 @@ extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); #include #elif defined(USE_ETHERNET_W5100) #include +#elif defined(USE_ETHERNET_W6100) +#include +#elif defined(USE_ETHERNET_W6300) +#include +// W6300 uses PIO QSPI, not Arduino SPI. The upstream Wiznet6300 class +// incorrectly returns needsSPI()=true, causing LwipIntfDev::begin() to +// call SPI.begin() which claims GPIOs that PIO QSPI needs. +// This wrapper hides needsSPI() with a version returning false. +class Wiznet6300NoSPI : public Wiznet6300 { + public: + using Wiznet6300::Wiznet6300; + constexpr bool needsSPI() const { return false; } +}; +using Wiznet6300lwIPFixed = LwipIntfDev; #elif defined(USE_ETHERNET_ENC28J60) #include #else @@ -70,6 +84,8 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_DM9051, ETHERNET_TYPE_LAN8670, ETHERNET_TYPE_ENC28J60, + ETHERNET_TYPE_W6100, + ETHERNET_TYPE_W6300, }; struct ManualIP { @@ -232,6 +248,8 @@ class EthernetComponent final : public Component { static constexpr uint32_t LINK_CHECK_INTERVAL = 500; // ms between link/IP polls #if defined(USE_ETHERNET_W5100) static constexpr uint32_t RESET_DELAY_MS = 150; // W5100S PLL lock time +#elif defined(USE_ETHERNET_W6300) + static constexpr uint32_t RESET_DELAY_MS = 100; // W6300 needs 100ms after hardware reset #else static constexpr uint32_t RESET_DELAY_MS = 10; #endif @@ -239,6 +257,10 @@ class EthernetComponent final : public Component { Wiznet5500lwIP *eth_{nullptr}; #elif defined(USE_ETHERNET_W5100) Wiznet5100lwIP *eth_{nullptr}; +#elif defined(USE_ETHERNET_W6100) + Wiznet6100lwIP *eth_{nullptr}; +#elif defined(USE_ETHERNET_W6300) + Wiznet6300lwIPFixed *eth_{nullptr}; #elif defined(USE_ETHERNET_ENC28J60) ENC28J60lwIP *eth_{nullptr}; #else diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2040.cpp index 9771bc59d5..ef7bd46332 100644 --- a/esphome/components/ethernet/ethernet_component_rp2040.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2040.cpp @@ -18,9 +18,14 @@ static const char *const TAG = "ethernet"; void EthernetComponent::setup() { // Configure SPI pins +#if !defined(USE_ETHERNET_W6300) SPI.setRX(this->miso_pin_); SPI.setTX(this->mosi_pin_); SPI.setSCK(this->clk_pin_); +#endif + // W6300 uses PIO QSPI with hardcoded pins, not Arduino SPI. + // SPI pin config is skipped; Wiznet6300lwIPFixed (needsSPI()=false) + // prevents LwipIntfDev::begin() from calling SPI.begin(). // Toggle reset pin if configured if (this->reset_pin_ >= 0) { @@ -40,6 +45,10 @@ void EthernetComponent::setup() { this->eth_ = new Wiznet5500lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT #elif defined(USE_ETHERNET_W5100) this->eth_ = new Wiznet5100lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT +#elif defined(USE_ETHERNET_W6100) + this->eth_ = new Wiznet6100lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT +#elif defined(USE_ETHERNET_W6300) + this->eth_ = new Wiznet6300lwIPFixed(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT #elif defined(USE_ETHERNET_ENC28J60) this->eth_ = new ENC28J60lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT #endif @@ -183,9 +192,23 @@ void EthernetComponent::dump_config() { type_str = "W5500"; #elif defined(USE_ETHERNET_W5100) type_str = "W5100"; +#elif defined(USE_ETHERNET_W6100) + type_str = "W6100"; +#elif defined(USE_ETHERNET_W6300) + type_str = "W6300"; #elif defined(USE_ETHERNET_ENC28J60) type_str = "ENC28J60"; #endif +#if defined(USE_ETHERNET_W6300) + // W6300 uses PIO QSPI with hardcoded pins — SPI pin fields are not used + ESP_LOGCONFIG(TAG, + "Ethernet:\n" + " Type: %s (PIO QSPI)\n" + " Connected: %s\n" + " IRQ Pin: %d\n" + " Reset Pin: %d", + type_str, YESNO(this->is_connected()), this->interrupt_pin_, this->reset_pin_); +#else ESP_LOGCONFIG(TAG, "Ethernet:\n" " Type: %s\n" @@ -198,6 +221,7 @@ void EthernetComponent::dump_config() { " Reset Pin: %d", type_str, YESNO(this->is_connected()), this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_, this->interrupt_pin_, this->reset_pin_); +#endif this->dump_connect_params_(); } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 4939c194e3..d8b4faced9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -300,6 +300,8 @@ #define USE_ETHERNET_OPENETH #define USE_ETHERNET_W5100 #define USE_ETHERNET_W5500 +#define USE_ETHERNET_W6100 +#define USE_ETHERNET_W6300 #define USE_ETHERNET_DM9051 #define CONFIG_ETH_SPI_ETHERNET_W5500 1 #define CONFIG_ETH_SPI_ETHERNET_DM9051 1 diff --git a/tests/components/ethernet/common-w6100-rp2040.yaml b/tests/components/ethernet/common-w6100-rp2040.yaml new file mode 100644 index 0000000000..8afbd2d7cd --- /dev/null +++ b/tests/components/ethernet/common-w6100-rp2040.yaml @@ -0,0 +1,18 @@ +ethernet: + type: W6100 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + 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/common-w6300-rp2040.yaml b/tests/components/ethernet/common-w6300-rp2040.yaml new file mode 100644 index 0000000000..c248bc9810 --- /dev/null +++ b/tests/components/ethernet/common-w6300-rp2040.yaml @@ -0,0 +1,18 @@ +ethernet: + type: W6300 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + 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-w6100.rp2040-ard.yaml b/tests/components/ethernet/test-w6100.rp2040-ard.yaml new file mode 100644 index 0000000000..bf119e97c4 --- /dev/null +++ b/tests/components/ethernet/test-w6100.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common-w6100-rp2040.yaml diff --git a/tests/components/ethernet/test-w6300.rp2040-ard.yaml b/tests/components/ethernet/test-w6300.rp2040-ard.yaml new file mode 100644 index 0000000000..4fa1bb76f4 --- /dev/null +++ b/tests/components/ethernet/test-w6300.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common-w6300-rp2040.yaml From 313b9fd5bf9503da438e6368892fb99426aaf072 Mon Sep 17 00:00:00 2001 From: Szewcson Date: Wed, 8 Apr 2026 05:05:18 +0200 Subject: [PATCH 18/21] [gdk101] Retry reset on interval for slow-booting sensor MCU (#11750) Co-authored-by: J. Nick Koston 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/gdk101/gdk101.cpp | 60 ++++++++++++++++++---------- esphome/components/gdk101/gdk101.h | 3 ++ 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index 8b381564b2..149973ba8a 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -6,9 +6,15 @@ namespace esphome { namespace gdk101 { static const char *const TAG = "gdk101"; -static const uint8_t NUMBER_OF_READ_RETRIES = 5; +static constexpr uint8_t NUMBER_OF_READ_RETRIES = 5; +static constexpr uint8_t NUMBER_OF_RESET_RETRIES = 10; +static constexpr uint32_t RESET_INTERVAL_ID = 0; +static constexpr uint32_t RESET_INTERVAL_MS = 1000; void GDK101Component::update() { + if (!this->reset_complete_) + return; + uint8_t data[2]; if (!this->read_dose_1m_(data)) { this->status_set_warning(LOG_STR("Failed to read dose 1m")); @@ -33,26 +39,45 @@ void GDK101Component::update() { } void GDK101Component::setup() { - uint8_t data[2]; - // first, reset the sensor + if (!this->try_reset_()) { + // Sensor MCU boots slowly after power cycle — retry on a short interval + this->reset_retries_remaining_ = NUMBER_OF_RESET_RETRIES; + this->set_interval(RESET_INTERVAL_ID, RESET_INTERVAL_MS, [this]() { + if (this->try_reset_()) { + if (this->reset_complete_) { + this->update(); + } + return; + } + if (--this->reset_retries_remaining_ == 0) { + this->cancel_interval(RESET_INTERVAL_ID); + this->mark_failed(LOG_STR("Reset failed after retries")); + } + }); + } +} + +/// Attempt to reset the sensor and read firmware version. Returns true on success or hard failure. +bool GDK101Component::try_reset_() { + uint8_t data[2] = {0}; if (!this->reset_sensor_(data)) { - this->status_set_error(LOG_STR("Reset failed!")); - this->mark_failed(); - return; + this->status_set_warning(LOG_STR("Sensor not answering reset, will retry")); + return false; } - // sensor should acknowledge success of the reset procedure if (data[0] != 1) { - this->status_set_error(LOG_STR("Reset not acknowledged!")); - this->mark_failed(); - return; + this->status_set_warning(LOG_STR("Reset not acknowledged, will retry")); + return false; } delay(10); - // read firmware version if (!this->read_fw_version_(data)) { - this->status_set_error(LOG_STR("Failed to read firmware version")); - this->mark_failed(); - return; + this->cancel_interval(RESET_INTERVAL_ID); + this->mark_failed(LOG_STR("Failed to read firmware version")); + return true; } + this->reset_complete_ = true; + this->status_clear_warning(); + this->cancel_interval(RESET_INTERVAL_ID); + return true; } void GDK101Component::dump_config() { @@ -92,12 +117,7 @@ bool GDK101Component::reset_sensor_(uint8_t *data) { // After sending reset command it looks that sensor start performing reset and is unresponsible during read // after a while we can send another reset command and read "0x01" as confirmation // Documentation not going in to such details unfortunately - if (!this->read_bytes_with_retry_(GDK101_REG_RESET, data, 2)) { - ESP_LOGE(TAG, "Updating GDK101 failed!"); - return false; - } - - return true; + return this->read_bytes_with_retry_(GDK101_REG_RESET, data, 2); } bool GDK101Component::read_dose_1m_(uint8_t *data) { diff --git a/esphome/components/gdk101/gdk101.h b/esphome/components/gdk101/gdk101.h index abe417e0f9..abe3fd60d8 100644 --- a/esphome/components/gdk101/gdk101.h +++ b/esphome/components/gdk101/gdk101.h @@ -44,12 +44,15 @@ class GDK101Component : public PollingComponent, public i2c::I2CDevice { protected: bool read_bytes_with_retry_(uint8_t a_register, uint8_t *data, uint8_t len); + bool try_reset_(); bool reset_sensor_(uint8_t *data); bool read_dose_1m_(uint8_t *data); bool read_dose_10m_(uint8_t *data); bool read_status_(uint8_t *data); bool read_fw_version_(uint8_t *data); bool read_measurement_duration_(uint8_t *data); + bool reset_complete_{false}; + uint8_t reset_retries_remaining_{0}; }; } // namespace gdk101 From 51f3f5c774a708b232296bc41e53151e3801531b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:08:28 +0000 Subject: [PATCH 19/21] Bump esphome-dashboard from 20260210.0 to 20260408.1 (#15552) 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 31f33ce7ee..c4b90b5ca9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.2.0 click==8.3.2 -esphome-dashboard==20260210.0 +esphome-dashboard==20260408.1 aioesphomeapi==44.12.0 zeroconf==0.148.0 puremagic==1.30 From 42d217422e9545d1e90be8e6cedb0bd1f1afd3c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 17:10:44 -1000 Subject: [PATCH 20/21] [speaker_source] Wrap pipeline constant via cg.templatable for TemplatableFn --- esphome/components/speaker_source/media_player.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index 7f0f776ee5..70feeac318 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -312,7 +312,8 @@ async def set_playlist_delay_action_to_code( parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) - cg.add(var.set_pipeline(config[CONF_PIPELINE])) + template_ = await cg.templatable(config[CONF_PIPELINE], args, cg.uint8) + cg.add(var.set_pipeline(template_)) template_ = await cg.templatable(config[CONF_DELAY], args, cg.uint32) cg.add(var.set_delay(template_)) From a6538d56d93e3bfcd411fc058e0ce9350e671f87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 19:10:19 -1000 Subject: [PATCH 21/21] [multiple] Fix codegen type mismatches and raw value setter calls - sprinkler: fix codegen to use cg.size_t for valve_number (was cg.uint8) - remote_base/toto: move send_times/send_wait defaults to codegen - remote_base/abbwelcome: wrap auto_message_id bool via cg.templatable - http_request: wrap capture_response bool via cg.templatable - core/automation.h: add casting trampoline to TemplatableValue (same as TemplatableFn) for codegen with mismatched return types --- esphome/components/http_request/__init__.py | 3 ++- esphome/components/remote_base/__init__.py | 9 ++++++++- esphome/components/remote_base/toto_protocol.h | 2 -- esphome/components/sprinkler/__init__.py | 6 +++--- esphome/components/sprinkler/automation.h | 3 +-- esphome/core/automation.h | 16 ++++++++++++++-- 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index ce1a3fcecc..90879c459e 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -307,7 +307,8 @@ async def http_request_action_to_code(config, action_id, template_arg, args): capture_response = config[CONF_CAPTURE_RESPONSE] if capture_response: - cg.add(var.set_capture_response(capture_response)) + template_ = await cg.templatable(capture_response, args, cg.bool_) + cg.add(var.set_capture_response(template_)) cg.add_define("USE_HTTP_REQUEST_RESPONSE") cg.add(var.set_max_response_buffer_size(config[CONF_MAX_RESPONSE_BUFFER_SIZE])) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 99eda76f81..042ac9d46a 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -2123,7 +2123,8 @@ async def abbwelcome_action(var, config, args): await cg.templatable(config[CONF_MESSAGE_TYPE], args, cg.uint8) ) ) - cg.add(var.set_auto_message_id(CONF_MESSAGE_ID not in config)) + template_ = await cg.templatable(CONF_MESSAGE_ID not in config, args, cg.bool_) + cg.add(var.set_auto_message_id(template_)) if CONF_MESSAGE_ID in config: cg.add( var.set_message_id( @@ -2231,3 +2232,9 @@ async def Toto_action(var, config, args): cg.add(var.set_rc_code_2(template_)) template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) cg.add(var.set_command(template_)) + # Set toto-specific defaults (only if user didn't configure repeat) + if CONF_REPEAT not in config: + template_ = await cg.templatable(3, args, cg.uint32) + cg.add(var.set_send_times(template_)) + template_ = await cg.templatable(36000, args, cg.uint32) + cg.add(var.set_send_wait(template_)) diff --git a/esphome/components/remote_base/toto_protocol.h b/esphome/components/remote_base/toto_protocol.h index 6a635b0f7c..53d453f7e3 100644 --- a/esphome/components/remote_base/toto_protocol.h +++ b/esphome/components/remote_base/toto_protocol.h @@ -35,8 +35,6 @@ template class TotoAction : public RemoteTransmitterActionBaserc_code_1_.value(x...); data.rc_code_2 = this->rc_code_2_.value(x...); data.command = this->command_.value(x...); - this->set_send_times(this->send_times_.value_or(x..., 3)); - this->set_send_wait(this->send_wait_.value_or(x..., 36000)); TotoProtocol().encode(dst, data); } }; diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index fb2beb5b16..efa5b0bf15 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -455,7 +455,7 @@ async def sprinkler_set_multiplier_to_code(config, action_id, template_arg, args async def sprinkler_set_queued_valve_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.uint8) + template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) cg.add(var.set_valve_number(template_)) template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) cg.add(var.set_valve_run_duration(template_)) @@ -487,7 +487,7 @@ async def sprinkler_set_valve_run_duration_to_code( ): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.uint8) + template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) cg.add(var.set_valve_number(template_)) template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) cg.add(var.set_valve_run_duration(template_)) @@ -525,7 +525,7 @@ async def sprinkler_start_full_cycle_to_code(config, action_id, template_arg, ar async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.uint8) + template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) cg.add(var.set_valve_to_start(template_)) if CONF_RUN_DURATION in config: template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index ed091ac5d7..c6fe2e4e02 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -108,8 +108,7 @@ template class StartSingleValveAction : public Action { public: explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - // valve_to_start uses TemplatableValue (not TemplatableFn) because it is set - // from both codegen (lambdas) and C++ (raw values in sprinkler.cpp). + // TemplatableValue (not TemplatableFn) — also set from C++ with raw values in sprinkler.cpp template void set_valve_to_start(V valve_to_start) { this->valve_to_start_ = valve_to_start; } TEMPLATABLE_VALUE(uint32_t, valve_run_duration) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index e448faad48..7d5981c3b8 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -110,9 +110,21 @@ template class TemplatableValue { // Accept stateless lambdas (convertible to function pointer) template TemplatableValue(F f) requires std::convertible_to : tag_(FN) { this->f_ = f; } - // Reject stateful lambdas at compile time + // Convertible return type (e.g., int -> uint8_t) — casting trampoline template - TemplatableValue(F) requires std::invocable &&(!std::convertible_to) = delete; + [[deprecated("Lambda return type does not match TemplatableValue — use the correct type in " + "codegen")]] TemplatableValue(F) requires(!std::convertible_to) && + std::invocable &&std::convertible_to, T> &&std::is_empty_v + &&std::default_initializable : tag_(FN) { + this->f_ = [](X... x) -> T { return static_cast(F{}(x...)); }; + } + + // Reject any callable that didn't match the above + template + TemplatableValue(F) requires std::invocable && + (!std::convertible_to) &&(!std::is_empty_v || + !std::convertible_to, T> || + !std::default_initializable) = delete; TemplatableValue(const TemplatableValue &other) : tag_(other.tag_) { if (this->tag_ == VALUE) {