From 1d3eea098e317b5c6bf9d6656a26f5b0825fa46a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 22 May 2026 13:00:22 +1200 Subject: [PATCH 1/4] [core] Support YAML frontmatter for arbitrary user metadata (#16552) --- esphome/core/__init__.py | 9 +- esphome/yaml_util.py | 29 +++++- tests/unit_tests/test_yaml_util.py | 158 +++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 3 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index e13d5668afc..580d7f64773 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -5,7 +5,7 @@ import math import os from pathlib import Path import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from esphome.const import ( CONF_COMMENT, @@ -569,6 +569,12 @@ class EsphomeCore: self.build_path: Path | None = None # The validated configuration, this is None until the config has been validated self.config: ConfigType | None = None + # YAML frontmatter loaded from user YAML files. Frontmatter is a leading + # YAML document separated by `---` from the actual configuration. It is + # ignored by config validation and code generation, but kept here so it + # can be inspected by callers (tooling, future features). Keyed by the + # resolved Path of the source file. + self.frontmatter: dict[Path, Any] = {} # The pending tasks in the task queue (mostly for C++ generation) # This is a priority queue (with heapq) # Each item is a tuple of form: (-priority, unique number, task) @@ -634,6 +640,7 @@ class EsphomeCore: self.config_path = None self.build_path = None self.config = None + self.frontmatter = {} self.event_loop = _FakeEventLoop() self.task_counter = 0 self.variables = {} diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b56d0244189..9a36ad089c1 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -768,10 +768,35 @@ def _load_yaml_internal_with_type( content: TextIOWrapper, yaml_loader: Callable[[Path], dict[str, Any]], ) -> Any: - """Load a YAML file.""" + """Load a YAML file. + + Supports an optional leading YAML frontmatter document: when the file + contains two YAML documents separated by ``---``, the first document is + treated as metadata and stored in :attr:`CORE.frontmatter` keyed by the + resolved file path, while the second document is returned as the actual + configuration. Frontmatter is ignored by config validation and code + generation. + """ loader = loader_type(content, fname, yaml_loader) try: - return loader.get_single_data() or OrderedDict() + documents: list[Any] = [] + while loader.check_data(): + documents.append(loader.get_data()) + if len(documents) > 2: + raise EsphomeError( + f"YAML file '{fname}' contains {len(documents)} documents but " + f"at most two are supported (an optional frontmatter document " + f"followed by the configuration)." + ) + if len(documents) == 2: + frontmatter = documents[0] + config = documents[1] + if frontmatter is not None: + CORE.frontmatter[Path(fname).resolve()] = frontmatter + return config if config is not None else OrderedDict() + if len(documents) == 1: + return documents[0] or OrderedDict() + return OrderedDict() except yaml.YAMLError as exc: raise EsphomeError(exc) from exc finally: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e97a188be41..de70a5307d1 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -34,6 +34,14 @@ def clear_secrets_cache() -> None: yaml_util._SECRET_CACHE.clear() +@pytest.fixture(autouse=True) +def clear_core_frontmatter() -> None: + """Reset CORE.frontmatter between tests.""" + core.CORE.frontmatter = {} + yield + core.CORE.frontmatter = {} + + def test_include_with_vars(fixture_path: Path) -> None: yaml_file = fixture_path / "yaml_util" / "includetest.yaml" @@ -1182,3 +1190,153 @@ def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None: with track_yaml_loads() as loaded: yaml_util.load_yaml(link) assert target.resolve() in loaded + + +# --------------------------------------------------------------------------- +# YAML frontmatter +# --------------------------------------------------------------------------- + + +def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None: + """A leading `---`-separated YAML document is stored as frontmatter and + stripped from the returned config.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "author: Jesse\nlabels: [office, climate]\n---\nesphome:\n name: my_node\n" + ) + + config = yaml_util.load_yaml(yaml_file) + + # Config does not contain frontmatter keys + assert "author" not in config + assert "labels" not in config + assert config["esphome"]["name"] == "my_node" + + # Frontmatter is stored on CORE keyed by resolved path + frontmatter = core.CORE.frontmatter[yaml_file.resolve()] + assert frontmatter["author"] == "Jesse" + assert frontmatter["labels"] == ["office", "climate"] + + +def test_frontmatter_absent_when_single_document(tmp_path: Path) -> None: + """A YAML file with a single document does not populate CORE.frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("esphome:\n name: my_node\n") + + yaml_util.load_yaml(yaml_file) + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_absent_when_leading_doc_separator(tmp_path: Path) -> None: + """A leading `---` with no content above it is just a document start marker, + not frontmatter, and must not populate CORE.frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("---\nesphome:\n name: my_node\n") + + config = yaml_util.load_yaml(yaml_file) + assert config["esphome"]["name"] == "my_node" + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_supports_arbitrary_keys(tmp_path: Path) -> None: + """Frontmatter keys are not validated — any structure is accepted.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "any_key: any_value\n" + "nested:\n" + " count: 42\n" + " items:\n" + " - a\n" + " - b\n" + "---\n" + "esphome:\n" + " name: t\n" + ) + + yaml_util.load_yaml(yaml_file) + frontmatter = core.CORE.frontmatter[yaml_file.resolve()] + assert frontmatter["any_key"] == "any_value" + assert frontmatter["nested"]["count"] == 42 + assert frontmatter["nested"]["items"] == ["a", "b"] + + +def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None: + """Frontmatter preserves deeply nested dict/list structures intact.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "device:\n" + " metadata:\n" + " location:\n" + " building: HQ\n" + " floor: 3\n" + " room:\n" + " number: 302\n" + " occupants:\n" + " - name: Jesse\n" + " role:\n" + " title: maintainer\n" + " since: 2021\n" + " - name: Alice\n" + " role:\n" + " title: contributor\n" + " since: 2024\n" + "---\n" + "esphome:\n" + " name: t\n" + ) + + yaml_util.load_yaml(yaml_file) + fm = core.CORE.frontmatter[yaml_file.resolve()] + room = fm["device"]["metadata"]["location"]["room"] + assert room["number"] == 302 + assert room["occupants"][0]["name"] == "Jesse" + assert room["occupants"][0]["role"]["title"] == "maintainer" + assert room["occupants"][0]["role"]["since"] == 2021 + assert room["occupants"][1]["role"]["title"] == "contributor" + + +def test_frontmatter_more_than_two_documents_raises(tmp_path: Path) -> None: + """Three or more YAML documents is unsupported and must raise.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("a: 1\n---\nb: 2\n---\nc: 3\n") + + with pytest.raises(EsphomeError, match="at most two are supported"): + yaml_util.load_yaml(yaml_file) + + +def test_frontmatter_empty_frontmatter_doc_not_stored(tmp_path: Path) -> None: + """An empty (null) frontmatter document is treated as no frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("---\n---\nesphome:\n name: t\n") + + config = yaml_util.load_yaml(yaml_file) + assert config["esphome"]["name"] == "t" + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_empty_config_doc(tmp_path: Path) -> None: + """An empty config document after a frontmatter document yields an empty config.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("only: frontmatter\n---\n") + + config = yaml_util.load_yaml(yaml_file) + assert config == {} + assert core.CORE.frontmatter[yaml_file.resolve()]["only"] == "frontmatter" + + +def test_frontmatter_included_file_stored(tmp_path: Path) -> None: + """Frontmatter on an !include'd file is also captured on CORE, keyed by + that file's resolved path.""" + inc = tmp_path / "child.yaml" + inc.write_text("child_meta: hello\n---\nchild_key: value\n") + main = tmp_path / "main.yaml" + main.write_text("esphome:\n name: t\nchild: !include child.yaml\n") + + config = yaml_util.load_yaml(main) + # !include is deferred; force resolution so the child file actually loads + force_load_include_files(config) + assert config["child"].load()["child_key"] == "value" + # Main file has no frontmatter + assert main.resolve() not in core.CORE.frontmatter + # Included file's frontmatter is captured + assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello" From 0b2eb6481f4ab2c73b0b85430093c1f6a9a33810 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 22 May 2026 13:42:50 +1200 Subject: [PATCH 2/4] [light] Add light.effect.next / light.effect.previous actions (#16491) --- esphome/components/light/__init__.py | 33 ++++- esphome/components/light/automation.h | 41 ++++++ esphome/components/light/automation.py | 76 ++++++++++- esphome/components/light/types.py | 1 + .../light/test_effect_validation.py | 127 +++++++++++++++++- tests/components/light/common.yaml | 10 ++ 6 files changed, 283 insertions(+), 5 deletions(-) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 9540c644860..68d9f85af27 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -86,10 +86,22 @@ class EffectRef: component_path: list[str | int] # path_context when the action was validated +@dataclass +class EffectCycleRef: + """A pending light.effect.next/previous action to validate. + + Records that the referenced light needs at least one effect configured. + """ + + light_id: ID + component_path: list[str | int] + + @dataclass class LightData: gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr effect_refs: list[EffectRef] = field(default_factory=list) + effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list) def _get_data() -> LightData: @@ -160,13 +172,15 @@ def _final_validate(config: ConfigType) -> ConfigType: this never runs — but the ID validator will catch the missing light ID separately. """ data = _get_data() - if not data.effect_refs: + if not data.effect_refs and not data.effect_cycle_refs: return config - # Drain the list so we only validate once even though + # Drain the lists so we only validate once even though # FINAL_VALIDATE_SCHEMA runs for each light platform instance. refs = data.effect_refs data.effect_refs = [] + cycle_refs = data.effect_cycle_refs + data.effect_cycle_refs = [] fconf = fv.full_config.get() @@ -188,6 +202,21 @@ def _final_validate(config: ConfigType) -> ConfigType: path=[cv.ROOT_CONFIG_PATH] + ref.component_path, ) + for ref in cycle_refs: + try: + light_path = fconf.get_path_for_id(ref.light_id)[:-1] + light_config = fconf.get_config_for_path(light_path) + except KeyError: + continue + + if not light_config.get(CONF_EFFECTS): + raise cv.FinalExternalInvalid( + f"Light '{ref.light_id}' has no effects configured, but a " + f"'light.effect.next' or 'light.effect.previous' action " + f"references it. Add at least one effect to the light.", + path=[cv.ROOT_CONFIG_PATH] + ref.component_path, + ) + return config diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 993d4a2ea66..260414f0330 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -104,6 +104,47 @@ template class DimRelativeAction : pub transition_length_{}; }; +// Cycle through the light's configured effects. `Forward` selects direction +// at compile time so the chosen branch is the only one that gets instantiated +// per action site. `include_none` is runtime so a single set of templates +// covers both the "wrap through None" and "skip None" variants. +template class LightEffectCycleAction : public Action { + public: + explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {} + + void set_include_none(bool include_none) { this->include_none_ = include_none; } + + void play(const Ts &...) override { + size_t count = this->parent_->get_effect_count(); + if (count == 0) { + return; + } + uint32_t current = this->parent_->get_current_effect_index(); + uint32_t next; + if (this->include_none_) { + uint32_t total = static_cast(count) + 1; + if constexpr (Forward) { + next = (current + 1) % total; + } else { + next = (current + total - 1) % total; + } + } else { + if constexpr (Forward) { + next = (current % static_cast(count)) + 1; + } else { + next = (current <= 1) ? static_cast(count) : current - 1; + } + } + auto call = this->parent_->turn_on(); + call.set_effect(next); + call.perform(); + } + + protected: + LightState *parent_; + bool include_none_{false}; +}; + template class LightIsOnCondition : public Condition { public: explicit LightIsOnCondition(LightState *state) : state_(state) {} diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index cef774af38d..7eaba9b117a 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -26,8 +26,8 @@ from esphome.const import ( CONF_WARM_WHITE, CONF_WHITE, ) -from esphome.core import CORE, EsphomeError, Lambda -from esphome.cpp_generator import LambdaExpression +from esphome.core import CORE, ID, EsphomeError, Lambda +from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType from esphome.types import ConfigType from .types import ( @@ -39,12 +39,15 @@ from .types import ( DimRelativeAction, LightCall, LightControlAction, + LightEffectCycleAction, LightIsOffCondition, LightIsOnCondition, LightState, ToggleAction, ) +CONF_INCLUDE_NONE = "include_none" + @automation.register_action( "light.toggle", @@ -253,6 +256,75 @@ async def light_control_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) +def _record_effect_cycle_ref(config: ConfigType) -> ConfigType: + """Record a cycle-action reference for later validation against the target light.""" + from . import EffectCycleRef, _get_data + + _get_data().effect_cycle_refs.append( + EffectCycleRef( + light_id=config[CONF_ID], + component_path=path_context.get(), + ) + ) + return config + + +LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(LightState), + cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean, + } +) +LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref) + +LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id( + LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA +) + + +@automation.register_action( + "light.effect.next", + LightEffectCycleAction, + LIGHT_EFFECT_CYCLE_ACTION_SCHEMA, + synchronous=True, +) +async def light_effect_next_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await _light_effect_cycle_to_code(config, action_id, template_arg, True) + + +@automation.register_action( + "light.effect.previous", + LightEffectCycleAction, + LIGHT_EFFECT_CYCLE_ACTION_SCHEMA, + synchronous=True, +) +async def light_effect_previous_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await _light_effect_cycle_to_code(config, action_id, template_arg, False) + + +async def _light_effect_cycle_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + forward: bool, +) -> MockObj: + paren = await cg.get_variable(config[CONF_ID]) + cycle_template_arg = cg.TemplateArguments(forward, *template_arg) + var = cg.new_Pvariable(action_id, cycle_template_arg, paren) + cg.add(var.set_include_none(config[CONF_INCLUDE_NONE])) + return var + + CONF_RELATIVE_BRIGHTNESS = "relative_brightness" LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema( { diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 534dcd21948..c7385cbee32 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -39,6 +39,7 @@ LIMIT_MODES = { # Actions ToggleAction = light_ns.class_("ToggleAction", automation.Action) LightControlAction = light_ns.class_("LightControlAction", automation.Action) +LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition) diff --git a/tests/component_tests/light/test_effect_validation.py b/tests/component_tests/light/test_effect_validation.py index 579e92c62a9..aab9072cc85 100644 --- a/tests/component_tests/light/test_effect_validation.py +++ b/tests/component_tests/light/test_effect_validation.py @@ -9,13 +9,17 @@ import pytest from esphome import config_validation as cv from esphome.components.light import ( + EffectCycleRef, EffectRef, _final_validate, _get_data, available_effects_str, find_effect_index, ) -from esphome.components.light.automation import _record_effect_ref +from esphome.components.light.automation import ( + _record_effect_cycle_ref, + _record_effect_ref, +) from esphome.config import Config, path_context from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME from esphome.core import ID, Lambda @@ -215,6 +219,111 @@ def test_final_validate_drains_refs() -> None: fv.full_config.reset(token) +# --- _final_validate: EffectCycleRef --- + + +def _setup_cycle_final_validate( + cycle_refs: list[EffectCycleRef], + light_configs: list[ConfigType], + declare_ids: list[tuple[ID, list[str | int]]], +) -> Token: + """Set up CORE.data and fv.full_config for EffectCycleRef final_validate tests.""" + data = _get_data() + data.effect_cycle_refs = cycle_refs + + full_conf = Config() + full_conf["light"] = light_configs + for id_, path in declare_ids: + full_conf.declare_ids.append((id_, path)) + + return fv.full_config.set(full_conf) + + +def test_final_validate_cycle_accepts_light_with_effects() -> None: + """Cycle ref against a light with effects should not raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_rejects_light_without_effects_key() -> None: + """Cycle ref against a light with no CONF_EFFECTS key should raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_rejects_light_with_empty_effects() -> None: + """Cycle ref against a light with empty effects list should raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: []}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_unknown_light_id_skipped() -> None: + """Cycle refs to unknown light IDs should be silently skipped.""" + data = _get_data() + data.effect_cycle_refs = [ + EffectCycleRef( + light_id=ID("nonexistent", is_declaration=True), + component_path=["esphome"], + ) + ] + + full_conf = Config() + token = fv.full_config.set(full_conf) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_drains_cycle_refs() -> None: + """Cycle refs should be drained after validation to avoid redundant runs.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + assert _get_data().effect_cycle_refs == [] + finally: + fv.full_config.reset(token) + + # --- _record_effect_ref --- @@ -278,3 +387,19 @@ def test_record_effect_ref_skips_no_effect_key() -> None: config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)} _record_effect_ref(config) assert _get_data().effect_refs == [] + + +# --- _record_effect_cycle_ref --- + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_cycle_ref() -> None: + """Cycle-action config should be recorded with light_id and path.""" + light_id = ID("led1", is_declaration=True) + config: ConfigType = {CONF_ID: light_id} + result = _record_effect_cycle_ref(config) + assert result is config + data = _get_data() + assert len(data.effect_cycle_refs) == 1 + assert data.effect_cycle_refs[0].light_id is light_id + assert data.effect_cycle_refs[0].component_path == ["esphome"] diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 044a8144fad..cd9b27768e7 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -103,6 +103,16 @@ esphome: - light.turn_on: id: test_monochromatic_light effect: !lambda 'return iteration > 1 ? "Strobe" : "none";' + # Cycle through configured effects (skip "None") + - light.effect.next: test_monochromatic_light + - light.effect.previous: test_monochromatic_light + # Cycle through effects including "None" + - light.effect.next: + id: test_monochromatic_light + include_none: true + - light.effect.previous: + id: test_monochromatic_light + include_none: true - light.dim_relative: id: test_monochromatic_light relative_brightness: 5% From 0b5e7ae8fa052d6e193f730e0024bcd6a54a5fae Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 22 May 2026 06:43:08 -0400 Subject: [PATCH 3/4] [sendspin] Bump sendspin-cpp to v0.6.1 (#16553) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 36f13f7d07d..b670bd3c4de 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 44c63e46cde..6bc166ff444 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.6.0 + version: 0.6.1 lvgl/lvgl: version: 9.5.0 From 6cb131ce54b4150efb96790d339e5cf2460f84c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 07:51:49 -0500 Subject: [PATCH 4/4] [esp8266] Use os_timer-based esp_delay() in delay() Replace the poll-based busy-wait introduced in #15662 with a call to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to properly suspend the cont task for the requested duration. The busy-wait only yielded via optimistic_yield(1000) once 1 ms had elapsed since the last task switch, so short waits like delay(1) often finished without ever yielding. That starved the SDK and extended interrupt latency, breaking timing sensitive interrupt-driven code such as the ESP8266 software-serial RX path used by fingerprint_grow. esp_delay()'s 1-arg form does not itself call millis(), so the slow Arduino millis() body is not pulled into IRAM by this path; the --wrap=millis goal of #15662 is preserved. Fixes #16558 --- esphome/components/esp8266/hal.cpp | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index e8f472dc8a6..3501c518593 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -5,6 +5,7 @@ #include #include +#include extern "C" { #include @@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() { return result; } -// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object -// call to the original millis() that --wrap can't intercept, so calling ::delay() -// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still -// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and -// WiFi run correctly. Theoretically less power-efficient than Arduino's -// os_timer-based delay() for long waits, but nearly all ESPHome delays are short -// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is -// negligible. +// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to +// suspend the cont task for `ms` milliseconds without polling millis(). This +// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay()) +// and lets the SDK run freely while we wait, which timing-sensitive +// interrupt-driven code (e.g. ESP8266 software-serial RX in components like +// fingerprint_grow) depends on. The poll-based busy-wait that this replaced +// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and +// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does +// not call millis(), so the slow Arduino millis() body is not pulled into IRAM +// by this path (the --wrap=millis goal of #15662 is preserved). void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); return; } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } + esp_delay(ms); } void arch_restart() {