From 9be87f66c383ee3b872035c0149c34ceecc413c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Mar 2026 14:23:35 -1000 Subject: [PATCH] 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}"