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.
Directory structure
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:
Python module structure
Minimum requirements
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
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.
Module/component setup
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
.
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.
Configuration validation
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.
Code generation
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.
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.
if bar := config.get(CONF_BAR):
cg.add(var.set_bar(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.
Further information
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 theCORE
information like the target platform.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.CODEOWNERS
: A list of GitHub usernames that are responsible for this component.script/build_codeowners.py
will update theCODEOWNERS
file.DEPENDENCIES
: A list of components that this component depends on. If these components are not present in the configuration, validation will fail and the user will be shown an error.MULTI_CONF
: If set toTrue
, 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 theuart
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 an an external i2c UART expander.
Final validation
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.
C++ component structure
Given the example Python code above, let's consider the following C++ code:
-
example_component.h:
#pragma once #include "esphome/core/component.h" namespace esphome { namespace example_component { class ExampleComponent : 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 example_component } // namespace esphome
-
example_component.cpp:
#include "esphome/core/log.h" #include "example_component.h" namespace esphome { namespace example_component { static const char *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 example_component } // namespace esphome
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.
Namespaces
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.
Component class
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.
Common methods
There are four methods Component
defines which all 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. Typically this is every 16 milliseconds, but there may be some variance as other components consume cycles to perform their own tasks.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 duringsetup()
; 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.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. Seesetup_priority
inesphome/core/component.h
for commonly-used values.
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.
In general, code (particularly in loop()
and/or update()
)
must not block.
Component-specific methods
Most components need to define "setter" methods since it's common to have at least one configuration variable which
must be set in order to configure the component. In ExampleComponent
, 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.