mirror of
https://github.com/esphome/esphome.git
synced 2026-09-18 02:28:42 +00:00
[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:
co-authored by
Claude Fable 5.1
J. Nick Koston
parent
ec1d8fa953
commit
d824a32ef1
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user