Interfacing via Serial/UART
We have an example, minimal UART-based component.
Let’s take a closer look at this example.
Python
Section titled “Python”In addition to the usual requisite imports, we have:
from esphome.components import uartThis allows us to use some functions we’ll need for validation and code generation later on.
DEPENDENCIES = ["uart"]Given that this component will use—and consequently depend on—a UART, we must add it as a dependency. This allows ESPHome to understand this requirement and generate an error if it is not met.
empty_uart_component_ns = cg.esphome_ns.namespace("empty_uart_component")EmptyUARTComponent = empty_uart_component_ns.class_( "EmptyUARTComponent", cg.Component, uart.UARTDevice)This is some boilerplate code which allows ESPHome code generation to understand the namespace and class names that the
component will use. Note that EmptyUARTComponent is inheriting from both Component and UARTDevice.
CONFIG_SCHEMA = ...This defines the configuration schema for the component as discussed in Configuration validation.
In particular, note that the schema is extended with .extend(uart.UART_DEVICE_SCHEMA) since this is a UART
component/platform.
Finally, in the to_code function, we have:
await uart.register_uart_device(var, config)Since this is a serial device which uses a UART, we must register it as such so it is handled appropriately by ESPHome.
When the component is created with cg.new_Pvariable(), uart.new_uart_component() creates it, registers it as a
component and registers it on the UART in one call:
# Beforevar = cg.new_Pvariable(config[CONF_ID])await cg.register_component(var, config)await uart.register_uart_device(var, config)
# Aftervar = await uart.new_uart_component(config)Arguments after config go to the constructor, for example await uart.new_uart_component(config, config[CONF_MODE]).
I2C and SPI have the same helper.
The C++ class for this example component is quite simple.
class EmptyUARTComponent : public uart::UARTDevice, public Component { ... };As mentioned in the codebase standards, all components/platforms must inherit from either Component or
PollingComponent; our example here is no different. Note that, since it’s a UART device, it also inherits from
UARTDevice.
Finally, the component implements the usual set of methods as described here. This is all that’s required for our minimal UART component!
Reading what has arrived
Section titled “Reading what has arrived”A component that parses a byte stream usually reads everything the UART has received on each pass of loop().
Reading in blocks costs less than one call per byte. read_available() on UARTDevice reads the bytes available
now through a buffer on the stack and calls a function for each byte. It replaces the available() and
read_array() batching loop that many components used to carry:
void Tuya::loop() { this->read_available([this](uint8_t b) { this->handle_char_(b); }); process_command_queue_();}read_available_chunks() does the same but calls the function once per block, as
(const uint8_t *data, size_t len), for code that copies or scans whole blocks. Details:
-
The buffer is 64 bytes by default. Both helpers take another size as the template argument.
-
Only the bytes that were available when the call started are read, so a fast sender cannot keep
loop()busy. To read a set number of bytes instead, pass it after the function; it must not be more thanavailable():this->read_available_chunks<128>([this](const uint8_t *data, size_t len) { this->parse_(data, len); }, count); -
If the function returns
bool, returningfalsestops the reading. Withread_available(), the rest of the current block has already been taken from the UART and is lost. A function that returnsvoidalways continues. -
Both return
falseonly when a read from the UART fails; stopping early still returnstrue.
A device that answers each command usually needs any old bytes dropped before the next command is sent, so a late
reply is not read as the new one. discard_available() reads and drops everything waiting in the receive buffer:
void MyDevice::send_command_(const uint8_t *command, size_t len) { this->discard_available(); this->write_array(command, len);}Unlike read_available(), it keeps reading while bytes arrive, so call it before a command rather than on a device
that sends without pause. It stops if a read fails. It is available in ESPHome 2026.11.0 and later
(esphome/esphome#21029).
UARTs Without Line Timing
Section titled “UARTs Without Line Timing”On a serial line, bytes arrive at the pace of the baud rate, so a reader can find the end of a frame from a quiet gap, such as the 3.5 characters that end a Modbus RTU frame. Some UARTs hand over received bytes in packets instead, and a gap between them says nothing about where a frame ends:
tcp_uartreads whatever bytes the network has delivered so far, in chunks timed by the network rather than by the line; its baud rate is only a placeholder.- A
usb_uartchannel receives USB packets from the adapter; an FTDI chip, for example, holds bytes for up to 16 ms by default (its latency timer). usb_cdc_acmlearns its baud rate only when the host opens the port.ble_nusreceives BLE packets.
These UARTs mark their declared id with uart.mark_unclocked in CONFIG_SCHEMA. A new UART like them, a component
that provides a uart::UARTComponent rather than a UARTDevice, does the same:
import esphome.codegen as cgfrom esphome.components import uartimport esphome.config_validation as cv
AUTO_LOAD = ["uart"]
my_link_ns = cg.esphome_ns.namespace("my_link")MyLink = my_link_ns.class_("MyLink", uart.UARTComponent, cg.Component)
CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.All(cv.declare_id(MyLink), uart.mark_unclocked), }).extend(cv.COMPONENT_SCHEMA)A UART device that ends a frame after a quiet gap calls uart.is_unclocked(config[CONF_UART_ID]) from to_code
(CONF_UART_ID is in esphome.const) and waits longer on such a UART, or does not rely on the gap. The marks are made
while the schemas run, so every to_code sees them, whatever the order of the YAML. is_unclocked() compares ids by
name, so a generated id is found too. Both are plain functions, not coroutines.
Components that own a UART
Section titled “Components that own a UART”A component that copies bytes into a UART from a faster source, such as a socket, should not block on the line.
paced_write_room() returns how many bytes a write can take now:
// Members: the bytes that wait for the UART, and the loop start time of the last write.const uint8_t *pending_{nullptr};size_t pending_len_{0};uint32_t last_write_ms_{0};
// Move at most what the UART can take now.size_t n = std::min(this->parent_->paced_write_room(this->last_write_ms_), this->pending_len_);if (n != 0) { this->write_array(this->pending_, n); this->pending_ += n; this->pending_len_ -= n; this->last_write_ms_ = App.get_loop_component_start_time();}It returns size_t, so std::min with a size_t length works as shown. What it returns:
- Where the driver reports its TX room,
available_for_write(); 0 means the TX buffer is full. - Otherwise, the bytes the line carries at 10 bits per byte (8N1) in the time since
last_write_ms, a loop start time fromApp.get_loop_component_start_time(). That time is capped at one loop interval and at 4 s, whichever is smaller. - Never less than one byte. At a low baud rate a loop that wakes often can therefore still get ahead of the line.
uart_tcp uses it.
A component that reads the UART for itself can reject other users in its final validation:
from esphome.components import uartimport esphome.config_validation as cvfrom esphome.const import CONF_UART_IDfrom esphome.core import COREimport esphome.final_validate as fvfrom esphome.types import ConfigType
DOMAIN = "my_bridge"
def _final_validate(config: ConfigType) -> ConfigType: uart_id = str(config[CONF_UART_ID]) # Grouped CI builds share one bus between components. if CORE.testing_mode: return config for domain, domain_conf in fv.full_config.get().items(): if domain != DOMAIN and uart.subtree_references_uart(domain_conf, uart_id): raise cv.Invalid( f"The UART '{uart_id}' is also used by '{domain}'. " f"{DOMAIN} requires exclusive use of that UART.", [CONF_UART_ID], ) return config
FINAL_VALIDATE_SCHEMA = _final_validateThe check is best effort: the third argument, conf_key, finds the UART under another key than uart_id, but bare
id: references, such as a uart.write action, and lambdas are not found. The loop above skips the component’s own
domain, which fits a top-level, single-instance component; a platform (whose config sits under sensor: and the like)
or a MULTI_CONF component must skip its own config node instead, or it rejects itself and misses a second instance
on the same bus. uart_tcp runs the check exactly as shown. The cdc_acm_uart bridge platform skips its whole
bridge: domain and records the ids it has claimed in fv.full_config.get().data, so a second bridge on the same
UART is rejected too.
Reading text lines
Section titled “Reading text lines”Devices that send text, one reading per line, need the bytes collected until the line ends. LineReader from
esphome/core/line_reader.h does this in a fixed buffer, with no std::string and no heap use. It takes bytes from any
source, not only a UART:
#include "esphome/core/line_reader.h"
LineReader<MAX_LINE_LENGTH, '\r', '\n'> line_reader_;void SMT100Component::loop() { while (this->available() != 0) { const int readch = this->read(); if (readch <= 0) continue; char *line = this->line_reader_.feed(readch); if (line != nullptr && line[0] != '\0') { char *token = strtok(line, ","); ... } }}LineReader<N, TERMINATOR_CHAR = '\n', IGNORE_CHAR = '\r'> has a buffer of N bytes, so a line holds up to N - 1
characters. TERMINATOR_CHAR ends a line and is not stored. IGNORE_CHAR is dropped wherever it appears; set it to
'\0' to drop nothing but NUL bytes. feed() takes one byte and returns the finished line, ending in a NUL, or
nullptr while the line is not complete; feed(c, len) also sets len to the line’s length. The line stays valid
until the next call on the reader, and the caller may change it, for example with strtok(). Empty lines are returned
too, as an empty string.
A line longer than N - 1 characters is dropped up to its terminator, and overflowed() is true right after the
byte that made it too long. A device that does not always end its lines can use that moment to report the start of
the line and start again, as hrxl_maxsonar_wr does:
size_t len;const char *line = this->line_reader_.feed(data, len);if (line != nullptr) { this->handle_line_(line, len);} else if (this->line_reader_.overflowed()) { ESP_LOGW(TAG, "Invalid data read from sensor: %s", this->line_reader_.partial()); this->line_reader_.reset();}partial() returns the line collected so far, for prompts that have no line end. reset() forgets it and starts a new
line with the next byte, restart_with(c) starts a new line with c, and discard_line() drops everything up to the
next terminator. log_if_overflowed(TAG) logs one shared warning when a line was dropped; a component that calls it
adds cg.add_define("USE_LINE_READER") in its to_code, which compiles that shared code in. Besides the buffer the
reader keeps one byte for its position, or two bytes when N is 254 or more.
Copyright © 2026 ESPHome - A project from the Open Home Foundation