mirror of
https://github.com/esphome/esphome.git
synced 2026-09-13 00:07:32 +00:00
[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:
co-authored by
Jesse Hills
pre-commit-ci-lite[bot]
Claude Fable 5.1
J. Nick Koston
parent
eecea15f4f
commit
ebb9037ea1
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user