[uart_mux] New component to share a UART between a CDC-ACM bridge and local consumers (#19066)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Keith Burzinski
2026-09-15 20:32:15 -05:00
committed by GitHub
co-authored by Claude Fable 5.1 J. Nick Koston
parent ec1d8fa953
commit d824a32ef1
12 changed files with 420 additions and 0 deletions
+1
View File
@@ -589,6 +589,7 @@ esphome/components/uart/* @esphome/core
esphome/components/uart/button/* @ssieb
esphome/components/uart/event/* @eoasmxd
esphome/components/uart/packet_transport/* @clydebarrow
esphome/components/uart_mux/* @kbx81
esphome/components/udp/* @clydebarrow
esphome/components/ufire_ec/* @pvizeli
esphome/components/ufire_ise/* @pvizeli
@@ -30,6 +30,7 @@ class CDCACMUARTBridge final : public Component {
void set_line_coding();
void set_line_state(bool dtr, bool rts);
uart::IDFUARTComponent *get_uart_parent() const { return this->uart_parent_; }
/**
* Stop forwarding in both directions and hand the UART back to its configured
+84
View File
@@ -0,0 +1,84 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32, uart
from esphome.components.cdc_acm_uart.bridge import CDCACMUARTBridge
from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3
import esphome.config_validation as cv
from esphome.const import CONF_ID
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@kbx81"]
DOMAIN = "uart_mux"
DEPENDENCIES = ["bridge", "uart"]
MULTI_CONF = True
CONF_BRIDGE_ID = "bridge_id"
CONF_INITIAL_ROUTE = "initial_route"
ROUTE_BRIDGE = "bridge"
ROUTE_LOCAL = "local"
uart_mux_ns = cg.esphome_ns.namespace("uart_mux")
UARTMux = uart_mux_ns.class_("UARTMux", uart.UARTComponent, cg.Component)
SelectLocalAction = uart_mux_ns.class_("SelectLocalAction", automation.Action)
SelectBridgeAction = uart_mux_ns.class_("SelectBridgeAction", automation.Action)
IsLocalCondition = uart_mux_ns.class_("IsLocalCondition", automation.Condition)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(UARTMux),
cv.Required(CONF_BRIDGE_ID): cv.use_id(CDCACMUARTBridge),
cv.Optional(CONF_INITIAL_ROUTE, default=ROUTE_BRIDGE): cv.one_of(
ROUTE_BRIDGE, ROUTE_LOCAL, lower=True
),
}
).extend(cv.COMPONENT_SCHEMA),
esp32.only_on_variant(
supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3],
),
)
def _final_validate(config: ConfigType) -> ConfigType:
# Two muxes on one bridge would each believe they own the bus.
owned = fv.full_config.get().data.setdefault(DOMAIN, set())
bridge_id = str(config[CONF_BRIDGE_ID])
if bridge_id in owned:
raise cv.Invalid(
f"The bridge '{bridge_id}' is already routed by another 'uart_mux'; "
"each bridge supports one mux.",
[CONF_BRIDGE_ID],
)
owned.add(bridge_id)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
bridge = await cg.get_variable(config[CONF_BRIDGE_ID])
var = cg.new_Pvariable(config[CONF_ID], bridge)
await cg.register_component(var, config)
if config[CONF_INITIAL_ROUTE] == ROUTE_LOCAL:
cg.add(var.set_start_local(True))
UART_MUX_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.Required(CONF_ID): cv.use_id(UARTMux)}
)
automation.register_simple_action(
"uart_mux.select_local", SelectLocalAction, UART_MUX_ACTION_SCHEMA, synchronous=True
)
automation.register_simple_action(
"uart_mux.select_bridge",
SelectBridgeAction,
UART_MUX_ACTION_SCHEMA,
synchronous=True,
)
automation.register_simple_condition(
"uart_mux.is_local", IsLocalCondition, UART_MUX_ACTION_SCHEMA
)
+127
View File
@@ -0,0 +1,127 @@
#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
#include "uart_mux.h"
#include "esphome/core/log.h"
#include "driver/uart.h"
namespace esphome::uart_mux {
static const char *const TAG = "uart_mux";
void UARTMux::setup() {
// A failed UART never assigned its port; nothing behind the mux can work.
if (this->uart_->is_failed()) {
ESP_LOGE(TAG, "UART parent failed; aborting");
this->mark_failed();
return;
}
this->settings_ = {
this->uart_->get_baud_rate(), this->uart_->get_rx_full_threshold(), this->uart_->get_rx_timeout(),
this->uart_->get_rx_buffer_size(), this->uart_->get_data_bits(), this->uart_->get_stop_bits(),
this->uart_->get_parity(),
};
this->apply_settings_();
if (this->start_local_) {
this->select_local();
} else {
// loop() only completes hand-offs; the bridge keeps the UART until an action.
this->disable_loop();
}
}
void UARTMux::loop() {
if (!this->bridge_->is_paused()) {
return;
}
// Bytes that arrived during the hand-off belong to neither owner.
this->flush_input_();
this->route_ = Route::ROUTE_LOCAL;
ESP_LOGD(TAG, "UART routed to local consumers");
this->disable_loop();
}
void UARTMux::dump_config() {
ESP_LOGCONFIG(TAG,
"UART Mux:\n"
" Start local: %s\n"
" Route: %s",
YESNO(this->start_local_),
this->route_ == Route::ROUTE_LOCAL ? LOG_STR_LITERAL("local")
: this->route_ == Route::ROUTE_PENDING_LOCAL ? LOG_STR_LITERAL("pending local")
: LOG_STR_LITERAL("bridge"));
}
void UARTMux::load_settings(bool dump_config) {
if (!this->load_settings_warned_) {
this->load_settings_warned_ = true;
ESP_LOGW(TAG, "load_settings() ignored; change the framing on the hardware UART instead");
}
// Undo whatever the caller set on us. Not re-sampled from the live UART, whose
// fields carry the host's line coding while the bridge owns the bus.
this->apply_settings_();
}
void UARTMux::apply_settings_() {
this->baud_rate_ = this->settings_.baud_rate;
this->data_bits_ = this->settings_.data_bits;
this->stop_bits_ = this->settings_.stop_bits;
this->parity_ = this->settings_.parity;
this->rx_full_threshold_ = this->settings_.rx_full_threshold;
this->rx_timeout_ = this->settings_.rx_timeout;
this->rx_buffer_size_ = this->settings_.rx_buffer_size;
}
void UARTMux::select_local() {
if (this->route_ != Route::ROUTE_BRIDGE) {
return;
}
ESP_LOGD(TAG, "Pausing bridge to route UART locally");
this->bridge_->pause();
this->route_ = Route::ROUTE_PENDING_LOCAL;
this->enable_loop();
}
void UARTMux::select_bridge() {
if (this->route_ == Route::ROUTE_BRIDGE) {
return;
}
// A bridge that failed setup() has no worker tasks; handing it the bus would kill
// the UART in both directions.
if (this->bridge_->is_failed()) {
ESP_LOGW(TAG, "Bridge failed; keeping the UART routed locally");
return;
}
// While the pause is still pending the bridge's RX task may be inside
// uart_read_bytes() on this port, and nothing local has run, so flush only a
// completed hand-off.
if (this->route_ == Route::ROUTE_LOCAL) {
this->flush_input_();
}
this->route_ = Route::ROUTE_BRIDGE;
ESP_LOGD(TAG, "UART routed to bridge");
this->bridge_->resume();
this->disable_loop();
}
void UARTMux::flush_input_() {
// Drain the UART component's one-byte peek cache first: the driver flush does not
// clear it, and draining afterwards could discard a freshly arrived byte instead.
uint8_t discard;
if (this->uart_->available() > 0) {
this->uart_->read_byte(&discard);
}
uart_flush_input(static_cast<uart_port_t>(this->uart_->get_hw_serial_number()));
}
void UARTMux::write_array(const uint8_t *data, size_t len) {
if (!this->is_local()) {
ESP_LOGV(TAG, "Dropping %zu bytes: UART routed to bridge", len);
return;
}
this->uart_->write_array(data, len);
}
} // namespace esphome::uart_mux
#endif
+109
View File
@@ -0,0 +1,109 @@
#pragma once
#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
#include "esphome/components/uart/uart_component.h"
#include "esphome/components/uart/uart_component_esp_idf.h"
#include "esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
namespace esphome::uart_mux {
/// Shares one hardware UART between a CDC-ACM UART bridge and local consumers. Local
/// consumers bind to the mux as their UART; it forwards to the hardware UART only
/// while routed locally and reports the route through is_connected(). Routing is
/// driven by the select_*() actions, typically from tinyusb's on_mount/on_unmount.
class UARTMux final : public uart::UARTComponent, public Component {
public:
explicit UARTMux(cdc_acm_uart::CDCACMUARTBridge *bridge) : uart_(bridge->get_uart_parent()), bridge_(bridge) {}
void setup() override;
void loop() override;
void dump_config() override;
// Between the hardware UART (BUS) and its consumers (modbus is BUS - 1): the
// mirrored framing must exist before anything reads it from us.
float get_setup_priority() const override { return setup_priority::BUS - 0.5f; }
/// Route locally at boot instead of leaving the UART with the bridge.
void set_start_local(bool start_local) { this->start_local_ = start_local; }
/// Pause the bridge and route the UART to local consumers once it has stopped.
void select_local();
/// Route the UART back to the bridge.
void select_bridge();
bool is_local() const { return this->route_ == Route::ROUTE_LOCAL; }
// uart::UARTComponent: forwarded while routed locally, inert otherwise.
void write_array(const uint8_t *data, size_t len) override;
bool peek_byte(uint8_t *data) override { return this->is_local() && this->uart_->peek_byte(data); }
bool read_array(uint8_t *data, size_t len) override { return this->is_local() && this->uart_->read_array(data, len); }
size_t available() override { return this->is_local() ? this->uart_->available() : 0; }
uart::UARTFlushResult flush() override {
return this->is_local() ? this->uart_->flush() : uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS;
}
bool is_connected() override { return this->is_local(); }
// Ignored: the bridge's tasks block inside the driver, and reinstalling it would
// pull it out from under them. The framing is the hardware UART's to change.
void load_settings(bool dump_config) override;
using UARTComponent::load_settings;
protected:
enum class Route : uint8_t {
ROUTE_BRIDGE,
ROUTE_PENDING_LOCAL, // pause() requested; the bridge may still be on the bus
ROUTE_LOCAL,
};
// The hardware UART's settings as configured. Taken once at setup, before the
// bridge can overwrite the live fields with a host's line coding.
struct Settings {
uint32_t baud_rate;
size_t rx_full_threshold;
size_t rx_timeout;
size_t rx_buffer_size;
uint8_t data_bits;
uint8_t stop_bits;
uart::UARTParityOptions parity;
};
void check_logger_conflict() override {}
void flush_input_();
// Publish settings_ through the UARTComponent getters.
void apply_settings_();
uart::IDFUARTComponent *uart_;
cdc_acm_uart::CDCACMUARTBridge *bridge_;
Settings settings_{};
Route route_{Route::ROUTE_BRIDGE};
bool start_local_{false};
bool load_settings_warned_{false};
};
template<typename... Ts> class SelectLocalAction final : public Action<Ts...> {
public:
explicit SelectLocalAction(UARTMux *parent) : parent_(parent) {}
void play(const Ts &...) override { this->parent_->select_local(); }
protected:
UARTMux *parent_;
};
template<typename... Ts> class SelectBridgeAction final : public Action<Ts...> {
public:
explicit SelectBridgeAction(UARTMux *parent) : parent_(parent) {}
void play(const Ts &...) override { this->parent_->select_bridge(); }
protected:
UARTMux *parent_;
};
template<typename... Ts> class IsLocalCondition final : public Condition<Ts...> {
public:
explicit IsLocalCondition(UARTMux *parent) : parent_(parent) {}
bool check(const Ts &...) override { return this->parent_->is_local(); }
protected:
UARTMux *parent_;
};
} // namespace esphome::uart_mux
#endif
+1
View File
@@ -97,6 +97,7 @@ ISOLATED_COMPONENTS = {
"neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)",
"packages": "cannot merge packages",
"tinyusb": "Conflicts with usb_host component - cannot be used together",
"uart_mux": "Depends on tinyusb which conflicts with usb_host",
"usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host",
}
@@ -0,0 +1,42 @@
"""Tests for the uart_mux component's final validation."""
import pytest
from esphome import config_validation as cv
from esphome.const import CONF_ID, PlatformFramework
from esphome.core import ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
CONF_BRIDGE_ID = "bridge_id"
def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None:
from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3
set_core_config(
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32S3}
)
def _mux_conf(mux_id: str, bridge_id: str) -> ConfigType:
return {CONF_ID: ID(mux_id), CONF_BRIDGE_ID: ID(bridge_id)}
def test_accepts_one_mux_per_bridge(set_core_config: SetCoreConfigCallable) -> None:
_set_esp32_s3(set_core_config)
from esphome.components import uart_mux
uart_mux._final_validate(_mux_conf("mux_0", "bridge_0"))
uart_mux._final_validate(_mux_conf("mux_1", "bridge_1"))
def test_rejects_two_muxes_on_one_bridge(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config)
from esphome.components import uart_mux
uart_mux._final_validate(_mux_conf("mux_0", "bridge_0"))
with pytest.raises(cv.Invalid, match="already routed by another 'uart_mux'"):
uart_mux._final_validate(_mux_conf("mux_1", "bridge_0"))
+44
View File
@@ -0,0 +1,44 @@
tinyusb:
id: tinyusb_test
on_mount:
- uart_mux.select_bridge: mux_0
on_unmount:
- uart_mux.select_local: mux_0
usb_manufacturer_str: ESPHomeTestManufacturer
usb_product_id: 0x1234
usb_product_str: ESPHomeTestProduct
usb_vendor_id: 0x2345
uart:
- id: uart_0
tx_pin: 14
rx_pin: 13
baud_rate: 115200
usb_cdc_acm:
interfaces:
- id: cdc_acm_1
bridge:
- platform: cdc_acm_uart
id: bridge_0
uart_id: uart_0
usb_cdc_acm_id: cdc_acm_1
uart_mux:
- id: mux_0
bridge_id: bridge_0
initial_route: local
interval:
- interval: 60s
then:
- if:
condition:
uart_mux.is_local: mux_0
then:
- lambda: |-
uint8_t byte;
if (id(mux_0).available() && id(mux_0).read_byte(&byte)) {
id(mux_0).write_byte(byte);
}
@@ -0,0 +1,2 @@
packages:
uart_mux: !include common.yaml
@@ -0,0 +1,7 @@
# ESP32-S2 has no USB_SERIAL_JTAG, so the logger defaults to USB_CDC, which shares
# the USB OTG peripheral with tinyusb. Use a hardware UART for logging instead.
logger:
hardware_uart: UART0
packages:
uart_mux: !include common.yaml
@@ -0,0 +1,2 @@
packages:
uart_mux: !include common.yaml