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}"