From 6354d4ad97b8aad395c98e0a6fa61e0a0bfbfff4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Mar 2026 13:58:30 -1000 Subject: [PATCH 1/5] [core] Eliminate trigger trampolines for common entity automations Add build_callback_automation() to register automation callbacks directly on parent components, bypassing the Trigger wrapper object. Migrates button, sensor, binary_sensor, switch, and text_sensor to the new pattern, eliminating 12 thin wrapper trigger classes from runtime instantiation. --- esphome/automation.py | 45 ++++++++++++++++++++ esphome/components/binary_sensor/__init__.py | 31 ++++++++++---- esphome/components/button/__init__.py | 5 ++- esphome/components/sensor/__init__.py | 10 +++-- esphome/components/switch/__init__.py | 25 ++++++++--- esphome/components/text_sensor/__init__.py | 10 +++-- esphome/core/automation.h | 3 +- 7 files changed, 103 insertions(+), 26 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 17966dc782..afa4ac3787 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -661,3 +661,48 @@ async def build_automation( actions = await build_action_list(config[CONF_THEN], templ, args) cg.add(obj.add_actions(actions)) return obj + + +async def build_callback_automation( + parent: MockObj, + callback_method: str, + args: TemplateArgsType, + config: ConfigType, + callback_args: TemplateArgsType | None = None, + condition: str | None = None, +) -> None: + """Build an Automation and register it as a callback on the parent. + + Eliminates the need for a Trigger wrapper object by registering the + automation's trigger() directly as a callback on the parent component. + + :param parent: The component object (e.g., button, sensor). + :param callback_method: Name of the callback method (e.g., "add_on_press_callback"). + :param args: Automation template args as list of (type, name) tuples. + :param config: The automation config dict. + :param callback_args: Lambda parameter types if different from args (e.g., for + conditional triggers where the callback receives (bool state) but the + automation is Automation<> with no args). Defaults to args. + :param condition: Optional C++ condition. Use callback arg names directly + (e.g., "state", "!state"). + """ + arg_types = [arg[0] for arg in args] + templ = cg.TemplateArguments(*arg_types) + obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ) + actions = await build_action_list(config[CONF_THEN], templ, args) + cg.add(obj.add_actions(actions)) + # Build trigger call expression: automation->trigger(arg1, arg2, ...) + trigger_args = [MockObj(arg[1], "") for arg in args] + trigger_expr = obj.trigger(*trigger_args) + if condition is not None: + body = [f"if ({condition}) {{ ", trigger_expr, "; }"] + else: + body = [trigger_expr, ";"] + # Use callback_args for the lambda parameters if provided (e.g., when the + # callback signature differs from the automation args due to filtering). + lambda_params = callback_args if callback_args is not None else args + # ESPHome codegen allocates all variables as static pointers, so they + # are accessible without explicit lambda capture. Using "" avoids + # -Wcapture-of-non-automatic-storage-duration warnings. + lambda_expr = LambdaExpression(body, lambda_params, capture="") + cg.add(getattr(parent, callback_method)(lambda_expr)) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 37cccc01be..b9ca005d9f 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -557,12 +557,24 @@ def binary_sensor_schema( @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_binary_sensor_automations(var, config): for conf in config.get(CONF_ON_PRESS, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, + "add_on_state_callback", + [], + conf, + callback_args=[(bool, "state")], + condition="state", + ) for conf in config.get(CONF_ON_RELEASE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, + "add_on_state_callback", + [], + conf, + callback_args=[(bool, "state")], + condition="!state", + ) for conf in config.get(CONF_ON_CLICK, []): trigger = cg.new_Pvariable( @@ -593,13 +605,14 @@ async def _build_binary_sensor_automations(var, config): await automation.build_automation(trigger, [], conf) for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(bool, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(bool, "x")], conf + ) for conf in config.get(CONF_ON_STATE_CHANGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, + await automation.build_callback_automation( + var, + "add_full_state_callback", [ (cg.optional.template(bool), "x_previous"), (cg.optional.template(bool), "x"), diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index 12d9ebaba6..b64b549699 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -91,8 +91,9 @@ def button_schema( @setup_entity("button") async def setup_button_core_(var, config): for conf in config.get(CONF_ON_PRESS, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_press_callback", [], conf + ) setup_device_class(config) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 9f3c1484b0..bc2d5d2577 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -898,11 +898,13 @@ async def build_filters(config): @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_sensor_automations(var, config): for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(float, "x")], conf + ) for conf in config.get(CONF_ON_RAW_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) + await automation.build_callback_automation( + var, "add_on_raw_state_callback", [(float, "x")], conf + ) for conf in config.get(CONF_ON_VALUE_RANGE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index bbafc54bd1..bf38763616 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -148,14 +148,27 @@ def switch_schema( @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_switch_automations(var, config): for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(bool, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(bool, "x")], conf + ) for conf in config.get(CONF_ON_TURN_ON, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, + "add_on_state_callback", + [], + conf, + callback_args=[(bool, "state")], + condition="state", + ) for conf in config.get(CONF_ON_TURN_OFF, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, + "add_on_state_callback", + [], + conf, + callback_args=[(bool, "state")], + condition="!state", + ) @setup_entity("switch") diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 97f394ecf7..6f3807ec40 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -204,12 +204,14 @@ async def build_filters(config): @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_text_sensor_automations(var, config): for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(cg.std_string, "x")], conf + ) for conf in config.get(CONF_ON_RAW_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + await automation.build_callback_automation( + var, "add_on_raw_state_callback", [(cg.std_string, "x")], conf + ) @setup_entity("text_sensor") diff --git a/esphome/core/automation.h b/esphome/core/automation.h index ca4a2c8b6b..0002db5ddf 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -470,6 +470,7 @@ template class ActionList { template class Automation { public: + Automation() = default; explicit Automation(Trigger *trigger) : trigger_(trigger) { this->trigger_->set_automation_parent(this); } void add_action(Action *action) { this->actions_.add_action(action); } @@ -487,7 +488,7 @@ template class Automation { int num_running() { return this->actions_.num_running(); } protected: - Trigger *trigger_; + Trigger *trigger_{nullptr}; ActionList actions_; }; From 6c905bd036edc0114a1cf4b25bd4624e122c115e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Mar 2026 14:09:10 -1000 Subject: [PATCH 2/5] Add unit tests for trigger callback lambda generation Tests the lambda output format for all variations used by build_callback_automation: no args, typed args, conditions, multiple args, and empty capture. --- tests/unit_tests/test_automation.py | 94 +++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 61fef8201d..94126bafad 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from esphome.automation import has_non_synchronous_actions +from esphome.cpp_generator import LambdaExpression, MockObj, RawExpression from esphome.util import RegistryEntry @@ -175,3 +176,96 @@ def test_has_non_synchronous_actions_dict_input( """Direct dict input (single action).""" assert has_non_synchronous_actions({"delay": "1s"}) is True assert has_non_synchronous_actions({"logger.log": "hello"}) is False + + +def _build_trigger_lambda( + automation_name: str, + args: list[tuple[str, str]], + callback_args: list[tuple[str, str]] | None = None, + condition: str | None = None, +) -> str: + """Build a trigger callback lambda the same way build_callback_automation does. + + Mirrors the logic in automation.build_callback_automation lines 694-708. + """ + obj = MockObj(automation_name, "->") + # Convert string type names to RawExpression (matching real codegen where + # types are MockObj/MockObjClass objects, not plain strings) + typed_args = [(RawExpression(t), n) for t, n in args] + trigger_args = [MockObj(arg[1], "") for arg in args] + trigger_expr = obj.trigger(*trigger_args) + if condition is not None: + body = [f"if ({condition}) {{ ", trigger_expr, "; }"] + else: + body = [trigger_expr, ";"] + lambda_params = ( + [(RawExpression(t), n) for t, n in callback_args] + if callback_args is not None + else typed_args + ) + lambda_expr = LambdaExpression(body, lambda_params, capture="") + return str(lambda_expr) + + +def test_trigger_callback_lambda_no_args() -> None: + """Button on_press: no args, no condition.""" + result = _build_trigger_lambda("auto_1", []) + assert result == "[]() {\n auto_1->trigger();\n}" + + +def test_trigger_callback_lambda_single_float_arg() -> None: + """Sensor on_value: single float arg.""" + result = _build_trigger_lambda("auto_1", [("float", "x")]) + assert result == "[](float x) {\n auto_1->trigger(x);\n}" + + +def test_trigger_callback_lambda_single_bool_arg() -> None: + """Switch on_state / binary_sensor on_state: single bool arg.""" + result = _build_trigger_lambda("auto_1", [("bool", "x")]) + assert result == "[](bool x) {\n auto_1->trigger(x);\n}" + + +def test_trigger_callback_lambda_condition_true() -> None: + """Binary_sensor on_press: condition filters on state=true.""" + result = _build_trigger_lambda( + "auto_1", + [], + callback_args=[("bool", "state")], + condition="state", + ) + assert result == ("[](bool state) {\n if (state) { auto_1->trigger(); }\n}") + + +def test_trigger_callback_lambda_condition_false() -> None: + """Binary_sensor on_release: condition filters on state=false.""" + result = _build_trigger_lambda( + "auto_1", + [], + callback_args=[("bool", "state")], + condition="!state", + ) + assert result == ("[](bool state) {\n if (!state) { auto_1->trigger(); }\n}") + + +def test_trigger_callback_lambda_multiple_args() -> None: + """Binary_sensor on_state_change: two optional args.""" + result = _build_trigger_lambda( + "auto_1", + [("optional", "x_previous"), ("optional", "x")], + ) + assert result == ( + "[](optional x_previous, optional x) {\n" + " auto_1->trigger(x_previous, x);\n}" + ) + + +def test_trigger_callback_lambda_string_arg() -> None: + """Text_sensor on_value: std::string arg.""" + result = _build_trigger_lambda("auto_1", [("std::string", "x")]) + assert result == "[](std::string x) {\n auto_1->trigger(x);\n}" + + +def test_trigger_callback_lambda_empty_capture() -> None: + """All generated lambdas use empty capture to avoid static storage warnings.""" + result = _build_trigger_lambda("auto_1", [("float", "x")]) + assert result.startswith("[](") From c127cacb9af9d773660620e84a08de8de20e60d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Mar 2026 14:16:30 -1000 Subject: [PATCH 3/5] Use template forwarder structs for callback deduplication Replace per-site lambda generation with TriggerForwarder, TriggerOnTrueForwarder, and TriggerOnFalseForwarder structs. The compiler generates one operator() per forwarder type shared across all call sites, avoiding flash duplication from unique lambdas. Also cleans up API: replaces condition/callback_args with bool_filter using TRIGGER_ON_TRUE/TRIGGER_ON_FALSE constants. --- esphome/automation.py | 50 ++++---- esphome/components/binary_sensor/__init__.py | 6 +- esphome/components/switch/__init__.py | 6 +- esphome/core/automation.h | 25 ++++ tests/unit_tests/test_automation.py | 121 ++++++++----------- 5 files changed, 109 insertions(+), 99 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index afa4ac3787..113b30ccf2 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -137,6 +137,9 @@ UpdateComponentAction = cg.esphome_ns.class_("UpdateComponentAction", Action) SuspendComponentAction = cg.esphome_ns.class_("SuspendComponentAction", Action) ResumeComponentAction = cg.esphome_ns.class_("ResumeComponentAction", Action) Automation = cg.esphome_ns.class_("Automation") +TriggerForwarder = cg.esphome_ns.class_("TriggerForwarder") +TriggerOnTrueForwarder = cg.esphome_ns.class_("TriggerOnTrueForwarder") +TriggerOnFalseForwarder = cg.esphome_ns.class_("TriggerOnFalseForwarder") LambdaCondition = cg.esphome_ns.class_("LambdaCondition", Condition) StatelessLambdaCondition = cg.esphome_ns.class_("StatelessLambdaCondition", Condition) @@ -663,46 +666,51 @@ async def build_automation( return obj +TRIGGER_ON_TRUE = "on_true" +TRIGGER_ON_FALSE = "on_false" + + async def build_callback_automation( parent: MockObj, callback_method: str, args: TemplateArgsType, config: ConfigType, - callback_args: TemplateArgsType | None = None, - condition: str | None = None, + bool_filter: str | None = None, ) -> None: """Build an Automation and register it as a callback on the parent. Eliminates the need for a Trigger wrapper object by registering the automation's trigger() directly as a callback on the parent component. + Uses template forwarder structs (TriggerForwarder, TriggerOnTrueForwarder, + TriggerOnFalseForwarder) so the compiler deduplicates the operator() body + across all call sites with the same signature. + :param parent: The component object (e.g., button, sensor). :param callback_method: Name of the callback method (e.g., "add_on_press_callback"). :param args: Automation template args as list of (type, name) tuples. :param config: The automation config dict. - :param callback_args: Lambda parameter types if different from args (e.g., for - conditional triggers where the callback receives (bool state) but the - automation is Automation<> with no args). Defaults to args. - :param condition: Optional C++ condition. Use callback arg names directly - (e.g., "state", "!state"). + :param bool_filter: Optional bool filter. Use TRIGGER_ON_TRUE to trigger only + when the bool callback arg is true, TRIGGER_ON_FALSE for false. + The automation will be Automation<> (no args) while the callback receives bool. """ arg_types = [arg[0] for arg in args] templ = cg.TemplateArguments(*arg_types) obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ) actions = await build_action_list(config[CONF_THEN], templ, args) cg.add(obj.add_actions(actions)) - # Build trigger call expression: automation->trigger(arg1, arg2, ...) - trigger_args = [MockObj(arg[1], "") for arg in args] - trigger_expr = obj.trigger(*trigger_args) - if condition is not None: - body = [f"if ({condition}) {{ ", trigger_expr, "; }"] + # Use template forwarder structs for deduplication. The compiler generates + # one operator() per forwarder type; different automation pointers are just + # data in the struct. + if bool_filter == TRIGGER_ON_TRUE: + forwarder = cg.RawExpression(f"{TriggerOnTrueForwarder}{{{obj}}}") + elif bool_filter == TRIGGER_ON_FALSE: + forwarder = cg.RawExpression(f"{TriggerOnFalseForwarder}{{{obj}}}") else: - body = [trigger_expr, ";"] - # Use callback_args for the lambda parameters if provided (e.g., when the - # callback signature differs from the automation args due to filtering). - lambda_params = callback_args if callback_args is not None else args - # ESPHome codegen allocates all variables as static pointers, so they - # are accessible without explicit lambda capture. Using "" avoids - # -Wcapture-of-non-automatic-storage-duration warnings. - lambda_expr = LambdaExpression(body, lambda_params, capture="") - cg.add(getattr(parent, callback_method)(lambda_expr)) + forwarder_type = ( + TriggerForwarder.template(templ) + if arg_types + else TriggerForwarder.template() + ) + forwarder = cg.RawExpression(f"{forwarder_type}{{{obj}}}") + cg.add(getattr(parent, callback_method)(forwarder)) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index b9ca005d9f..06ff70f0fb 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -562,8 +562,7 @@ async def _build_binary_sensor_automations(var, config): "add_on_state_callback", [], conf, - callback_args=[(bool, "state")], - condition="state", + bool_filter=automation.TRIGGER_ON_TRUE, ) for conf in config.get(CONF_ON_RELEASE, []): @@ -572,8 +571,7 @@ async def _build_binary_sensor_automations(var, config): "add_on_state_callback", [], conf, - callback_args=[(bool, "state")], - condition="!state", + bool_filter=automation.TRIGGER_ON_FALSE, ) for conf in config.get(CONF_ON_CLICK, []): diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index bf38763616..1a370ccc3d 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -157,8 +157,7 @@ async def _build_switch_automations(var, config): "add_on_state_callback", [], conf, - callback_args=[(bool, "state")], - condition="state", + bool_filter=automation.TRIGGER_ON_TRUE, ) for conf in config.get(CONF_ON_TURN_OFF, []): await automation.build_callback_automation( @@ -166,8 +165,7 @@ async def _build_switch_automations(var, config): "add_on_state_callback", [], conf, - callback_args=[(bool, "state")], - condition="!state", + bool_filter=automation.TRIGGER_ON_FALSE, ) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 0002db5ddf..021e9ec68e 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -492,4 +492,29 @@ template class Automation { ActionList actions_; }; +/// Callback forwarder that triggers an Automation directly. +/// One operator() instantiation per Automation signature, shared across all call sites. +template struct TriggerForwarder { + Automation *automation; + void operator()(Ts... args) const { this->automation->trigger(args...); } +}; + +/// Callback forwarder that triggers an Automation<> only when the bool arg is true. +struct TriggerOnTrueForwarder { + Automation<> *automation; + void operator()(bool state) const { + if (state) + this->automation->trigger(); + } +}; + +/// Callback forwarder that triggers an Automation<> only when the bool arg is false. +struct TriggerOnFalseForwarder { + Automation<> *automation; + void operator()(bool state) const { + if (!state) + this->automation->trigger(); + } +}; + } // namespace esphome diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 94126bafad..c5a2d9852d 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -5,8 +5,15 @@ from unittest.mock import patch import pytest -from esphome.automation import has_non_synchronous_actions -from esphome.cpp_generator import LambdaExpression, MockObj, RawExpression +from esphome.automation import ( + TRIGGER_ON_FALSE, + TRIGGER_ON_TRUE, + TriggerForwarder, + TriggerOnFalseForwarder, + TriggerOnTrueForwarder, + has_non_synchronous_actions, +) +from esphome.cpp_generator import MockObj, RawExpression from esphome.util import RegistryEntry @@ -178,94 +185,68 @@ def test_has_non_synchronous_actions_dict_input( assert has_non_synchronous_actions({"logger.log": "hello"}) is False -def _build_trigger_lambda( +def _build_forwarder( automation_name: str, args: list[tuple[str, str]], - callback_args: list[tuple[str, str]] | None = None, - condition: str | None = None, + bool_filter: str | None = None, ) -> str: - """Build a trigger callback lambda the same way build_callback_automation does. + """Build a trigger forwarder expression the same way build_callback_automation does. - Mirrors the logic in automation.build_callback_automation lines 694-708. + Mirrors the forwarder selection logic in automation.build_callback_automation. """ + import esphome.codegen as cg + obj = MockObj(automation_name, "->") - # Convert string type names to RawExpression (matching real codegen where - # types are MockObj/MockObjClass objects, not plain strings) - typed_args = [(RawExpression(t), n) for t, n in args] - trigger_args = [MockObj(arg[1], "") for arg in args] - trigger_expr = obj.trigger(*trigger_args) - if condition is not None: - body = [f"if ({condition}) {{ ", trigger_expr, "; }"] - else: - body = [trigger_expr, ";"] - lambda_params = ( - [(RawExpression(t), n) for t, n in callback_args] - if callback_args is not None - else typed_args - ) - lambda_expr = LambdaExpression(body, lambda_params, capture="") - return str(lambda_expr) + if bool_filter == TRIGGER_ON_TRUE: + return f"{TriggerOnTrueForwarder}{{{obj}}}" + if bool_filter == TRIGGER_ON_FALSE: + return f"{TriggerOnFalseForwarder}{{{obj}}}" + arg_types = [RawExpression(t) for t, _ in args] + templ = cg.TemplateArguments(*arg_types) if arg_types else cg.TemplateArguments() + forwarder_type = TriggerForwarder.template(templ) + return f"{forwarder_type}{{{obj}}}" -def test_trigger_callback_lambda_no_args() -> None: - """Button on_press: no args, no condition.""" - result = _build_trigger_lambda("auto_1", []) - assert result == "[]() {\n auto_1->trigger();\n}" +def test_trigger_forwarder_no_args() -> None: + """Button on_press: TriggerForwarder<> with no args.""" + result = _build_forwarder("auto_1", []) + assert result == "TriggerForwarder<>{auto_1}" -def test_trigger_callback_lambda_single_float_arg() -> None: - """Sensor on_value: single float arg.""" - result = _build_trigger_lambda("auto_1", [("float", "x")]) - assert result == "[](float x) {\n auto_1->trigger(x);\n}" +def test_trigger_forwarder_single_float_arg() -> None: + """Sensor on_value: TriggerForwarder.""" + result = _build_forwarder("auto_1", [("float", "x")]) + assert result == "TriggerForwarder{auto_1}" -def test_trigger_callback_lambda_single_bool_arg() -> None: - """Switch on_state / binary_sensor on_state: single bool arg.""" - result = _build_trigger_lambda("auto_1", [("bool", "x")]) - assert result == "[](bool x) {\n auto_1->trigger(x);\n}" +def test_trigger_forwarder_single_bool_arg() -> None: + """Switch on_state: TriggerForwarder.""" + result = _build_forwarder("auto_1", [("bool", "x")]) + assert result == "TriggerForwarder{auto_1}" -def test_trigger_callback_lambda_condition_true() -> None: - """Binary_sensor on_press: condition filters on state=true.""" - result = _build_trigger_lambda( - "auto_1", - [], - callback_args=[("bool", "state")], - condition="state", - ) - assert result == ("[](bool state) {\n if (state) { auto_1->trigger(); }\n}") +def test_trigger_forwarder_on_true() -> None: + """Binary_sensor on_press / switch on_turn_on: TriggerOnTrueForwarder.""" + result = _build_forwarder("auto_1", [], bool_filter=TRIGGER_ON_TRUE) + assert result == "TriggerOnTrueForwarder{auto_1}" -def test_trigger_callback_lambda_condition_false() -> None: - """Binary_sensor on_release: condition filters on state=false.""" - result = _build_trigger_lambda( - "auto_1", - [], - callback_args=[("bool", "state")], - condition="!state", - ) - assert result == ("[](bool state) {\n if (!state) { auto_1->trigger(); }\n}") +def test_trigger_forwarder_on_false() -> None: + """Binary_sensor on_release / switch on_turn_off: TriggerOnFalseForwarder.""" + result = _build_forwarder("auto_1", [], bool_filter=TRIGGER_ON_FALSE) + assert result == "TriggerOnFalseForwarder{auto_1}" -def test_trigger_callback_lambda_multiple_args() -> None: - """Binary_sensor on_state_change: two optional args.""" - result = _build_trigger_lambda( +def test_trigger_forwarder_multiple_args() -> None: + """Binary_sensor on_state_change: TriggerForwarder with two args.""" + result = _build_forwarder( "auto_1", [("optional", "x_previous"), ("optional", "x")], ) - assert result == ( - "[](optional x_previous, optional x) {\n" - " auto_1->trigger(x_previous, x);\n}" - ) + assert result == "TriggerForwarder, optional>{auto_1}" -def test_trigger_callback_lambda_string_arg() -> None: - """Text_sensor on_value: std::string arg.""" - result = _build_trigger_lambda("auto_1", [("std::string", "x")]) - assert result == "[](std::string x) {\n auto_1->trigger(x);\n}" - - -def test_trigger_callback_lambda_empty_capture() -> None: - """All generated lambdas use empty capture to avoid static storage warnings.""" - result = _build_trigger_lambda("auto_1", [("float", "x")]) - assert result.startswith("[](") +def test_trigger_forwarder_string_arg() -> None: + """Text_sensor on_value: TriggerForwarder.""" + result = _build_forwarder("auto_1", [("std::string", "x")]) + assert result == "TriggerForwarder{auto_1}" From 9be87f66c383ee3b872035c0149c34ceecc413c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Mar 2026 14:23:35 -1000 Subject: [PATCH 4/5] Accept custom forwarder types, migrate number and lock Replace bool_filter with generic forwarder parameter that accepts any struct type. Components can define their own forwarders with custom fields (e.g., LockStateForwarder needs both automation and lock entity pointers). Migrates number (NumberStateTrigger) and lock (LockStateTrigger) to prove the API is flexible enough for diverse patterns. --- esphome/automation.py | 41 +++++++++++--------- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/lock/__init__.py | 21 ++++++++-- esphome/components/lock/automation.h | 10 +++++ esphome/components/number/__init__.py | 5 ++- esphome/components/switch/__init__.py | 4 +- tests/unit_tests/test_automation.py | 36 ++++++++++------- 7 files changed, 79 insertions(+), 42 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 113b30ccf2..f65c45b2ea 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -666,33 +666,33 @@ async def build_automation( return obj -TRIGGER_ON_TRUE = "on_true" -TRIGGER_ON_FALSE = "on_false" - - async def build_callback_automation( parent: MockObj, callback_method: str, args: TemplateArgsType, config: ConfigType, - bool_filter: str | None = None, + forwarder: MockObjClass | None = None, + forwarder_extra_args: list | None = None, ) -> None: """Build an Automation and register it as a callback on the parent. Eliminates the need for a Trigger wrapper object by registering the automation's trigger() directly as a callback on the parent component. - Uses template forwarder structs (TriggerForwarder, TriggerOnTrueForwarder, - TriggerOnFalseForwarder) so the compiler deduplicates the operator() body - across all call sites with the same signature. + Uses template forwarder structs so the compiler deduplicates the operator() + body across all call sites with the same signature. :param parent: The component object (e.g., button, sensor). :param callback_method: Name of the callback method (e.g., "add_on_press_callback"). :param args: Automation template args as list of (type, name) tuples. :param config: The automation config dict. - :param bool_filter: Optional bool filter. Use TRIGGER_ON_TRUE to trigger only - when the bool callback arg is true, TRIGGER_ON_FALSE for false. - The automation will be Automation<> (no args) while the callback receives bool. + :param forwarder: Optional forwarder type to use instead of the default + TriggerForwarder. Pass any struct type whose aggregate init takes + an Automation pointer as the first field (e.g., TriggerOnTrueForwarder, + or a custom component-defined forwarder). + :param forwarder_extra_args: Optional list of extra MockObj args to pass to the + forwarder after the automation pointer in aggregate init. For example, + a lock forwarder needs the lock entity pointer: [lock_var]. """ arg_types = [arg[0] for arg in args] templ = cg.TemplateArguments(*arg_types) @@ -702,15 +702,18 @@ async def build_callback_automation( # Use template forwarder structs for deduplication. The compiler generates # one operator() per forwarder type; different automation pointers are just # data in the struct. - if bool_filter == TRIGGER_ON_TRUE: - forwarder = cg.RawExpression(f"{TriggerOnTrueForwarder}{{{obj}}}") - elif bool_filter == TRIGGER_ON_FALSE: - forwarder = cg.RawExpression(f"{TriggerOnFalseForwarder}{{{obj}}}") - else: - forwarder_type = ( + if forwarder is None: + forwarder = ( TriggerForwarder.template(templ) if arg_types else TriggerForwarder.template() ) - forwarder = cg.RawExpression(f"{forwarder_type}{{{obj}}}") - cg.add(getattr(parent, callback_method)(forwarder)) + init_args = str(obj) + if forwarder_extra_args: + extra = ", ".join(str(a) for a in forwarder_extra_args) + init_args = f"{init_args}, {extra}" + cg.add( + getattr(parent, callback_method)( + cg.RawExpression(f"{forwarder}{{{init_args}}}") + ) + ) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 06ff70f0fb..4ae3f24cb5 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -562,7 +562,7 @@ async def _build_binary_sensor_automations(var, config): "add_on_state_callback", [], conf, - bool_filter=automation.TRIGGER_ON_TRUE, + forwarder=automation.TriggerOnTrueForwarder, ) for conf in config.get(CONF_ON_RELEASE, []): @@ -571,7 +571,7 @@ async def _build_binary_sensor_automations(var, config): "add_on_state_callback", [], conf, - bool_filter=automation.TRIGGER_ON_FALSE, + forwarder=automation.TriggerOnFalseForwarder, ) for conf in config.get(CONF_ON_CLICK, []): diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index fe4db23ae3..b4a5621e3e 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -35,6 +35,7 @@ LockLockTrigger = lock_ns.class_("LockLockTrigger", automation.Trigger.template( LockUnlockTrigger = lock_ns.class_("LockUnlockTrigger", automation.Trigger.template()) LockState = lock_ns.enum("LockState") +LockStateForwarder = lock_ns.class_("LockStateForwarder") LOCK_STATES = { "LOCKED": LockState.LOCK_STATE_LOCKED, @@ -94,11 +95,23 @@ def lock_schema( @setup_entity("lock") async def _setup_lock_core(var, config): for conf in config.get(CONF_ON_LOCK, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, + "add_on_state_callback", + [], + conf, + forwarder=LockStateForwarder.template(LockState.LOCK_STATE_LOCKED), + forwarder_extra_args=[var], + ) for conf in config.get(CONF_ON_UNLOCK, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, + "add_on_state_callback", + [], + conf, + forwarder=LockStateForwarder.template(LockState.LOCK_STATE_UNLOCKED), + forwarder_extra_args=[var], + ) if mqtt_id := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index 6f3c422693..c150d2b3a8 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -66,4 +66,14 @@ template class LockStateTrigger : public Trigger<> { using LockLockTrigger = LockStateTrigger; using LockUnlockTrigger = LockStateTrigger; +/// Forwarder that triggers an Automation<> when a Lock reaches a specific state. +template struct LockStateForwarder { + Automation<> *automation; + Lock *lock; + void operator()() const { + if (this->lock->state == State) + this->automation->trigger(); + } +}; + } // namespace esphome::lock diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 0570ac0b1e..7ee60b637a 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -248,8 +248,9 @@ def number_schema( @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_number_automations(var, config): for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(float, "x")], conf + ) for conf in config.get(CONF_ON_VALUE_RANGE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 1a370ccc3d..28ed329768 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -157,7 +157,7 @@ async def _build_switch_automations(var, config): "add_on_state_callback", [], conf, - bool_filter=automation.TRIGGER_ON_TRUE, + forwarder=automation.TriggerOnTrueForwarder, ) for conf in config.get(CONF_ON_TURN_OFF, []): await automation.build_callback_automation( @@ -165,7 +165,7 @@ async def _build_switch_automations(var, config): "add_on_state_callback", [], conf, - bool_filter=automation.TRIGGER_ON_FALSE, + forwarder=automation.TriggerOnFalseForwarder, ) diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index c5a2d9852d..e33c3ad84e 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -6,8 +6,6 @@ from unittest.mock import patch import pytest from esphome.automation import ( - TRIGGER_ON_FALSE, - TRIGGER_ON_TRUE, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, @@ -188,7 +186,8 @@ def test_has_non_synchronous_actions_dict_input( def _build_forwarder( automation_name: str, args: list[tuple[str, str]], - bool_filter: str | None = None, + forwarder: MockObj | None = None, + extra_args: list[str] | None = None, ) -> str: """Build a trigger forwarder expression the same way build_callback_automation does. @@ -197,14 +196,16 @@ def _build_forwarder( import esphome.codegen as cg obj = MockObj(automation_name, "->") - if bool_filter == TRIGGER_ON_TRUE: - return f"{TriggerOnTrueForwarder}{{{obj}}}" - if bool_filter == TRIGGER_ON_FALSE: - return f"{TriggerOnFalseForwarder}{{{obj}}}" - arg_types = [RawExpression(t) for t, _ in args] - templ = cg.TemplateArguments(*arg_types) if arg_types else cg.TemplateArguments() - forwarder_type = TriggerForwarder.template(templ) - return f"{forwarder_type}{{{obj}}}" + if forwarder is None: + arg_types = [RawExpression(t) for t, _ in args] + templ = ( + cg.TemplateArguments(*arg_types) if arg_types else cg.TemplateArguments() + ) + forwarder = TriggerForwarder.template(templ) + init_args = str(obj) + if extra_args: + init_args += ", " + ", ".join(extra_args) + return f"{forwarder}{{{init_args}}}" def test_trigger_forwarder_no_args() -> None: @@ -227,13 +228,13 @@ def test_trigger_forwarder_single_bool_arg() -> None: def test_trigger_forwarder_on_true() -> None: """Binary_sensor on_press / switch on_turn_on: TriggerOnTrueForwarder.""" - result = _build_forwarder("auto_1", [], bool_filter=TRIGGER_ON_TRUE) + result = _build_forwarder("auto_1", [], forwarder=TriggerOnTrueForwarder) assert result == "TriggerOnTrueForwarder{auto_1}" def test_trigger_forwarder_on_false() -> None: """Binary_sensor on_release / switch on_turn_off: TriggerOnFalseForwarder.""" - result = _build_forwarder("auto_1", [], bool_filter=TRIGGER_ON_FALSE) + result = _build_forwarder("auto_1", [], forwarder=TriggerOnFalseForwarder) assert result == "TriggerOnFalseForwarder{auto_1}" @@ -250,3 +251,12 @@ def test_trigger_forwarder_string_arg() -> None: """Text_sensor on_value: TriggerForwarder.""" result = _build_forwarder("auto_1", [("std::string", "x")]) assert result == "TriggerForwarder{auto_1}" + + +def test_trigger_forwarder_custom_with_extra_args() -> None: + """Lock on_lock: custom forwarder with extra args for entity pointer.""" + lock_forwarder = MockObj("LockStateForwarder", "") + result = _build_forwarder( + "auto_1", [], forwarder=lock_forwarder, extra_args=["lock_var"] + ) + assert result == "LockStateForwarder{auto_1, lock_var}" From ee1da106142eb973ece2376d9d2106f0ce438ade Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Mar 2026 14:32:51 -1000 Subject: [PATCH 5/5] Remove unused trigger_ back-pointer from Automation The trigger_ field was only set in the constructor and never read afterward. Removing it saves 4 bytes per Automation instance. --- esphome/core/automation.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 021e9ec68e..6e1abcef55 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -471,7 +471,7 @@ template class ActionList { template class Automation { public: Automation() = default; - explicit Automation(Trigger *trigger) : trigger_(trigger) { this->trigger_->set_automation_parent(this); } + explicit Automation(Trigger *trigger) { trigger->set_automation_parent(this); } void add_action(Action *action) { this->actions_.add_action(action); } void add_actions(const std::initializer_list *> &actions) { this->actions_.add_actions(actions); } @@ -488,7 +488,6 @@ template class Automation { int num_running() { return this->actions_.num_running(); } protected: - Trigger *trigger_{nullptr}; ActionList actions_; };