Skip to content

Implementing Automations

ESPHome automations consist of three building blocks:

  • Triggers fire when something happens (state change, button press, etc.)
  • Actions do something (set a value, call a service, etc.)
  • Conditions check whether something is true (is the component on? is the value above a threshold?)

This page covers how to implement all three in a component. For a minimal working example, see the empty_automation starter component.

NOTE

Mark primitives final

The Action, Trigger, and Condition subclasses shown below are leaf classes — nothing in ESPHome derives from them — so they are marked final. See Marking leaf classes as final for the rationale.

NOTE

All Python examples below assume the standard ESPHome imports are present: esphome.codegen as cg, esphome.config_validation as cv, relevant constants from esphome.const, and typing imports (ConfigType from esphome.types, MockObj from esphome.cpp_generator, TemplateArgsType from esphome.automation).

There are two ways to connect a trigger to an automation. Use the callback method (preferred) for simple cases where the forwarder only needs a single pointer. Use the Trigger class method when the forwarder needs extra state beyond a single pointer.

The callback method eliminates a separate Trigger C++ class entirely. Instead, a lightweight forwarder struct is registered directly as a callback on the parent component. The forwarder must be pointer-sized (a single Automation* field) so it fits inline in Callback::ctx_ without heap allocation.

Built-in forwarders in esphome/core/automation.h:

ForwarderCallback signatureBehavior
TriggerForwarder<Ts...>void(const Ts&...)Forwards all args to Automation<Ts...>::trigger()
TriggerOnTrueForwardervoid(bool)Triggers Automation<> only when true
TriggerOnFalseForwardervoid(bool)Triggers Automation<> only when false

No trigger class declaration is needed. The schema uses an empty dict:

from esphome import automation
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
}).extend(cv.COMPONENT_SCHEMA)

In to_code, use build_callback_automation:

async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
for conf in config.get(CONF_ON_STATE, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [(bool, "x")], conf
)

The arguments to build_callback_automation:

  1. parent — the component variable
  2. callback_method — name of the C++ method to register the callback (e.g. "add_on_state_callback")
  3. args — template args as [(type, name)] tuples, exposed as variables in the user’s then: block. This controls the Automation<Ts...> template parameters, not the callback signature. When using a custom forwarder, the forwarder’s operator() signature must match the callback, but args can differ (e.g. args=[] with TriggerOnTrueForwarder which receives bool but triggers Automation<>)
  4. config — the automation config dict
  5. forwarder (optional) — override the default TriggerForwarder<Ts...>
  6. params, forward, when (optional) — reshape or filter the callback arguments, see Different callback arguments or a filter; they cannot be combined with forwarder

For boolean filtering (e.g. on_press / on_release on a void(bool) callback), pass a forwarder. Note that the forwarder receives the bool from the callback but triggers Automation<> with no args:

for conf_key, forwarder in (
(CONF_ON_PRESS, automation.TriggerOnTrueForwarder),
(CONF_ON_RELEASE, automation.TriggerOnFalseForwarder),
):
for conf in config.get(conf_key, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [], conf, forwarder=forwarder
)

Several callback automations on one parent go in a module-level _CALLBACK_AUTOMATIONS tuple; each CallbackAutomation entry takes the config key, the method name, the args and, optionally, the same forwarder, params, forward and when keywords:

_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_CONNECT, "set_on_connect", [(cg.bool_, "session_present")]
),
automation.CallbackAutomation(
CONF_ON_DISCONNECT, "set_on_disconnect", [(MQTTClientDisconnectReason, "reason")]
),
)
async def to_code(config: ConfigType) -> None:
...
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)

No C++ trigger class is needed — the component just needs the callback registration method:

class MyComponent : public Component {
public:
// Templatized to accept both std::function and lightweight forwarder structs
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
protected:
CallbackManager<void(bool)> state_callback_;
};

When the state changes, call the callback:

void MyComponent::publish_state(bool value) {
this->state_ = value;
this->state_callback_.call(value);
}

For state-specific filtering (e.g. trigger only when entering a particular enum state), define a custom forwarder in the component’s automation.h. The forwarder receives the enum value as a callback argument and only triggers when it matches the compile-time template parameter:

#include "esphome/core/automation.h"
// Must be pointer-sized (single Automation* field) to avoid heap allocation.
template<MyState State> struct StateEnterForwarder {
Automation<> *automation;
void operator()(MyState state) const {
if (state == State)
this->automation->trigger();
}
};
static_assert(sizeof(StateEnterForwarder<MY_STATE_ACTIVE>) <= sizeof(void *));
static_assert(std::is_trivially_copyable_v<StateEnterForwarder<MY_STATE_ACTIVE>>);

Then in Python:

StateEnterForwarder = my_ns.class_("StateEnterForwarder")
# In to_code:
await automation.build_callback_automation(
var, "add_on_state_callback", [], conf,
forwarder=StateEnterForwarder.template(cg.RawExpression("MY_STATE_ACTIVE")),
)

When the parent callback’s parameters are not the automation’s arguments, or the trigger should only fire for some values, pass params, forward and when to build_callback_automation. The helper then generates a capture-less lambda instead of a forwarder struct; being empty, the lambda is stored inline by Callback and std::function alike, so nothing is allocated and no Trigger class is needed.

  • params are the parent callback’s parameters as [(type, name)], rendered as const T &.
  • forward are the expressions passed to trigger(); the default is the parameter names. Write the parent as automation.parent_ref(var), which names it from global scope so a parameter cannot shadow it, and compose lookups on it with MockObj calls rather than f-strings of C++.
  • when is a filter the callback returns early on: a plain string, or an ApplyCall that compares against config values as conditions do. An ApplyCall whose config keys are absent is skipped, so an optional filter needs no if. The filter names the parent as {parent}, see Filters on the parent’s state.

A select’s callback carries only the index, while on_value exposes the option text and the index:

parent = automation.parent_ref(var)
index = cg.RawExpression("index")
await automation.build_callback_automation(
var,
"add_on_state_callback",
[(cg.StringRef, "x"), (cg.size_t, "i")],
conf,
params=[(cg.size_t, "index")],
forward=[cg.StringRef(parent.option_at(index)), index],
)

Generated:

select_test_select->add_on_state_callback([](const std::remove_cvref_t<size_t> & index) -> void {
::automation_id->trigger(StringRef(::select_test_select->option_at(index)), index);
});

A filter that checks the parent writes it as {parent}, which renders as the parent named from global scope. The entry then needs nothing from to_code and sits in the module-level table. Valve on_open and on_closed fire only in their end state:

_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_OPEN, "add_on_state_callback", when="{parent}->is_fully_open()"
),
automation.CallbackAutomation(
CONF_ON_CLOSED, "add_on_state_callback", when="{parent}->is_fully_closed()"
),
)

Generated for a valve with the id the_valve:

the_valve->add_on_state_callback([]() -> void {
if (!(::the_valve->is_fully_open()))
return;
::automation_id->trigger();
});

A filter that mixes the callback’s parameters and the parent is one expression, for example "state == improv::STATE_STOPPED && !{parent}->is_failed()". build_trigger_callback has no parent unless it is given one with parent=, and a filter that names {parent} without it is rejected.

A callback that carries nothing while the automation receives the parent (fan on_state, the display menu triggers) is one line. The third argument is a single (type, name) tuple for the automation’s one argument, not a list:

await automation.build_parent_callback_automation(
var, "add_on_state_callback", (Fan.operator("ptr"), "x"), conf
)

Generated for a fan with the id the_fan:

the_fan->add_on_state_callback([]() -> void {
::automation_id->trigger(::the_fan);
});

When the registration takes extra arguments, use build_trigger_callback with the same keywords. It builds the automation and returns the lambda, and the component writes the registration call itself, so constant arguments such as a topic and qos are ordinary call arguments. mqtt.on_message with its optional payload filter:

for conf in config.get(CONF_ON_MESSAGE, []):
callback = await automation.build_trigger_callback(
[(cg.std_string, "x")],
conf,
params=[(cg.std_string, "topic"), (cg.std_string, "payload")],
forward=["payload"],
when=automation.ApplyCall(
"StringRef(payload) == {}",
((CONF_PAYLOAD, cg.std_string, automation.string_ref_literal),),
),
)
cg.add(var.subscribe(cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS]))

Generated for payload: "ON" on ESP8266, where cg.progmem_string keeps the topic literal in flash and string_ref_literal renders the payload as a flash literal (StringRef("ON", 2) on other platforms):

mqtt_client->subscribe(progmem_string(ESPHOME_F("livingroom/ota_mode")), [](const std::remove_cvref_t<std::string> & topic, const std::remove_cvref_t<std::string> & payload) -> void {
if (!(StringRef(payload) == ESPHOME_F("ON")))
return;
::automation_id->trigger(payload);
}, 1);

Use this when the trigger needs mutable state beyond a single Automation* pointer (e.g. tracked previous state for edge detection, or timing logic). A forwarder struct must be pointer-sized with only an Automation* field, so any additional state requires a full Trigger subclass.

Declare the trigger class and reference it in the schema:

TurnOnTrigger = my_ns.class_(
"TurnOnTrigger", automation.Trigger.template()
)
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger),
}
),
}).extend(cv.COMPONENT_SCHEMA)

In to_code, use build_automation:

async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
for conf in config.get(CONF_ON_TURN_ON, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)

Several such triggers on one parent go in a module-level _TRIGGER_AUTOMATIONS tuple of (conf_key, args) pairs. build_trigger_automations instantiates each entry’s class from its CONF_TRIGGER_ID, passing var to the constructor (nothing when var is None), and builds the automations:

_TRIGGER_AUTOMATIONS = (
(CONF_ON_TURN_ON, []),
(CONF_ON_TURN_OFF, []),
(CONF_ON_SPEED_SET, [(cg.int_, "x")]),
)
async def to_code(config: ConfigType) -> None:
...
await automation.build_trigger_automations(var, config, _TRIGGER_AUTOMATIONS)

Define the trigger class in automation.h. This example fires only on the off-to-on transition, requiring mutable state (last_on_) that cannot be stored in a pointer-sized forwarder:

class TurnOnTrigger final : public Trigger<> {
public:
explicit TurnOnTrigger(MyComponent *parent) : parent_(parent) {
parent->add_on_state_callback([this](bool state) {
if (state && !this->last_on_)
this->trigger();
this->last_on_ = state;
});
this->last_on_ = parent->get_state();
}
protected:
MyComponent *parent_;
bool last_on_;
};

The trigger needs to track mutable state (last_on_) across callback invocations. A forwarder struct must contain only a single Automation* field — it cannot store extra state. When the trigger logic requires additional fields, use a Trigger subclass instead. The Trigger base class manages the connection to the Automation object.

Use this when the component itself decides when an event fires and passes the values, for example a keypad hub that fires on_key with the key code, and nothing else listens for that event. The hub keeps an AutomationList sized at build time and triggers it directly: each automation costs one pointer-sized slot (4 bytes on 32-bit chips), there is no Trigger object and no heap vector, and an unused list compiles out.

List each event once with its count define and trigger arguments, then call build_automation_lists:

_AUTOMATION_LISTS = (
automation.AutomationList(CONF_ON_KEY, "MY_HUB_ON_KEY_COUNT", [(cg.uint8, "x")]),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await automation.build_automation_lists(var, config, _AUTOMATION_LISTS)

The schema uses plain automation.validate_automation() with no trigger class. For each automation, the helper calls add_<conf_key>_automation(automation) on the hub (add_on_key_automation above). The count define is the largest number of automations any one hub instance has for that event; it is not emitted when no hub uses the event. Add each define to esphome/core/defines.h for static analysis.

class MyHub : public Component {
public:
#ifdef MY_HUB_ON_KEY_COUNT
void add_on_key_automation(Automation<uint8_t> *automation) { this->key_automations_.add(automation); }
#endif
protected:
void on_key_(uint8_t key) {
#ifdef MY_HUB_ON_KEY_COUNT
this->key_automations_.trigger(key);
#endif
}
#ifdef MY_HUB_ON_KEY_COUNT
AutomationList<MY_HUB_ON_KEY_COUNT, uint8_t> key_automations_;
#endif
};

trigger() runs every automation in the order they were added. It shares one copy of its loop between lists of different sizes with the same arguments. When building an argument costs something, such as formatting a string, check empty() first and build it once per event, not once per automation.

Prefer a callback (above) when the parent already has a callback list for the event, or when other code listens to it too.

When each automation needs its own stored value next to it, such as a threshold the hub compares against, build the automations one at a time with automation.new_automation(args, conf) and pass each one to the hub’s own setter with that value. It returns the Automation<args> with its actions and no trigger.

ScenarioMethodExample
Simple forwardingCallbackbutton on_press — TriggerForwarder<> forwards directly
Boolean filteringCallback with built-in forwarderbinary_sensor on_press/on_release — TriggerOnTrueForwarder / TriggerOnFalseForwarder
Enum state filteringCallback with custom forwarderlock on_lock/on_unlock — LockStateForwarder<State> checks enum, single pointer (esphome/esphome#15199, pending)
Different callback arguments or a constant filterCallback with params, forward, whenselect on_value — forwards the option text looked up from the index
Callback carries nothing, automation gets the parentbuild_parent_callback_automationfan on_state — forwards ::the_fan
Registration takes extra argumentsbuild_trigger_callbackmqtt on_message — the component passes the lambda to subscribe(topic, callback, qos), filtered on the configured payload
Hub fires the event itself, no other listenersAutomationListwiegand on_key — AutomationList<WIEGAND_ON_KEY_COUNT, uint8_t> (esphome/esphome#21790, pending)
Extra state neededTrigger classfan on_turn_on — FanTurnOnTrigger tracks last_on_ for edge detection (mutable state, can’t be a forwarder)
Complex logic (timing, state machine)Trigger classbinary_sensor on_multi_click — MultiClickTrigger with timing, cooldown, and multiple state fields

Some triggers pass arguments that are only valid while the callback runs: a StringRef into a receive buffer that the next message overwrites, a JsonObject into a document freed when the callback returns, a span into a buffer the hub reuses. Deferring actions (delay, wait_until, script.wait, …) keep the trigger arguments and use them later — DelayAction copies them by value, which copies the pointer but not the data — so a lambda that runs after them reads stale or freed memory.

If the argument cannot be copied cheaply, reject deferring actions at config validation. automation.validate_synchronous_automation() is validate_automation() with that check added:

cv.Optional(CONF_ON_JSON): automation.validate_synchronous_automation(
"on_json and on_data", "the received data"
),

The first argument says where the automation is and the second what is short-lived; both go into the error message:

Deferring actions (delay, wait_until, script.wait, ...) are not allowed in on_json and on_data: the
received data is only valid while the automation runs. Copy what you need into globals first, then
defer in a separate script or automation.

The other parameters (extra_schema, extra_validators, single) are passed to validate_automation().

When the automation is one alternative among others, for example inside cv.Any(...), chain automation.validate_no_deferring_actions() after it with cv.All(...) so the check runs on the validated value; it ignores values without an action list, such as a returning lambda:

cv.Optional(CONF_ON_NO_RESPONSE): cv.All(
cv.Any(cv.returning_lambda, automation.validate_automation(single=True)),
automation.validate_no_deferring_actions(
"modbus_client handlers", "the request/response data"
),
),

Both helpers use automation.has_non_synchronous_actions(), which also finds deferring actions nested in if, repeat and similar actions. It depends on the synchronous= flag each action is registered with, see Actions; an action registered without the flag is treated as synchronous=False (with a warning at registration), so it counts as deferring.

Actions are template classes that perform an operation when invoked by an automation.

Most actions do nothing but pass configured values on to their parent. Register those with automation.register_apply_action; no C++ class and no builder are written, and the action is always synchronous:

automation.register_apply_action(
"my_component.set_gains",
cv.Schema({
cv.Required(CONF_ID): cv.use_id(MyComponent),
cv.Required(CONF_KP): cv.templatable(cv.float_),
cv.Optional(CONF_KI): cv.templatable(cv.float_),
}),
automation.ApplyField(CONF_KP, "set_kp", cg.float_),
automation.ApplyField(CONF_KI, "set_ki", cg.float_),
)

The action is the core ApplyAction<Ts...>, which stores one function pointer. Code generation folds the parent and every configured field into one stateless function: constants become immediates, user lambdas are called inline with the trigger arguments, and an absent optional key emits nothing, so the action costs one pointer however many fields it has. With kp: 1.5 in the config the generated function body is my_component->set_kp(1.5f);.

  • ApplyField(conf_key, target, type_) forwards one key:
    • target is a setter name, or a statement template when it contains {} (for example "position = {}"; double a literal brace). A template may name the parent as {parent}.
    • type_ is the C++ type a user lambda must return. A plain string is raw C++ type text and may use {parent} when the type is only known per instance.
    • conf_key may be a tuple of keys to read a nested section.
    • std::string constants are emitted as a plain literal, or as progmem_string(ESPHOME_F(...)) on ESP8266 so the literal stays in flash.
    • const_fn= renders a constant when cg.safe_exp is not the right spelling; it receives the action config and the value. A !lambda value bypasses it, so the target must also accept a plain type_ argument.
  • ApplyCall("set_range({}, {})", ((CONF_LOW, cg.float_), (CONF_HIGH, cg.float_))) folds several keys into one statement; each arg may carry a third const_fn element. It is skipped when none of the keys is set and a partial set is a config error. A call with no keys, such as ApplyCall("stop()") or a trailing ApplyCall("publish_state()"), is always emitted, in the order given. An ApplyCall template may name the parent as {parent} too.
  • ApplyBytes(CONF_DATA, "transmit_packet({})") forwards a byte payload. The key is validated with cv.templatable(...) over a byte list or string, and the target’s single {} expands to data, len. A constant payload is a shared PROGMEM table passed directly, so it costs no RAM; on ESP8266 it is first copied out, to the stack up to stack= bytes (default 64) and to the heap beyond that, unless progmem_target= names a call that reads flash itself, such as uart’s write_array_progmem. A user lambda returns std::vector<uint8_t> and its data and size are passed. The template may name the parent as {parent}.
  • call="make_call" is for actions that build a call object: every statement, follow-up calls included, targets the call object returned by parent->make_call(), and perform() on it is appended last.

cover.control and cover.template.publish in the ESPHome repository are in-tree examples; sx127x.send_packet and uart.write show ApplyBytes. The hand-written class below is for actions whose play() has logic beyond forwarding values.

When play() has its own logic but the builder only needs to look up the parent component and construct the C++ object, register the action with one call and no builder function:

MyAction = my_ns.class_("MyAction", automation.Action)
automation.register_simple_action(
"my_component.do_something",
MyAction,
cv.Schema({cv.GenerateID(): cv.use_id(MyComponent)}),
synchronous=True,
)

register_simple_action passes the object named by config[CONF_ID] to the constructor. Two sibling helpers cover the other constructor shapes:

  • register_parented_action for a class deriving from Parented<T>: the object is constructed without arguments and set_parent() receives the parent.
  • register_bare_action for a constructor that takes no arguments at all, typically an action that reaches a global singleton.

Set synchronous=True if the action completes immediately (no async operations like delays or waits). Set synchronous=False if the action defers play_next_() to a later point (e.g. after a delay or async operation completes).

These helpers are available in ESPHome 2026.10.0 and later (esphome/esphome#19321). When the builder must also set fields, or on earlier versions, use the @automation.register_action decorator on a builder function instead (see the templatable example below). The builder body that matches each helper is:

  • register_simple_action: await cg.get_variable(config[CONF_ID]) and return cg.new_Pvariable(action_id, template_arg, parent).
  • register_parented_action: create var = cg.new_Pvariable(action_id, template_arg), then await cg.register_parented(var, config[CONF_ID]) and return var.
  • register_bare_action: return cg.new_Pvariable(action_id, template_arg) with no lookup.
template<typename... Ts> class MyAction final : public Action<Ts...> {
public:
explicit MyAction(MyComponent *parent) : parent_(parent) {}
void play(const Ts &...) override {
this->parent_->do_something();
}
protected:
MyComponent *parent_;
};

The register_parented_action shape derives from Parented<T> instead, which supplies set_parent() and the parent_ member, so the class declares no constructor:

template<typename... Ts> class MyAction final : public Action<Ts...>, public Parented<MyComponent> {
public:
void play(const Ts &...) override { this->parent_->do_something(); }
};

For a hand-written action class that accepts templatable values:

template<typename... Ts> class SetValueAction final : public Action<Ts...> {
public:
explicit SetValueAction(MyComponent *parent) : parent_(parent) {}
TEMPLATABLE_VALUE(bool, state)
void play(const Ts &...x) override {
this->parent_->set_state(this->state_.value(x...));
}
protected:
MyComponent *parent_;
};

Values passed to a TEMPLATABLE_VALUE setter from Python codegen should always be wrapped with cg.templatable(), even when the value is a literal constant. The setter’s storage is TemplatableFn for trivially copyable types (bool, int, float, enums, pointers) and TemplatableValue otherwise; TemplatableFn holds only a function pointer and will not accept a raw C++ value, so failing to wrap compiles on 2026.3.x and earlier only when the setter type is TemplatableValue, which accepts raw constants:

@automation.register_action(
"my_component.set_state",
SetValueAction,
cv.Schema({
cv.GenerateID(): cv.use_id(MyComponent),
cv.Required(CONF_STATE): cv.templatable(cv.boolean),
}),
synchronous=True,
)
async def set_state_to_code(
config: ConfigType, action_id: MockObj, template_arg: MockObj, args: TemplateArgsType
) -> MockObj:
parent = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, parent)
template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_)
cg.add(var.set_state(template_))
return var

Same imports as the earlier Python snippets on this page apply (cg, cv, automation, constants, typing aliases); CONF_STATE and SetValueAction are defined by the component itself.

Passing a raw value such as cg.add(var.set_state(config[CONF_STATE])) worked on 2026.3.x and earlier for literal constants but fails to compile on 2026.4.0 and later, because trivially copyable types use TemplatableFn (pointer-sized function-pointer storage, 4 bytes on 32-bit platforms), for which implicit conversion from raw values is not possible. See the TemplatableFn blog post for details.

Conditions are template classes that return a boolean to control automation flow.

Most conditions are one expression on their parent. Register those with automation.register_apply_condition; no C++ class and no builder are written:

automation.register_apply_condition(
"my_component.is_active",
cv.Schema({cv.GenerateID(): cv.use_id(MyComponent)}),
"is_active()",
)

The condition is the core ApplyCondition<Ts...>, which stores one function pointer to a stateless function that returns the expression applied to the parent, my_component->is_active() here. To compare against a configured value pass an ApplyCall instead of a string, with the same {} placeholders, (conf_key, type_) args and const_fn as for actions: automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)) generates my_component->state == true for state: true and calls a user lambda inline. Every key named by the call must be present in the config. The expression is appended to parent->, so it must start with a parent member; a leading ! would generate my_component->!is_active(), so negate with == false. The template may name the parent as {parent} later in the expression, but {parent} cannot start it, so it does not get around the leading ! limit.

cover.is_open and rtttl.is_playing in the ESPHome repository are in-tree examples. The hand-written class below is for a check() that needs more than one expression on the parent.

MyCondition = my_ns.class_("MyCondition", automation.Condition)
automation.register_simple_condition(
"my_component.is_ready",
MyCondition,
cv.Schema({cv.GenerateID(): cv.use_id(MyComponent)}),
)

register_parented_condition and register_bare_condition mirror the action helpers. A condition whose builder must also set fields uses the @automation.register_condition decorator on a builder function. The builder mirrors the templatable action example above with condition_id in place of action_id; register_condition takes no synchronous= parameter.

template<typename... Ts> class MyCondition final : public Condition<Ts...> {
public:
explicit MyCondition(MyComponent *parent) : parent_(parent) {}
bool check(const Ts &...) override {
if (!this->parent_->is_active())
return false;
return this->parent_->error_count() == 0;
}
protected:
MyComponent *parent_;
};