[bridge] New component and cdc_acm_uart platform (#11689)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
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-11 22:08:23 -05:00
committed by GitHub
co-authored by Jesse Hills pre-commit-ci-lite[bot] Claude Fable 5.1 J. Nick Koston
parent eecea15f4f
commit ebb9037ea1
18 changed files with 983 additions and 29 deletions
+3
View File
@@ -100,6 +100,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
esphome/components/bmp581_spi/* @danielkent-net @kahrendt
esphome/components/bp1658cj/* @Cossid
esphome/components/bp5758d/* @Cossid
esphome/components/bridge/* @kbx81
esphome/components/bthome_mithermometer/* @nagyrobi
esphome/components/button/* @esphome/core
esphome/components/bytebuffer/* @clydebarrow
@@ -111,6 +112,8 @@ esphome/components/captive_portal/* @esphome/core
esphome/components/cc1101/* @gabest11 @lygris
esphome/components/ccs811/* @habbie
esphome/components/cd74hc4067/* @asoehlke
esphome/components/cdc_acm_uart/* @kbx81
esphome/components/cdc_acm_uart/bridge/* @kbx81
esphome/components/ch422g/* @clydebarrow @jesterret
esphome/components/ch423/* @dwmw2
esphome/components/chsc6x/* @kkosik20
+4
View File
@@ -0,0 +1,4 @@
CODEOWNERS = ["@kbx81"]
DOMAIN = "bridge"
IS_PLATFORM_COMPONENT = True
@@ -0,0 +1 @@
CODEOWNERS = ["@kbx81"]
@@ -0,0 +1,114 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import esp32, uart, usb_cdc_acm
from esphome.components.bridge import DOMAIN as BRIDGE_DOMAIN
from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3
import esphome.config_validation as cv
from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@kbx81"]
DEPENDENCIES = ["tinyusb", "uart", "usb_cdc_acm"]
CONF_DTR_PIN = "dtr_pin"
CONF_RTS_PIN = "rts_pin"
CONF_USB_CDC_ACM_ID = "usb_cdc_acm_id"
cdc_acm_uart_ns = cg.esphome_ns.namespace("cdc_acm_uart")
CDCACMUARTBridge = cdc_acm_uart_ns.class_("CDCACMUARTBridge", cg.Component)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(CDCACMUARTBridge),
cv.Required(CONF_UART_ID): cv.use_id(uart.IDFUARTComponent),
cv.Required(CONF_USB_CDC_ACM_ID): cv.use_id(usb_cdc_acm.USBCDCACMInstance),
cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema,
}
).extend(cv.COMPONENT_SCHEMA),
# Narrower than usb_cdc_acm's variant list on purpose: S31/H4 untested on
# hardware; extend once verified.
esp32.only_on_variant(
supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3],
),
)
def _subtree_references_uart(node: object, uart_id: str) -> bool:
"""Return True if any dict in the subtree has a uart_id entry naming this bus."""
if isinstance(node, dict):
return any(
(key == CONF_UART_ID and str(value) == uart_id)
or _subtree_references_uart(value, uart_id)
for key, value in node.items()
)
if isinstance(node, list):
return any(_subtree_references_uart(item, uart_id) for item in node)
return False
def _reject_debug(uart_conf: ConfigType) -> ConfigType:
# The worker tasks use the IDF driver directly, so the uart debugger never sees
# bridge traffic and its dummy_receiver would drain RX bytes on the main loop.
if CONF_DEBUG in uart_conf:
raise cv.Invalid(
"A bridged UART cannot use 'debug'; the bridge bypasses the UART "
"component's read/write path.",
[CONF_DEBUG],
)
return uart_conf
def _final_validate(config: ConfigType) -> ConfigType:
full_config = fv.full_config.get()
# Bridges of any platform must own their interfaces exclusively; shared ring
# buffers and overwritten callbacks would corrupt both streams silently. The
# seen-set is keyed on the bridge domain so future platforms share it.
# Other components bind either interface through the same uart_id key (the CDC
# instance is itself a uart::UARTComponent) and would race the worker tasks.
# Bare `id:` references (a uart.write action) cannot be distinguished; not caught.
data = full_config.data.setdefault(BRIDGE_DOMAIN, {})
for conf_key, label in (
(CONF_UART_ID, "UART"),
(CONF_USB_CDC_ACM_ID, "USB CDC-ACM interface"),
):
owned_id = str(config[conf_key])
used = data.setdefault(conf_key, set())
if owned_id in used:
raise cv.Invalid(
f"The {label} '{owned_id}' is already bridged by another 'bridge' "
f"instance; each bridge requires its own {label}.",
[conf_key],
)
used.add(owned_id)
for domain, domain_conf in full_config.items():
if domain == BRIDGE_DOMAIN:
continue
if _subtree_references_uart(domain_conf, owned_id):
raise cv.Invalid(
f"The {label} '{owned_id}' is also used by '{domain}'; a bridge "
f"requires exclusive use of its {label}.",
[conf_key],
)
fv.id_declaration_match_schema(_reject_debug)(config[CONF_UART_ID])
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
uart_component = await cg.get_variable(config[CONF_UART_ID])
usb_cdc = await cg.get_variable(config[CONF_USB_CDC_ACM_ID])
var = cg.new_Pvariable(config[CONF_ID], uart_component, usb_cdc)
await cg.register_component(var, config)
if dtr_pin_config := config.get(CONF_DTR_PIN):
dtr_pin = await cg.gpio_pin_expression(dtr_pin_config)
cg.add(var.set_dtr_pin(dtr_pin))
if rts_pin_config := config.get(CONF_RTS_PIN):
rts_pin = await cg.gpio_pin_expression(rts_pin_config)
cg.add(var.set_rts_pin(rts_pin))
@@ -0,0 +1,468 @@
#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
#include "cdc_acm_uart_bridge.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <algorithm>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/ringbuf.h"
#include "driver/uart.h"
#include "soc/soc_caps.h"
namespace esphome::cdc_acm_uart {
static const char *const TAG = "cdc_acm_uart";
static constexpr size_t UART_TASK_STACK_SIZE = 4096;
static constexpr size_t RINGBUF_RETRY_CHUNK_SIZE = 64;
static constexpr uint32_t LOG_THROTTLE_MS = 1000;
static constexpr uint32_t UART_RELOAD_SETTLE_MS = 20;
// Above the default priority but below the USB/Wi-Fi system tasks.
static constexpr UBaseType_t TASK_PRIORITY = 4;
static bool should_log_now(uint32_t *last_ms, uint32_t interval_ms) {
uint32_t now = millis();
if ((now - *last_ms) >= interval_ms) {
*last_ms = now;
return true;
}
return false;
}
static bool ringbuf_send_with_retry(RingbufHandle_t ringbuf, const uint8_t *data, size_t len, uint32_t *log_ms) {
if (len == 0) {
return true;
}
if (xRingbufferSend(ringbuf, data, len, pdMS_TO_TICKS(1)) == pdTRUE) {
return true;
}
size_t offset = 0;
while (offset < len) {
size_t chunk = std::min(RINGBUF_RETRY_CHUNK_SIZE, len - offset);
if (xRingbufferSend(ringbuf, data + offset, chunk, pdMS_TO_TICKS(1)) != pdTRUE) {
if (should_log_now(log_ms, LOG_THROTTLE_MS)) {
ESP_LOGW(TAG, "USB TX buffer full; some data is lost");
}
return false;
}
offset += chunk;
}
return true;
}
void CDCACMUARTBridge::setup() {
// Line state starts deasserted (no host yet); active-low DTR#/RTS# wiring is
// handled by configuring the pins inverted, so deasserted idles HIGH.
if (this->dtr_pin_ != nullptr) {
this->dtr_pin_->setup();
this->dtr_pin_->digital_write(false);
}
if (this->rts_pin_ != nullptr) {
this->rts_pin_->setup();
this->rts_pin_->digital_write(false);
}
// A failed UART never assigned its port number, so the worker tasks would run
// against an indeterminate port.
if (this->uart_parent_->is_failed()) {
ESP_LOGE(TAG, "UART parent failed; aborting");
this->mark_failed();
return;
}
this->configured_baud_rate_ = this->uart_parent_->get_baud_rate();
this->configured_parity_ = this->uart_parent_->get_parity();
this->configured_stop_bits_ = this->uart_parent_->get_stop_bits();
this->configured_data_bits_ = this->uart_parent_->get_data_bits();
// usb_cdc_acm sets up first (priority IO > HARDWARE). Any interface failing marks
// the hub failed, and a failed hub no longer runs loop(), so line coding and line
// state events would never reach this bridge even if its own interface is healthy.
if (this->usb_cdc_parent_->get_parent()->is_failed()) {
ESP_LOGE(TAG, "USB CDC ACM failed; aborting");
this->mark_failed();
return;
}
// Per-instance task names (keyed on the CDC interface number) keep task dumps
// unambiguous with multiple bridges.
char tx_task_name[] = "cdc_uart_tx_0";
char rx_task_name[] = "cdc_uart_rx_0";
const char itf_char = format_hex_char(this->usb_cdc_parent_->get_itf());
tx_task_name[sizeof(tx_task_name) - 2] = itf_char;
rx_task_name[sizeof(rx_task_name) - 2] = itf_char;
xTaskCreate(uart_tx_task_fn, tx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_tx_task_handle_);
if (this->uart_tx_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Failed to create UART TX task");
this->mark_failed();
return;
}
xTaskCreate(uart_rx_task_fn, rx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_rx_task_handle_);
if (this->uart_rx_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Failed to create UART RX task");
vTaskDelete(this->uart_tx_task_handle_);
this->uart_tx_task_handle_ = nullptr;
this->mark_failed();
return;
}
// Only register callbacks once both tasks exist, so a failed setup never drives
// DTR/RTS from a dead bridge.
this->usb_cdc_parent_->set_line_state_callback([this](bool dtr, bool rts) { this->set_line_state(dtr, rts); });
this->usb_cdc_parent_->set_line_coding_callback([this](uint32_t, uint8_t, uint8_t, uint8_t) {
this->host_coding_seen_ = true;
// Another component owns the UART's framing while paused; resume() re-syncs.
if (this->paused_ == 0) {
this->set_line_coding();
}
});
// Release the workers only now: until here a failed setup may still delete the TX
// task, which is safe only while it is parked and owns nothing in the driver.
xTaskNotifyGive(this->uart_tx_task_handle_);
xTaskNotifyGive(this->uart_rx_task_handle_);
// loop() only services line-coding reloads; stay off the main loop until one is
// scheduled.
this->disable_loop();
}
void CDCACMUARTBridge::dump_config() {
ESP_LOGCONFIG(TAG,
"CDC-ACM UART Bridge:\n"
" UART Bus: %u\n"
" USB CDC Interface: %u",
this->uart_parent_->get_hw_serial_number(), this->usb_cdc_parent_->get_itf());
LOG_PIN(" DTR Pin: ", this->dtr_pin_);
LOG_PIN(" RTS Pin: ", this->rts_pin_);
}
void CDCACMUARTBridge::on_shutdown() {
// The UART (BUS) shuts down after this component (HARDWARE) and deletes its driver,
// freeing the ring buffer and mutexes the worker tasks block on. Suspending the
// tasks unlinks them from those objects first.
if (this->uart_rx_task_handle_ != nullptr) {
vTaskSuspend(this->uart_rx_task_handle_);
}
if (this->uart_tx_task_handle_ != nullptr) {
vTaskSuspend(this->uart_tx_task_handle_);
}
}
void CDCACMUARTBridge::loop() {
switch (this->state_) {
case MainState::MAIN_STATE_RELOAD_PENDING:
if ((App.get_loop_component_start_time() - this->reload_requested_at_) < UART_RELOAD_SETTLE_MS) {
return;
}
// Deliberately not gated on tx_idle_(): a host that re-codes the line mid-stream
// wants the new framing now, and its own in-flight bytes are its concern.
// apply_settings_live() rewrites the framing registers without reinstalling the
// driver, so the worker tasks blocked inside it are undisturbed.
this->uart_parent_->apply_settings_live();
this->state_ = MainState::MAIN_STATE_RUNNING;
break;
case MainState::MAIN_STATE_PAUSING:
case MainState::MAIN_STATE_RESUMING:
// Let a host write that was in flight drain, FIFO included, before a reload
// flushes the FIFOs and truncates it.
if (!this->tx_idle_()) {
return;
}
if (this->state_ == MainState::MAIN_STATE_PAUSING) {
this->restore_configured_framing_();
this->state_ = MainState::MAIN_STATE_PAUSED;
} else {
this->finish_resume_();
}
break;
default:
break;
}
this->disable_loop();
}
void CDCACMUARTBridge::set_line_coding() {
if (!this->sync_host_framing_()) {
return;
}
// Coalesce rapid line-coding updates from the host.
this->reload_requested_at_ = App.get_loop_component_start_time();
this->state_ = MainState::MAIN_STATE_RELOAD_PENDING;
// Main-loop context (via USBCDCACMInstance::process_events_).
this->enable_loop();
}
bool CDCACMUARTBridge::sync_host_framing_() {
// usb_cdc_acm has already translated the wire coding onto the CDC instance (main
// loop); mirror it here so the framing translation has a single source of truth.
bool changed = false;
// Reject 0 (the CDC B0/hang-up encoding; older IDF revisions divide by the rate)
// and rates above the SoC ceiling. Anything in between is the driver's call,
// matching what a YAML-configured UART accepts.
const uint32_t baud = this->usb_cdc_parent_->get_baud_rate();
if (baud == 0 || baud > SOC_UART_BITRATE_MAX) {
ESP_LOGW(TAG, "Ignoring unsupported baud rate %" PRIu32 " from host; keeping %" PRIu32, baud,
this->uart_parent_->get_baud_rate());
} else if (this->uart_parent_->get_baud_rate() != baud) {
this->uart_parent_->set_baud_rate(baud);
changed = true;
}
const uint8_t stop_bits = this->usb_cdc_parent_->get_stop_bits();
if (this->uart_parent_->get_stop_bits() != stop_bits) {
this->uart_parent_->set_stop_bits(stop_bits);
changed = true;
}
const auto parity = this->usb_cdc_parent_->get_parity();
if (this->uart_parent_->get_parity() != parity) {
this->uart_parent_->set_parity(parity);
changed = true;
}
// USB CDC permits data-bit counts the UART cannot represent (up to 16).
const uint8_t data_bits = this->usb_cdc_parent_->get_data_bits();
if (data_bits < 5 || data_bits > 8) {
ESP_LOGW(TAG, "Ignoring unsupported data bits %u from host; keeping %u", data_bits,
this->uart_parent_->get_data_bits());
} else if (this->uart_parent_->get_data_bits() != data_bits) {
this->uart_parent_->set_data_bits(data_bits);
changed = true;
}
if (changed) {
ESP_LOGV(TAG, "Line coding: baud=%" PRIu32 ", data_bits=%u, stop_bits=%u, parity=%u",
this->uart_parent_->get_baud_rate(), this->uart_parent_->get_data_bits(),
this->uart_parent_->get_stop_bits(), static_cast<uint8_t>(this->uart_parent_->get_parity()));
}
return changed;
}
void CDCACMUARTBridge::pause() {
if (this->state_ == MainState::MAIN_STATE_PAUSING || this->state_ == MainState::MAIN_STATE_PAUSED) {
return;
}
this->paused_ = 1;
// A null RX task means setup() has not completed (or failed): nothing to stop, and
// the framing snapshot does not exist yet. Should setup() run later, the RX task
// starts parked.
if (this->uart_rx_task_handle_ == nullptr) {
this->state_ = MainState::MAIN_STATE_PAUSED;
return;
}
// Drops a coalesced host reload or a pending resume; loop() restores the framing
// once any host write in flight has drained.
this->state_ = MainState::MAIN_STATE_PAUSING;
this->enable_loop();
}
void CDCACMUARTBridge::resume() {
if (this->state_ != MainState::MAIN_STATE_PAUSING && this->state_ != MainState::MAIN_STATE_PAUSED) {
return;
}
if (this->uart_rx_task_handle_ == nullptr) {
this->paused_ = 0;
this->state_ = MainState::MAIN_STATE_RUNNING;
return;
}
// A restore still waiting on the TX side is moot: the host's framing is kept.
if (!this->tx_idle_()) {
this->state_ = MainState::MAIN_STATE_RESUMING;
this->enable_loop();
return;
}
this->finish_resume_();
this->disable_loop();
}
void CDCACMUARTBridge::finish_resume_() {
// Take the bus back at a known framing before either task runs again: the host's
// if it ever sent one, else the YAML framing (the other owner may have changed it).
if (this->host_coding_seen_) {
this->sync_host_framing_();
this->uart_parent_->apply_settings_live();
} else {
this->restore_configured_framing_();
}
this->paused_ = 0;
this->state_ = MainState::MAIN_STATE_RUNNING;
this->drive_line_state_();
xTaskNotifyGive(this->uart_rx_task_handle_);
}
bool CDCACMUARTBridge::tx_idle_() {
const auto uart_num = static_cast<uart_port_t>(this->uart_parent_->get_hw_serial_number());
return this->tx_busy_ == 0 && uart_wait_tx_done(uart_num, 0) == ESP_OK;
}
void CDCACMUARTBridge::restore_configured_framing_() {
// Always applied: the cached settings can lead the hardware by a pending reload,
// so they are no proof of what is live.
this->uart_parent_->set_baud_rate(this->configured_baud_rate_);
this->uart_parent_->set_parity(this->configured_parity_);
this->uart_parent_->set_stop_bits(this->configured_stop_bits_);
this->uart_parent_->set_data_bits(this->configured_data_bits_);
this->uart_parent_->apply_settings_live();
}
void CDCACMUARTBridge::set_line_state(bool dtr, bool rts) {
ESP_LOGV(TAG, "Line state: DTR=%d, RTS=%d", dtr, rts);
this->host_dtr_ = dtr;
this->host_rts_ = rts;
// Frozen while paused: a host opening the port must not reset a peer that another
// component is talking to.
if (this->paused_ == 0) {
this->drive_line_state_();
}
}
void CDCACMUARTBridge::drive_line_state_() {
if (this->dtr_pin_ != nullptr) {
this->dtr_pin_->digital_write(this->host_dtr_);
}
if (this->rts_pin_ != nullptr) {
this->rts_pin_->digital_write(this->host_rts_);
}
}
void CDCACMUARTBridge::uart_rx_task_fn(void *arg) {
auto *bridge = static_cast<CDCACMUARTBridge *>(arg);
bridge->uart_rx_task_();
}
void CDCACMUARTBridge::uart_tx_task_fn(void *arg) {
auto *bridge = static_cast<CDCACMUARTBridge *>(arg);
bridge->uart_tx_task_();
}
void CDCACMUARTBridge::uart_rx_task_() {
TaskHandle_t usb_tx_handle = this->usb_cdc_parent_->get_tx_task_handle();
RingbufHandle_t usb_tx_ringbuf = this->usb_cdc_parent_->get_tx_ringbuf();
uart_port_t uart_num = static_cast<uart_port_t>(this->uart_parent_->get_hw_serial_number());
// Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs.
uint32_t tx_full_log_ms = millis() - LOG_THROTTLE_MS;
uint32_t err_log_ms = millis() - LOG_THROTTLE_MS;
uint8_t *data = this->uart_rx_buffer_.data();
const size_t buf_size = this->uart_rx_buffer_.size();
// Released by setup() once both tasks exist.
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
while (true) {
if (this->paused_ != 0) {
// Parked until resume() notifies; nothing is read, so the other owner sees
// every byte.
this->rx_parked_ = 1;
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
this->rx_parked_ = 0;
continue;
}
// Block until at least one byte is available from UART.
int total_rx_size = uart_read_bytes(uart_num, data, 1, pdMS_TO_TICKS(UART_RX_WAIT_MS));
if (total_rx_size < 0) {
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
ESP_LOGE(TAG, "UART read failed: %d", total_rx_size);
}
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
if (total_rx_size == 0) {
continue;
}
// pause() landed during the read: don't forward a byte to a host that is gone.
if (this->paused_ != 0) {
continue;
}
// Drain the currently buffered burst without waiting.
while (true) {
int rx_data_size = uart_read_bytes(uart_num, data + total_rx_size, buf_size - total_rx_size, 0);
if (rx_data_size < 0) {
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
ESP_LOGE(TAG, "UART read failed: %d", rx_data_size);
}
break;
}
if (rx_data_size == 0) {
break;
}
ESP_LOGV(TAG, "UART RX: %d bytes", rx_data_size);
total_rx_size += rx_data_size;
if (total_rx_size >= (int) buf_size) {
break;
}
}
ringbuf_send_with_retry(usb_tx_ringbuf, data, total_rx_size, &tx_full_log_ms);
ESP_LOGV(TAG, "UART RX: waking up USB TX task");
xTaskNotifyGive(usb_tx_handle);
}
}
void CDCACMUARTBridge::uart_tx_task_() {
RingbufHandle_t usb_rx_ringbuf = this->usb_cdc_parent_->get_rx_ringbuf();
uart_port_t uart_num = static_cast<uart_port_t>(this->uart_parent_->get_hw_serial_number());
uint8_t *data_to_uart = this->uart_tx_buffer_.data();
const size_t buf_size = this->uart_tx_buffer_.size();
size_t rx_size;
// Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs.
uint32_t err_log_ms = millis() - LOG_THROTTLE_MS;
uint32_t drop_log_ms = millis() - LOG_THROTTLE_MS;
// Released by setup() once both tasks exist.
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
while (true) {
ESP_LOGV(TAG, "Waiting for data to send to UART");
esp_err_t ret = usb_cdc_acm::ringbuf_read_bytes(usb_rx_ringbuf, data_to_uart, buf_size, &rx_size, portMAX_DELAY);
if (ret != ESP_OK) {
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
ESP_LOGE(TAG, "USB RX RingBuf read failed");
}
// Yield: this task runs above the main loop, so a persistent failure must not
// become a tight loop.
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
// Another component owns the UART; host bytes must not interleave with its traffic.
// tx_busy_ goes up before the check so is_paused() cannot miss a write in flight.
this->tx_busy_ = 1;
if (this->paused_ != 0) {
this->tx_busy_ = 0;
if (should_log_now(&drop_log_ms, LOG_THROTTLE_MS)) {
ESP_LOGW(TAG, "Paused; dropping %zu bytes from host", rx_size);
}
continue;
}
ESP_LOGV(TAG, "Sending %zu bytes to UART", rx_size);
// Signed: uart_write_bytes() returns -1 on error.
int xfer_size = uart_write_bytes(uart_num, data_to_uart, rx_size);
this->tx_busy_ = 0;
if (xfer_size < 0) {
if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) {
ESP_LOGE(TAG, "UART write failed: %d", xfer_size);
}
} else if (static_cast<size_t>(xfer_size) != rx_size) {
ESP_LOGW(TAG, "UART write incomplete (%d/%zu bytes)", xfer_size, rx_size);
}
}
}
} // namespace esphome::cdc_acm_uart
#endif
@@ -0,0 +1,117 @@
#pragma once
#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
#include "esphome/components/uart/uart_component_esp_idf.h"
#include "esphome/components/usb_cdc_acm/usb_cdc_acm.h"
#include "esphome/core/component.h"
#include <array>
#include <atomic>
#include "sdkconfig.h"
namespace esphome::cdc_acm_uart {
class CDCACMUARTBridge final : public Component {
public:
// Upper bound on the RX task's blocking read, so pause() takes effect without
// aborting the read. Arriving bytes still unblock it immediately.
static constexpr uint32_t UART_RX_WAIT_MS = 250;
CDCACMUARTBridge(uart::IDFUARTComponent *uart_parent, usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent)
: uart_parent_(uart_parent), usb_cdc_parent_(usb_cdc_parent) {}
void setup() override;
void loop() override;
void dump_config() override;
void on_shutdown() override;
float get_setup_priority() const override { return setup_priority::HARDWARE; }
void set_dtr_pin(GPIOPin *dtr_pin) { this->dtr_pin_ = dtr_pin; }
void set_rts_pin(GPIOPin *rts_pin) { this->rts_pin_ = rts_pin; }
void set_line_coding();
void set_line_state(bool dtr, bool rts);
/**
* Stop forwarding in both directions and hand the UART back to its configured
* framing, so another component may use the bus. Main-loop only. The RX task parks
* within UART_RX_WAIT_MS (a byte it was already reading is discarded). A host write
* already in flight is allowed to drain first, which at low baud rates can take
* seconds; the framing is restored only after that, so poll is_paused() rather than
* waiting a fixed interval. Host bytes not yet written to the UART are discarded.
* The DTR/RTS outputs hold their state while paused and follow the host again on
* resume().
*/
void pause();
/**
* Re-apply the host's line coding and line state, then resume forwarding. Main-loop
* only. Deferred until any host write still draining has finished, so the reload
* never truncates it.
*/
void resume();
/// True once both worker tasks are off the bus and the configured framing is restored.
/// With no RX task (setup() failed or has not run) there is nothing to wait for.
bool is_paused() const {
return this->state_ == MainState::MAIN_STATE_PAUSED &&
(this->uart_rx_task_handle_ == nullptr || this->rx_parked_ != 0);
}
protected:
static void uart_rx_task_fn(void *arg);
static void uart_tx_task_fn(void *arg);
void uart_rx_task_();
void uart_tx_task_();
void restore_configured_framing_();
// True when the TX task has no write in flight and the UART TX FIFO has drained.
bool tx_idle_();
void finish_resume_();
void drive_line_state_();
// Copy the host's line coding onto the UART settings; true if anything changed.
bool sync_host_framing_();
TaskHandle_t uart_rx_task_handle_{nullptr};
TaskHandle_t uart_tx_task_handle_{nullptr};
GPIOPin *dtr_pin_{nullptr};
GPIOPin *rts_pin_{nullptr};
uint32_t reload_requested_at_{0};
// Worker staging, each sized to the CDC ring buffer it feeds or drains.
std::array<uint8_t, CONFIG_TINYUSB_CDC_TX_BUFSIZE> uart_rx_buffer_{};
std::array<uint8_t, CONFIG_TINYUSB_CDC_RX_BUFSIZE> uart_tx_buffer_{};
uart::IDFUARTComponent *uart_parent_;
usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent_;
// YAML framing, captured at setup; the host's line coding overwrites the UART's
// settings, so pause() needs the original to restore.
uint32_t configured_baud_rate_{0};
uart::UARTParityOptions configured_parity_{uart::UART_CONFIG_PARITY_NONE};
uint8_t configured_stop_bits_{0};
uint8_t configured_data_bits_{0};
// Written on the main loop, read by both worker tasks. uint8_t rather than bool:
// GCC on Xtensa emits an out-of-line call for atomic<bool>.
std::atomic<uint8_t> paused_{0};
// Raised by the RX task while parked and by the TX task around each UART write, so
// the pause hand-off knows when the bus is actually free.
std::atomic<uint8_t> rx_parked_{0};
std::atomic<uint8_t> tx_busy_{0};
// Main-loop state; paused_ mirrors it for the worker tasks.
enum class MainState : uint8_t {
MAIN_STATE_RUNNING,
MAIN_STATE_RELOAD_PENDING, // host line coding debounced, forwarding continues
MAIN_STATE_PAUSING, // waiting for TX idle to restore the configured framing
MAIN_STATE_PAUSED,
MAIN_STATE_RESUMING, // resume() requested while a host write still drains
};
MainState state_{MainState::MAIN_STATE_RUNNING};
// Host line state, recorded even while paused so resume() can re-drive the pins.
bool host_dtr_{false};
bool host_rts_{false};
// True once the host has sent any line coding; resume() then re-syncs to it.
bool host_coding_seen_{false};
};
} // namespace esphome::cdc_acm_uart
#endif
@@ -7,15 +7,47 @@
#include "esphome/core/lock_free_queue.h"
#include "esphome/components/uart/uart_component.h"
#include <array>
#include <atomic>
#include <cstring>
#include <functional>
#include "freertos/ringbuf.h"
#include "esp_err.h"
#include "tinyusb_cdc_acm.h"
namespace esphome::usb_cdc_acm {
static const uint8_t EVENT_QUEUE_SIZE = 12;
// Drain up to out_buf_sz bytes from a byte ring buffer, handling FreeRTOS's wrapped
// case with a second read. Shared with the cdc_acm_uart bridge platform, whose worker
// tasks drain the same ring buffers.
inline esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size,
TickType_t x_ticks_to_wait) {
size_t read_sz;
uint8_t *buf = static_cast<uint8_t *>(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz));
if (buf == nullptr) {
return ESP_FAIL;
}
memcpy(out_buf, buf, read_sz);
vRingbufferReturnItem(ring_buf, (void *) buf);
*rx_data_size = read_sz;
// Buffer's data can be wrapped, in which case we should perform another read
if (*rx_data_size < out_buf_sz) {
buf = static_cast<uint8_t *>(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size));
if (buf != nullptr) {
memcpy(out_buf + *rx_data_size, buf, read_sz);
vRingbufferReturnItem(ring_buf, (void *) buf);
*rx_data_size += read_sz;
}
}
return ESP_OK;
}
// Callback types for line coding and line state changes
using LineCodingCallback = std::function<void(uint32_t bit_rate, uint8_t stop_bits, uint8_t parity, uint8_t data_bits)>;
using LineStateCallback = std::function<void(bool dtr, bool rts)>;
@@ -103,6 +135,8 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented<USBC
RingbufHandle_t usb_tx_ringbuf_{nullptr};
RingbufHandle_t usb_rx_ringbuf_{nullptr};
// TX task staging; a member rather than a stack array so it does not size the task stack.
std::array<uint8_t, CONFIG_TINYUSB_CDC_TX_BUFSIZE> usb_tx_staging_{};
// Non-zero while the TX task holds bytes it has pulled from the ring buffer but not
// yet handed to TinyUSB; lets flush() account for data that is in neither the ring
// buffer nor TinyUSB's FIFO.
@@ -104,30 +104,6 @@ static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *ev
instance->queue_line_coding_event(bit_rate, stop_bits, parity, data_bits);
}
static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size,
TickType_t x_ticks_to_wait) {
size_t read_sz;
uint8_t *buf = static_cast<uint8_t *>(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz));
if (buf == nullptr) {
return ESP_FAIL;
}
memcpy(out_buf, buf, read_sz);
vRingbufferReturnItem(ring_buf, (void *) buf);
*rx_data_size = read_sz;
// Buffer's data can be wrapped, in which case we should perform another read
buf = static_cast<uint8_t *>(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size));
if (buf != nullptr) {
memcpy(out_buf + *rx_data_size, buf, read_sz);
vRingbufferReturnItem(ring_buf, (void *) buf);
*rx_data_size += read_sz;
}
return ESP_OK;
}
//==============================================================================
// USBCDCACMInstance Implementation
//==============================================================================
@@ -192,7 +168,7 @@ void USBCDCACMInstance::usb_tx_task_fn(void *arg) {
}
void USBCDCACMInstance::usb_tx_task() {
uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0};
uint8_t *data = this->usb_tx_staging_.data();
size_t tx_data_size = 0;
// Back-dated so a stall within the first LOG_THROTTLE_MS of uptime still logs
// immediately (unsigned arithmetic keeps this wrap-safe).
+1
View File
@@ -81,6 +81,7 @@ ISOLATED_SIGNATURE_PREFIX = "isolated_"
# NOTE: This should be kept in sync with both test_build_components and split_components_for_ci.py
ISOLATED_COMPONENTS = {
"animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display",
"cdc_acm_uart": "Depends on tinyusb which conflicts with usb_host",
"esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged",
"ethernet": "Defines ethernet: which conflicts with wifi: used by most components",
"ethernet_info": "Related to ethernet component which conflicts with wifi",
@@ -0,0 +1,154 @@
"""Tests for the bridge cdc_acm_uart platform's final validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.cdc_acm_uart import bridge
from esphome.components.cdc_acm_uart.bridge import CONF_USB_CDC_ACM_ID
from esphome.config import Config
from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID, PlatformFramework
from esphome.core import ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
_final_validate = bridge._final_validate
def _set_esp32_s3(set_core_config: SetCoreConfigCallable, **kwargs) -> None:
from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32S3},
**kwargs,
)
def _full_config(uarts: list[ConfigType] | None = None, **domains) -> Config:
"""A full config declaring uart_0 and uart_1 (plus any extra entries), as the ID
pass leaves it, so the debug check can resolve a uart_id to its declaration."""
uarts = uarts or [{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1")}]
full = Config()
full["uart"] = uarts
for index, uart_conf in enumerate(uarts):
full.declare_ids.append((uart_conf[CONF_ID], ["uart", index, CONF_ID]))
full.update(domains)
return full
def _bridge_config(uart_id: str, cdc_id: str) -> dict:
return {CONF_UART_ID: ID(uart_id), CONF_USB_CDC_ACM_ID: ID(cdc_id)}
def test_accepts_distinct_uart_and_cdc_interfaces(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config, full_config=_full_config())
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
_final_validate(_bridge_config("uart_1", "cdc_acm_2"))
def test_rejects_two_bridges_sharing_a_uart(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config, full_config=_full_config())
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
with pytest.raises(cv.Invalid, match="already bridged"):
_final_validate(_bridge_config("uart_0", "cdc_acm_2"))
def test_rejects_two_bridges_sharing_a_cdc_interface(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config, full_config=_full_config())
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
with pytest.raises(cv.Invalid, match="already bridged"):
_final_validate(_bridge_config("uart_1", "cdc_acm_1"))
def test_rejects_uart_shared_with_another_component(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(
set_core_config,
full_config=_full_config(
sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_0")}],
),
)
with pytest.raises(cv.Invalid, match="exclusive"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_rejects_cdc_interface_shared_with_another_component(
set_core_config: SetCoreConfigCallable,
) -> None:
# The CDC instance is itself a uart::UARTComponent, so other components can bind
# it as a plain UART via uart_id -- that must be rejected just like UART sharing.
_set_esp32_s3(
set_core_config,
full_config=_full_config(
sensor=[{"platform": "pzemac", CONF_UART_ID: ID("cdc_acm_1")}],
),
)
with pytest.raises(cv.Invalid, match="exclusive"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_rejects_uart_referenced_from_nested_config(
set_core_config: SetCoreConfigCallable,
) -> None:
# References can sit arbitrarily deep, e.g. inside an automation's action list.
_set_esp32_s3(
set_core_config,
full_config=_full_config(
binary_sensor=[
{
"platform": "gpio",
"on_press": [{"then": [{CONF_UART_ID: ID("uart_0")}]}],
}
],
),
)
with pytest.raises(cv.Invalid, match="exclusive"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_ignores_other_components_on_other_uarts(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(
set_core_config,
full_config=_full_config(
sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_1")}],
# The bridge domain itself is skipped: this bridge's own entry (and any
# bridge-vs-bridge sharing, which the seen-set already rejects) must not
# trip the exclusivity scan.
bridge=[_bridge_config("uart_0", "cdc_acm_1")],
),
)
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_rejects_debug_on_bridged_uart(
set_core_config: SetCoreConfigCallable,
) -> None:
# The bridge talks to the IDF driver directly, so the uart debugger would see
# nothing and its dummy_receiver would steal RX bytes.
_set_esp32_s3(
set_core_config,
full_config=_full_config(uarts=[{CONF_ID: ID("uart_0"), CONF_DEBUG: {}}]),
)
with pytest.raises(cv.Invalid, match="debug"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_allows_debug_on_other_uart(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(
set_core_config,
full_config=_full_config(
uarts=[{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1"), CONF_DEBUG: {}}]
),
)
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
+8 -3
View File
@@ -60,7 +60,7 @@ def reset_core() -> Generator[None]:
@pytest.fixture(autouse=True)
def reset_full_config() -> Generator[None]:
"""Give each test a clean final-validate config and restore it after."""
token = final_validate.full_config.set({})
token = final_validate.full_config.set(Config())
yield
final_validate.full_config.reset(token)
@@ -75,7 +75,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]:
*,
core_data: ConfigType | None = None,
platform_data: ConfigType | None = None,
full_config: dict[str, ConfigType] | None = None,
full_config: dict[str, ConfigType] | Config | None = None,
) -> None:
platform, framework = platform_framework.value
@@ -94,7 +94,12 @@ def set_core_config() -> Generator[SetCoreConfigCallable]:
CORE.data[platform.value] = platform_data
config.path_context.set([])
final_validate.full_config.set(full_config or Config())
# Production always installs a Config (a FinalValidateConfig), never a plain dict.
if not isinstance(full_config, Config):
full = Config()
full.update(full_config or {})
full_config = full
final_validate.full_config.set(full_config)
yield setter
+2 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Protocol
from esphome.config import Config
from esphome.const import PlatformFramework
from esphome.types import ConfigType
@@ -18,5 +19,5 @@ class SetCoreConfigCallable(Protocol):
*,
core_data: ConfigType | None = None,
platform_data: ConfigType | None = None,
full_config: dict[str, ConfigType] | None = None,
full_config: dict[str, ConfigType] | Config | None = None,
) -> None: ...
+18
View File
@@ -0,0 +1,18 @@
tinyusb:
id: tinyusb_test
usb_lang_id: 0x0123
usb_manufacturer_str: ESPHomeTestManufacturer
usb_product_id: 0x1234
usb_product_str: ESPHomeTestProduct
usb_serial_str: ESPHomeTestSerialNumber
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
@@ -0,0 +1,12 @@
# Second UART/CDC pair for a two-bridge setup. Kept out of common.yaml because the
# ESP32-S2 has only two UART controllers and the logger occupies one, so a second
# uart there would fail at runtime.
uart:
- id: uart_1
tx_pin: 15
rx_pin: 16
baud_rate: 115200
usb_cdc_acm:
interfaces:
- id: cdc_acm_2
@@ -0,0 +1,15 @@
packages:
cdc_acm_uart: !include common.yaml
cdc_acm_uart_dual: !include common_dual.yaml
bridge:
- platform: cdc_acm_uart
uart_id: uart_0
usb_cdc_acm_id: cdc_acm_1
dtr_pin: 40
rts_pin: 41
- platform: cdc_acm_uart
uart_id: uart_1
usb_cdc_acm_id: cdc_acm_2
dtr_pin: 20
rts_pin: 21
@@ -0,0 +1,14 @@
# 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:
cdc_acm_uart: !include common.yaml
bridge:
- platform: cdc_acm_uart
uart_id: uart_0
usb_cdc_acm_id: cdc_acm_1
dtr_pin: 40
rts_pin: 41
@@ -0,0 +1,17 @@
packages:
cdc_acm_uart: !include common.yaml
cdc_acm_uart_dual: !include common_dual.yaml
bridge:
- platform: cdc_acm_uart
uart_id: uart_0
usb_cdc_acm_id: cdc_acm_1
dtr_pin: 40
rts_pin: 41
- platform: cdc_acm_uart
uart_id: uart_1
usb_cdc_acm_id: cdc_acm_2
# GPIO19/20 are USB D-/D+ on the S3 (which the CDC side itself uses); use
# unrelated free pins here.
dtr_pin: 17
rts_pin: 18