From 6111791706cc614424d2d02cfba00e0377e02be8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:23:04 -0400 Subject: [PATCH] Have zigbee proxying piggyback off of serial proxying? --- esphome/components/api/api.proto | 32 +- esphome/components/api/api_connection.cpp | 18 +- esphome/components/api/api_connection.h | 3 +- esphome/components/api/api_pb2.cpp | 35 +- esphome/components/api/api_pb2.h | 42 +- esphome/components/api/api_pb2_dump.cpp | 25 +- esphome/components/api/api_pb2_service.cpp | 22 +- esphome/components/api/api_pb2_service.h | 6 +- esphome/components/serial_proxy/__init__.py | 16 +- .../components/serial_proxy/serial_proxy.cpp | 82 +++- .../components/serial_proxy/serial_proxy.h | 32 ++ esphome/components/uart/uart_component.h | 21 - esphome/components/zigbee_proxy/__init__.py | 84 ++-- .../components/zigbee_proxy/zigbee_proxy.cpp | 451 ++++++------------ .../components/zigbee_proxy/zigbee_proxy.h | 173 +++---- esphome/core/defines.h | 2 +- tests/components/zigbee_proxy/common.yaml | 9 +- .../zigbee_proxy/test-usb.esp32-s3-idf.yaml | 13 +- .../zigbee_proxy/test.esp32-idf.yaml | 1 + .../zigbee_proxy/test.esp8266-ard.yaml | 1 + .../zigbee_proxy/test.rp2040-ard.yaml | 3 + 21 files changed, 474 insertions(+), 597 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 8ca3e70100..5c5540e4c5 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -69,7 +69,6 @@ service APIConnection { rpc zwave_proxy_frame(ZWaveProxyFrame) returns (void) {} rpc zwave_proxy_request(ZWaveProxyRequest) returns (void) {} - rpc zigbee_proxy_frame(ZigbeeProxyFrame) returns (void) {} rpc zigbee_proxy_request(ZigbeeProxyRequest) returns (void) {} rpc infrared_rf_transmit_raw_timings(InfraredRFTransmitRawTimingsRequest) returns (void) {} @@ -79,6 +78,7 @@ service APIConnection { rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {} rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} + rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} } @@ -2738,6 +2738,23 @@ message SerialProxyRequestResponse { string error_message = 4; // Additional detail on failure (optional) } +// How a port treats the bytes passing through it. RAW is a plain byte pipe; EZSP_ASH lets +// a protocol-aware tap acknowledge NCP frames and read network metadata. A client that is +// about to flash firmware selects RAW first, which definitively disables that injection. +enum SerialProxyMode { + SERIAL_PROXY_MODE_RAW = 0; + SERIAL_PROXY_MODE_EZSP_ASH = 1; +} + +message SerialProxySetModeRequest { + option (id) = 151; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; + SerialProxyMode mode = 2; +} + // ==================== BLUETOOTH CONNECTION PARAMS ==================== message BluetoothSetConnectionParamsRequest { option (id) = 145; @@ -2762,19 +2779,8 @@ message BluetoothSetConnectionParamsResponse { // ==================== ZIGBEE ==================== -message ZigbeeProxyFrame { - option (id) = 149; - option (source) = SOURCE_BOTH; - option (ifdef) = "USE_ZIGBEE_PROXY"; - option (no_delay) = true; - - bytes data = 1; -} - enum ZigbeeProxyRequestType { - ZIGBEE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; - ZIGBEE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; - ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 2; + ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0; } message ZigbeeProxyRequest { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 40d95a8f7f..f49ec8b01d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -192,11 +192,6 @@ APIConnection::~APIConnection() { zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE); } #endif -#ifdef USE_ZIGBEE_PROXY - if (zigbee_proxy::global_zigbee_proxy != nullptr && zigbee_proxy::global_zigbee_proxy->get_api_connection() == this) { - zigbee_proxy::global_zigbee_proxy->unsubscribe_api_connection(this); - } -#endif #ifdef USE_SERIAL_PROXY for (auto *proxy : App.get_serial_proxies()) { if (proxy->get_api_connection() == this) { @@ -1386,10 +1381,6 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { #endif #ifdef USE_ZIGBEE_PROXY -void APIConnection::on_zigbee_proxy_frame(const ZigbeeProxyFrame &msg) { - zigbee_proxy::global_zigbee_proxy->zigbee_proxy_frame(this, msg); -} - void APIConnection::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) { zigbee_proxy::global_zigbee_proxy->zigbee_proxy_request(this, msg); } @@ -1633,6 +1624,15 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { } } +void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); + return; + } + proxies[msg.instance]->set_mode(this, msg.mode); +} + void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); } #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index c79104cbe1..53157166cf 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -219,9 +219,7 @@ class APIConnection final : public APIServerConnectionBase { #endif #ifdef USE_ZIGBEE_PROXY - void on_zigbee_proxy_frame(const ZigbeeProxyFrame &msg); void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg); - bool send_zigbee_proxy_frame(const ZigbeeProxyFrame &msg) { return this->send_message(msg); } #endif #ifdef USE_ALARM_CONTROL_PANEL @@ -245,6 +243,7 @@ class APIConnection final : public APIServerConnectionBase { void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg); void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg); void on_serial_proxy_request(const SerialProxyRequest &msg); + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg); void send_serial_proxy_data(const SerialProxyDataReceived &msg); #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c6f42139ea..c824e06236 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4156,6 +4156,19 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { size += ProtoSize::calc_length(1, this->error_message.size()); return size; } +bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { + switch (field_id) { + case 1: + this->instance = value; + break; + case 2: + this->mode = static_cast(value); + break; + default: + return false; + } + return true; +} #endif #ifdef USE_BLUETOOTH_PROXY bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -4194,28 +4207,6 @@ uint32_t BluetoothSetConnectionParamsResponse::calculate_size() const { } #endif #ifdef USE_ZIGBEE_PROXY -bool ZigbeeProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; - } - return true; -} -uint8_t *ZigbeeProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); - return pos; -} -uint32_t ZigbeeProxyFrame::calculate_size() const { - uint32_t size = 0; - size += ProtoSize::calc_length(1, this->data_len); - return size; -} bool ZigbeeProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 42a3018e85..4101af5d96 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -351,12 +351,14 @@ enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_TIMEOUT = 3, SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4, }; +enum SerialProxyMode : uint32_t { + SERIAL_PROXY_MODE_RAW = 0, + SERIAL_PROXY_MODE_EZSP_ASH = 1, +}; #endif #ifdef USE_ZIGBEE_PROXY enum ZigbeeProxyRequestType : uint32_t { - ZIGBEE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0, - ZIGBEE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1, - ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 2, + ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0, }; #endif @@ -3302,6 +3304,22 @@ class SerialProxyRequestResponse final : public ProtoMessage { protected: }; +class SerialProxySetModeRequest final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 151; + static constexpr uint8_t ESTIMATED_SIZE = 6; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); } +#endif + uint32_t instance{0}; + enums::SerialProxyMode mode{}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; +}; #endif #ifdef USE_BLUETOOTH_PROXY class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { @@ -3342,24 +3360,6 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { }; #endif #ifdef USE_ZIGBEE_PROXY -class ZigbeeProxyFrame final : public ProtoDecodableMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 149; - static constexpr uint8_t ESTIMATED_SIZE = 19; -#ifdef HAS_PROTO_MESSAGE_DUMP - const LogString *message_name() const override { return LOG_STR("zigbee_proxy_frame"); } -#endif - const uint8_t *data{nullptr}; - uint16_t data_len{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; -}; class ZigbeeProxyRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 150; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 5eb56de790..a799fa2fad 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -856,14 +856,20 @@ template<> const char *proto_enum_to_string(enums::Ser return ESPHOME_PSTR("UNKNOWN"); } } +template<> const char *proto_enum_to_string(enums::SerialProxyMode value) { + switch (value) { + case enums::SERIAL_PROXY_MODE_RAW: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW"); + case enums::SERIAL_PROXY_MODE_EZSP_ASH: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_EZSP_ASH"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #endif #ifdef USE_ZIGBEE_PROXY template<> const char *proto_enum_to_string(enums::ZigbeeProxyRequestType value) { switch (value) { - case enums::ZIGBEE_PROXY_REQUEST_TYPE_SUBSCRIBE: - return ESPHOME_PSTR("ZIGBEE_PROXY_REQUEST_TYPE_SUBSCRIBE"); - case enums::ZIGBEE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: - return ESPHOME_PSTR("ZIGBEE_PROXY_REQUEST_TYPE_UNSUBSCRIBE"); case enums::ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO: return ESPHOME_PSTR("ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO"); default: @@ -2734,6 +2740,12 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error_message"), this->error_message); return out.c_str(); } +const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest")); + dump_field(out, ESPHOME_PSTR("instance"), this->instance); + dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); + return out.c_str(); +} #endif #ifdef USE_BLUETOOTH_PROXY const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { @@ -2753,11 +2765,6 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const } #endif #ifdef USE_ZIGBEE_PROXY -const char *ZigbeeProxyFrame::dump_to(DumpBuffer &out) const { - MessageDumpHelper helper(out, ESPHOME_PSTR("ZigbeeProxyFrame")); - dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); - return out.c_str(); -} const char *ZigbeeProxyRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ZigbeeProxyRequest")); dump_field(out, ESPHOME_PSTR("type"), static_cast(this->type)); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index a7d51aa646..127b20b45d 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -705,17 +705,6 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_ZIGBEE_PROXY - case ZigbeeProxyFrame::MESSAGE_TYPE: { - ZigbeeProxyFrame msg; - msg.decode(msg_data, msg_size); -#ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_zigbee_proxy_frame"), msg); -#endif - this->on_zigbee_proxy_frame(msg); - break; - } -#endif #ifdef USE_ZIGBEE_PROXY case ZigbeeProxyRequest::MESSAGE_TYPE: { ZigbeeProxyRequest msg; @@ -726,6 +715,17 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_zigbee_proxy_request(msg); break; } +#endif +#ifdef USE_SERIAL_PROXY + case SerialProxySetModeRequest::MESSAGE_TYPE: { + SerialProxySetModeRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg); +#endif + this->on_serial_proxy_set_mode_request(msg); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index b10b47626c..712799d7bd 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -233,13 +233,13 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif +#ifdef USE_SERIAL_PROXY + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){}; +#endif #ifdef USE_BLUETOOTH_PROXY void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif -#ifdef USE_ZIGBEE_PROXY - void on_zigbee_proxy_frame(const ZigbeeProxyFrame &value){}; -#endif #ifdef USE_ZIGBEE_PROXY void on_zigbee_proxy_request(const ZigbeeProxyRequest &value){}; #endif diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index f9b8c375d2..e2f26c2435 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -18,7 +18,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_NAME +from esphome.const import CONF_ID, CONF_MODE, CONF_NAME from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority @@ -29,6 +29,7 @@ MULTI_CONF = True serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy") SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice) +SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap") api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums") SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType") @@ -38,6 +39,15 @@ SERIAL_PROXY_PORT_TYPES = { "RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485, } +SerialProxyMode = api_enums_ns.enum("SerialProxyMode") +# The mode a port starts in. `raw` is a plain byte pipe; `ezsp_ash` lets a tap +# acknowledge NCP frames and read network metadata off the wire. Clients may change +# it at runtime, so this only decides what the device boots into. +SERIAL_PROXY_MODES = { + "RAW": SerialProxyMode.SERIAL_PROXY_MODE_RAW, + "EZSP_ASH": SerialProxyMode.SERIAL_PROXY_MODE_EZSP_ASH, +} + CONF_DTR_PIN = "dtr_pin" CONF_PORT_TYPE = "port_type" CONF_RTS_PIN = "rts_pin" @@ -62,6 +72,9 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(SerialProxy), cv.Required(CONF_NAME): cv.string_strict, cv.Required(CONF_PORT_TYPE): cv.enum(SERIAL_PROXY_PORT_TYPES, upper=True), + cv.Optional(CONF_MODE, default="RAW"): cv.enum( + SERIAL_PROXY_MODES, upper=True + ), cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema, } @@ -86,6 +99,7 @@ async def to_code(config): cg.add(cg.App.register_serial_proxy(var)) cg.add(var.set_name(config[CONF_NAME])) cg.add(var.set_port_type(config[CONF_PORT_TYPE])) + cg.add(var.set_mode(config[CONF_MODE])) cg.add_define("USE_SERIAL_PROXY") # Track instance count for the FINAL priority define diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 458c0e264a..6b0c97ee95 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -29,20 +29,41 @@ void SerialProxy::setup() { #ifdef USE_API // instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data this->outgoing_msg_.instance = this->instance_index_; +#endif +#ifdef USE_SERIAL_PROXY_TAP + // A tap sets itself up before this runs (its setup priority is higher), so it may + // already be waiting on the port -- a boot-time handshake with the device, say. Leaving + // the loop enabled is what lets that finish; without it the tap would stall until a + // client happened to subscribe. + if (this->tap_ != nullptr && this->tap_->tap_needs_port()) { + return; + } #endif // No subscriber at startup; disable loop until a client subscribes this->disable_loop(); } +void SerialProxy::reset_mode_() { + // The mode belongs to a session, not to the port. Carrying a departed client's choice + // over to the next one would inject protocol bytes into a stream that never asked for + // them -- a firmware upload, or any client built before this request existed and so + // unable to turn it off. Guessing RAW is the safe direction: a client that wanted + // protocol handling and did not ask for it merely sends its own acknowledgements. + if (this->mode_ == api::enums::SERIAL_PROXY_MODE_RAW) { + return; + } + ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_); + this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW; +} + void SerialProxy::loop() { #ifdef USE_API // Detect subscriber disconnect - if (this->api_connection_ != nullptr && - (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() || - !api_is_connected())) { + if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() || + !this->api_connection_->is_connection_setup() || !api_is_connected())) { ESP_LOGW(TAG, "Subscriber disconnected"); this->api_connection_ = nullptr; - this->parent_->release(this); + this->reset_mode_(); } // With no subscriber there is normally nothing to do, but a tap may still need the port @@ -80,7 +101,7 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) { #ifdef USE_SERIAL_PROXY_TAP // Before forwarding, so a tap that answers the device (an acknowledgement, say) is not // waiting on the network round trip to a subscriber that may not even exist. - if (this->tap_ != nullptr) { + if (this->tap_observing_()) { this->tap_->on_device_rx(buffer, to_read); } #endif @@ -94,6 +115,24 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) { #endif #ifdef USE_SERIAL_PROXY_TAP + +bool SerialProxy::tap_observing_() const { + if (this->tap_ == nullptr) { + return false; + } + // A tap that needs the port is mid-protocol-work of its own -- the boot-time handshake + // with the device, which runs before any client has connected and so before anyone could + // have chosen a mode. Withholding bytes from it there would strand it, so it is served + // regardless of mode. + if (this->tap_->tap_needs_port()) { + return true; + } + // Otherwise the mode decides. RAW must be inert: a client that flips to RAW before + // flashing firmware is entitled to a byte pipe with nothing injecting protocol bytes + // into it, and "the tap turned out not to recognise the stream" is not good enough. + return this->mode_ == api::enums::SERIAL_PROXY_MODE_EZSP_ASH; +} + void SerialProxy::tap_pump() { #ifdef USE_API const size_t available = this->available(); @@ -109,12 +148,14 @@ void SerialProxy::dump_config() { "Serial Proxy [%" PRIu32 "]:\n" " Name: %s\n" " Port Type: %s\n" + " Mode: %s\n" " RTS Pin: %s\n" " DTR Pin: %s", this->instance_index_, this->name_ != nullptr ? this->name_ : "", this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? "RS485" : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? "RS232" : "TTL", + this->mode_ == api::enums::SERIAL_PROXY_MODE_EZSP_ASH ? "EZSP_ASH" : "RAW", this->rts_pin_ != nullptr ? "configured" : "not configured", this->dtr_pin_ != nullptr ? "configured" : "not configured"); } @@ -188,6 +229,29 @@ void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrat } } +void SerialProxy::set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { +#ifdef USE_API + if (this->port_claimed_by_other_(api_connection)) { + ESP_LOGW(TAG, "Ignoring mode request from client without port access [%" PRIu32 "]", this->instance_index_); + return; + } +#endif + ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, + mode == api::enums::SERIAL_PROXY_MODE_EZSP_ASH ? "EZSP_ASH" : "RAW"); + const bool leaving_protocol_mode = + this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; + this->mode_ = mode; + +#ifdef USE_SERIAL_PROXY_TAP + // Only for an explicit client request, not for reset_mode_() at the end of a session: + // an ordinary disconnect says nothing about the device, whereas a client deliberately + // asking for raw bytes usually precedes changing what the device is. + if (leaving_protocol_mode && this->tap_ != nullptr) { + this->tap_->on_protocol_disabled(); + } +#endif +} + void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) { #ifdef USE_API // Bytes from a client other than the live subscriber would interleave with the @@ -203,7 +267,7 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui #ifdef USE_SERIAL_PROXY_TAP // After the write, so the tap observes the same ordering the device does - if (this->tap_ != nullptr) { + if (this->tap_observing_()) { this->tap_->on_client_tx(data, len); } #endif @@ -264,10 +328,6 @@ 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; @@ -277,7 +337,7 @@ void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api:: return; } this->api_connection_ = nullptr; - this->parent_->release(this); + this->reset_mode_(); this->disable_loop(); ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_); break; diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index c6e60a14b6..87dc0ff44f 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -26,6 +26,7 @@ class APIConnection; namespace enums { enum SerialProxyPortType : uint32_t; enum SerialProxyRequestType : uint32_t; +enum SerialProxyMode : uint32_t; } // namespace enums } // namespace esphome::api @@ -61,6 +62,12 @@ class SerialProxyTap { /// True when the port must keep reading even with no subscriber attached, so a tap can /// do its own protocol work while nobody is listening. virtual bool tap_needs_port() const = 0; + + /// A client explicitly turned protocol handling off for this port. Distinct from the + /// automatic reset when a session ends: this one means a client intends to do something + /// else with the device -- reflash it, most likely -- so anything the tap believes about + /// it should be treated as suspect. + virtual void on_protocol_disabled() = 0; }; #endif @@ -89,6 +96,15 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Get the port type api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; } + /// Set the initial mode (from YAML configuration) + void set_mode(api::enums::SerialProxyMode mode) { this->mode_ = mode; } + + /// Get the current mode + api::enums::SerialProxyMode get_mode() const { return this->mode_; } + + /// Handle a mode change requested by an API client + void set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode); + /// Configure UART parameters and apply them /// @param api_connection The API connection requesting the change /// @param baudrate Baud rate in bits per second @@ -139,6 +155,10 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// must ask for it back. void tap_request_port() { this->enable_loop(); } + /// Whether the underlying device is present. On a USB UART this tracks enumeration, so + /// a tap can notice the device being unplugged and plugged back in. + bool is_device_connected() const { return this->parent_->is_connected(); } + /// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the /// main loop is running -- during setup, for instance, while a component is still /// blocking on can_proceed(). @@ -155,6 +175,15 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool port_claimed_by_other_(api::APIConnection *api_connection) const; #endif + /// Return the port to RAW when a subscriber goes away, so the mode never outlives it. + /// Not tap-gated: the mode is a client-visible property whether or not a tap acts on it. + void reset_mode_(); + +#ifdef USE_SERIAL_PROXY_TAP + /// True when the tap should be shown the traffic passing through this port + bool tap_observing_() const; +#endif + /// Instance index for identifying this proxy in API messages uint32_t instance_index_{0}; @@ -172,6 +201,9 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Port type api::enums::SerialProxyPortType port_type_{}; + /// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW + api::enums::SerialProxyMode mode_{}; + /// Optional GPIO pins for modem control GPIOPin *rts_pin_{nullptr}; GPIOPin *dtr_pin_{nullptr}; diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index b86723baeb..3e52531791 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -166,26 +166,6 @@ 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. @@ -230,7 +210,6 @@ 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/__init__.py b/esphome/components/zigbee_proxy/__init__.py index 4951db634d..2880513bf8 100644 --- a/esphome/components/zigbee_proxy/__init__.py +++ b/esphome/components/zigbee_proxy/__init__.py @@ -1,34 +1,26 @@ import esphome.codegen as cg -from esphome.components import uart, usb_uart +from esphome.components import serial_proxy import esphome.config_validation as cv -from esphome.const import ( - CONF_BUFFER_SIZE, - CONF_ID, - CONF_POWER_SAVE_MODE, - CONF_UART_ID, - CONF_WIFI, -) +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI import esphome.final_validate as fv CODEOWNERS = ["@kbx81"] -DEPENDENCIES = ["api", "uart"] +DEPENDENCIES = ["api", "serial_proxy"] CONF_INITIAL_TIMEOUT = "initial_timeout" CONF_MIN_TIMEOUT = "min_timeout" CONF_MAX_TIMEOUT = "max_timeout" +CONF_SERIAL_PROXY_ID = "serial_proxy_id" -# Default ACK timeout values calibrated for hardware UART (460800 baud, ~2-5 ms round-trip) -_DEFAULT_HW_INITIAL_TIMEOUT = 1600 -_DEFAULT_HW_MIN_TIMEOUT = 400 -_DEFAULT_HW_MAX_TIMEOUT = 3200 - -# 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 +# Default ACK timeout values for the boot-time metadata harvest +_DEFAULT_INITIAL_TIMEOUT = 1600 +_DEFAULT_MIN_TIMEOUT = 400 +_DEFAULT_MAX_TIMEOUT = 3200 zigbee_proxy_ns = cg.esphome_ns.namespace("zigbee_proxy") -ZigbeeProxy = zigbee_proxy_ns.class_("ZigbeeProxy", cg.Component, uart.UARTDevice) +ZigbeeProxy = zigbee_proxy_ns.class_( + "ZigbeeProxy", cg.Component, serial_proxy.SerialProxyTap +) def final_validate(config): @@ -46,18 +38,23 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(ZigbeeProxy), + cv.Required(CONF_SERIAL_PROXY_ID): cv.use_id(serial_proxy.SerialProxy), cv.Optional(CONF_BUFFER_SIZE): cv.SplitDefault( cv.int_range(min=256, max=2048), esp8266=512, default=1024, ), - 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), + cv.Optional( + CONF_INITIAL_TIMEOUT, default=_DEFAULT_INITIAL_TIMEOUT + ): cv.int_range(min=10, max=10000), + cv.Optional(CONF_MIN_TIMEOUT, default=_DEFAULT_MIN_TIMEOUT): cv.int_range( + min=10, max=5000 + ), + cv.Optional(CONF_MAX_TIMEOUT, default=_DEFAULT_MAX_TIMEOUT): cv.int_range( + min=50, max=10000 + ), } - ) - .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA), + ).extend(cv.COMPONENT_SCHEMA), ) FINAL_VALIDATE_SCHEMA = final_validate @@ -66,39 +63,18 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await uart.register_uart_device(var, config) + + sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID]) + cg.add(var.set_serial_proxy(sp)) cg.add_define("USE_ZIGBEE_PROXY") + # Compiles the tap interface into serial_proxy; without it the port is a plain byte pipe + cg.add_define("USE_SERIAL_PROXY_TAP") # Set buffer size via define for compile-time allocation if CONF_BUFFER_SIZE in config: cg.add_define("ZIGBEE_PROXY_BUFFER_SIZE", config[CONF_BUFFER_SIZE]) - # 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_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, - ) - min_timeout = config.get( - CONF_MIN_TIMEOUT, - _DEFAULT_USB_MIN_TIMEOUT if is_usb else _DEFAULT_HW_MIN_TIMEOUT, - ) - max_timeout = config.get( - CONF_MAX_TIMEOUT, - _DEFAULT_USB_MAX_TIMEOUT if is_usb else _DEFAULT_HW_MAX_TIMEOUT, - ) - - cg.add(var.set_initial_timeout(initial_timeout)) - cg.add(var.set_min_timeout(min_timeout)) - cg.add(var.set_max_timeout(max_timeout)) + cg.add(var.set_initial_timeout(config[CONF_INITIAL_TIMEOUT])) + cg.add(var.set_min_timeout(config[CONF_MIN_TIMEOUT])) + cg.add(var.set_max_timeout(config[CONF_MAX_TIMEOUT])) diff --git a/esphome/components/zigbee_proxy/zigbee_proxy.cpp b/esphome/components/zigbee_proxy/zigbee_proxy.cpp index 9cce3a4a6f..19d8ab7056 100644 --- a/esphome/components/zigbee_proxy/zigbee_proxy.cpp +++ b/esphome/components/zigbee_proxy/zigbee_proxy.cpp @@ -3,8 +3,6 @@ #ifdef USE_ZIGBEE_PROXY #include "esphome/core/log.h" -#include "esphome/core/application.h" -#include "esphome/core/util.h" #include "esphome/components/api/api_server.h" #include "ezsp_commands.h" @@ -12,19 +10,18 @@ #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"; -static constexpr uint32_t BOOT_SEQUENCE_TIMEOUT_MS = 10000; // Overall boot-harvest timeout -static constexpr uint32_t RECOVERY_RETRY_INTERVAL_MS = 30000; // Retry interval for a failed NCP link -static constexpr uint32_t CLIENT_TX_RETRY_TIMEOUT_MS = 5000; // Give up on a backpressured client frame -static constexpr size_t NETWORK_INFO_PAYLOAD_SIZE = 19; // ieee(8) + extended_pan(8) + pan_id(2) + channel(1) -static constexpr size_t ZIGBEE_MAX_LOG_BYTES = 168; // Cap verbose hex dumps (168 * 3 = 504 byte buffer) +// A freshly attached USB device answers its enumeration before its CDC endpoints will +// actually carry bytes, so an RST sent the instant it appears is written into a void and +// is only recovered by the 3 s RSTACK retry. zwave_proxy defers its own first query for +// the same reason. +static constexpr uint32_t DEVICE_SETTLE_MS = 500; +static constexpr uint32_t BOOT_SEQUENCE_TIMEOUT_MS = 10000; // Overall boot-harvest timeout +static constexpr size_t NETWORK_INFO_PAYLOAD_SIZE = 19; // ieee(8) + extended_pan(8) + pan_id(2) + channel(1) +static constexpr size_t ZIGBEE_MAX_LOG_BYTES = 168; // Cap verbose hex dumps (168 * 3 = 504 byte buffer) ZigbeeProxy *global_zigbee_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -32,10 +29,12 @@ 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(); + + // The port reads and forwards on its own; we only observe what passes and inject the + // occasional acknowledgement. The harvest below runs before any client connects, so the + // port has to keep reading with nobody subscribed -- hence the explicit request. + this->parent_->set_tap(this); + this->parent_->tap_request_port(); // Initialize state this->ash_state_ = AshState::DISCONNECTED; @@ -48,73 +47,94 @@ 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; - } + // Watch for the radio being unplugged and plugged back in. The whole point of the + // metadata is that a stick moved from another host is recognised here, and that move is + // a hot-plug: harvesting only at boot would miss it entirely and leave the device + // advertising nothing for a radio that is sitting right there. + const bool connected = this->parent_->is_device_connected(); + if (connected != this->was_connected_) { + this->was_connected_ = connected; + this->on_device_presence_changed_(connected); + } + + // A re-harvest owed from on_protocol_disabled(), now that the port is idle again + if (this->reharvest_pending_ && !this->boot_sequence_active_ && this->parent_->get_api_connection() == nullptr) { + ESP_LOGI(TAG, "Port idle again, re-reading network info"); + this->reharvest_pending_ = false; + this->parent_->tap_request_port(); + this->reset_ash_protocol_(); 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); - } - // A subscriber drives its own session: it opens with an RST and negotiates its own - // EZSP version. Harvesting here would put a second RST on the wire alongside the - // client's and renegotiate the NCP underneath it, so only harvest when there is - // something left to learn. - if (this->api_connection_ != nullptr && this->network_info_.valid) { - this->reset_ncp_link_(); - } else { - this->reset_ash_protocol_(); - } + // Bytes arrive through on_device_rx(), so the only work left on an idle tick is the + // presence check above -- an atomic load and a compare. The loop deliberately stays + // enabled for it: disabling it would mean a stick plugged in later is never noticed. + if (!this->boot_sequence_active_) { + return; } - // Process incoming UART data - this->process_uart_(); - // Check for ACK timeout and handle retransmission if (this->tx_buffer_pending_ && this->check_ack_timeout_()) { this->handle_retransmission_(); } - if (this->boot_sequence_active_) { - this->check_boot_timeouts_(); - } else if (this->api_connection_ == nullptr && this->ash_state_ == AshState::CONNECTING && - millis() - this->setup_time_ > ASH_RESET_TIMEOUT) { - ESP_LOGE(TAG, "RSTACK timeout, NCP not responding"); - this->ash_state_ = AshState::FAILED; - } + this->check_boot_timeouts_(); +} - // Guard against a subscriber that disconnected without unsubscribing - if (this->api_connection_ != nullptr && (!this->api_connection_->is_connection_setup() || !api_is_connected())) { - ESP_LOGW(TAG, "Subscriber disconnected"); - this->unsubscribe_api_connection(this->api_connection_); - } +void ZigbeeProxy::on_device_rx(const uint8_t *data, size_t len) { + for (size_t i = 0; i < len; i++) { + const uint8_t byte = data[i]; + ESP_LOGV(TAG, "RX: 0x%02X", byte); - // No autonomous recovery while a client is subscribed. A subscriber owns the link: it - // opens with its own RST and resets whenever it decides it needs to. Resetting on its - // behalf relays an RSTACK it never asked for, which bellows treats as fatal -- and if it - // happens to be driving a bootloader over this interface, injecting ASH into the - // transfer is worse still. A broken link is the client's to notice and repair. + 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; + } + + if (this->boot_sequence_active_) { + // Harvest: this component is the ASH endpoint and consumes the frames itself + this->parse_byte_(byte); + continue; + } + + // Observation only: the detector never gates forwarding, so it adds no latency and a + // frame it cannot parse still reaches the client, which judges it for itself. + this->detector_.from_ncp(byte); + + // Outside the harvest the detector is the only thing watching the link, so its + // progress is what tells us the NCP is alive -- and hence that any earlier bootloader + // detection is stale. + if (this->detector_.state() != AshDetectState::IDLE) { + this->ash_state_ = AshState::CONNECTED; + } + + uint8_t ack_num; + if (this->detector_.take_pending_ack(ack_num)) { + // The client suppresses its own ACKs, so this is the only acknowledgement the NCP + // will see. Only ever sent for a frame that passed CRC and arrived in sequence. + this->send_ack_frame_(ack_num); + const uint8_t *ezsp = this->detector_.last_ezsp_frame(); + const size_t ezsp_length = this->detector_.last_ezsp_frame_length(); + this->sniff_network_info_(ezsp, ezsp_length); + this->sniff_stack_status_(ezsp, ezsp_length); + } + } +} + +void ZigbeeProxy::on_client_tx(const uint8_t *data, size_t len) { + // Scanning this direction only matters while waiting for the version command that + // completes the handshake. Outside that window it is skipped entirely -- which is what + // makes a firmware upload, all of which flows this way, essentially free. + if (!this->detector_.needs_host_scan()) { + return; + } + for (size_t i = 0; i < len; i++) { + this->detector_.from_host(data[i]); + } } void ZigbeeProxy::check_boot_timeouts_() { @@ -179,7 +199,7 @@ bool ZigbeeProxy::can_proceed() { } // loop() is not called while setup is blocked, so run the boot machinery here - this->process_uart_(); + this->parent_->tap_pump(); if (this->tx_buffer_pending_ && this->check_ack_timeout_()) { this->handle_retransmission_(); } @@ -197,38 +217,6 @@ void ZigbeeProxy::api_connection_authenticated(api::APIConnection *conn) { void ZigbeeProxy::zigbee_proxy_request(api::APIConnection *api_connection, const api::ZigbeeProxyRequest &msg) { switch (msg.type) { - case api::enums::ZIGBEE_PROXY_REQUEST_TYPE_SUBSCRIBE: - if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { - // 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; - // A subscriber owns the link from here on, so abandon any harvest in flight rather - // than interleaving our own EZSP commands with the client's session. Metadata for - // this session comes from watching the client's own traffic instead. - if (this->boot_sequence_active_) { - ESP_LOGD(TAG, "Abandoning boot harvest, client owns the link"); - this->boot_sequence_active_ = false; - this->boot_state_ = BootState::IDLE; - } - this->detector_.reset(); - break; - - case api::enums::ZIGBEE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: - if (this->api_connection_ == api_connection) { - ESP_LOGD(TAG, "Client unsubscribed"); - this->unsubscribe_api_connection(api_connection); - } - break; - case api::enums::ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO: this->send_network_info_changed_msg_(api_connection); break; @@ -239,36 +227,6 @@ void ZigbeeProxy::zigbee_proxy_request(api::APIConnection *api_connection, const } } -void ZigbeeProxy::unsubscribe_api_connection(api::APIConnection *conn) { - if (this->api_connection_ != conn) { - return; - } - this->api_connection_ = nullptr; - // Anything already buffered belongs to the departed session - this->relay_length_ = 0; - this->detector_.reset(); -} - -void ZigbeeProxy::zigbee_proxy_frame(api::APIConnection *api_connection, const api::ZigbeeProxyFrame &msg) { - if (this->api_connection_ != api_connection) { - ESP_LOGW(TAG, "Frame received from non-subscribed client"); - return; - } - - // Transparent relay: the client's ASH bytes reach the NCP untouched, so the two share - // one sequence space and nothing here can desynchronize it. - this->write_array(msg.data, msg.data_len); - - // Scanning this direction only matters while waiting for the version command that - // completes the handshake. Outside that window it is a pure passthrough -- which is - // what makes a firmware upload, all of which flows this way, essentially free. - if (this->detector_.needs_host_scan()) { - for (size_t i = 0; i < msg.data_len; i++) { - this->detector_.from_host(msg.data[i]); - } - } -} - uint64_t ZigbeeProxy::get_ieee_address() const { uint64_t addr = 0; for (size_t i = 0; i < ZIGBEE_IEEE_ADDR_SIZE; i++) { @@ -285,13 +243,6 @@ 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"); @@ -314,33 +265,12 @@ void ZigbeeProxy::reset_ash_protocol_() { this->boot_sequence_active_ = true; this->ezsp_sequence_ = 0; - // Abandon RSTACKs owed from a previous attempt: they can no longer arrive in a state - // where suppressing them is correct, and a stale count would swallow a real reset. - this->own_rst_outstanding_ = 0; - this->send_rst_frame_(); } -void ZigbeeProxy::reset_ncp_link_() { - this->ash_state_ = AshState::CONNECTING; - this->setup_time_ = millis(); // Reset timeout reference for the RSTACK wait - this->tx_sequence_ = 0; - this->rx_sequence_ = 0; - this->tx_buffer_pending_ = false; - this->tx_retry_count_ = 0; - this->parsing_state_ = ParsingState::WAIT_FLAG_START; - this->relay_length_ = 0; - this->detector_.reset(); - this->send_rst_frame_(false); -} - -void ZigbeeProxy::send_rst_frame_(bool own_reset) { - if (own_reset) { - this->own_rst_outstanding_++; - } - +void ZigbeeProxy::send_rst_frame_() { // Build a combined buffer: 32 CAN bytes followed immediately by the RST frame, - // sent as a single write_array call. This ensures correct byte ordering and + // sent as a single write. This ensures correct byte ordering and // minimizes the number of USB bulk transfers (all bytes fit in one USB FS packet). static constexpr uint8_t ASH_CAN_BYTE = 0x1A; static constexpr size_t CAN_COUNT = 32; @@ -353,8 +283,7 @@ void ZigbeeProxy::send_rst_frame_(bool own_reset) { char hex_buf[format_hex_pretty_size(MAX_RST_FRAME_SIZE)]; #endif ESP_LOGV(TAG, "RST frame bytes (%u): %s", rst_len, format_hex_pretty_to(hex_buf, combined + CAN_COUNT, rst_len)); - this->write_array(combined, CAN_COUNT + rst_len); - this->flush(); + this->parent_->write_from_tap(combined, CAN_COUNT + rst_len); ESP_LOGV(TAG, "Sent RST frame (with %u CAN bytes prefix)", CAN_COUNT); } @@ -364,29 +293,14 @@ void ZigbeeProxy::handle_rstack_frame_(const uint8_t *data, size_t length) { this->rx_sequence_ = 0; this->clear_tx_buffer_(); - // Account for this RSTACK before deciding whether the client should see it. Only a - // reset we did not cause is news to the client; relaying one of ours makes bellows - // call enter_failed_state() and cancel every command it has in flight. - bool solicited_by_us = this->own_rst_outstanding_ > 0; - if (solicited_by_us) { - this->own_rst_outstanding_--; - } - if (this->boot_state_ == BootState::WAIT_RSTACK) { // Initial RSTACK - start boot sequence ESP_LOGV(TAG, "Received RSTACK, starting EZSP initialization"); this->ash_state_ = AshState::CONNECTED; - // 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); - } + // Stale bytes preceding the RSTACK (leftover UART FIFO content, or a partial prior + // frame) need no draining: the port owns the read side now, so anything before the + // RSTACK has already passed through the parser and been discarded by frame delimiting. this->boot_state_ = BootState::SEND_VERSION; this->advance_boot_state_(); @@ -399,28 +313,11 @@ void ZigbeeProxy::handle_rstack_frame_(const uint8_t *data, size_t length) { // Now check for WiFi/Zigbee channel conflicts this->check_wifi_zigbee_conflict_(); - } else if (this->ash_state_ == AshState::CONNECTING) { - if (solicited_by_us) { - // One of our own resets answered while a client reset is still outstanding. Stay in - // CONNECTING and keep waiting for the RSTACK the client is actually owed. - ESP_LOGV(TAG, "Consumed own RSTACK while awaiting the client's"); - return; - } - // RSTACK during connecting (triggered by client RST forwarding) - ESP_LOGV(TAG, "Received RSTACK, NCP ready"); - this->ash_state_ = AshState::CONNECTED; - } else if (solicited_by_us) { - // Surplus RSTACK from one of our own resets, most often an RST retry racing a reply - // that was merely slow. The client never asked for it, so swallow it. - ESP_LOGV(TAG, "Consumed surplus RSTACK from own reset"); - this->ash_state_ = AshState::CONNECTED; - } else if (this->api_connection_ != nullptr) { - // A reset we did not cause: the NCP rebooted on its own, which invalidates the - // client's session, so it has to hear about it. - ESP_LOGW(TAG, "NCP reset unexpectedly, notifying client"); - this->ash_state_ = AshState::CONNECTED; } else { - ESP_LOGW(TAG, "Unexpected RSTACK received (boot_state=%d)", static_cast(this->boot_state_)); + // An RSTACK outside the harvest belongs to whoever reset the NCP -- a client opening + // its own session, most likely. Nothing to do but note that the link is alive. + ESP_LOGV(TAG, "RSTACK received outside boot sequence (boot_state=%d)", static_cast(this->boot_state_)); + this->ash_state_ = AshState::CONNECTED; } } @@ -463,21 +360,16 @@ void ZigbeeProxy::handle_error_frame_(const uint8_t *data, size_t length) { break; } + // Reported only. This frame is only ever seen during the boot harvest, whose overall + // timeout already guarantees forward progress; resetting the NCP here would restart that + // timeout and could block startup indefinitely on a link that keeps erroring. ESP_LOGE(TAG, "NCP error: %s (0x%02X)", error_str, error_code); - - if (this->api_connection_ != nullptr) { - // Forward error to client - } else { - // No client, attempt recovery ourselves - ESP_LOGV(TAG, "Attempting recovery"); - this->reset_ash_protocol_(); - } } bool ZigbeeProxy::send_ack_frame_(uint8_t ack_num) { uint8_t frame[8]; size_t length = this->build_frame_(frame, sizeof(frame), nullptr, 0, AshFrameType::ACK, 0, ack_num); - this->write_array(frame, length); + this->parent_->write_from_tap(frame, length); this->last_ack_sent_ = ack_num; ESP_LOGV(TAG, "Sent ACK for frame %d", ack_num); return true; @@ -486,7 +378,7 @@ bool ZigbeeProxy::send_ack_frame_(uint8_t ack_num) { bool ZigbeeProxy::send_nak_frame_(uint8_t ack_num) { uint8_t frame[8]; size_t length = this->build_frame_(frame, sizeof(frame), nullptr, 0, AshFrameType::NAK, 0, ack_num); - this->write_array(frame, length); + this->parent_->write_from_tap(frame, length); ESP_LOGW(TAG, "Sent NAK for frame %d", ack_num); return true; } @@ -514,7 +406,7 @@ bool ZigbeeProxy::send_data_frame_(const uint8_t *data, size_t length, bool retr format_hex_pretty_to(hex_buf, this->tx_buffer_.data(), frame_length)); // Send frame - this->write_array(this->tx_buffer_.data(), frame_length); + this->parent_->write_from_tap(this->tx_buffer_.data(), frame_length); // Start ACK timer this->tx_buffer_pending_ = true; @@ -576,7 +468,7 @@ void ZigbeeProxy::handle_retransmission_() { ASH_MAX_RETRIES); // Resend the pending frame - this->write_array(this->tx_pending_buffer_.data(), this->tx_pending_length_); + this->parent_->write_from_tap(this->tx_pending_buffer_.data(), this->tx_pending_length_); this->start_ack_timer_(); } @@ -1023,73 +915,53 @@ void ZigbeeProxy::check_bootloader_mode_(uint8_t prev_byte, uint8_t byte) { } } -// UART processing (precondition: available() > 0, see inline process_uart_ in the header) -void ZigbeeProxy::process_uart_slow_() { - do { - uint8_t byte; - if (!this->read_byte(&byte)) { - return; - } - - // Verbose logging for debugging (ESP_LOGV already checks log level) - ESP_LOGV(TAG, "RX: 0x%02X", byte); - - 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; - } - - if (this->boot_sequence_active_) { - // Harvest: this component is the ASH endpoint and consumes the frames itself - this->parse_byte_(byte); - } else { - this->relay_ncp_byte_(byte); - } - } while (this->available()); - - this->relay_flush_(); -} - -// ==================== Transparent relay ==================== - -void ZigbeeProxy::relay_ncp_byte_(uint8_t byte) { - if (this->relay_length_ >= sizeof(this->relay_buffer_)) { - this->relay_flush_(); - } - this->relay_buffer_[this->relay_length_++] = byte; - - // Observation only: the detector never gates forwarding, so it adds no latency and a - // frame it cannot parse still reaches the client, which judges it for itself. - this->detector_.from_ncp(byte); - - // In relay mode the detector is the only thing watching the link, so its progress is - // what tells us the NCP is alive. Without this ash_state_ sits at CONNECTING, times out - // into FAILED, and autonomous recovery resets the NCP underneath a working session -- - // relaying an RSTACK the client never asked for, which kills it outright. - if (this->detector_.state() != AshDetectState::IDLE) { - this->ash_state_ = AshState::CONNECTED; - } - - uint8_t ack_num; - if (this->detector_.take_pending_ack(ack_num)) { - // The client suppresses its own ACKs, so this is the only acknowledgement the NCP - // will see. Only ever sent for a frame that passed CRC and arrived in sequence. - this->send_ack_frame_(ack_num); - const uint8_t *ezsp = this->detector_.last_ezsp_frame(); - const size_t ezsp_length = this->detector_.last_ezsp_frame_length(); - this->sniff_network_info_(ezsp, ezsp_length); - this->sniff_stack_status_(ezsp, ezsp_length); - } -} - // A proxied getNetworkParameters response is the only authoritative view of the network // available while a client owns the link, so metadata is refreshed from the client's own // traffic rather than by injecting commands. Read-only: a frame that fails any check // simply leaves the previous values in place. +void ZigbeeProxy::on_device_presence_changed_(bool connected) { + if (!connected) { + ESP_LOGD(TAG, "Radio disconnected, discarding network info"); + this->boot_sequence_active_ = false; + this->boot_state_ = BootState::IDLE; + this->reharvest_pending_ = false; + if (this->network_info_.valid) { + this->network_info_ = {}; + this->send_network_info_changed_msg_(); + } + return; + } + + // A radio just appeared. Whatever we knew described a different one, so start over. + ESP_LOGI(TAG, "Radio connected, reading network info"); + if (this->network_info_.valid) { + this->network_info_ = {}; + this->send_network_info_changed_msg_(); + } + this->reharvest_pending_ = true; + this->reharvest_after_ = millis() + DEVICE_SETTLE_MS; +} + +void ZigbeeProxy::on_protocol_disabled() { + // Everything here was read from a radio that a client is now taking over, so none of it + // can be trusted: it survives a reflash to Thread, or to nothing at all, and would leave + // us advertising a network that no longer exists. Reporting nothing is the honest answer + // until a fresh harvest says otherwise. + // + // The harvest cannot run now -- the client holds the port -- so it is deferred. Once the + // client goes away the port stays open for us (tap_needs_port) and loop() picks it up. + this->reharvest_pending_ = true; + this->reharvest_after_ = 0; + this->enable_loop(); + + if (!this->network_info_.valid) { + return; + } + ESP_LOGD(TAG, "Protocol handling disabled, discarding network info"); + this->network_info_ = {}; + this->send_network_info_changed_msg_(); +} + void ZigbeeProxy::sniff_network_info_(const uint8_t *frame, size_t length) { // Every frame after the version handshake uses extended framing, so the header size is // fixed and needs no knowledge of the negotiated version. @@ -1097,7 +969,7 @@ void ZigbeeProxy::sniff_network_info_(const uint8_t *frame, size_t length) { return; } - // The relay never derandomizes, so work on a copy: the original bytes are already on + // The observed bytes are the port's, not ours, so work on a copy: they are already on // their way to the client and must stay untouched. uint8_t decoded[EZSP_EXTENDED_HEADER_SIZE + NETWORK_PARAMS_RESPONSE_SIZE]; memcpy(decoded, frame, sizeof(decoded)); @@ -1166,25 +1038,6 @@ void ZigbeeProxy::sniff_stack_status_(const uint8_t *frame, size_t length) { this->send_network_info_changed_msg_(); } -void ZigbeeProxy::relay_flush_() { - if (this->relay_length_ == 0) { - return; - } - const size_t length = this->relay_length_; - this->relay_length_ = 0; - if (this->api_connection_ == nullptr) { - return; - } - this->outgoing_proto_msg_.data = this->relay_buffer_; - this->outgoing_proto_msg_.data_len = length; - if (!this->api_connection_->send_zigbee_proxy_frame(this->outgoing_proto_msg_)) { - // API TX backpressure. Dropping bytes is recoverable: the client sees a truncated - // frame, fails its CRC and NAKs, and the NCP retransmits. Withholding our ACK would - // achieve the same thing more slowly, and buffering risks unbounded growth. - ESP_LOGW(TAG, "Dropped %u relayed bytes (API TX buffer full)", length); - } -} - } // namespace esphome::zigbee_proxy #endif // USE_ZIGBEE_PROXY diff --git a/esphome/components/zigbee_proxy/zigbee_proxy.h b/esphome/components/zigbee_proxy/zigbee_proxy.h index 5d5404c663..1175104211 100644 --- a/esphome/components/zigbee_proxy/zigbee_proxy.h +++ b/esphome/components/zigbee_proxy/zigbee_proxy.h @@ -5,24 +5,14 @@ #include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_pb2.h" +#include "esphome/components/serial_proxy/serial_proxy.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" -#include "esphome/components/uart/uart.h" #include "ash_protocol.h" #include "ash_detector.h" #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 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; -} -#endif - namespace esphome::zigbee_proxy { // Timeout configuration structure @@ -44,6 +34,11 @@ struct NetworkInfo { enum ZigbeeProxyFeature : uint32_t { FEATURE_ZIGBEE_PROXY_ENABLED = 1 << 0, + // Set only when the harvest actually read a network off the radio. Without it a client + // cannot tell "a Zigbee radio with no network formed" from "not a Zigbee radio at all" + // -- both otherwise present as ENABLED with an all-zero payload, and the second happens + // whenever the NCP has been reflashed to Thread or is simply not responding. + FEATURE_ZIGBEE_NETWORK_INFO_VALID = 1 << 1, }; // Boot-time initialization state machine @@ -62,7 +57,12 @@ enum class BootState : uint8_t { FAILED, // Boot sequence failed }; -class ZigbeeProxy : public uart::UARTDevice, public Component { +// Watches a `serial_proxy` port carrying an EZSP NCP and reports what it learns about the +// Zigbee network. It never carries client traffic: the serial proxy owns the port and the +// bytes, and this component only observes them, plus two exceptions where it writes to the +// port itself -- the boot-time metadata harvest, which runs before any client connects, and +// the ASH acknowledgements a client asks it to send on its behalf. +class ZigbeeProxy : public serial_proxy::SerialProxyTap, public Component { public: ZigbeeProxy(); @@ -72,16 +72,40 @@ class ZigbeeProxy : public uart::UARTDevice, public Component { float get_setup_priority() const override; bool can_proceed() override; + void set_serial_proxy(serial_proxy::SerialProxy *parent) { this->parent_ = parent; } + + // SerialProxyTap + void on_device_rx(const uint8_t *data, size_t len) override; + void on_client_tx(const uint8_t *data, size_t len) override; + bool tap_needs_port() const override { + if (this->boot_sequence_active_) { + return true; + } + // A pending re-harvest waits for the port to go idle. Starting one under a subscriber + // would inject our own ASH frames into whatever it is doing -- most likely the very + // firmware upload that invalidated the metadata. + return this->reharvest_pending_ && this->parent_->get_api_connection() == nullptr; + } + + /// The port stopped handling our protocol, so whatever we know about the radio may no + /// longer be true -- a client asking for raw bytes is usually about to reflash it. + void on_protocol_disabled() override; + + /// The radio was unplugged or a new one appeared; metadata describes neither. + void on_device_presence_changed_(bool connected); + // API integration void api_connection_authenticated(api::APIConnection *conn); void zigbee_proxy_request(api::APIConnection *api_connection, const api::ZigbeeProxyRequest &msg); - void zigbee_proxy_frame(api::APIConnection *api_connection, const api::ZigbeeProxyFrame &msg); - api::APIConnection *get_api_connection() { return this->api_connection_; } - // Drop the subscription of a disconnecting client (called from APIConnection teardown) - void unsubscribe_api_connection(api::APIConnection *conn); // Feature flags - uint32_t get_feature_flags() const { return ZigbeeProxyFeature::FEATURE_ZIGBEE_PROXY_ENABLED; } + uint32_t get_feature_flags() const { + uint32_t flags = ZigbeeProxyFeature::FEATURE_ZIGBEE_PROXY_ENABLED; + if (this->network_info_.valid) { + flags |= ZigbeeProxyFeature::FEATURE_ZIGBEE_NETWORK_INFO_VALID; + } + return flags; + } // Network information accessors const NetworkInfo &get_network_info() const { return this->network_info_; } @@ -93,25 +117,10 @@ 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 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. - void set_usb_uart_channel(usb_uart::USBUartChannel *channel); -#endif - protected: // ASH Protocol State Machine void reset_ash_protocol_(); - // Resets the NCP link without running the metadata harvest, leaving the client to - // negotiate its own session. The resulting RSTACK is relayed rather than consumed. - void reset_ncp_link_(); - // `own_reset` marks a reset we originate ourselves (harvest, retry, final RST) so its - // RSTACK is consumed here instead of being relayed to a client that never asked for - // one -- bellows treats an unsolicited RSTACK as fatal and cancels every pending - // command. Pass false when relaying a client's own RST. - void send_rst_frame_(bool own_reset = true); + void send_rst_frame_(); void handle_rstack_frame_(const uint8_t *data, size_t length); void handle_error_frame_(const uint8_t *data, size_t length); // Applies a frame's ackNum to the pending TX frame. Returns true if it @@ -168,89 +177,36 @@ class ZigbeeProxy : public uart::UARTDevice, public Component { // Bootloader detection (fed consecutive raw byte pairs while not CONNECTED) void check_bootloader_mode_(uint8_t prev_byte, uint8_t byte); - // 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; - } - this->process_uart_slow_(); - } - // Precondition: caller must guarantee available() > 0 (see inline process_uart_ above) - void process_uart_slow_(); - - // Client-side (left) ASH session - void client_parse_byte_(uint8_t byte); - void client_parse_control_byte_(uint8_t control); - bool client_validate_frame_crc_(); - void client_send_ack_frame_(uint8_t ack_num); - void client_send_nak_frame_(uint8_t ack_num); - void client_send_rstack_frame_(uint8_t reset_code); - void client_send_data_frame_(const uint8_t *data, size_t length); - void client_send_error_frame_(uint8_t error_code); - void client_send_raw_frame_(const uint8_t *frame, size_t length); - // Transparent relay. NCP bytes are forwarded to the client verbatim and in bulk; the - // detector only observes them, so it never gates or delays forwarding. - void relay_ncp_byte_(uint8_t byte); - void relay_flush_(); // Reads network metadata out of a proxied getNetworkParameters response. Read-only, so a // misparse costs a missed update rather than corrupting anything. void sniff_network_info_(const uint8_t *frame, size_t length); // Invalidates network metadata when the stack reports it has left the network. void sniff_stack_status_(const uint8_t *frame, size_t length); - // Pre-allocated message - always ready to send - api::ZigbeeProxyFrame outgoing_proto_msg_; - - // NCP-side (right) ASH buffers + // NCP-side ASH buffers std::array rx_buffer_; std::array tx_buffer_; std::array tx_pending_buffer_; // For retransmission - // Client-side (left) ASH buffers - - // Client -> NCP queue: EZSP payloads accepted while the ASH TX window is occupied // Network information NetworkInfo network_info_; // Timeout configuration TimeoutConfig timeout_config_; - // Pointers (aligned together) - api::APIConnection *api_connection_{nullptr}; // Current subscribed client + // The port this component observes. Owns the UART and the bytes; every write we make + // goes through it. + serial_proxy::SerialProxy *parent_{nullptr}; - // NCP-side (right) 32-bit values - uint32_t setup_time_{0}; // Time when last RST frame was sent - uint32_t boot_start_time_{0}; // Time when the boot sequence began (for overall timeout) - uint32_t ack_timer_start_{0}; // Time when ACK timer started - uint32_t last_rtt_ms_{0}; // Last measured round-trip time - uint32_t last_recovery_attempt_{0}; // Time of last automatic reset attempt from FAILED + uint32_t setup_time_{0}; // Time when last RST frame was sent + uint32_t boot_start_time_{0}; // Time when the boot sequence began (for overall timeout) + uint32_t ack_timer_start_{0}; // Time when ACK timer started + uint32_t last_rtt_ms_{0}; // Last measured round-trip time - // Client-side (left) 32-bit values - - // NCP-side (right) 16-bit values uint16_t rx_buffer_index_{0}; // Index for populating rx_buffer_ uint16_t tx_pending_length_{0}; // Length of pending TX frame for retransmission uint16_t calculated_crc_{0}; // CRC calculated during frame reception - // Client-side (left) 16-bit values - - // NCP-side (right) 8-bit values uint8_t tx_sequence_{0}; // TX sequence number (0-7) uint8_t rx_sequence_{0}; // RX sequence number (0-7) uint8_t tx_retry_count_{0}; // Number of retransmission attempts @@ -258,16 +214,11 @@ class ZigbeeProxy : public uart::UARTDevice, public Component { uint8_t last_ack_sent_{0}; // Last ACK number sent uint8_t last_rx_byte_{0}; // Previous raw RX byte (bootloader detection) - // Client-side (left) 8-bit values - - // NCP-side enums and booleans AshState ash_state_{AshState::DISCONNECTED}; ParsingState parsing_state_{ParsingState::WAIT_FLAG_START}; BootloaderState bootloader_state_{BootloaderState::NORMAL}; BootState boot_state_{BootState::IDLE}; - // Client-side enums and booleans - uint8_t ezsp_version_{0}; // NCP's EZSP protocol version uint8_t ezsp_sequence_{0}; // EZSP frame sequence number uint8_t ezsp_requested_version_{0}; // Version we last requested (for re-negotiation) @@ -276,28 +227,20 @@ class ZigbeeProxy : public uart::UARTDevice, public Component { // with frame ID 0x0058. Tracks whether that second handshake has happened. bool ezsp_version_confirmed_{false}; - bool tx_buffer_pending_{false}; // True if waiting for ACK from NCP - bool escape_next_byte_{false}; // True if next NCP 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 + bool tx_buffer_pending_{false}; // True if waiting for ACK from NCP + bool escape_next_byte_{false}; // True if next NCP byte should be unescaped bool boot_sequence_active_{false}; // True during boot-time init + // Set when the metadata was discarded and a fresh harvest is owed once the port frees up + bool reharvest_pending_{false}; + // Last observed device presence, for spotting a hot-plug + bool was_connected_{false}; + // Earliest millis() at which a pending re-harvest may start + uint32_t reharvest_after_{0}; // Decides when acknowledging on the client's behalf is safe. Armed only by the ASH // session handshake, so a bootloader or Thread NCP never triggers it. AshDetector detector_; - - // Bytes staged for the client. Forwarding in bulk once per UART drain avoids an API - // message per byte; the size only bounds latency, not correctness. - static constexpr size_t RELAY_BUFFER_SIZE = 256; - uint8_t relay_buffer_[RELAY_BUFFER_SIZE]; - size_t relay_length_{0}; - - // RSTACKs still owed to us for resets we sent ourselves. A retry can put two RSTs on - // the wire when the first RSTACK was only slow rather than lost, so the NCP answers - // with more RSTACKs than we asked for; the surplus must not reach a client. - uint8_t own_rst_outstanding_{0}; }; extern ZigbeeProxy *global_zigbee_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d5750fb784..24358c9fcb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -166,6 +166,7 @@ #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY +#define USE_SERIAL_PROXY_TAP #define USE_SETUP_PRIORITY_OVERRIDE #define USE_STATUS_LED #define USE_STATUS_SENSOR @@ -182,7 +183,6 @@ #define USE_WATER_HEATER #define USE_WATER_HEATER_VISUAL_OVERRIDES #define USE_ZIGBEE_PROXY -#define USE_ZIGBEE_PROXY_USB_UART #define USE_ZWAVE_PROXY // Feature flags which do not work for zephyr diff --git a/tests/components/zigbee_proxy/common.yaml b/tests/components/zigbee_proxy/common.yaml index 22d6aba890..8623b73991 100644 --- a/tests/components/zigbee_proxy/common.yaml +++ b/tests/components/zigbee_proxy/common.yaml @@ -14,5 +14,10 @@ uart: rx_pin: ${rx_pin} baud_rate: 115200 -zigbee_proxy: - uart_id: zigbee_uart +# The port owns the UART and carries every byte; zigbee_proxy only taps it +serial_proxy: + - id: zigbee_serial + uart_id: zigbee_uart + name: Zigbee + port_type: TTL + mode: ezsp_ash diff --git a/tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml b/tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml index 0b4008f27d..8d7407ce01 100644 --- a/tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml +++ b/tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml @@ -15,7 +15,14 @@ usb_uart: - 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) +# The tapped port may be a USB CDC ACM channel just as well as a hardware UART: +# zigbee_proxy never touches the UART itself, so it does not care which it is. +serial_proxy: + - id: zigbee_usb_serial + uart_id: zigbee_usb_channel + name: Zigbee + port_type: TTL + mode: ezsp_ash + zigbee_proxy: - uart_id: zigbee_usb_channel + serial_proxy_id: zigbee_usb_serial diff --git a/tests/components/zigbee_proxy/test.esp32-idf.yaml b/tests/components/zigbee_proxy/test.esp32-idf.yaml index 9b06a7a8ac..6c4c86a9a4 100644 --- a/tests/components/zigbee_proxy/test.esp32-idf.yaml +++ b/tests/components/zigbee_proxy/test.esp32-idf.yaml @@ -8,6 +8,7 @@ esp32: <<: !include common.yaml zigbee_proxy: + serial_proxy_id: zigbee_serial buffer_size: 1024 initial_timeout: 1600 min_timeout: 400 diff --git a/tests/components/zigbee_proxy/test.esp8266-ard.yaml b/tests/components/zigbee_proxy/test.esp8266-ard.yaml index afdb702780..9c970b37d8 100644 --- a/tests/components/zigbee_proxy/test.esp8266-ard.yaml +++ b/tests/components/zigbee_proxy/test.esp8266-ard.yaml @@ -8,4 +8,5 @@ esp8266: <<: !include common.yaml zigbee_proxy: + serial_proxy_id: zigbee_serial buffer_size: 512 diff --git a/tests/components/zigbee_proxy/test.rp2040-ard.yaml b/tests/components/zigbee_proxy/test.rp2040-ard.yaml index 16b2a4b006..0837d4ee65 100644 --- a/tests/components/zigbee_proxy/test.rp2040-ard.yaml +++ b/tests/components/zigbee_proxy/test.rp2040-ard.yaml @@ -3,3 +3,6 @@ substitutions: rx_pin: GPIO1 <<: !include common.yaml + +zigbee_proxy: + serial_proxy_id: zigbee_serial