diff --git a/esphome/components/zigbee_proxy/__init__.py b/esphome/components/zigbee_proxy/__init__.py index 2ba4cbc6ed..66a5617687 100644 --- a/esphome/components/zigbee_proxy/__init__.py +++ b/esphome/components/zigbee_proxy/__init__.py @@ -18,10 +18,10 @@ _DEFAULT_HW_INITIAL_TIMEOUT = 1600 _DEFAULT_HW_MIN_TIMEOUT = 400 _DEFAULT_HW_MAX_TIMEOUT = 3200 -# Optimized ACK timeout values for USB CDC ACM paths (~20-30 ms round-trip) -_DEFAULT_USB_INITIAL_TIMEOUT = 200 -_DEFAULT_USB_MIN_TIMEOUT = 50 -_DEFAULT_USB_MAX_TIMEOUT = 500 +# Optimized ACK timeout values for USB CDC ACM paths (~3-5 ms round-trip with RX callback) +_DEFAULT_USB_INITIAL_TIMEOUT = 30 +_DEFAULT_USB_MIN_TIMEOUT = 15 +_DEFAULT_USB_MAX_TIMEOUT = 200 zigbee_proxy_ns = cg.esphome_ns.namespace("zigbee_proxy") ZigbeeProxy = zigbee_proxy_ns.class_("ZigbeeProxy", cg.Component, uart.UARTDevice) @@ -47,13 +47,13 @@ CONFIG_SCHEMA = cv.All( esp8266=512, default=1024, ), - # When usb_uart_id is present the component selects USB-optimized ACK - # timeout defaults (~20-30 ms round-trip) instead of the hardware UART - # defaults (~2-5 ms round-trip). Explicit timeout keys always win. + # 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_INITIAL_TIMEOUT): cv.int_range(min=100, max=10000), - cv.Optional(CONF_MIN_TIMEOUT): cv.int_range(min=100, max=5000), - cv.Optional(CONF_MAX_TIMEOUT): cv.int_range(min=500, max=10000), + 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), } ) .extend(cv.COMPONENT_SCHEMA) @@ -78,10 +78,15 @@ async def to_code(config): cg.add_define("ZIGBEE_PROXY_BUFFER_SIZE", config[CONF_BUFFER_SIZE]) # Select timeout defaults based on UART transport type. - # USB CDC ACM has higher round-trip latency (~20-30 ms) than hardware UART - # (~2-5 ms), so the adaptive ACK timeout algorithm needs different starting - # points to avoid unnecessary stalls at boot. + # 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 + if is_usb: + cg.add_define("USE_ZIGBEE_PROXY_USB_UART") + usb_ch = await cg.get_variable(config[CONF_USB_UART_ID]) + cg.add(var.set_usb_uart_channel(usb_ch)) + initial_timeout = config.get( CONF_INITIAL_TIMEOUT, _DEFAULT_USB_INITIAL_TIMEOUT if is_usb else _DEFAULT_HW_INITIAL_TIMEOUT, diff --git a/esphome/components/zigbee_proxy/ash_protocol.cpp b/esphome/components/zigbee_proxy/ash_protocol.cpp index 46752ea385..163bd499e8 100644 --- a/esphome/components/zigbee_proxy/ash_protocol.cpp +++ b/esphome/components/zigbee_proxy/ash_protocol.cpp @@ -31,8 +31,8 @@ static const uint16_t CRC_TABLE[256] = { 0x1CE0, 0x0CC1, 0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8, 0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0}; -uint16_t ZigbeeProxy::calculate_crc_(const uint8_t *data, size_t length) { - uint16_t crc = ASH_CRC_INIT; +uint16_t ZigbeeProxy::calculate_crc_(const uint8_t *data, size_t length, uint16_t init) { + uint16_t crc = init; for (size_t i = 0; i < length; i++) { crc = (crc << 8) ^ CRC_TABLE[(crc >> 8) ^ data[i]]; } @@ -241,8 +241,6 @@ bool ZigbeeProxy::parse_byte_(uint8_t byte) { this->parsing_state_ = ParsingState::WAIT_DATA; ESP_LOGV(TAG, "Frame start detected (control byte 0x%02X)", byte); } - // Check for bootloader patterns - this->check_bootloader_mode_(&byte, 1); break; case ParsingState::WAIT_CONTROL: @@ -275,8 +273,9 @@ bool ZigbeeProxy::parse_byte_(uint8_t byte) { if (this->validate_frame_crc_()) { this->parse_control_byte_(this->rx_buffer_[0]); } else { - // CRC failed - log frame contents for debugging - ESP_LOGW(TAG, "CRC failed, frame (%u bytes): %s", this->rx_buffer_index_, + // CRC failed - WARN logs byte count only; hex dump at VERBOSE to avoid heap allocation in production + ESP_LOGW(TAG, "CRC failed (%u bytes)", this->rx_buffer_index_); + ESP_LOGV(TAG, "CRC failed frame: %s", format_hex_pretty(this->rx_buffer_.data(), this->rx_buffer_index_).c_str()); this->send_nak_frame_(this->rx_sequence_); } @@ -354,13 +353,6 @@ size_t ZigbeeProxy::build_frame_(uint8_t *output, const uint8_t *data, size_t le output[pos++] = control; } - // Prepare CRC calculation buffer (control + data) - uint8_t crc_buffer[MAX_ASH_FRAME_SIZE]; - crc_buffer[0] = control; - if (length > 0) { - memcpy(crc_buffer + 1, data, length); - } - // Add data payload with stuffing for (size_t i = 0; i < length; i++) { uint8_t byte = data[i]; @@ -373,8 +365,11 @@ size_t ZigbeeProxy::build_frame_(uint8_t *output, const uint8_t *data, size_t le } } - // Calculate CRC - uint16_t crc = this->calculate_crc_(crc_buffer, 1 + length); + // Calculate CRC incrementally over control byte then data (avoids a MAX_ASH_FRAME_SIZE stack copy) + uint16_t crc = this->calculate_crc_(&control, 1); + if (length > 0) { + crc = this->calculate_crc_(data, length, crc); + } // Add CRC with stuffing (big-endian) uint8_t crc_high = (crc >> 8) & 0xFF; diff --git a/esphome/components/zigbee_proxy/zigbee_proxy.cpp b/esphome/components/zigbee_proxy/zigbee_proxy.cpp index ce31772ea3..2ad8672778 100644 --- a/esphome/components/zigbee_proxy/zigbee_proxy.cpp +++ b/esphome/components/zigbee_proxy/zigbee_proxy.cpp @@ -11,6 +11,10 @@ #include "esphome/components/wifi/wifi_component.h" #endif +#ifdef USE_ZIGBEE_PROXY_USB_UART +#include "esphome/components/usb_uart/usb_uart.h" +#endif + namespace esphome::zigbee_proxy { static const char *const TAG = "zigbee_proxy"; @@ -184,6 +188,13 @@ void ZigbeeProxy::set_timeout_config(uint32_t initial_ms, uint32_t min_ms, uint3 ESP_LOGV(TAG, "Timeout config updated: initial=%u, min=%u, max=%u", initial_ms, min_ms, max_ms); } +#ifdef USE_ZIGBEE_PROXY_USB_UART +void ZigbeeProxy::set_usb_uart_channel(usb_uart::USBUartChannel *channel) { + channel->set_rx_callback([this]() { this->process_uart_(); }); + ESP_LOGD(TAG, "Registered USB UART RX callback for low-latency processing"); +} +#endif + // ASH Protocol State Machine void ZigbeeProxy::reset_ash_protocol_() { ESP_LOGV(TAG, "Resetting ASH protocol"); @@ -232,17 +243,15 @@ void ZigbeeProxy::handle_rstack_frame_(const uint8_t *data, size_t length) { ESP_LOGV(TAG, "Received RSTACK, starting EZSP initialization"); this->ash_state_ = AshState::CONNECTED; - // Non-blocking drain: give the NCP 10 ms to settle and flush any stale bytes. - // yield() lets the USB task deliver buffered bytes into the ring buffer so they - // can be consumed here rather than contaminating the next EZSP exchange. - uint32_t drain_deadline = millis() + 10; - while (millis() < drain_deadline) { - while (this->available()) { - uint8_t discard; - this->read_byte(&discard); - ESP_LOGV(TAG, "Draining post-RSTACK byte: 0x%02X", discard); - } - yield(); + // Drain any stale bytes that arrived before the RSTACK (e.g. leftover + // UART FIFO bytes on HW UART, or a partial prior frame on USB CDC). + // For USB CDC the input_buffer_ is already fully up-to-date at this point + // (the RX callback just moved all pending chunks into it), so this loop + // completes immediately rather than spinning with yield(). + while (this->available()) { + uint8_t discard; + this->read_byte(&discard); + ESP_LOGV(TAG, "Draining post-RSTACK byte: 0x%02X", discard); } this->boot_state_ = BootState::SEND_VERSION; @@ -329,7 +338,6 @@ bool ZigbeeProxy::send_ack_frame_(uint8_t ack_num) { uint8_t frame[8]; size_t length = this->build_frame_(frame, nullptr, 0, AshFrameType::ACK, 0, ack_num); this->write_array(frame, length); - this->flush(); this->last_ack_sent_ = ack_num; ESP_LOGV(TAG, "Sent ACK for frame %d", ack_num); return true; @@ -339,7 +347,6 @@ bool ZigbeeProxy::send_nak_frame_(uint8_t ack_num) { uint8_t frame[8]; size_t length = this->build_frame_(frame, nullptr, 0, AshFrameType::NAK, 0, ack_num); this->write_array(frame, length); - this->flush(); ESP_LOGW(TAG, "Sent NAK for frame %d", ack_num); return true; } @@ -368,7 +375,6 @@ bool ZigbeeProxy::send_data_frame_(const uint8_t *data, size_t length, bool retr // Send frame this->write_array(this->tx_buffer_.data(), frame_length); - this->flush(); // Start ACK timer this->tx_buffer_pending_ = true; @@ -431,7 +437,6 @@ void ZigbeeProxy::handle_retransmission_() { // Resend the pending frame this->write_array(this->tx_pending_buffer_.data(), this->tx_pending_length_); - this->flush(); this->start_ack_timer_(); } @@ -749,14 +754,7 @@ void ZigbeeProxy::handle_network_params_response_(const uint8_t *data, size_t le } bool ZigbeeProxy::set_ieee_address_(const uint8_t *new_address) { - bool changed = false; - - for (size_t i = 0; i < ZIGBEE_IEEE_ADDR_SIZE; i++) { - if (this->network_info_.ieee_address[i] != new_address[i]) { - changed = true; - break; - } - } + bool changed = memcmp(this->network_info_.ieee_address.data(), new_address, ZIGBEE_IEEE_ADDR_SIZE) != 0; if (changed) { memcpy(this->network_info_.ieee_address.data(), new_address, ZIGBEE_IEEE_ADDR_SIZE); @@ -917,11 +915,10 @@ void ZigbeeProxy::client_send_rstack_frame_(uint8_t reset_code) { } void ZigbeeProxy::client_send_data_frame_(const uint8_t *data, size_t length) { - uint8_t frame[MAX_ASH_FRAME_SIZE]; - size_t frame_length = - this->build_frame_(frame, data, length, AshFrameType::DATA, this->client_tx_sequence_, this->client_rx_sequence_); + size_t frame_length = this->build_frame_(this->client_tx_buffer_.data(), data, length, AshFrameType::DATA, + this->client_tx_sequence_, this->client_rx_sequence_); this->client_tx_sequence_ = (this->client_tx_sequence_ + 1) & ASH_MAX_SEQUENCE; - this->client_send_raw_frame_(frame, frame_length); + this->client_send_raw_frame_(this->client_tx_buffer_.data(), frame_length); ESP_LOGV(TAG, "Sent client DATA frame, payload %u bytes", length); } diff --git a/esphome/components/zigbee_proxy/zigbee_proxy.h b/esphome/components/zigbee_proxy/zigbee_proxy.h index b4193aaca3..518d4b7f15 100644 --- a/esphome/components/zigbee_proxy/zigbee_proxy.h +++ b/esphome/components/zigbee_proxy/zigbee_proxy.h @@ -12,6 +12,16 @@ #include +// 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. +#ifdef USE_ZIGBEE_PROXY_USB_UART +namespace esphome::usb_uart { +class USBUartChannel; +} +#endif + namespace esphome::zigbee_proxy { // Timeout configuration structure @@ -83,6 +93,14 @@ class ZigbeeProxy : public uart::UARTDevice, public Component { void set_min_timeout(uint32_t timeout_ms) { this->timeout_config_.min_timeout_ms = timeout_ms; } 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. + /// 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. + void set_usb_uart_channel(usb_uart::USBUartChannel *channel); +#endif + protected: // ASH Protocol State Machine void reset_ash_protocol_(); @@ -99,7 +117,7 @@ class ZigbeeProxy : public uart::UARTDevice, public Component { bool validate_frame_crc_(); size_t build_frame_(uint8_t *output, const uint8_t *data, size_t length, AshFrameType type, uint8_t frame_num = 0, uint8_t ack_num = 0, bool retx = false); - uint16_t calculate_crc_(const uint8_t *data, size_t length); + uint16_t calculate_crc_(const uint8_t *data, size_t length, uint16_t init = ASH_CRC_INIT); // Sequence number management void increment_tx_sequence_() { this->tx_sequence_ = (this->tx_sequence_ + 1) & ASH_MAX_SEQUENCE; }