Writing Custom ESPHome Components in C++: External Components, Sensors, and YAML Packages
<p>ESPHome's YAML configuration covers an enormous range of sensors and devices out of the box, but eventually every serious ESPHome user hits a wall: a sensor with no existing component, a display that needs custom drawing logic, or a device that needs to combine several existing components in a way YAML alone can't express. That's what external components are for. This guide walks through writing your own ESPHome component in C++ — from a minimal custom sensor to packaging it as a reusable external component referenced by YAML — for makers who are comfortable with ESP32 firmware but haven't yet looked under ESPHome's hood.</p>
<h2>When You Actually Need a Custom Component</h2> <p>Before reaching for C++, it's worth checking whether the problem can be solved without leaving YAML:</p> <ul> <li><strong>Lambdas</strong> let you write small snippets of inline C++ directly in a YAML sensor, binary_sensor, or automation block — this covers a surprising amount of "custom logic" without needing a full component.</li> <li><strong>Template sensors and switches</strong> combine existing entities with math or logic (unit conversions, combining two sensors into a derived value) purely in YAML.</li> <li><strong>Packages</strong> let you reuse a block of YAML configuration (not code) across multiple devices — useful for standardizing a sensor wiring pattern across many project instances.</li> </ul> <p>Reach for a genuine external component when you need: a new communication protocol driver (a sensor over an unsupported I2C/SPI chip, for example), tight timing that a lambda can't provide reliably, or reusable code you want to publish and version separately from any one device's YAML.</p>
<h2>ESPHome's Component Model</h2> <p>Every ESPHome component is a C++ class that inherits from a small set of base classes depending on what it does — <code>Component</code> for the core lifecycle hooks, plus one of <code>Sensor</code>, <code>BinarySensor</code>, <code>Switch</code>, <code>TextSensor</code>, or similar depending on what kind of entity it exposes to Home Assistant. The lifecycle methods you'll override are:</p> <table> <tr><th>Method</th><th>Called</th><th>Typical Use</th></tr> <tr><td><code>setup()</code></td><td>Once, at boot</td><td>Initialize the sensor chip, configure GPIO pins, check for the device on the bus</td></tr> <tr><td><code>loop()</code></td><td>Every main loop iteration</td><td>Non-blocking polling, state machines — never use <code>delay()</code> here</td></tr> <tr><td><code>update()</code></td><td>On the interval set by <code>update_interval:</code> in YAML (for <code>PollingComponent</code>)</td><td>Read a sensor and publish its value — the most common pattern for simple sensors</td></tr> <tr><td><code>dump_config()</code></td><td>At boot, for diagnostics</td><td>Log the component's configuration so it shows up in the boot log for debugging</td></tr> </table>
<h2>Minimal Example: A Custom Polling Sensor</h2> <p>The cleanest starting point is a sensor that inherits from both <code>PollingComponent</code> (for the update-interval lifecycle) and <code>Sensor</code> (to expose a value to Home Assistant). Directory layout for an external component named <code>my_sensor</code>:</p> <pre> esphome/ components/ my_sensor/ __init__.py sensor.py my_sensor.h my_sensor.cpp </pre> <p><code>my_sensor.h</code> declares the class:</p> <pre> #pragma once #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h"
namespace esphome { namespace my_sensor {
class MySensor : public PollingComponent, public sensor::Sensor { public: void setup() override; void update() override; void dump_config() override; };
} // namespace my_sensor } // namespace esphome </pre> <p><code>my_sensor.cpp</code> implements it — this example reads a value over I2C and publishes it, following the same pattern as most real-world sensor drivers:</p> <pre> #include "my_sensor.h" #include "esphome/core/log.h"
namespace esphome { namespace my_sensor {
static const char *const TAG = "my_sensor";
void MySensor::setup() { ESP_LOGCONFIG(TAG, "Setting up My Sensor..."); // Initialize hardware here — configure the bus, check the device ID, etc. }
void MySensor::update() { float value = 0.0f; // Replace with real hardware read logic this->publish_state(value); }
void MySensor::dump_config() { ESP_LOGCONFIG(TAG, "My Sensor:"); LOG_SENSOR(" ", "Value", this); }
} // namespace my_sensor } // namespace esphome </pre>
<h2>The Python Side: Defining the YAML Schema</h2> <p>ESPHome components are configured through a small Python file that defines what YAML keys are valid and generates the C++ code that instantiates your class. This is the part that trips up C++-only developers — ESPHome's build system uses Python (via its own codegen library, not raw compilation) to translate YAML into generated C++ at build time.</p> <p><code>sensor.py</code> for the example above:</p> <pre> import esphome.codegen as cg import esphome.config_validation as cv from esphome.components import sensor, i2c from esphome.const import CONF_ID, UNIT_CELSIUS, ICON_THERMOMETER
my_sensor_ns = cg.esphome_ns.namespace("my_sensor") MySensor = my_sensor_ns.class_( "MySensor", cg.PollingComponent, sensor.Sensor )
CONFIG_SCHEMA = sensor.sensor_schema( MySensor, unit_of_measurement=UNIT_CELSIUS, icon=ICON_THERMOMETER, accuracy_decimals=2, ).extend(cv.polling_component_schema("60s"))
async def to_code(config): var = await sensor.new_sensor(config) await cg.register_component(var, config) </pre> <p>This gives you a component usable in YAML exactly like a built-in one:</p> <pre> external_components:
- source:
type: local path: components
sensor:
- platform: my_sensor
name: "My Custom Sensor" update_interval: 30s </pre>
<h2>Referencing External Components from GitHub</h2> <p>Once a component works locally, the <code>external_components</code> block can pull it directly from a git repository instead of a local folder — this is how most community-published ESPHome components are distributed and how you should package one for reuse across your own projects or to share with others:</p> <pre> external_components:
- source: github://your-username/esphome-my-sensor
components: [my_sensor] refresh: 1d </pre> <p>Pin to a specific git ref (<code>ref: v1.0.0</code>) for anything going into a production device — tracking a moving branch means a breaking change upstream can silently change your device's behavior on the next OTA build.</p>
<h2>Debugging Custom Components</h2> <ul> <li><strong>Build errors are almost always namespace or include mistakes.</strong> Every class must live inside <code>esphome::your_component_name</code>, and the <code>.h</code> file needs a header guard (<code>#pragma once</code>) or you'll get baffling "redefinition" errors from unrelated-looking code.</li> <li><strong>Use <code>ESP_LOGD</code>/<code>ESP_LOGCONFIG</code> liberally, not <code>Serial.print</code>.</strong> ESPHome's logging system handles log levels and remote logging (including over the API back to Home Assistant) — raw Serial output bypasses all of that and can conflict with the UART if you're also using serial for a sensor.</li> <li><strong>Never block in <code>loop()</code>.</strong> A <code>delay()</code> call or a blocking I2C read with a long timeout in <code>loop()</code> stalls the entire ESPHome scheduler, including WiFi and the API connection to Home Assistant — this shows up as devices "going unavailable" intermittently. Use <code>update()</code> with a <code>PollingComponent</code> interval, or a proper non-blocking state machine in <code>loop()</code>.</li> <li><strong>Validate the Python schema separately from the C++.</strong> Run <code>esphome config your-device.yaml</code> to catch schema errors before a full compile — it's much faster than waiting through a full build to discover a YAML key typo.</li> </ul>
<p>Writing a real ESPHome component takes a bit more up-front investment than a lambda, but the payoff is a driver that behaves exactly like a built-in one — showing up cleanly in Home Assistant, participating properly in the update-interval and logging systems, and reusable across every device on the bench without copy-pasting YAML. For anyone building custom sensor hardware for their shop or home, it's the difference between a one-off hack and something worth maintaining.</p>