From e80aa9579bad624c064182d3c983324051a410d9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:47:02 -0400 Subject: [PATCH] Handle more of the EZSP protocol and try to detect the bootloader --- .../components/serial_proxy/serial_proxy.cpp | 27 +- esphome/components/uart/uart_component.h | 21 ++ .../components/zigbee_proxy/ezsp_commands.h | 9 +- .../components/zigbee_proxy/zigbee_proxy.cpp | 257 +++++++++--------- .../components/zigbee_proxy/zigbee_proxy.h | 27 +- 5 files changed, 197 insertions(+), 144 deletions(-) diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 4b3a907416..83c7a36a02 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -47,6 +47,7 @@ void SerialProxy::loop() { !api_is_connected()) { ESP_LOGW(TAG, "Subscriber disconnected"); this->api_connection_ = nullptr; + this->parent_->release(this); this->disable_loop(); return; } @@ -126,17 +127,26 @@ void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrat return; } - // Apply validated parameters - uart_comp->set_baud_rate(baudrate); - uart_comp->set_stop_bits(stop_bits); - uart_comp->set_data_bits(data_size); - - // Map parity value to UARTParityOptions + // Skip a no-op reconfigure. Clients routinely re-send identical settings on every + // port open, and on a USB UART each apply is a CDC SET_LINE_CODING control transfer. + // Some bridges watch line-coding changes as a signalling channel (a magic baud + // sequence to enter a bootloader, say), so redundant applies are not harmless. static const uart::UARTParityOptions PARITY_MAP[] = { uart::UART_CONFIG_PARITY_NONE, uart::UART_CONFIG_PARITY_EVEN, uart::UART_CONFIG_PARITY_ODD, }; + if (uart_comp->get_baud_rate() == baudrate && uart_comp->get_stop_bits() == stop_bits && + uart_comp->get_data_bits() == data_size && uart_comp->get_parity() == PARITY_MAP[parity]) { + ESP_LOGV(TAG, "Settings unchanged, skipping reconfigure [%" PRIu32 "]", this->instance_index_); + return; + } + + // Apply validated parameters + uart_comp->set_baud_rate(baudrate); + uart_comp->set_stop_bits(stop_bits); + uart_comp->set_data_bits(data_size); + uart_comp->set_parity(PARITY_MAP[parity]); // load_settings() is available on ESP8266 and ESP32 platforms @@ -218,6 +228,10 @@ void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api:: ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); } this->api_connection_ = api_connection; + // Take the UART unconditionally. Another device may be bound to it (a protocol + // proxy over the same radio, say); raw serial access is the more explicit, more + // destructive operation -- typically a firmware update -- so it wins. + this->parent_->claim(this); this->enable_loop(); ESP_LOGV(TAG, "API connection subscribed to serial proxy [%" PRIu32 "]", this->instance_index_); break; @@ -227,6 +241,7 @@ void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api:: return; } this->api_connection_ = nullptr; + this->parent_->release(this); this->disable_loop(); ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_); break; diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 3e52531791..b86723baeb 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -166,6 +166,26 @@ class UARTComponent { // @return Baud rate in bits per second. uint32_t get_baud_rate() const { return baud_rate_; } + /** Exclusive access for devices that share one bus. + * + * Several UARTDevices may be bound to the same UART while only one may drive it at a + * time -- a protocol-aware proxy and a raw serial proxy over the same radio, say. The + * claim is advisory: each device must check `is_claimed_by_other()` before reading or + * writing. + * + * Claiming is unconditional so a claimant is never blocked by a holder that went away + * without releasing (a client whose connection died silently, for instance). Callers + * that want to defer to a live holder must check before claiming. + */ + void claim(void *owner) { this->exclusive_owner_ = owner; } + void release(void *owner) { + if (this->exclusive_owner_ == owner) + this->exclusive_owner_ = nullptr; + } + bool is_claimed_by_other(void *owner) const { + return this->exclusive_owner_ != nullptr && this->exclusive_owner_ != owner; + } + #if defined(USE_ESP8266) || defined(USE_ESP32) /** * Load the UART settings. @@ -210,6 +230,7 @@ class UARTComponent { size_t rx_full_threshold_{RX_FULL_THRESHOLD_UNSET}; size_t rx_timeout_{0}; uint32_t baud_rate_{0}; + void *exclusive_owner_{nullptr}; // see claim() uint8_t stop_bits_{0}; uint8_t data_bits_{0}; UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; diff --git a/esphome/components/zigbee_proxy/ezsp_commands.h b/esphome/components/zigbee_proxy/ezsp_commands.h index 7d408ae130..9575e3019f 100644 --- a/esphome/components/zigbee_proxy/ezsp_commands.h +++ b/esphome/components/zigbee_proxy/ezsp_commands.h @@ -33,7 +33,14 @@ static constexpr uint16_t EZSP_NETWORK_INIT = 0x0017; // Initialize n static constexpr uint16_t EZSP_NETWORK_STATE = 0x0018; // Get network state static constexpr uint16_t EZSP_GET_EUI64 = 0x0026; // Get IEEE address static constexpr uint16_t EZSP_GET_NETWORK_PARAMETERS = 0x0028; // Get network parameters -static constexpr uint16_t EZSP_LAUNCH_STANDALONE_BOOTLOADER = 0x008F; // Reboot into the bootloader +static constexpr uint16_t EZSP_SET_CONFIGURATION_VALUE = 0x0053; // Set a stack config value + +// Stack configuration. CONFIG_STACK_PROFILE must be set to ZigBee PRO before +// networkInit, or the NCP answers NOT_JOINED for a node that is in fact joined +// and getNetworkParameters then fails, leaving PAN ID and channel unreadable. +// The NCP boots with stack profile 0, so this is not optional. +static constexpr uint8_t EZSP_CONFIG_STACK_PROFILE = 0x0C; +static constexpr uint16_t STACK_PROFILE_ZIGBEE_PRO = 2; // EZSP Frame IDs - Callbacks (NCP to host, async) static constexpr uint16_t EZSP_STACK_STATUS_HANDLER = 0x0019; // Stack status callback diff --git a/esphome/components/zigbee_proxy/zigbee_proxy.cpp b/esphome/components/zigbee_proxy/zigbee_proxy.cpp index e6f488942e..eac4075000 100644 --- a/esphome/components/zigbee_proxy/zigbee_proxy.cpp +++ b/esphome/components/zigbee_proxy/zigbee_proxy.cpp @@ -32,6 +32,10 @@ ZigbeeProxy::ZigbeeProxy() { global_zigbee_proxy = this; } void ZigbeeProxy::setup() { this->setup_time_ = millis(); + // Remember the configured line rate. Another device sharing this UART may change it + // (a flasher stepping through baud rates to reach a bootloader, say) and has no way + // to know what to restore, so we put it back ourselves when we take the bus again. + this->configured_baud_rate_ = this->parent_->get_baud_rate(); // Initialize state this->ash_state_ = AshState::DISCONNECTED; @@ -44,6 +48,38 @@ void ZigbeeProxy::setup() { } void ZigbeeProxy::loop() { + // Own the UART only while harvesting network info or serving a subscriber. Idling on + // the bus otherwise would fight whichever device holds it -- a serial proxy carrying a + // firmware update, say -- and the autonomous recovery below would inject ASH resets + // into the middle of someone else's transfer. + if (!this->should_own_uart_()) { + if (this->owns_uart_) { + this->owns_uart_ = false; + if (this->api_connection_ != nullptr) { + ESP_LOGW(TAG, "UART claimed by another device, dropping subscriber"); + this->unsubscribe_api_connection(this->api_connection_); + } + ESP_LOGD(TAG, "Released UART"); + this->boot_sequence_active_ = false; + // The NCP may be reflashed while we are away, so nothing about the link can be + // assumed on return. + this->ash_state_ = AshState::FAILED; + } + return; + } + + if (!this->owns_uart_) { + ESP_LOGI(TAG, "Acquired UART, resetting NCP link"); + this->owns_uart_ = true; + if (this->parent_->get_baud_rate() != this->configured_baud_rate_) { + ESP_LOGI(TAG, "Restoring baud rate %" PRIu32 " (was %" PRIu32 ")", this->configured_baud_rate_, + this->parent_->get_baud_rate()); + this->parent_->set_baud_rate(this->configured_baud_rate_); + this->parent_->load_settings(false); + } + this->reset_ash_protocol_(); + } + // Process incoming UART data this->process_uart_(); @@ -72,9 +108,10 @@ void ZigbeeProxy::loop() { // Send any queued client frames if the ASH TX window opened up this->drain_ncp_tx_queue_(); - // Autonomous recovery: with no client subscribed, periodically retry a failed NCP link - // (or a failed boot harvest) so a late-powered NCP does not require a client RST - if (this->api_connection_ == nullptr && + // Autonomous recovery: periodically retry a failed NCP link so a late-powered NCP does + // not require a client RST. Only while subscribed -- with nobody listening there is + // nothing to recover for, and resetting could disturb another device's use of the bus. + if (this->api_connection_ != nullptr && (this->ash_state_ == AshState::FAILED || (this->boot_state_ == BootState::FAILED && !this->boot_sequence_active_)) && millis() - this->last_recovery_attempt_ > RECOVERY_RETRY_INTERVAL_MS) { @@ -166,8 +203,15 @@ void ZigbeeProxy::zigbee_proxy_request(api::APIConnection *api_connection, const switch (msg.type) { case api::enums::ZIGBEE_PROXY_REQUEST_TYPE_SUBSCRIBE: if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { - ESP_LOGW(TAG, "Another client is already subscribed"); - return; + // A living subscriber keeps exclusive access. Its connection may be dead without + // loop() having noticed yet (e.g. the client crashed and reconnected quickly); + // in that case let the new client take over instead of locking it out for the + // full API keepalive timeout. + if (this->api_connection_->is_connection_setup()) { + ESP_LOGW(TAG, "Another client is already subscribed"); + return; + } + ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); } ESP_LOGD(TAG, "Client subscribed"); this->api_connection_ = api_connection; @@ -208,13 +252,6 @@ void ZigbeeProxy::zigbee_proxy_frame(api::APIConnection *api_connection, const a return; } - if (this->in_raw_relay_()) { - // The NCP is in the bootloader, so the client is speaking its menu/XMODEM - // protocol rather than ASH. Pass it straight through. - this->write_array(msg.data, msg.data_len); - return; - } - // Feed raw bytes into the client-side ASH parser for (size_t i = 0; i < msg.data_len; i++) { this->client_parse_byte_(msg.data[i]); @@ -281,7 +318,6 @@ void ZigbeeProxy::set_usb_uart_channel(usb_uart::USBUartChannel *channel) { // ASH Protocol State Machine void ZigbeeProxy::reset_ash_protocol_() { ESP_LOGV(TAG, "Resetting ASH protocol"); - this->raw_buffer_index_ = 0; this->ash_state_ = AshState::CONNECTING; this->tx_sequence_ = 0; this->rx_sequence_ = 0; @@ -534,8 +570,16 @@ void ZigbeeProxy::handle_retransmission_() { } // Boot-time NCP initialization sequence -// Sequence: RST -> RSTACK -> version() -> networkInit() -> stackStatus -> -// getNetworkParameters() -> getEui64() -> RST -> RSTACK +// Sequence: RST -> RSTACK -> version() -> setConfigurationValue(STACK_PROFILE) -> +// networkInit() -> stackStatus -> getNetworkParameters() -> getEui64() -> +// RST -> RSTACK +// +// The stack profile must be set before networkInit. The NCP boots with profile 0, +// and on profile 0 it reports NOT_JOINED for a node that is joined, which makes +// getNetworkParameters fail and leaves PAN ID and channel unreadable. Verified by +// bisecting bellows' config writes against a ZBT-2 on a known network: profile 2 +// alone flips networkInit from NOT_JOINED to OK, and none of the other values +// bellows writes make any difference. // // getEui64 comes last, after networkInit has brought the stack up: asking earlier // makes the NCP answer with error frame 0x0058 instead of the address. bellows @@ -553,6 +597,11 @@ void ZigbeeProxy::advance_boot_state_() { this->boot_state_ = BootState::WAIT_EUI64; break; + case BootState::SEND_STACK_PROFILE: + this->send_stack_profile_(); + this->boot_state_ = BootState::WAIT_STACK_PROFILE; + break; + case BootState::SEND_NETWORK_INIT: this->send_network_init_(); this->boot_state_ = BootState::WAIT_STACK_STATUS; @@ -635,20 +684,39 @@ void ZigbeeProxy::handle_boot_data_frame_(const uint8_t *data, size_t length) { } break; + case BootState::WAIT_STACK_PROFILE: + if (frame_id == EZSP_SET_CONFIGURATION_VALUE && is_response) { + // Response is an EzspStatus, not an sl_status_t. A failure here is not fatal: + // networkInit will report NOT_JOINED and the harvest still yields the IEEE + // address, so log it and carry on rather than abandoning the sequence. + if (payload_length >= 1 && payload[0] != 0x00) { + ESP_LOGW(TAG, "setConfigurationValue(CONFIG_STACK_PROFILE) failed: 0x%02X", payload[0]); + } + this->boot_state_ = BootState::SEND_NETWORK_INIT; + this->advance_boot_state_(); + } + break; + case BootState::WAIT_STACK_STATUS: if (frame_id == EZSP_STACK_STATUS_HANDLER && is_callback) { this->handle_stack_status_(payload, payload_length); } else if (frame_id == EZSP_NETWORK_INIT && is_response) { - // networkInit response contains a 32-bit sl_status_t - // Some NCPs proceed directly without stackStatusHandler callback + // networkInit response carries a 32-bit sl_status_t. It only reports that the + // request was accepted -- the stack comes up asynchronously afterwards and + // announces itself with a stackStatusHandler callback. Querying network + // parameters here, before that callback, races the stack and the NCP answers + // NOT_JOINED even when the radio is on a network. if (payload_length >= 1) { uint8_t status = payload[0]; ESP_LOGV(TAG, "networkInit response: status=0x%02X", status); - // Proceed to getNetworkParameters regardless of status - // getNetworkParameters will tell us if there's actually a network - ESP_LOGV(TAG, "networkInit complete, querying network parameters"); - this->boot_state_ = BootState::SEND_GET_NETWORK_PARAMS; - this->advance_boot_state_(); + if (status == static_cast(SlStatus::NOT_JOINED)) { + // No network to bring up, so no callback is coming + ESP_LOGD(TAG, "NCP has no network configured"); + this->boot_state_ = BootState::SEND_GET_EUI64; + this->advance_boot_state_(); + } + // Otherwise stay in WAIT_STACK_STATUS for the callback; the boot timeout + // guarantees forward progress if it never arrives. } } break; @@ -696,6 +764,22 @@ void ZigbeeProxy::send_get_eui64_() { this->send_data_frame_(cmd, sizeof(cmd), false); } +void ZigbeeProxy::send_stack_profile_() { + uint8_t cmd[] = { + this->ezsp_sequence_++, // Sequence + EZSP_FRAME_CONTROL_COMMAND, // Frame control (low) + EZSP_FRAME_CONTROL_EXTENDED, // Frame control (high) + EZSP_SET_CONFIGURATION_VALUE & 0xFF, // Frame ID (low) + (EZSP_SET_CONFIGURATION_VALUE >> 8) & 0xFF, // Frame ID (high) + EZSP_CONFIG_STACK_PROFILE, // configId + STACK_PROFILE_ZIGBEE_PRO & 0xFF, // value (low) + (STACK_PROFILE_ZIGBEE_PRO >> 8) & 0xFF, // value (high) + }; + ash_randomize(cmd, sizeof(cmd)); + ESP_LOGV(TAG, "Sending EZSP setConfigurationValue(CONFIG_STACK_PROFILE, 2)"); + this->send_data_frame_(cmd, sizeof(cmd), false); +} + void ZigbeeProxy::send_network_init_() { // networkInitStruct: [bitmask (2 bytes)] - use 0x0000 for default uint8_t cmd[] = { @@ -751,7 +835,7 @@ void ZigbeeProxy::handle_version_response_(const uint8_t *data, size_t length) { // NCP accepted our requested version - treat as success ESP_LOGV(TAG, "NCP accepted EZSP v%d", ncp_version); this->ezsp_version_ = ncp_version; - this->boot_state_ = BootState::SEND_NETWORK_INIT; + this->boot_state_ = BootState::SEND_STACK_PROFILE; this->advance_boot_state_(); return; } @@ -817,7 +901,7 @@ void ZigbeeProxy::handle_version_response_(const uint8_t *data, size_t length) { return; } - this->boot_state_ = BootState::SEND_NETWORK_INIT; + this->boot_state_ = BootState::SEND_STACK_PROFILE; this->advance_boot_state_(); } @@ -1012,99 +1096,31 @@ void ZigbeeProxy::check_wifi_zigbee_conflict_() { #endif } -// Bootloader detection, fed consecutive raw byte pairs. The Gecko bootloader speaks -// neither ASH nor EZSP, so once detected the NCP link is relayed verbatim in both -// directions (see process_uart_slow_ and zigbee_proxy_frame) and normal ASH parsing -// is suspended -- a flasher needs the raw menu and XMODEM streams to reach the client. +// Bootloader detection - fed consecutive raw byte pairs while the ASH link is not CONNECTED +// (bootloader output only ever appears in place of the RSTACK after a reset). Detection is +// advisory only: raw bootloader traffic is carried by a `serial_proxy` bound to the same +// UART, not by this component. void ZigbeeProxy::check_bootloader_mode_(uint8_t prev_byte, uint8_t byte) { - // An ASH RSTACK means the application is running again, so ASH resumes. This is the - // only way out of the relay: while it is active nothing else parses the NCP stream. - if (prev_byte == ASH_FLAG_BYTE && byte == static_cast(AshFrameType::RSTACK)) { - if (this->in_raw_relay_()) { - ESP_LOGI(TAG, "NCP returned to application mode, resuming ASH"); - this->bootloader_state_ = BootloaderState::NORMAL; - // Clear the failure that put us in the relay, so the RSTACK below is parsed - // as ASH and the link comes back up on its own. - this->ash_state_ = AshState::CONNECTING; - this->parsing_state_ = ParsingState::WAIT_FLAG_START; - this->raw_buffer_index_ = 0; - } - return; - } - - // Silicon Labs bootloader menu prompt (0xC1 0x0D) + // Check for Silicon Labs bootloader menu prompt (0xC1 0x0D) if (prev_byte == 0xC1 && byte == 0x0D) { if (this->bootloader_state_ != BootloaderState::MENU) { - ESP_LOGW(TAG, "NCP in bootloader menu mode, relaying raw bytes to client"); + ESP_LOGW(TAG, "NCP in bootloader menu mode detected\n" + " Flash NCP firmware via the serial proxy, or power cycle the device"); this->bootloader_state_ = BootloaderState::MENU; } return; } - // XMODEM-CRC poll ('C'), only meaningful once the bootloader is already talking: - // treating it as an entry condition on its own would false-positive on ASH payload. - if (byte == 0x43 && this->bootloader_state_ == BootloaderState::MENU) { - ESP_LOGW(TAG, "NCP bootloader upload mode detected"); - this->bootloader_state_ = BootloaderState::DETECTED; + // Check for upload begin (0x43) + if (byte == 0x43) { + if (this->bootloader_state_ != BootloaderState::DETECTED) { + ESP_LOGW(TAG, "NCP bootloader upload mode detected"); + this->bootloader_state_ = BootloaderState::DETECTED; + } return; } } -// The NCP is not speaking ASH: either it is in its bootloader, or the ASH link gave -// up entirely. Relaying raw in both cases is what lets a flasher reach -- and recover -// -- a device that is already sitting in the bootloader when the proxy starts. -bool ZigbeeProxy::in_raw_relay_() const { - return this->bootloader_state_ != BootloaderState::NORMAL || this->ash_state_ == AshState::FAILED; -} - -// True if a client-bound EZSP payload is `launchStandaloneBootloader`. The payload is -// still randomized here (it is forwarded to the NCP untouched), so only the frame ID -// bytes are unmasked, using the fixed prefix of the ASH pseudo-random sequence. -bool ZigbeeProxy::is_launch_bootloader_command_(const uint8_t *payload, size_t length) { - // Extended EZSP header: [seq] [fc_lo] [fc_hi] [id_lo] [id_hi] - if (length < 5) { - return false; - } - - uint8_t header[5]; - memcpy(header, payload, sizeof(header)); - ash_randomize(header, sizeof(header)); - - // Commands only; a response or callback carrying the same ID is the NCP's reply - if ((header[1] & (EZSP_FRAME_CONTROL_RESPONSE | EZSP_FRAME_CONTROL_CALLBACK)) != 0) { - return false; - } - - uint16_t frame_id = header[3] | (static_cast(header[4]) << 8); - return frame_id == EZSP_LAUNCH_STANDALONE_BOOTLOADER; -} - -// Relay raw NCP bytes to the client, batching them so an XMODEM transfer does not -// become one API message per byte. Flushed when the UART drains or the buffer fills. -void ZigbeeProxy::queue_raw_to_client_(uint8_t byte) { - if (this->api_connection_ == nullptr) { - return; - } - - this->raw_buffer_[this->raw_buffer_index_++] = byte; - if (this->raw_buffer_index_ >= this->raw_buffer_.size()) { - this->flush_raw_to_client_(); - } -} - -void ZigbeeProxy::flush_raw_to_client_() { - if (this->raw_buffer_index_ == 0) { - return; - } - - // Best effort: the bootloader protocols carry their own retries, and there is no - // ASH session to resynchronize here. - if (!this->send_to_client_(this->raw_buffer_.data(), this->raw_buffer_index_)) { - ESP_LOGW(TAG, "Dropped %u raw bytes to client (API TX buffer full)", this->raw_buffer_index_); - } - this->raw_buffer_index_ = 0; -} - // UART processing (precondition: available() > 0, see inline process_uart_ in the header) void ZigbeeProxy::process_uart_slow_() { do { @@ -1116,23 +1132,17 @@ void ZigbeeProxy::process_uart_slow_() { // Verbose logging for debugging (ESP_LOGV already checks log level) ESP_LOGV(TAG, "RX: 0x%02X", byte); - // Runs unconditionally: a client can launch the bootloader over EZSP while the - // ASH link is up, so detection cannot be gated on the link being down. - this->check_bootloader_mode_(this->last_rx_byte_, byte); - this->last_rx_byte_ = byte; - - if (this->in_raw_relay_()) { - // Bootloader traffic is not ASH. Relay it untouched and keep the parser out of - // it: feeding menu text or XMODEM to parse_byte_ would fail CRC and NAK the NCP - // mid-transfer. - this->queue_raw_to_client_(byte); - continue; + if (this->ash_state_ != AshState::CONNECTED) { + this->check_bootloader_mode_(this->last_rx_byte_, byte); + this->last_rx_byte_ = byte; + } else if (this->bootloader_state_ != BootloaderState::NORMAL) { + // Normal traffic while connected clears any stale bootloader detection + ESP_LOGV(TAG, "NCP returned to normal operation"); + this->bootloader_state_ = BootloaderState::NORMAL; } this->parse_byte_(byte); } while (this->available()); - - this->flush_raw_to_client_(); } // ==================== Client-side ASH session ==================== @@ -1413,21 +1423,10 @@ void ZigbeeProxy::client_parse_control_byte_(uint8_t control) { // Forward EZSP payload to NCP via right-side ASH; NAK without consuming if the // NCP link is down or the TX queue is full so the client retransmits ESP_LOGV(TAG, "Client DATA → NCP, EZSP payload %u bytes", payload_length); - bool launching_bootloader = is_launch_bootloader_command_(payload, payload_length); if (!this->send_frame(payload, payload_length)) { this->client_send_nak_frame_(this->client_rx_sequence_); return; } - - if (launching_bootloader) { - // The NCP is about to reboot into its bootloader, which speaks neither ASH - // nor EZSP. Switch to the raw relay now: waiting to recognize bootloader - // output would deadlock, since that output only appears once the client's - // (non-ASH) bytes reach the NCP, and those are exactly what the relay - // carries. Cleared again when an ASH RSTACK shows the app is back. - ESP_LOGI(TAG, "Client launched NCP bootloader, relaying raw bytes"); - this->bootloader_state_ = BootloaderState::MENU; - } } // Accepted: advance the sequence and ACK immediately rather than relying on the diff --git a/esphome/components/zigbee_proxy/zigbee_proxy.h b/esphome/components/zigbee_proxy/zigbee_proxy.h index d7f3837fce..b12b3d550d 100644 --- a/esphome/components/zigbee_proxy/zigbee_proxy.h +++ b/esphome/components/zigbee_proxy/zigbee_proxy.h @@ -53,6 +53,8 @@ enum class BootState : uint8_t { WAIT_VERSION, // Waiting for version response SEND_GET_EUI64, // Send getEui64 command WAIT_EUI64, // Waiting for EUI64 response + SEND_STACK_PROFILE, // Send setConfigurationValue(CONFIG_STACK_PROFILE) + WAIT_STACK_PROFILE, // Waiting for setConfigurationValue response SEND_NETWORK_INIT, // Send networkInit command WAIT_STACK_STATUS, // Waiting for stackStatusHandler callback SEND_GET_NETWORK_PARAMS, // Send getNetworkParameters command @@ -155,6 +157,7 @@ class ZigbeeProxy : public uart::UARTDevice, public Component { void handle_boot_data_frame_(const uint8_t *data, size_t length); void send_ezsp_version_(); void send_get_eui64_(); + void send_stack_profile_(); void send_network_init_(); void send_get_network_params_(); void handle_version_response_(const uint8_t *data, size_t length); @@ -169,19 +172,27 @@ class ZigbeeProxy : public uart::UARTDevice, public Component { // WiFi/Zigbee channel conflict detection void check_wifi_zigbee_conflict_(); - // Bootloader detection (fed consecutive raw byte pairs) + // Bootloader detection (fed consecutive raw byte pairs while not CONNECTED) void check_bootloader_mode_(uint8_t prev_byte, uint8_t byte); - // Raw NCP <-> client relay used while the NCP is in its bootloader - bool in_raw_relay_() const; - static bool is_launch_bootloader_command_(const uint8_t *payload, size_t length); - void queue_raw_to_client_(uint8_t byte); - void flush_raw_to_client_(); + + // True when this component should be driving the shared UART: nobody else holds a + // claim on it, and we have either a subscriber to serve or a boot harvest to finish. + bool should_own_uart_() const { + return !this->parent_->is_claimed_by_other(const_cast(this)) && + (this->boot_sequence_active_ || this->api_connection_ != nullptr); + } // UART processing // Inline fast-path: UART::available() is cheap (ring-buffer head/tail compare on most // backends), so an idle loop tick skips the out-of-line drain entirely. When bytes are // pending the slow path drains with do/while so available() is checked once per byte. ESPHOME_ALWAYS_INLINE void process_uart_() { + // Checked here rather than only in loop(): on a USB UART the RX callback calls this + // directly from the USB component's loop, so a guard in loop() alone would still let + // us consume bytes belonging to whichever device holds the claim. + if (!this->owns_uart_ && !this->boot_sequence_active_) { + return; + } if (!this->available()) { return; } @@ -299,9 +310,9 @@ class ZigbeeProxy : public uart::UARTDevice, public Component { bool escape_next_byte_{false}; // True if next NCP byte should be unescaped bool client_escape_next_byte_{false}; // True if next client byte should be unescaped bool network_info_ready_{false}; // True when network info retrieved + bool owns_uart_{false}; // True while this component drives the UART + uint32_t configured_baud_rate_{0}; // Line rate to restore after another device - std::array raw_buffer_{}; // Bootloader relay batching buffer - size_t raw_buffer_index_{0}; bool boot_sequence_active_{false}; // True during boot-time init };