diff --git a/esphome/automation.py b/esphome/automation.py index 4a2c10cb52d..32f423d2a34 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -906,6 +906,21 @@ async def while_action_to_code( return var +def _shadow_iteration(args: TemplateArgsType) -> TemplateArgsType: + """Rename an inherited `iteration` so the inner repeat's own counter can shadow it.""" + names = {name for _, name in args} + renamed: TemplateArgsType = [] + for type_, name in args: + if name == "iteration": + depth = 1 + while f"iteration_{depth}" in names: + depth += 1 + name = f"iteration_{depth}" + names.add(name) + renamed.append((type_, name)) + return renamed + + @register_action( "repeat", RepeatAction, @@ -929,7 +944,7 @@ async def repeat_action_to_code( actions = await build_action_list( config[CONF_THEN], cg.TemplateArguments(cg.uint32, *template_arg.args), - [(cg.uint32, "iteration"), *args], + [(cg.uint32, "iteration"), *_shadow_iteration(args)], ) for action in actions: cg.add(var.add_then_action(action)) diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index 6b7290277a1..f8396e9648f 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -23,6 +23,10 @@ esphome: then: - logger.log: my_script is running +globals: + - id: nested_total + type: int + script: - id: my_script mode: single @@ -71,3 +75,16 @@ script: count: 5 then: - logger.log: looping! + - repeat: + count: 2 + then: + - repeat: + count: 2 + then: + - delay: 200ms + - lambda: 'ESP_LOGD("main", "inner iteration %u", iteration);' + - globals.set: + id: nested_total + value: !lambda |- + int total = iteration; + return total; diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index fe0b1b02ed1..b56d0a2920e 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -13,9 +13,13 @@ from esphome.automation import ( ApplyCondition, ApplyField, CallbackAutomation, + LambdaAction, + RepeatAction, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, + _shadow_iteration, + build_action, build_callback_automation, build_callback_automations, build_parent_callback_automation, @@ -41,7 +45,13 @@ from esphome.automation import ( ) import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN +from esphome.const import ( + CONF_AUTOMATION_ID, + CONF_COUNT, + CONF_ID, + CONF_THEN, + CONF_TYPE_ID, +) from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression from esphome.types import ConfigType @@ -1545,3 +1555,56 @@ async def test_build_callback_automation_forwarder(mock_cg: MockCodegen) -> None assert str(mock_cg.add.call_args.args[0]) == ( f"parent->add_cb(TriggerForwarder{{{NEW_OBJ}}})" ) + + +def test_shadow_iteration_leaves_other_names() -> None: + args = [(cg.std_string, "prefix"), (cg.uint32, "iteration_1")] + assert _shadow_iteration(args) == args + + +def test_shadow_iteration_renames_inherited_counter() -> None: + assert _shadow_iteration([(cg.uint32, "iteration"), (cg.bool_, "x")]) == [ + (cg.uint32, "iteration_1"), + (cg.bool_, "x"), + ] + + +def test_shadow_iteration_picks_first_unused_suffix() -> None: + """A counter keeps its suffix at deeper levels; the new one takes the next free name.""" + args = [ + (cg.uint32, "iteration"), + (cg.uint32, "iteration_1"), + (cg.int_, "iteration_3"), + ] + assert _shadow_iteration(args) == [ + (cg.uint32, "iteration_2"), + (cg.uint32, "iteration_1"), + (cg.int_, "iteration_3"), + ] + + +def _repeat_conf(id_: str, then: list[ConfigType]) -> ConfigType: + return { + "repeat": {CONF_COUNT: 2, CONF_THEN: then}, + CONF_TYPE_ID: ID(id_, is_declaration=True, type=RepeatAction), + } + + +@pytest.mark.asyncio +async def test_nested_repeat_lambda_parameters_are_distinct() -> None: + """Each repeat level adds its own `iteration`; inherited ones are renamed, not duplicated.""" + CORE.config = {} + log = { + "lambda": Lambda('ESP_LOGD("t", "%u", iteration);'), + CONF_TYPE_ID: ID("log_action", is_declaration=True, type=LambdaAction), + } + conf = _repeat_conf( + "outer", [_repeat_conf("middle", [_repeat_conf("inner", [log])])] + ) + await build_action(conf, cg.TemplateArguments(cg.uint8), [(cg.uint8, "level")]) + text = CORE.cpp_main_section + assert ( + "(uint32_t iteration, uint32_t iteration_2, uint32_t iteration_1, uint8_t level)" + in text + ) + assert "uint32_t iteration, uint32_t iteration)" not in text