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.
Actions that only forward values
Section titled “Actions that only forward values”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:targetis a setter name, or a statement template when it contains{}(for example"position = {}"; double a literal brace).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_keymay be a tuple of keys to read a nested section.std::stringconstants are emitted as a plain literal, or asprogmem_string(ESPHOME_F(...))on ESP8266 so the literal stays in flash.const_fn=renders a constant whencg.safe_expis not the right spelling; it receives the action config and the value. A!lambdavalue bypasses it, so the target must also accept a plaintype_argument.
ApplyCall("set_range({}, {})", ((CONF_LOW, cg.float_), (CONF_HIGH, cg.float_)))folds several keys into one statement; each arg may carry a thirdconst_fnelement. It is skipped when none of the keys is set and a partial set is a config error. A call with no keys, such asApplyCall("stop()")or a trailingApplyCall("publish_state()"), is always emitted, in the order given.call="make_call"is for actions that build a call object: every statement, follow-up calls included, targets the call object returned byparent->make_call(), andperform()on it is appended last.
cover.control and cover.template.publish in the ESPHome repository are in-tree examples. The hand-written class below is for actions whose play() has logic beyond forwarding values.
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 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 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