Skip to content

Component Architecture

All components within ESPHome have a specific structure. This structure exists because:

  • It allows the Python parts of ESPHome to:
    • Easily determine which parts of the C++ codebase are required to complete a build.
    • Understand how to interact with the component/platform so it can be configured correctly.
  • It makes understanding and maintaining the codebase easier.
esphome
├─ components
│ ├─ example_component
│ │ ├─ __init__.py
│ │ ├─ example_component.h
│ │ ├─ example_component.cpp

This is the most basic component directory structure where the component would be used at the top-level of the YAML configuration.

example_component:

At the heart of every ESPHome component is the CONFIG_SCHEMA and the to_code method.

The CONFIG_SCHEMA is based on and extends Voluptuous, which is a data validation library. This allows the YAML to be parsed and converted to a Python object and performs strong validation against the data types to ensure they match.

import esphome.config_validation as cv
import esphome.codegen as cg
from esphome.const import CONF_ID
DOMAIN = "example_component"
CONF_FOO = "foo"
CONF_BAR = "bar"
CONF_BAZ = "baz"
example_component_ns = cg.esphome_ns.namespace("example_component")
ExampleComponent = example_component_ns.class_("ExampleComponent", cg.Component)
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(ExampleComponent),
cv.Required(CONF_FOO): cv.boolean,
cv.Optional(CONF_BAR): cv.string,
cv.Optional(CONF_BAZ): cv.int_range(0, 255),
})
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_foo(config[CONF_FOO]))
if bar := config.get(CONF_BAR):
cg.add(var.set_bar(bar))
if baz := config.get(CONF_BAZ):
cg.add(var.set_baz(baz))

Let’s break this down a bit.

import esphome.config_validation as cv
import esphome.codegen as cg

config_validation is a module that contains all the common validation method that are used to validate the configuration. Components may contain their own validations as well and this is very extensible. codegen is a module that contains all the code generation method that are used to generate the C++ code that is placed into main.cpp.

DOMAIN = "example_component"

Every component defines DOMAIN as a plain string equal to its folder name. Refer to the component by name through this constant rather than repeating the string. CI fails if a component’s __init__.py does not define it. See DOMAIN below.

example_component_ns = cg.esphome_ns.namespace("example_component")

This is the C++ namespace inside the esphome namespace. It is required here so that the codegen knows the exact namespace of the class that is being created. The namespace must match the name of the component.

ExampleComponent = example_component_ns.class_("ExampleComponent", cg.Component)

This is the class that is being created. The first argument is the name of the class and any subsequent arguments are the base classes that this class inherits from.

CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(ExampleComponent),
cv.Required(CONF_FOO): cv.boolean,
cv.Optional(CONF_BAR): cv.string,
cv.Optional(CONF_BAZ): cv.int_range(0, 255),
})

This is the schema that will allow user configuration for this component. This example requires the user to provide a boolean value for the key foo. The user also may or may not (hence Optional) provide a string value for the key bar and an integer value between 0 and 255 for they key baz. The cv.GenerateID() is a special function that generates a unique ID (C++ variable name used in the generated code) for this component but also allows the user to specify their own id in their configuration in the event that they wish to refer to it in their automations.

An option with a fixed set of choices that maps to a C++ enum is validated with cv.enum(), which takes a dict from each choice to its enumerator. Build that dict with options() on the enum instead of writing each line out:

DimMethod = ac_dimmer_ns.enum("DimMethod")
# Written out by hand
DIM_METHODS = {
"LEADING_PULSE": DimMethod.DIM_METHOD_LEADING_PULSE,
"LEADING": DimMethod.DIM_METHOD_LEADING,
"TRAILING": DimMethod.DIM_METHOD_TRAILING,
}
# The same dict built by options()
DIM_METHODS = DimMethod.options(
"LEADING_PULSE", "LEADING", "TRAILING", prefix="DIM_METHOD_"
)
CONFIG_SCHEMA = cv.Schema(
{
cv.Optional(CONF_METHOD, default="leading pulse"): cv.enum(
DIM_METHODS, upper=True, space="_"
),
}
)

Each choice maps to prefix plus the choice in upper case, with every character that is not a letter or a digit turned into _. prefix is optional and defaults to empty: adc’s sampling_mode.options("avg", "min", "max") maps "avg" to SamplingMode::AVG, and is used with cv.enum(SAMPLING_MODES, lower=True).

options() returns a plain dict. When a choice does not follow the rule, add it to that dict, or write the whole dict out by hand:

MODES = MyEnum.options("A", "B", prefix="MODE_")
MODES["LEGACY"] = MyEnum.MODE_OLD_NAME

The to_code method is called after the entire configuration has been validated. It is given the parsed config object for this instance of this component and uses it to determine exactly what C++ code is placed into the generated main.cpp file. It translates the user configuration into the C++ instance method calls, setting variables on the object as required/specified.

var = cg.new_Pvariable(config[CONF_ID])

var becomes a special object that represents the actual C++ object that will be generated. The CONF_ID that represents the above cv.GenerateID() contains both the id string and the class name of the component — in our example, this is ExampleComponent. Subsequent arguments to new_Pvariable are arguments that can be passed to the constructor of the class.

await cg.register_component(var, config)

This line generates App.register_component(var) in C++ which registers the component so that its setup, loop and/or update methods are called correctly.

For a component on a bus, these two calls and the bus registration are replaced by one helper: i2c.new_i2c_component(), uart.new_uart_component(), spi.new_spi_component(), modbus.new_modbus_client_component(), ble_device_base.new_ble_device_component() or ble_client.new_ble_node_component(). Each takes config and passes any further arguments to the constructor, for example var = await i2c.new_i2c_component(config). The modbus and BLE helpers are available in ESPHome 2026.11.0 and later (esphome/esphome#21025).

Assuming the user has foo: true in their YAML configuration, this line:

cg.add(var.set_foo(config[CONF_FOO]))

…will result in this line:

var->set_foo(true);

…in the generated main.cpp file.

cg.add_optional(var.set_bar, config.get(CONF_BAR))

If the user has set bar in the configuration, this line will generate the C++ code to call set_bar on the object. If the config value is not set, then we do not call the setter function. Only None is skipped, so 0 and False are still set.

Pins and references to other components have helpers that build the value and pass it to the setter in one call. cg.add_pin() builds the pin expression; cg.add_variable() looks up the component that an ID points to:

await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])
await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN))
await cg.add_variable(var.set_time, config.get(CONF_TIME_ID))

Both do nothing when given None, so config.get() handles an optional key without an if. Both return what they created (or None) for the rare case where it is used again. For a class that derives from Parented<T>, use await cg.register_parented(var, config[CONF_MY_HUB_ID]) instead.

cg.add_pin() and cg.add_variable() are available in ESPHome 2026.11.0 and later (esphome/esphome#21018). An external component that supports earlier releases builds the value with cg.gpio_pin_expression() or cg.get_variable() and calls the setter with cg.add().

cg.add_optional() is available in ESPHome 2026.11.0 and later (esphome/esphome#21180). For earlier releases, use an if block:

if (bar := config.get(CONF_BAR)) is not None:
cg.add(var.set_bar(bar))
  • ALIASES: A list of legacy names that should transparently route to this component’s canonical name. This allows a component to be renamed without breaking existing user configurations: a legacy top-level YAML key is rewritten to the canonical name during validation (emitting a one-shot deprecation warning), and esphome.components.<alias> imports resolve to the canonical package so DEPENDENCIES/AUTO_LOAD and direct imports keep working under either spelling. If both the legacy and canonical keys are present in a configuration, validation fails rather than silently dropping one.
  • ALIAS_REMOVAL_VERSION: An optional version string (e.g. "2027.6.0") cited in the deprecation warning emitted when an alias from ALIASES is used, telling users when the legacy name will stop working.
  • AUTO_LOAD: A list of components that will be automatically loaded if they are not already specified in the configuration. This can be a method that can be run with access to the CORE information like the target platform.
  • CODEOWNERS: A list of GitHub usernames that are responsible for this component. script/build_codeowners.py will update the CODEOWNERS file.
  • CONFLICTS_WITH: A list of components which conflict with this component. If the user has one of them in their config, a validation error will be generated.
  • DEPENDENCIES: A list of components that this component depends on. If these components are not present in the configuration, or loaded by another component, validation will fail and the user will be shown an error.
  • DOMAIN: Required. The component’s name as a plain string, equal to its folder name (e.g. DOMAIN = "uart"). Keep it in alphabetical order with the other metadata constants such as CODEOWNERS and DEPENDENCIES. The lint_component_domain check in script/ci-custom.py fails if a component’s top-level __init__.py does not define it with the right value. Platform modules such as <name>/sensor/__init__.py are not checked. See DOMAIN.
  • MULTI_CONF: If set to True, the user can use this component multiple times in their configuration. If set to a number, the user can use this component that number of times.
  • MULTI_CONF_NO_DEFAULT: This is a special flag that allows the component to be auto-loaded without an instance of the configuration. An example of this is the uart component. This component can be auto-loaded so that all of the UART headers will be available but potentially there is no native UART instance, but one provided by another component such as an external I2C UART expander.
  • PREFETCH_FILES: A generator that lets a component’s remote files be downloaded in one parallel batch before schema validation instead of one at a time during it. See Remote file prefetching.

Use DOMAIN wherever code needs the component’s name, instead of repeating the string literal. Inside the component:

from esphome.core import CORE
AUTO_LOAD = ["socket"]
CODEOWNERS = ["@example"]
DEPENDENCIES = ["i2c"]
DOMAIN = "example_component"
MULTI_CONF = True
def _get_data() -> dict:
# Per-build state for this component, keyed by its name
return CORE.data.setdefault(DOMAIN, {})

From another component, import it under a clear alias:

from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
from esphome.core import CORE
if PSRAM_DOMAIN in CORE.loaded_integrations:
...

The imported constant also works with other name-based helpers, such as cv.requires_component(PSRAM_DOMAIN).

ESPHome has a mechanism to run a final validation step after all of the configuration is initially deemed to be individually valid. This final validation gives an instance of a component the ability to check the configuration of any other components and potentially fail the validation stage if an important dependent configuration does not match.

For example, many components that rely on uart can use the FINAL_VALIDATE_SCHEMA to ensure that the tx_pin and/or rx_pin are configured.

A platform often needs a setting of the hub it belongs to, for example a sensor that only works when the hub has a certain option set. fv.get_declaring_config(id) returns the config block that declares an ID, so the hub’s options are one call away:

import esphome.final_validate as fv
def _final_validate(config: ConfigType) -> None:
if config[CONF_TYPE] not in _MAGNETIC_FIELDS:
return
bmi270_config = fv.get_declaring_config(config[CONF_BMI270_ID])
if bmi270_config.get(CONF_AUX_DEVICE) != "BMM150":
raise cv.Invalid(
"This sensor requires the parent bmi270's 'aux_device' to be set to 'BMM150'",
path=[CONF_TYPE],
)
FINAL_VALIDATE_SCHEMA = _final_validate

This replaces looking the path up by hand:

full_config = fv.full_config.get()
bmi270_path = full_config.get_path_for_id(config[CONF_BMI270_ID])[:-1]
bmi270_config = full_config.get_config_for_path(bmi270_path)

It reads the full configuration being validated. Outside final validation, such as in to_code, pass the config as the second argument: fv.get_declaring_config(config[CONF_BMI270_ID], CORE.config). It raises KeyError for an ID that nothing declares.

Some components are platform components: instead of a single configuration block, the top-level YAML key holds a list of entries, each selecting a platform that provides its own CONFIG_SCHEMA/to_code. sensor, switch and image are all platform components:

image:
- platform: file
file: "image.png"
id: my_image

A component opts into this by setting IS_PLATFORM_COMPONENT = True in its __init__.py. Each platform then lives in its own component package, in a module named after the platform component’s domain — esphome/components/<platform>/<domain>.py — exposing its own CONFIG_SCHEMA and to_code exactly like a regular component. For example, platform: file under image: resolves to esphome/components/file/image.py.

Two optional hooks on the platform component’s own __init__.py module let it reshape the raw domain config — the list of platform:-tagged entries — before any individual entry’s CONFIG_SCHEMA runs or its IDs are registered:

A removable deprecation shim: rewrites a pre-platform-component config shape (for example, a bare list or a monolithic dict your component used to accept before it became a platform component) into the normalized platform:-tagged list, logging a one-time deprecation warning. Delete it, along with any code that only exists to understand the old shape, once its removal version has passed.

def migrate_legacy_config(config: object) -> list[dict] | None:
if _is_already_platform_tagged(config):
return None # Nothing to do.
_LOGGER.warning("... deprecated ...")
return [{"platform": "file", **entry} for entry in _flatten(config)]
LEGACY_CONFIG_MIGRATE = migrate_legacy_config

A permanent (non-deprecated) counterpart: lets any platform entry — for any platform the component provides — expand into multiple entries. image uses this so a single entry can provide defaults: (options merged into every image) plus files: (a list of per-image overrides), instead of repeating shared options on every entry. It only has to be implemented once, in the base image component, and every platform it provides (file, animation, online_image) gets the feature for free.

image:
- platform: file
defaults:
type: RGB565
resize: 64x64
files:
- file: a.png
id: img_a
- file: b.png
id: img_b

This expands to two platform: file entries, each with type and resize merged in; keys on a files: item override the defaults. A simplified version of the hook, showing how malformed input is rejected:

def _expand_entry(index: int, entry: dict) -> list[dict]:
if "files" not in entry:
if "defaults" in entry:
raise cv.Invalid("'defaults' may only be used together with 'files'", path=[index])
return [entry]
if extra := set(entry) - {"platform", "defaults", "files"}:
raise cv.Invalid(f"'files' cannot be combined with {', '.join(sorted(extra))}", path=[index])
files = entry["files"]
if not isinstance(files, list) or not files:
raise cv.Invalid("'files' must be a non-empty list", path=[index])
defaults = entry.get("defaults") or {}
if not isinstance(defaults, dict):
raise cv.Invalid("'defaults' must be a mapping", path=[index])
if not all(isinstance(file_entry, dict) for file_entry in files):
raise cv.Invalid("each entry in 'files' must be a mapping", path=[index])
return [{"platform": entry["platform"], **defaults, **file_entry} for file_entry in files]
def expand_platform_config(config: list[dict]) -> list[dict]:
result = []
for index, entry in enumerate(config):
result.extend(_expand_entry(index, entry))
return result
EXPAND_PLATFORM_CONFIG = expand_platform_config

Contract:

  • Do only structural dict manipulation here — leave semantic validation (for example, “is this key valid for this platform”) to each platform’s own CONFIG_SCHEMA, which still runs on every entry the hook returns.
  • To reject malformed input, raise cv.Invalid, optionally with path=[index] where index is the offending entry’s position in the input list, so the error is anchored to that entry rather than to the whole domain. Do not raise other exception types for user-facing errors — they will surface as an unhandled traceback rather than a config error.
  • The return value must always be a list. Returning anything else is treated as a bug in the component, not a user configuration error.
  • It runs once per domain, after LEGACY_CONFIG_MIGRATE (if defined) and after the domain’s raw config has been normalized into a list, but before any entry’s CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, or ID registration.

See esphome/components/image/__init__.py for the full implementation this is based on.

Components that download remote files during validation (fonts, icons, firmware blobs, configuration data) can declare a module-level PREFETCH_FILES hook. Without it, each config entry downloads its files one at a time while its schema is validated. With it, ESPHome collects the remote files of every component before schema validation starts and downloads them together in one parallel batch, then the schema validators read them straight from the local cache, normally with no further network traffic.

For the common case of one remote file per config entry, build the hook from a per-entry extractor with external_files.single_stage_prefetch:

from esphome import external_files
from esphome.external_files import RemoteFile
from esphome.types import ConfigType
def _extract_file(entry: ConfigType) -> RemoteFile | None:
if isinstance(url := entry.get(CONF_URL), str):
return RemoteFile(url, external_files.compute_local_file_path(DOMAIN, url))
return None
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_file)

Components with staged downloads write PREFETCH_FILES as a generator instead. ESPHome calls it once per run with the raw list of the component’s config entries and downloads each yielded batch before resuming the generator.

The contract:

  • The hook runs before schema validation, so the entries are raw and unvalidated: keys may be missing, shorthand may not be expanded and values may be the wrong type. Skip anything the hook does not recognize; never raise for invalid configuration. The schema validators remain the only place that reports configuration errors. A hook that raises anyway is caught and logged and the config still validates; it only loses the batching speedup.
  • Each yield is one download stage: a list of esphome.external_files.RemoteFile(url, path). Most components yield a single batch. When the address of one file is only known from the content of another, yield again: the generator only resumes after the previous stage finished downloading, so the second stage can read the fetched files. The font component uses this to fetch the Google Fonts CSS first and then the font file the CSS points to.
  • When nothing downstream can verify the bytes (for example firmware without a checksum), pass RemoteFile(url, path, allow_stale=False): a cached copy that could not be revalidated against the server is then an error rather than a silent fallback. allow_stale defaults to True.
  • Compute cache paths with the same helper functions the schema validator uses, for example external_files.compute_local_file_path(DOMAIN, url), so the prefetched file lands exactly where the validator looks. A wrong path only wastes one download; the validator still fetches the file itself.
  • The schema validator should download through external_files.download_content(url, path). Files fetched by the prefetch are remembered for the rest of the run, so that call returns the cached bytes without touching the network, and a failed download is reported once with the proper configuration path instead of timing out again for every entry that references it.
  • For platform components, the hook is usually declared in the platform module and receives only that platform’s entries. A hook on the domain module is honored too and receives every entry; duplicate files between the two are downloaded only once. A platform that shares another platform’s file handling can re-export the hook with a simple assignment, as animation’s image platform does by re-exporting the file image platform’s hook.

Prefetching only changes how fast files arrive, never whether a configuration is valid, so a component works identically with or without the hook. It is worth adding whenever a realistic configuration references more than a couple of remote files.

Hardware frequently maps to more than one entity: a temperature/humidity sensor exposes two readings, a relay board exposes several switches, a multi-zone controller exposes a climate entity per zone. ESPHome has two established ways to model this, both valid and both common in-tree. One nests the entities as optional sub-configs under a single platform entry; the other, useful where a top-level hub component already exists, gives each entity its own platform entry distinguished by a type key (via cv.typed_schema()). Which one fits depends on whether the entities are facets of a single measurement cycle or independent things sharing a connection.

Both patterns are worked through in full, with real in-tree examples, in Exposing multiple sensors from one component. The entity-type pages in the navigation tree each describe the equivalent for their own type.

Many child entities do nothing but call one method of their hub: a button that runs hub->restart(), or a switch that publishes its new state and runs hub->set_bluetooth(state). These need no C++ class of their own. Declare the entity with its domain’s Apply class and pass apply= to the sub_* call; code generation then writes a small function with that call, and the entity runs it when it is pressed or changed:

CONFIG_SCHEMA = {
cv.GenerateID(CONF_LD2410_ID): cv.use_id(LD2410Component),
cv.Optional(CONF_BLUETOOTH): switch.switch_schema(
switch.ApplySwitch,
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_BLUETOOTH,
),
}
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD2410_ID])
switches = switch.sub_switches(config, parent=hub)
await switches(
CONF_BLUETOOTH,
hub.set_bluetooth_switch,
apply="set_bluetooth(state)",
publish="before",
)

apply is the C++ call on the hub, written without the hub in front of it. It can use the names in the table:

DomainClassNames in applyWhat publish publishes
buttonbutton.ApplyButtonentity(not available)
switchswitch.ApplySwitchentity, statestate
numbernumber.ApplyNumberentity, valuevalue
selectselect.ApplySelectentity, index, option (the selected option’s text)index

publish="before" or publish="after" publishes the new state before or after the call. Leave it out when the hub publishes the state itself, for example once the device confirms the change. Code generation stops with an error when the Apply class has no apply=, when apply= is used with another class or without parent=, and when publish= is used without apply= or on a button. The setter can be None when the hub keeps no pointer to the entity. For a platform whose whole config is one entity, call .create(config, setter, apply=..., publish=...) on the sub_* result instead.

This removes a class, a header and a source file per entity, and the entity stores no hub pointer. Entities whose apply and publish are the same share one generated function, and so one class. To get that for a group of similar entities, pass entity to the hub and let the hub find out which one changed, as ld2410 does for its 18 gate threshold numbers with apply="set_gate_threshold(entity)":

void LD2410Component::set_gate_threshold(number::Number *n) {
// Found by pointer: up to 18 compares per write beat storing the gate in each number
uint8_t gate = 0;
while (gate < TOTAL_GATES && this->gate_move_threshold_numbers_[gate] != n &&
this->gate_still_threshold_numbers_[gate] != n)
gate++;
if (gate == TOTAL_GATES)
return;
...
}

Keep a C++ class when the entity has state or logic of its own beyond that one call.

Given the example Python code above, let’s consider the following C++ code:

This represents the minimum required code to implement a component in ESPHome:

  • example_component.h:

    #pragma once
    #include "esphome/core/component.h"
    namespace esphome::example_component {
    class ExampleComponent final : public Component {
    public:
    void setup() override;
    void loop() override;
    void dump_config() override;
    void set_foo(bool foo) { this->foo_ = foo;}
    void set_bar(std::string bar) { this->bar_ = bar;}
    void set_baz(int baz) { this->baz_ = baz;}
    protected:
    bool foo_{false};
    std::string bar_{};
    int baz_{0};
    };
    } // namespace esphome::example_component
  • example_component.cpp:

    #include "esphome/core/log.h"
    #include "example_component.h"
    namespace esphome::example_component {
    ESPHOME_LOG_TAG(TAG, "example_component.component");
    void ExampleComponent::setup() {
    // Code here should perform all component initialization,
    // whether hardware, memory, or otherwise
    }
    void ExampleComponent::loop() {
    // Tasks here will be performed at every call of the main application loop.
    // Note: code here MUST NOT BLOCK (see below)
    }
    void ExampleComponent::dump_config(){
    ESP_LOGCONFIG(TAG, "Example component");
    ESP_LOGCONFIG(TAG, " foo = %s", TRUEFALSE(this->foo_));
    ESP_LOGCONFIG(TAG, " bar = %s", this->bar_.c_str());
    ESP_LOGCONFIG(TAG, " baz = %i", this->baz_);
    }
    } // namespace esphome::example_component

This represents the minimum required code to implement a component in ESPHome. While most of it is likely reasonably self-explanatory, let’s walk through it for completeness.

All components must have their own namespace, named appropriately based on the name of the component. The component’s namespace will always be placed within the esphome namespace.

Any component exists as at least one C++ class. In ESPHome, components always inherit from either the Component or PollingComponent classes. The latter of these defines an additional update() method which is called on a periodic basis based on user configuration. This is often useful for hardware such as sensors which are queried periodically for a new measurement/reading.

Note that ExampleComponent above is marked final: it is a leaf class that nothing else in ESPHome derives from. Mark your own user-configurable component and platform classes final unless another in-tree class subclasses them. See Marking leaf classes as final for details.

There are several methods Component defines which components typically implement. They are as follows:

  • setup(): This method is called once as ESPHome starts up to perform initialization of the component. This may mean simply initializing some memory/variables or performing a series of read/write calls to look for and initialize some (sensor, display, etc.) hardware connected via some bus (I2C, SPI, serial/UART, one-wire, etc.).
  • loop(): This method is called at each iteration of ESPHome’s main application loop. The loop runs approximately every 7ms (~7000 times per minute), but there may be some variance as other components consume cycles to perform their own tasks. Components can dynamically control their participation in the main loop (see Component Loop Control in the Advanced Topics page).
  • dump_config(): This method is called as-needed to “dump” the device’s current configuration. Typically this happens once after booting and then each time a new client connects to monitor logs (assuming logging is enabled). Note that this method is to be used only to dump configuration values determined during setup(); this method is not permitted to contain any other types of calls to (for example) perform bus reads and/or writes. We require that this method is implemented for all components.

In addition, for PollingComponent:

  • update(): This method is called at an interval defined in the user’s YAML configuration. For many components, the interval defaults to 60 seconds, but this may be overridden by the user to fit their use case.

WARNING

Code in these methods (and elsewhere) must not block.

A component reports trouble through its status flags, which drive the status LED and the device’s warning and error state:

  • status_set_warning() / status_set_error() set the flag until the component clears it with status_clear_warning() / status_clear_error(). Pass a LOG_STR("...") message to log why; without one the log says “unspecified”. status_comm_result() (below) sets or clears the warning from a read or write result.
  • status_momentary_warning() / status_momentary_error() set the flag and clear it again after a timeout (5000 ms by default). Use them for one-off failures, such as a single failed read. A new call restarts the timeout.
  • mark_failed() marks the component as failed when it cannot work at all, typically from setup().
this->status_momentary_warning(); // cleared after 5000 ms
this->status_momentary_warning(1000); // cleared after 1000 ms
this->status_momentary_error(LOG_STR("Read failed"), 500); // logs the reason

Before ESPHome 2026.11.0 the momentary calls required a name as their first argument, for example status_momentary_warning("read", 1000). The named form still compiles but is deprecated, and will be removed in 2027.5.0: the name was only used to key the timer, it is kept as a string (RAM on ESP8266), and it can clash with the component’s own set_timeout() names. Drop the name to migrate.

A read or write that runs again on every update sets the warning when it fails and clears it when it works again. status_comm_result(ok) does both and returns ok, so the failure check stays one line:

// Before
if (!this->read_bytes(REG_RED_DATA_LSB, buffer, sizeof(buffer))) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->status_clear_warning();
// After
if (!this->status_comm_result(this->read_bytes(REG_RED_DATA_LSB, buffer, sizeof(buffer))))
return;

Without a message the warning says “Communication failed” (ESP_LOG_MSG_COMM_FAIL). Pass a LOG_STR("...") as the second argument for another text, for example this->status_comm_result(ok, LOG_STR("Failed to read one or more registers")). Use it for failures after setup(); a device that cannot be set up at all still calls mark_failed(). A component has one warning flag, so a success clears a warning set by any other check too; do not mix it with other status_set_warning() conditions in the same update.

  • “Setter” methods: it’s common to have at least one configuration variable which must be defined by the user in order to configure a component/platform. In ExampleComponent (as above), we have three such variables: “foo”, “bar” and “baz”. As mentioned earlier, these methods are the same methods referred from within the to_code function in Python; the values contained in the user’s YAML configuration are passed through to these setter methods as they are placed into the generated main.cpp file produced by ESPHome’s code generation (codegen). It’s important to note that these methods will be called (and, thus, variables set) before the setup() method is called.
  • get_setup_priority(): This method is called to determine the component’s setup priority. This is used specifically to ensure components are initialized in an appropriate order. For example, an I2C sensor cannot be initialized before the I2C bus is initialized; therefore, for I2C sensors, this must return a value indicating that it is to be initialized only after (I2C) busses are initialized. See setup_priority in esphome/core/component.h for commonly-used values.

For components that need to handle shutdown gracefully (such as network connections or hardware cleanup), ESPHome provides additional lifecycle methods:

ESPHome has two shutdown modes:

Safe shutdown (used for OTA updates, deep sleep, and graceful reboots):

  1. on_safe_shutdown(): Called first for critical cleanup operations
  2. on_shutdown(): Called to initiate shutdown (send disconnect messages, stop accepting new connections)
  3. teardown(): Called repeatedly to gracefully close connections and flush buffers. Returns true when complete or false if more time is needed
  4. on_powerdown(): Called after all teardowns complete to power down hardware

Forced reboot (used for crashes, watchdog resets, or App.reboot()):

  1. on_shutdown(): Called to attempt minimal cleanup
  2. System restarts immediately (no teardown or powerdown)

Method details:

  • on_safe_shutdown(): Only called during safe shutdowns. Used for critical operations that must happen before any other shutdown procedures. Not called during forced reboots or crashes.
  • on_shutdown(): Always called when possible. Components should start their shutdown process here (e.g., send disconnect messages, stop accepting new connections) but should NOT power down hardware or close connections yet.
  • teardown(): Only called during safe shutdowns. This is where connections are closed and buffers are flushed. The system will call this repeatedly (with a timeout) until all components return true.
  • on_powerdown(): Only called during safe shutdowns after all components have completed their teardown. This is the appropriate place to power down hardware, put chips into sleep mode, or turn off power supplies.
float ExampleComponent::get_setup_priority() const {
// Return the setup priority of this component
// Higher values mean this component will be set up later
return setup_priority::DATA;
}
void ExampleComponent::on_safe_shutdown() {
// Optional: Critical cleanup operations for safe shutdowns only
// This is called first, before any other shutdown procedures
ESP_LOGI(TAG, "Safe shutdown initiated");
}
void ExampleComponent::on_shutdown() {
// Optional: Start shutdown process
// For example, send a disconnect message but don't close connections yet
ESP_LOGI(TAG, "Starting shutdown");
}
bool ExampleComponent::teardown() {
// Optional: Finish any pending operations
// Return false if more time is needed, true when done
// This will be called multiple times until it returns true or timeout is reached
// Note: Log messages here will likely only go to serial console
// as network connections are being closed. Avoid excessive logging
// to prevent slowing down the shutdown process.
return true;
}
void ExampleComponent::on_powerdown() {
// Optional: Power down hardware after all teardowns are complete
// This is called last, after all components have finished teardown
// Note: At this point, network connections are closed. Log messages
// will only appear on serial console. Keep logging minimal to avoid
// delaying shutdown.
}

For more advanced component development topics, including Component Loop Control which allows components to dynamically enable or disable their participation in the main loop for performance optimization, see the Advanced Topics page.

  • To help you get started, we have a number of “starter” components.
  • We encourage you to have a look at other components within ESPHome; it’s often easiest to take something which already exists and modify it to fit your needs.
  • If you’re looking for information on a specific component type, see the navigation tree on the left.