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
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).
Triggers
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.
Callback method (preferred)
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:
| Forwarder | Callback signature | Behavior |
|---|---|---|
TriggerForwarder<Ts...> |
void(const Ts&...) |
Forwards all args to Automation<Ts...>::trigger() |
TriggerOnTrueForwarder |
void(bool) |
Triggers Automation<> only when true |
TriggerOnFalseForwarder |
void(bool) |
Triggers Automation<> only when false |
Python
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:
parent-- the component variablecallback_method-- name of the C++ method to register the callback (e.g."add_on_state_callback")args-- template args as[(type, name)]tuples, exposed as variables in the user'sthen:block. This controls theAutomation<Ts...>template parameters, not the callback signature. When using a custom forwarder, the forwarder'soperator()signature must match the callback, butargscan differ (e.g.args=[]withTriggerOnTrueForwarderwhich receivesboolbut triggersAutomation<>)config-- the automation config dictforwarder(optional) -- override the defaultTriggerForwarder<Ts...>
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
)
C++
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);
}
Custom forwarders
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")),
)
Trigger class method
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.
Python
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)
C++
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 : 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.
When to use which method
| Scenario | Method | Example |
|---|---|---|
| Simple forwarding | Callback | button on_press -- TriggerForwarder<> forwards directly |
| Boolean filtering | Callback with built-in forwarder | binary_sensor on_press/on_release -- TriggerOnTrueForwarder / TriggerOnFalseForwarder |
| Enum state filtering | Callback with custom forwarder | lock on_lock/on_unlock -- LockStateForwarder<State> checks enum, single pointer (esphome/esphome#15199, pending) |
| Extra state needed | Trigger class | fan on_turn_on -- FanTurnOnTrigger tracks last_on_ for edge detection (mutable state, can't be a forwarder) |
| Complex logic (timing, state machine) | Trigger class | binary_sensor on_multi_click -- MultiClickTrigger with timing, cooldown, and multiple state fields |
Actions
Actions are template classes that perform an operation when invoked by an automation.
Python
MyAction = my_ns.class_("MyAction", automation.Action)
@automation.register_action(
"my_component.do_something",
MyAction,
cv.Schema({cv.GenerateID(): cv.use_id(MyComponent)}),
synchronous=True,
)
async def my_action_to_code(
config: ConfigType, action_id: MockObj, template_arg: MockObj, args: TemplateArgsType
) -> MockObj:
parent = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, parent)
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).
C++
template<typename... Ts> class MyAction : public Action<Ts...> {
public:
explicit MyAction(MyComponent *parent) : parent_(parent) {}
void play(const Ts &...) override {
this->parent_->do_something();
}
protected:
MyComponent *parent_;
};
For actions that accept templatable values from the user config:
template<typename... Ts> class SetValueAction : 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_;
};
Conditions
Conditions are template classes that return a boolean to control automation flow.
Python
MyCondition = my_ns.class_("MyCondition", automation.Condition)
@automation.register_condition(
"my_component.is_active",
MyCondition,
cv.Schema({cv.GenerateID(): cv.use_id(MyComponent)}),
)
async def my_condition_to_code(
config: ConfigType, condition_id: MockObj, template_arg: MockObj, args: TemplateArgsType
) -> MockObj:
parent = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(condition_id, template_arg, parent)
C++
template<typename... Ts> class MyCondition : public Condition<Ts...> {
public:
explicit MyCondition(MyComponent *parent) : parent_(parent) {}
bool check(const Ts &...) override { return this->parent_->is_active(); }
protected:
MyComponent *parent_;
};