From 5846977cf621f379b6d0965cbc7297e3034baf7b Mon Sep 17 00:00:00 2001 From: kbx81 Date: Mon, 3 Aug 2026 16:55:28 -0500 Subject: [PATCH] [zigbee_proxy] Auto-detect USB UART channel from uart_id, drop usb_uart_id The usb_uart_id key was redundant: uart_id already points at the channel. A new usb_uart.is_usb_uart_channel() helper checks the config tree (use_id resolution does not narrow the ID type), and zigbee_proxy uses it to enable the RX callback fast path and USB timeout defaults automatically. --- esphome/components/usb_uart/__init__.py | 17 +++++++++- esphome/components/zigbee_proxy/__init__.py | 31 ++++++++++++------- .../components/zigbee_proxy/zigbee_proxy.h | 6 ++-- .../zigbee_proxy/test-usb.esp32-s3-idf.yaml | 21 +++++++++++++ 4 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index a921b6fbf0..fcafea0651 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -16,7 +16,7 @@ from esphome.const import ( CONF_DUMMY_RECEIVER, CONF_ID, ) -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.cpp_types import Component AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] @@ -26,6 +26,21 @@ usb_uart_ns = cg.esphome_ns.namespace("usb_uart") USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component) USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent) + +def is_usb_uart_channel(uart_id: ID) -> bool: + """Return True if the given ID refers to a channel of a configured usb_uart device. + + Lets UART device components detect that their uart_id points at a USB UART + channel (use_id resolution does not narrow the ID's type) so they can enable + USB-specific features such as the RX callback. + """ + return any( + channel[CONF_ID] == uart_id + for device in CORE.config.get("usb_uart") or [] + for channel in device[CONF_CHANNELS] + ) + + UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, diff --git a/esphome/components/zigbee_proxy/__init__.py b/esphome/components/zigbee_proxy/__init__.py index 3559bce148..fb7bd64632 100644 --- a/esphome/components/zigbee_proxy/__init__.py +++ b/esphome/components/zigbee_proxy/__init__.py @@ -1,13 +1,18 @@ import esphome.codegen as cg from esphome.components import uart, usb_uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI +from esphome.const import ( + CONF_BUFFER_SIZE, + CONF_ID, + CONF_POWER_SAVE_MODE, + CONF_UART_ID, + CONF_WIFI, +) import esphome.final_validate as fv CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["api", "uart"] -CONF_BUFFER_SIZE = "buffer_size" CONF_INITIAL_TIMEOUT = "initial_timeout" CONF_MIN_TIMEOUT = "min_timeout" CONF_MAX_TIMEOUT = "max_timeout" @@ -47,10 +52,10 @@ CONFIG_SCHEMA = cv.All( esp8266=512, default=1024, ), - # When usb_uart_id is present the component registers an RX callback - # for zero-wakeup-cycle data delivery and selects USB-optimized ACK - # timeout defaults. Explicit timeout keys always win. - cv.Optional(CONF_USB_UART_ID): cv.use_id(usb_uart.USBUartChannel), + cv.Optional(CONF_USB_UART_ID): cv.invalid( + "'usb_uart_id' has been removed; a USB UART channel is now " + "detected automatically from 'uart_id'" + ), cv.Optional(CONF_INITIAL_TIMEOUT): cv.int_range(min=10, max=10000), cv.Optional(CONF_MIN_TIMEOUT): cv.int_range(min=10, max=5000), cv.Optional(CONF_MAX_TIMEOUT): cv.int_range(min=50, max=10000), @@ -74,14 +79,16 @@ async def to_code(config): if CONF_BUFFER_SIZE in config: cg.add_define("ZIGBEE_PROXY_BUFFER_SIZE", config[CONF_BUFFER_SIZE]) - # Select timeout defaults based on UART transport type. - # USB CDC ACM with the RX callback has ~3-5 ms round-trip latency; hardware - # UART is similar (~2-5 ms). Different defaults are kept so that future - # non-callback USB paths still get conservative starting values. - is_usb = CONF_USB_UART_ID in config + # A uart_id pointing at a USB UART channel is detected automatically: the + # component then registers an RX callback for zero-wakeup-cycle data delivery + # and selects USB-optimized ACK timeout defaults. Explicit timeout keys always + # win. USB CDC ACM with the RX callback has ~3-5 ms round-trip latency; + # hardware UART is similar (~2-5 ms). Different defaults are kept so that + # future non-callback USB paths still get conservative starting values. + is_usb = usb_uart.is_usb_uart_channel(config[CONF_UART_ID]) if is_usb: cg.add_define("USE_ZIGBEE_PROXY_USB_UART") - usb_ch = await cg.get_variable(config[CONF_USB_UART_ID]) + usb_ch = await cg.get_variable(config[CONF_UART_ID]) cg.add(var.set_usb_uart_channel(usb_ch)) initial_timeout = config.get( diff --git a/esphome/components/zigbee_proxy/zigbee_proxy.h b/esphome/components/zigbee_proxy/zigbee_proxy.h index 9b43faa10c..2529520043 100644 --- a/esphome/components/zigbee_proxy/zigbee_proxy.h +++ b/esphome/components/zigbee_proxy/zigbee_proxy.h @@ -14,8 +14,8 @@ // Forward-declare USBUartChannel so the set_usb_uart_channel() setter can be declared // without pulling usb_uart.h into every translation unit that includes this header. -// USE_ZIGBEE_PROXY_USB_UART is defined by the Python to_code() only when usb_uart_id -// is present in the YAML, ensuring the header is actually in the build path. +// USE_ZIGBEE_PROXY_USB_UART is defined by the Python to_code() only when uart_id +// resolves to a USB UART channel, ensuring the header is actually in the build path. #ifdef USE_ZIGBEE_PROXY_USB_UART namespace esphome::usb_uart { class USBUartChannel; @@ -100,7 +100,7 @@ class ZigbeeProxy : public uart::UARTDevice, public Component { void set_max_timeout(uint32_t timeout_ms) { this->timeout_config_.max_timeout_ms = timeout_ms; } #ifdef USE_ZIGBEE_PROXY_USB_UART - /// Called from generated code when usb_uart_id is configured. + /// Called from generated code when uart_id resolves to a USB UART channel. /// Registers an RX callback on the channel so incoming bytes are processed /// immediately in the same USBUartComponent::loop() iteration they arrive, /// without waiting for the next ZigbeeProxy::loop() call. diff --git a/tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml b/tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml new file mode 100644 index 0000000000..0b4008f27d --- /dev/null +++ b/tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml @@ -0,0 +1,21 @@ +wifi: + ssid: test + password: password + power_save_mode: none + +api: + +usb_host: + +usb_uart: + - type: CDC_ACM + vid: 0x303A + pid: 0x831A + channels: + - id: zigbee_usb_channel + baud_rate: 460800 + +# uart_id points at a USB UART channel; the component must detect this +# automatically and enable the RX callback fast path (USE_ZIGBEE_PROXY_USB_UART) +zigbee_proxy: + uart_id: zigbee_usb_channel