From fd6ee51c714e8835f022c5a7d474c3311124e236 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 19:15:17 -1000 Subject: [PATCH 1/7] empty head From 85d87d73c14339e6e89359d224d0a2a0032b296b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 19:26:48 -1000 Subject: [PATCH 2/7] [esp32_ble_tracker] Wrap continuous bool via cg.templatable for TemplatableFn --- esphome/components/esp32_ble_tracker/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index b9c4c28ccf..d758b400c4 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -378,7 +378,8 @@ async def esp32_ble_tracker_start_scan_action_to_code( ): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - cg.add(var.set_continuous(config[CONF_CONTINUOUS])) + template_ = await cg.templatable(config[CONF_CONTINUOUS], args, cg.bool_) + cg.add(var.set_continuous(template_)) return var From a01ba05dca37779742c9969e1b6e796a33b9b2ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 21:20:26 -1000 Subject: [PATCH 3/7] fix missing templatable in lightwaverf and match name to code --- esphome/components/lightwaverf/__init__.py | 16 +++++++--------- esphome/components/lightwaverf/lightwaverf.h | 6 +----- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 46c400cb0e..76eabc2b71 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -61,15 +61,13 @@ async def send_raw_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) - repeats = await cg.templatable(config[CONF_REPEAT], args, int) - inverted = await cg.templatable(config[CONF_INVERTED], args, bool) - pulse_length = await cg.templatable(config[CONF_PULSE_LENGTH], args, int) - code = config[CONF_CODE] - - cg.add(var.set_repeats(repeats)) - cg.add(var.set_inverted(inverted)) - cg.add(var.set_pulse_length(pulse_length)) - cg.add(var.set_data(code)) + template_ = await cg.templatable(config[CONF_REPEAT], args, cg.int_) + cg.add(var.set_repeat(template_)) + template_ = await cg.templatable(config[CONF_INVERTED], args, cg.int_) + cg.add(var.set_inverted(template_)) + template_ = await cg.templatable(config[CONF_PULSE_LENGTH], args, cg.int_) + cg.add(var.set_pulse_length(template_)) + cg.add(var.set_code(config[CONF_CODE])) return var diff --git a/esphome/components/lightwaverf/lightwaverf.h b/esphome/components/lightwaverf/lightwaverf.h index ee4e91e9d1..6210e6b5d4 100644 --- a/esphome/components/lightwaverf/lightwaverf.h +++ b/esphome/components/lightwaverf/lightwaverf.h @@ -45,11 +45,7 @@ template class SendRawAction : public Action { TEMPLATABLE_VALUE(int, inverted); TEMPLATABLE_VALUE(int, pulse_length); TEMPLATABLE_VALUE(std::vector, code); - - void set_repeats(const int &data) { repeat_ = data; } - void set_inverted(const int &data) { inverted_ = data; } - void set_pulse_length(const int &data) { pulse_length_ = data; } - void set_data(const std::vector &data) { code_ = data; } + void set_code(std::initializer_list data) { this->code_ = std::vector(data); } void play(const Ts &...x) { int repeats = this->repeat_.value(x...); From a5819d1185dd774edb465d894668cb37a0c7fc8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 21:23:27 -1000 Subject: [PATCH 4/7] another one --- esphome/components/cc1101/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 2709290862..0feb384ac2 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -423,11 +423,10 @@ def _register_setter_actions(): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) data = config[CONF_VALUE] - if cg.is_template(data): - templ_ = await cg.templatable(data, args, _type) - cg.add(getattr(var, _setter)(templ_)) - else: - cg.add(getattr(var, _setter)(_map[data] if _map else data)) + if _map and not cg.is_template(data): + data = _map[data] + templ_ = await cg.templatable(data, args, _type) + cg.add(getattr(var, _setter)(templ_)) return var automation.register_action( From 64d3fa52cebdfcb0bc93cba67c8a2de59a46c74f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 21:25:41 -1000 Subject: [PATCH 5/7] Fix test_build_components crash with top-level !include test files The IncludeFile representer added in #15549 defers !include resolution, but the component test scripts assumed load_yaml always returns a dict. Test files using top-level !include (e.g. usb_uart) caused a TypeError: 'argument of type IncludeFile is not iterable'. Resolve IncludeFile objects by calling .load() before processing. --- script/analyze_component_buses.py | 3 +++ script/merge_component_configs.py | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 17af7af577..e6c6884734 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -221,6 +221,9 @@ def analyze_yaml_file(yaml_file: Path) -> dict[str, Any]: try: data = yaml_util.load_yaml(yaml_file) + # Top-level !include returns an IncludeFile that must be resolved + if isinstance(data, yaml_util.IncludeFile): + data = data.load() result["loaded"] = True except Exception: # pylint: disable=broad-exception-caught return result diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 41bbafcd02..453d4109dd 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -27,6 +27,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from esphome import yaml_util from esphome.config_helpers import merge_config +from esphome.yaml_util import IncludeFile from script.analyze_component_buses import PACKAGE_DEPENDENCIES, get_common_bus_packages # Prefix for dependency markers in package tracking @@ -46,7 +47,12 @@ def load_yaml_file(yaml_file: Path) -> dict: if not yaml_file.exists(): raise FileNotFoundError(f"YAML file not found: {yaml_file}") - return yaml_util.load_yaml(yaml_file) + data = yaml_util.load_yaml(yaml_file) + # Top-level !include (e.g., `!include common.yaml`) returns an IncludeFile + # that must be resolved before we can work with it as a dict. + if isinstance(data, IncludeFile): + data = data.load() + return data @lru_cache(maxsize=256) From 01ae6954395d7c088382cab99c33df973d4a498f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 21:33:06 -1000 Subject: [PATCH 6/7] Fix load_yaml returning unresolved IncludeFile for top-level !include Move the IncludeFile resolution into _load_yaml_internal so all callers get resolved content, rather than patching individual call sites. --- esphome/yaml_util.py | 7 ++++++- script/analyze_component_buses.py | 3 --- script/merge_component_configs.py | 8 +------- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 520379e51d..59d851c02e 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -599,9 +599,14 @@ def _load_yaml_internal(fname: Path) -> Any: listener(fname) try: with fname.open(encoding="utf-8") as f_handle: - return parse_yaml(fname, f_handle) + res = parse_yaml(fname, f_handle) except (UnicodeDecodeError, OSError) as err: raise EsphomeError(f"Error reading file {fname}: {err}") from err + # Top-level !include returns a deferred IncludeFile; resolve it so + # callers always receive the final content. + if isinstance(res, IncludeFile): + res = res.load() + return res def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> Any: diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index e6c6884734..17af7af577 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -221,9 +221,6 @@ def analyze_yaml_file(yaml_file: Path) -> dict[str, Any]: try: data = yaml_util.load_yaml(yaml_file) - # Top-level !include returns an IncludeFile that must be resolved - if isinstance(data, yaml_util.IncludeFile): - data = data.load() result["loaded"] = True except Exception: # pylint: disable=broad-exception-caught return result diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 453d4109dd..41bbafcd02 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -27,7 +27,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from esphome import yaml_util from esphome.config_helpers import merge_config -from esphome.yaml_util import IncludeFile from script.analyze_component_buses import PACKAGE_DEPENDENCIES, get_common_bus_packages # Prefix for dependency markers in package tracking @@ -47,12 +46,7 @@ def load_yaml_file(yaml_file: Path) -> dict: if not yaml_file.exists(): raise FileNotFoundError(f"YAML file not found: {yaml_file}") - data = yaml_util.load_yaml(yaml_file) - # Top-level !include (e.g., `!include common.yaml`) returns an IncludeFile - # that must be resolved before we can work with it as a dict. - if isinstance(data, IncludeFile): - data = data.load() - return data + return yaml_util.load_yaml(yaml_file) @lru_cache(maxsize=256) From b53f1c694b803c82cd214201719df4f6661e8a9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 21:36:49 -1000 Subject: [PATCH 7/7] Add test for top-level !include resolution in load_yaml --- tests/unit_tests/test_yaml_util.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 2c01019abd..bfd60de44d 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -640,6 +640,18 @@ def test_include_in_list_context() -> None: assert config["values"] == ["alpha", "beta", "gamma"] +def test_top_level_include_resolved_by_load_yaml(tmp_path: Path) -> None: + """load_yaml resolves a top-level !include so callers always get a dict.""" + child = tmp_path / "child.yaml" + child.write_text("key: value\n") + main = tmp_path / "main.yaml" + main.write_text("!include child.yaml\n") + + result = yaml_util.load_yaml(main) + assert isinstance(result, dict) + assert result["key"] == "value" + + def test_include_plain_filename_loads_after_deferred_refactor() -> None: """!include with a plain filename (no $ expressions) still loads correctly.