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).
Triggers
Section titled “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)
Section titled “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
Section titled “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 )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
Section titled “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
Section titled “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
Section titled “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)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.
When to use which method
Section titled “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
Section titled “Actions”Actions are template classes that perform an operation when invoked by an automation.
Python
Section titled “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).
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_;};For actions that accept templatable values from the user config:
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 varSame 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
Section titled “Conditions”Conditions are template classes that return a boolean to control automation flow.
Python
Section titled “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)template<typename... Ts> class MyCondition final : public Condition<Ts...> { public: explicit MyCondition(MyComponent *parent) : parent_(parent) {} bool check(const Ts &...) override { return this->parent_->is_active(); }
protected: MyComponent *parent_;};Copyright © 2026 ESPHome - A project from the Open Home Foundation