From d824a32ef1f77c8be80e3d5800c2ca8848585a49 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 15 Sep 2026 20:32:15 -0500 Subject: [PATCH] [uart_mux] New component to share a UART between a CDC-ACM bridge and local consumers (#19066) Co-authored-by: Claude Fable 5.1 Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../cdc_acm_uart/bridge/cdc_acm_uart_bridge.h | 1 + esphome/components/uart_mux/__init__.py | 84 ++++++++++++ esphome/components/uart_mux/uart_mux.cpp | 127 ++++++++++++++++++ esphome/components/uart_mux/uart_mux.h | 109 +++++++++++++++ script/analyze_component_buses.py | 1 + tests/component_tests/uart_mux/__init__.py | 0 tests/component_tests/uart_mux/test_init.py | 42 ++++++ tests/components/uart_mux/common.yaml | 44 ++++++ .../uart_mux/test.esp32-p4-idf.yaml | 2 + .../uart_mux/test.esp32-s2-idf.yaml | 7 + .../uart_mux/test.esp32-s3-idf.yaml | 2 + 12 files changed, 420 insertions(+) create mode 100644 esphome/components/uart_mux/__init__.py create mode 100644 esphome/components/uart_mux/uart_mux.cpp create mode 100644 esphome/components/uart_mux/uart_mux.h create mode 100644 tests/component_tests/uart_mux/__init__.py create mode 100644 tests/component_tests/uart_mux/test_init.py create mode 100644 tests/components/uart_mux/common.yaml create mode 100644 tests/components/uart_mux/test.esp32-p4-idf.yaml create mode 100644 tests/components/uart_mux/test.esp32-s2-idf.yaml create mode 100644 tests/components/uart_mux/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 246a210c7cb..044d0051193 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h index 64522c86bd5..405b794653d 100644 --- a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h @@ -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 diff --git a/esphome/components/uart_mux/__init__.py b/esphome/components/uart_mux/__init__.py new file mode 100644 index 00000000000..6c479f0cdd2 --- /dev/null +++ b/esphome/components/uart_mux/__init__.py @@ -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 +) diff --git a/esphome/components/uart_mux/uart_mux.cpp b/esphome/components/uart_mux/uart_mux.cpp new file mode 100644 index 00000000000..df2ee5ccf59 --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.cpp @@ -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(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 diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h new file mode 100644 index 00000000000..1eb23747142 --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.h @@ -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 class SelectLocalAction final : public Action { + public: + explicit SelectLocalAction(UARTMux *parent) : parent_(parent) {} + void play(const Ts &...) override { this->parent_->select_local(); } + + protected: + UARTMux *parent_; +}; + +template class SelectBridgeAction final : public Action { + public: + explicit SelectBridgeAction(UARTMux *parent) : parent_(parent) {} + void play(const Ts &...) override { this->parent_->select_bridge(); } + + protected: + UARTMux *parent_; +}; + +template class IsLocalCondition final : public Condition { + 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 diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index b805d5155a2..8bbb9ed7f9b 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -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", } diff --git a/tests/component_tests/uart_mux/__init__.py b/tests/component_tests/uart_mux/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/uart_mux/test_init.py b/tests/component_tests/uart_mux/test_init.py new file mode 100644 index 00000000000..2221ec56694 --- /dev/null +++ b/tests/component_tests/uart_mux/test_init.py @@ -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")) diff --git a/tests/components/uart_mux/common.yaml b/tests/components/uart_mux/common.yaml new file mode 100644 index 00000000000..3477f78b708 --- /dev/null +++ b/tests/components/uart_mux/common.yaml @@ -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); + } diff --git a/tests/components/uart_mux/test.esp32-p4-idf.yaml b/tests/components/uart_mux/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..ced6f1158eb --- /dev/null +++ b/tests/components/uart_mux/test.esp32-p4-idf.yaml @@ -0,0 +1,2 @@ +packages: + uart_mux: !include common.yaml diff --git a/tests/components/uart_mux/test.esp32-s2-idf.yaml b/tests/components/uart_mux/test.esp32-s2-idf.yaml new file mode 100644 index 00000000000..5eaa3b38479 --- /dev/null +++ b/tests/components/uart_mux/test.esp32-s2-idf.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 diff --git a/tests/components/uart_mux/test.esp32-s3-idf.yaml b/tests/components/uart_mux/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..ced6f1158eb --- /dev/null +++ b/tests/components/uart_mux/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + uart_mux: !include common.yaml