Writing Custom ESPHome Components in C++: External Components, Sensors, and YAML Packages
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.
When You Actually Need a Custom Component
Before reaching for C++, it's worth checking whether the problem can be solved without leaving YAML:
- Lambdas 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.
- Template sensors and switches combine existing entities with math or logic (unit conversions, combining two sensors into a derived value) purely in YAML.
- Packages let you reuse a block of YAML configuration (not code) across multiple devices — useful for standardizing a sensor wiring pattern across many project instances.
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.
ESPHome's Component Model
Every ESPHome component is a C++ class that inherits from a small set of base classes depending on what it does — Component for the core lifecycle hooks, plus one of Sensor, BinarySensor, Switch, TextSensor, or similar depending on what kind of entity it exposes to Home Assistant. The lifecycle methods you'll override are:
MethodCalledTypical Use setup()Once, at bootInitialize the sensor chip, configure GPIO pins, check for the device on the bus loop()Every main loop iterationNon-blocking polling, state machines — never use delay() here update()On the interval set by update_interval: in YAML (for PollingComponent)Read a sensor and publish its value — the most common pattern for simple sensors dump_config()At boot, for diagnosticsLog the component's configuration so it shows up in the boot log for debuggingMinimal Example: A Custom Polling Sensor
The cleanest starting point is a sensor that inherits from both PollingComponent (for the update-interval lifecycle) and Sensor (to expose a value to Home Assistant). Directory layout for an external component named my_sensor:
esphome/ components/ my_sensor/ __init__.py sensor.py my_sensor.h my_sensor.cppmy_sensor.h declares the class:
#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 esphomemy_sensor.cpp implements it — this example reads a value over I2C and publishes it, following the same pattern as most real-world sensor drivers:
#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 esphomeThe Python Side: Defining the YAML Schema
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.
sensor.py for the example above:
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)This gives you a component usable in YAML exactly like a built-in one:
external_components: - source: type: local path: components sensor: - platform: my_sensor name: "My Custom Sensor" update_interval: 30sReferencing External Components from GitHub
Once a component works locally, the external_components 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:
external_components: - source: github://your-username/esphome-my-sensor components: [my_sensor] refresh: 1dPin to a specific git ref (ref: v1.0.0) 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.
Debugging Custom Components
- Build errors are almost always namespace or include mistakes. Every class must live inside esphome::your_component_name, and the .h file needs a header guard (#pragma once) or you'll get baffling "redefinition" errors from unrelated-looking code.
- Use ESP_LOGD/ESP_LOGCONFIG liberally, not Serial.print. 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.
- Never block in loop(). A delay() call or a blocking I2C read with a long timeout in loop() stalls the entire ESPHome scheduler, including WiFi and the API connection to Home Assistant — this shows up as devices "going unavailable" intermittently. Use update() with a PollingComponent interval, or a proper non-blocking state machine in loop().
- Validate the Python schema separately from the C++. Run esphome config your-device.yaml to catch schema errors before a full compile — it's much faster than waiting through a full build to discover a YAML key typo.
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.