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.
This commit is contained in:
J. Nick Koston
2026-03-25 14:23:35 -10:00
parent c127cacb9a
commit 9be87f66c3
7 changed files with 79 additions and 42 deletions
+22 -19
View File
@@ -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<Ts...>. 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}}}")
)
)
+2 -2
View File
@@ -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, []):
+17 -4
View File
@@ -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)
+10
View File
@@ -66,4 +66,14 @@ template<LockState State> class LockStateTrigger : public Trigger<> {
using LockLockTrigger = LockStateTrigger<LockState::LOCK_STATE_LOCKED>;
using LockUnlockTrigger = LockStateTrigger<LockState::LOCK_STATE_UNLOCKED>;
/// Forwarder that triggers an Automation<> when a Lock reaches a specific state.
template<LockState State> struct LockStateForwarder {
Automation<> *automation;
Lock *lock;
void operator()() const {
if (this->lock->state == State)
this->automation->trigger();
}
};
} // namespace esphome::lock
+3 -2
View File
@@ -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)
+2 -2
View File
@@ -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,
)
+23 -13
View File
@@ -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<std::string>."""
result = _build_forwarder("auto_1", [("std::string", "x")])
assert result == "TriggerForwarder<std::string>{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<LOCK_STATE_LOCKED>", "")
result = _build_forwarder(
"auto_1", [], forwarder=lock_forwarder, extra_args=["lock_var"]
)
assert result == "LockStateForwarder<LOCK_STATE_LOCKED>{auto_1, lock_var}"