From 80254f8689f2b7e9e6b5d034184352bd7c05fc7e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:04:02 +0000 Subject: [PATCH] Strip out Zigbee proxy's EZSP, leaving only enough for ASH auto-ACKIng --- esphome/components/api/api.proto | 21 - esphome/components/api/api_connection.cpp | 18 - esphome/components/api/api_connection.h | 4 - esphome/components/api/api_pb2.cpp | 48 - esphome/components/api/api_pb2.h | 35 +- esphome/components/api/api_pb2_defines.h | 2 + esphome/components/api/api_pb2_dump.cpp | 24 - esphome/components/api/api_pb2_service.cpp | 11 - esphome/components/api/api_pb2_service.h | 4 - esphome/components/api/api_server.cpp | 8 - esphome/components/api/api_server.h | 3 - esphome/components/zigbee_proxy/__init__.py | 64 +- .../components/zigbee_proxy/ash_detector.cpp | 10 +- .../components/zigbee_proxy/ash_detector.h | 16 +- .../components/zigbee_proxy/ash_protocol.cpp | 409 +------ .../components/zigbee_proxy/ash_protocol.h | 80 +- .../components/zigbee_proxy/ezsp_commands.h | 88 -- .../components/zigbee_proxy/zigbee_proxy.cpp | 1080 +---------------- .../components/zigbee_proxy/zigbee_proxy.h | 232 +--- 19 files changed, 88 insertions(+), 2069 deletions(-) delete mode 100644 esphome/components/zigbee_proxy/ezsp_commands.h diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index aae8b30c50..9d79b0ec23 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -70,8 +70,6 @@ service APIConnection { rpc zwave_proxy_frame(ZWaveProxyFrame) returns (void) {} rpc zwave_proxy_request(ZWaveProxyRequest) returns (void) {} - rpc zigbee_proxy_request(ZigbeeProxyRequest) returns (void) {} - rpc infrared_rf_transmit_raw_timings(InfraredRFTransmitRawTimingsRequest) returns (void) {} rpc serial_proxy_configure(SerialProxyConfigureRequest) returns (void) {} @@ -340,10 +338,6 @@ message DeviceInfoResponse { // all-zeros PSK, so the api encryption key can be provisioned without being // sent in plaintext (protects against passive sniffing, not active MITM) bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; - - // Indicates if Zigbee proxy support is available and features supported - uint32 zigbee_proxy_feature_flags = 27 [(field_ifdef) = "USE_ZIGBEE_PROXY"]; - uint64 zigbee_ieee_address = 28 [(field_ifdef) = "USE_ZIGBEE_PROXY"]; } // ==================== DEVICE CAPABILITIES ==================== @@ -2930,18 +2924,3 @@ message BluetoothSetConnectionParamsResponse { uint64 address = 1; int32 error = 2; } - -// ==================== ZIGBEE ==================== - -enum ZigbeeProxyRequestType { - ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0; -} - -message ZigbeeProxyRequest { - option (id) = 155; - option (source) = SOURCE_BOTH; - option (ifdef) = "USE_ZIGBEE_PROXY"; - - ZigbeeProxyRequestType type = 1; - bytes data = 2; -} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7f42e32b53..a132bb6f72 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -48,9 +48,6 @@ #ifdef USE_ZWAVE_PROXY #include "esphome/components/zwave_proxy/zwave_proxy.h" #endif -#ifdef USE_ZIGBEE_PROXY -#include "esphome/components/zigbee_proxy/zigbee_proxy.h" -#endif #ifdef USE_SERIAL_PROXY_USB_INFO #include "esphome/components/usb_host/usb_host.h" #endif @@ -1395,12 +1392,6 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { } #endif -#ifdef USE_ZIGBEE_PROXY -void APIConnection::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) { - zigbee_proxy::global_zigbee_proxy->zigbee_proxy_request(this, msg); -} -#endif - #ifdef USE_ALARM_CONTROL_PANEL bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) { return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE, @@ -1831,11 +1822,6 @@ void APIConnection::complete_authentication_() { zwave_proxy::global_zwave_proxy->api_connection_authenticated(this); } #endif -#ifdef USE_ZIGBEE_PROXY - if (zigbee_proxy::global_zigbee_proxy != nullptr) { - zigbee_proxy::global_zigbee_proxy->api_connection_authenticated(this); - } -#endif } bool APIConnection::send_hello_response_(const HelloRequest &msg) { @@ -1988,10 +1974,6 @@ bool APIConnection::send_device_info_response_() { info.configured_line_states = proxy->get_configured_modem_pins(); } #endif -#ifdef USE_ZIGBEE_PROXY - resp.zigbee_proxy_feature_flags = zigbee_proxy::global_zigbee_proxy->get_feature_flags(); - resp.zigbee_ieee_address = zigbee_proxy::global_zigbee_proxy->get_ieee_address(); -#endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; #ifndef USE_API_NOISE_PSK_FROM_YAML diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7d82156b2f..c60e49b250 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -223,10 +223,6 @@ class APIConnection final : public APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &msg); #endif -#ifdef USE_ZIGBEE_PROXY - void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg); -#endif - #ifdef USE_ALARM_CONTROL_PANEL bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel); void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f8174e298a..1a0aaefd6a 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -175,12 +175,6 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ #endif #ifdef USE_API_NOISE ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); -#endif -#ifdef USE_ZIGBEE_PROXY - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->zigbee_proxy_feature_flags); -#endif -#ifdef USE_ZIGBEE_PROXY - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 28, this->zigbee_ieee_address); #endif return pos; } @@ -246,12 +240,6 @@ uint32_t DeviceInfoResponse::calculate_size() const { #endif #ifdef USE_API_NOISE size += ProtoSize::calc_bool(2, this->api_encryption_provisionable); -#endif -#ifdef USE_ZIGBEE_PROXY - size += ProtoSize::calc_uint32(2, this->zigbee_proxy_feature_flags); -#endif -#ifdef USE_ZIGBEE_PROXY - size += ProtoSize::calc_uint64(2, this->zigbee_ieee_address); #endif return size; } @@ -4353,41 +4341,5 @@ uint32_t BluetoothSetConnectionParamsResponse::calculate_size() const { return size; } #endif -#ifdef USE_ZIGBEE_PROXY -bool ZigbeeProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->type = static_cast(value); - break; - default: - return false; - } - return true; -} -bool ZigbeeProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; - } - return true; -} -uint8_t *ZigbeeProxyRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data, this->data_len); - return pos; -} -uint32_t ZigbeeProxyRequest::calculate_size() const { - uint32_t size = 0; - size += this->type ? 2 : 0; - size += ProtoSize::calc_length(1, this->data_len); - return size; -} -#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 43248ffb5d..7fa1560a02 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -373,11 +373,6 @@ enum SerialProxyMode : uint32_t { SERIAL_PROXY_MODE_PROTOCOL = 1, }; #endif -#ifdef USE_ZIGBEE_PROXY -enum ZigbeeProxyRequestType : uint32_t { - ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0, -}; -#endif } // namespace enums @@ -560,7 +555,7 @@ class SerialProxyInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 322; + static constexpr uint16_t ESTIMATED_SIZE = 312; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } #endif @@ -618,12 +613,6 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_API_NOISE bool api_encryption_provisionable{false}; -#endif -#ifdef USE_ZIGBEE_PROXY - uint32_t zigbee_proxy_feature_flags{0}; -#endif -#ifdef USE_ZIGBEE_PROXY - uint64_t zigbee_ieee_address{0}; #endif uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; @@ -3515,27 +3504,5 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { protected: }; #endif -#ifdef USE_ZIGBEE_PROXY -class ZigbeeProxyRequest final : public ProtoDecodableMessage { - public: - static constexpr uint16_t MESSAGE_TYPE = 155; - static constexpr uint8_t ESTIMATED_SIZE = 21; -#ifdef HAS_PROTO_MESSAGE_DUMP - const LogString *message_name() const override { return LOG_STR("zigbee_proxy_request"); } -#endif - enums::ZigbeeProxyRequestType type{}; - 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; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; -}; -#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_defines.h b/esphome/components/api/api_pb2_defines.h index 1c0ee876d8..3603fac6d7 100644 --- a/esphome/components/api/api_pb2_defines.h +++ b/esphome/components/api/api_pb2_defines.h @@ -3,8 +3,10 @@ #pragma once #include "esphome/core/defines.h" +#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS) #ifndef USE_API_VARINT64 #define USE_API_VARINT64 #endif +#endif namespace esphome::api {} // namespace esphome::api diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ab354f5cca..b264c917ac 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -893,16 +893,6 @@ template<> const char *proto_enum_to_string(enums::Seria } } #endif -#ifdef USE_ZIGBEE_PROXY -template<> const char *proto_enum_to_string(enums::ZigbeeProxyRequestType value) { - switch (value) { - case enums::ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO: - return ESPHOME_PSTR("ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO"); - default: - return ESPHOME_PSTR("UNKNOWN"); - } -} -#endif const char *HelloRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HelloRequest")); @@ -1032,12 +1022,6 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif #ifdef USE_API_NOISE dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable); -#endif -#ifdef USE_ZIGBEE_PROXY - dump_field(out, ESPHOME_PSTR("zigbee_proxy_feature_flags"), this->zigbee_proxy_feature_flags); -#endif -#ifdef USE_ZIGBEE_PROXY - dump_field(out, ESPHOME_PSTR("zigbee_ieee_address"), this->zigbee_ieee_address); #endif return out.c_str(); } @@ -2878,14 +2862,6 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const return out.c_str(); } #endif -#ifdef USE_ZIGBEE_PROXY -const char *ZigbeeProxyRequest::dump_to(DumpBuffer &out) const { - MessageDumpHelper helper(out, ESPHOME_PSTR("ZigbeeProxyRequest")); - dump_field(out, ESPHOME_PSTR("type"), static_cast(this->type)); - dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); - return out.c_str(); -} -#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 442652d595..7c86374a0f 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -733,17 +733,6 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_serial_proxy_get_usb_info_request(msg); break; } -#endif -#ifdef USE_ZIGBEE_PROXY - case ZigbeeProxyRequest::MESSAGE_TYPE: { - ZigbeeProxyRequest msg; - msg.decode(msg_data, msg_size); -#ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_zigbee_proxy_request"), msg); -#endif - this->on_zigbee_proxy_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 9dd2cfd0f0..ed2e01bd10 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -245,10 +245,6 @@ class APIServerConnectionBase { #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif - -#ifdef USE_ZIGBEE_PROXY - void on_zigbee_proxy_request(const ZigbeeProxyRequest &value){}; -#endif }; } // namespace esphome::api diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 668af2f355..43d35363d3 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -404,14 +404,6 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { } #endif -#ifdef USE_ZIGBEE_PROXY -void APIServer::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) { - // Very infrequent and small - send to all clients rather than tracking a subscription - for (auto &c : this->active_clients()) - c->send_message(msg); -} -#endif - #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key, const std::vector *timings) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 77bbf09ee0..072a583901 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -189,9 +189,6 @@ class APIServer final : public Component, #ifdef USE_ZWAVE_PROXY void on_zwave_proxy_request(const ZWaveProxyRequest &msg); #endif -#ifdef USE_ZIGBEE_PROXY - void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg); -#endif #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif diff --git a/esphome/components/zigbee_proxy/__init__.py b/esphome/components/zigbee_proxy/__init__.py index 2880513bf8..ba421e3221 100644 --- a/esphome/components/zigbee_proxy/__init__.py +++ b/esphome/components/zigbee_proxy/__init__.py @@ -3,27 +3,20 @@ 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_WIFI import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] -DEPENDENCIES = ["api", "serial_proxy"] +DEPENDENCIES = ["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 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, serial_proxy.SerialProxyTap ) -def final_validate(config): +def _final_validate(config: ConfigType) -> ConfigType: full_config = fv.full_config.get() if (wifi_conf := full_config.get(CONF_WIFI)) and ( wifi_conf.get(CONF_POWER_SAVE_MODE, "").lower() != "none" @@ -34,47 +27,30 @@ def final_validate(config): return config -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, 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), -) +CONFIG_SCHEMA = 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, + ), + } +).extend(cv.COMPONENT_SCHEMA) -FINAL_VALIDATE_SCHEMA = final_validate +FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - +async def to_code(config: ConfigType) -> None: sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID]) - cg.add(var.set_serial_proxy(sp)) + var = cg.new_Pvariable(config[CONF_ID], sp) + await cg.register_component(var, config) 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]) - - 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])) + if (buffer_size := config.get(CONF_BUFFER_SIZE)) is not None: + cg.add_define("ZIGBEE_PROXY_BUFFER_SIZE", buffer_size) diff --git a/esphome/components/zigbee_proxy/ash_detector.cpp b/esphome/components/zigbee_proxy/ash_detector.cpp index c4f7a48ac3..f88b6298f5 100644 --- a/esphome/components/zigbee_proxy/ash_detector.cpp +++ b/esphome/components/zigbee_proxy/ash_detector.cpp @@ -14,10 +14,9 @@ static constexpr size_t ASH_MIN_FRAME_SIZE = 1 + ASH_CRC_SIZE; // The opening EZSP version command is a constant: control 0x00 (frmNum 0, ackNum 0) // followed by [seq=0][frameControl=0][frameId=0] randomized by 0x42 0x21 0xA8. Only the -// requested version varies, as version ^ 0x54, so it can be recovered for free. +// trailing requested-version byte varies, so the first four bytes pin the frame exactly. static constexpr uint8_t EZSP_VERSION_CMD_PREFIX[] = {0x00, 0x42, 0x21, 0xA8}; static constexpr size_t EZSP_VERSION_CMD_SIZE = 5; -static constexpr uint8_t EZSP_VERSION_RANDOM_MASK = 0x54; // Consecutive frames we could not accept, with neither a good frame nor a retransmission // in between, before concluding the peer is no longer speaking ASH. A real ASH peer must @@ -25,7 +24,7 @@ static constexpr uint8_t EZSP_VERSION_RANDOM_MASK = 0x54; // here -- garbage on the line is not, since noise proves nothing either way. static constexpr uint8_t MAX_UNCONFIRMED_REJECTS = 4; -bool ash_reset_code_is_known(uint8_t code) { +static bool ash_reset_code_is_known(uint8_t code) { switch (code) { case 0x00: // RESET_UNKNOWN case 0x01: // RESET_EXTERNAL @@ -138,13 +137,10 @@ void AshDetector::reset() { this->state_ = AshDetectState::IDLE; this->rx_sequence_ = 0; this->ack_owed_ = false; - this->data_frame_ready_ = false; this->unconfirmed_rejects_ = 0; - this->negotiated_version_ = 0; } void AshDetector::from_ncp(uint8_t byte) { - this->data_frame_ready_ = false; switch (this->ncp_scanner_.feed(byte)) { case ScanResult::FRAME: this->handle_ncp_frame_(); @@ -202,7 +198,6 @@ void AshDetector::handle_ncp_frame_() { this->rx_sequence_ = (this->rx_sequence_ + 1) & ASH_MAX_SEQUENCE; this->pending_ack_ = this->rx_sequence_; this->ack_owed_ = true; - this->data_frame_ready_ = true; this->unconfirmed_rejects_ = 0; } @@ -235,7 +230,6 @@ void AshDetector::from_host(uint8_t byte) { } } - this->negotiated_version_ = body[4] ^ EZSP_VERSION_RANDOM_MASK; this->state_ = AshDetectState::ARMED; this->rx_sequence_ = 0; this->ack_owed_ = false; diff --git a/esphome/components/zigbee_proxy/ash_detector.h b/esphome/components/zigbee_proxy/ash_detector.h index bf1488eec9..eb14f88c39 100644 --- a/esphome/components/zigbee_proxy/ash_detector.h +++ b/esphome/components/zigbee_proxy/ash_detector.h @@ -86,18 +86,6 @@ class AshDetector { // An acknowledgement became owed after the last from_ncp() call. Clears the flag. bool take_pending_ack(uint8_t &ack_num); - // The EZSP frame carried by the DATA frame just accepted, for metadata sniffing. The - // ASH control byte is skipped, so offset 0 is the EZSP sequence number. Still - // randomized, and valid only until the next from_ncp() call. - const uint8_t *last_ezsp_frame() const { return this->ncp_scanner_.frame() + 1; } - size_t last_ezsp_frame_length() const { - const size_t length = this->ncp_scanner_.length(); - return length > 0 ? length - 1 : 0; - } - - AshDetectState state() const { return this->state_; } - uint8_t negotiated_version() const { return this->negotiated_version_; } - protected: void handle_ncp_frame_(); void reject_(); @@ -107,10 +95,8 @@ class AshDetector { AshDetectState state_{AshDetectState::IDLE}; uint8_t rx_sequence_{0}; uint8_t pending_ack_{0}; - bool ack_owed_{false}; - bool data_frame_ready_{false}; uint8_t unconfirmed_rejects_{0}; - uint8_t negotiated_version_{0}; + bool ack_owed_{false}; }; } // namespace esphome::zigbee_proxy diff --git a/esphome/components/zigbee_proxy/ash_protocol.cpp b/esphome/components/zigbee_proxy/ash_protocol.cpp index 7d517d9f92..829a80a229 100644 --- a/esphome/components/zigbee_proxy/ash_protocol.cpp +++ b/esphome/components/zigbee_proxy/ash_protocol.cpp @@ -1,16 +1,7 @@ -#include "zigbee_proxy.h" - -#ifdef USE_ZIGBEE_PROXY - -#include "esphome/core/log.h" -#include "esphome/core/helpers.h" +#include "ash_protocol.h" namespace esphome::zigbee_proxy { -static const char *const TAG = "zigbee_proxy"; - -static constexpr size_t ASH_MAX_LOG_BYTES = 168; // Cap verbose hex dumps (168 * 3 = 504 byte buffer) - // CRC-CCITT lookup table for polynomial 0x1021 (x^16 + x^12 + x^5 + 1) static const uint16_t CRC_TABLE[256] = { 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7, 0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, @@ -33,14 +24,6 @@ static const uint16_t CRC_TABLE[256] = { 0x1CE0, 0x0CC1, 0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8, 0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0}; -void ash_randomize(uint8_t *data, size_t length) { - uint8_t rand = 0x42; - for (size_t i = 0; i < length; i++) { - data[i] ^= rand; - rand = (rand & 0x01) ? static_cast((rand >> 1) ^ 0xB8) : static_cast(rand >> 1); - } -} - uint16_t ash_crc16(const uint8_t *data, size_t length, uint16_t init) { uint16_t crc = init; for (size_t i = 0; i < length; i++) { @@ -49,396 +32,28 @@ uint16_t ash_crc16(const uint8_t *data, size_t length, uint16_t init) { return crc; } -uint16_t ZigbeeProxy::calculate_crc_(const uint8_t *data, size_t length, uint16_t init) { - return ash_crc16(data, length, init); -} - -bool ZigbeeProxy::validate_frame_crc_() { - // CRC is calculated over control byte + data - // rx_buffer_[0] contains control byte, rx_buffer_[1..rx_buffer_index_-3] contains data - // rx_buffer_[rx_buffer_index_-2] and rx_buffer_[rx_buffer_index_-1] contain CRC - if (this->rx_buffer_index_ < 3) { - // Frame too short to contain CRC - return false; - } - - // Calculate CRC over control + data (exclude CRC bytes) - uint16_t calculated = this->calculate_crc_(this->rx_buffer_.data(), this->rx_buffer_index_ - 2); - - // Extract received CRC (big-endian) - uint16_t received = (static_cast(this->rx_buffer_[this->rx_buffer_index_ - 2]) << 8) | - this->rx_buffer_[this->rx_buffer_index_ - 1]; - - if (calculated != received) { - ESP_LOGW(TAG, "CRC validation failed: calculated=0x%04X, received=0x%04X", calculated, received); - return false; - } - - return true; -} - -bool ZigbeeProxy::handle_ack_num_(uint8_t ack_num) { - // ackNum means "I expect frame N next", i.e. everything up to N-1 arrived, so a - // pending frame numbered ack_num-1 has been acknowledged. Carried by DATA, ACK - // and NAK alike. - if (!this->tx_buffer_pending_ || ack_num != ((this->tx_pending_frame_num_ + 1) & ASH_MAX_SEQUENCE)) { - return false; - } - - uint32_t rtt = millis() - this->ack_timer_start_; - this->update_adaptive_timeout_(rtt); - ESP_LOGV(TAG, "Frame %d acknowledged, RTT: %u ms", this->tx_pending_frame_num_, rtt); - this->clear_tx_buffer_(); - return true; -} - -void ZigbeeProxy::parse_control_byte_(uint8_t control) { - // Decode frame type based on bit patterns: - // DATA: 0xxxxxxx (bit 7 = 0) - // ACK: 10x0xxxx (bits 7-6 = 10, bit 5 = 0) - // NAK: 10x1xxxx (bits 7-6 = 10, bit 5 = 1) - // RST: 11000000 (0xC0) - // RSTACK: 11000001 (0xC1) - // ERROR: 11000010 (0xC2) - - AshFrameType frame_type; - if ((control & 0x80) == 0) { - // Bit 7 = 0: DATA frame - frame_type = AshFrameType::DATA; - } else if ((control & 0xC0) == 0x80) { - // Bits 7-6 = 10: ACK or NAK - // ACK format: 100nrPPP (bit 5 = 0) - // NAK format: 101nrPPP (bit 5 = 1) - if ((control & 0x20) == 0) { - frame_type = AshFrameType::ACK; - } else { - frame_type = AshFrameType::NAK; - } - } else { - // Bits 7-6 = 11: control frames (RST, RSTACK, ERROR) - uint8_t control_bits = control & 0x07; - if (control_bits == 0x00) { - frame_type = AshFrameType::RST; - } else if (control_bits == 0x01) { - frame_type = AshFrameType::RSTACK; - } else if (control_bits == 0x02) { - frame_type = AshFrameType::ERROR; - } else { - ESP_LOGW(TAG, "Unknown control frame type: 0x%02X", control); - return; - } - } - - // Extract sequence numbers from DATA frame format: 0ffrPPPP - // Bits 6-4 = frmNum, bit 3 = reTx, bits 2-0 = ackNum - uint8_t frame_num = (control >> 4) & 0x07; // Bits 6-4 - uint8_t ack_num = control & 0x07; // Bits 2-0 - bool retx = (control & 0x08) != 0; // Bit 3 (for DATA frames) - - ESP_LOGV(TAG, "Parsed control byte: type=%d, frmNum=%d, ackNum=%d, reTx=%d", static_cast(frame_type), frame_num, - ack_num, retx); - - // Handle frame based on type - switch (frame_type) { - case AshFrameType::DATA: { - // Process the piggybacked ACK first: ackNum is valid regardless of the DATA - // frame's own sequence ordering - if (this->handle_ack_num_(ack_num)) { - ESP_LOGV(TAG, "ACK received (piggybacked in DATA)"); - } - - // Check sequence number - if (frame_num != this->rx_sequence_) { - if (retx && frame_num == ((this->rx_sequence_ - 1) & ASH_MAX_SEQUENCE)) { - // Retransmission of a frame we already ACKed (our ACK was lost) - re-ACK and discard - ESP_LOGV(TAG, "Duplicate DATA frame %d, re-sending ACK", frame_num); - this->send_ack_frame_(this->rx_sequence_); - } else { - ESP_LOGW(TAG, "Out of sequence DATA frame: expected %d, got %d", this->rx_sequence_, frame_num); - this->send_nak_frame_(this->rx_sequence_); - } - return; - } - - // Increment RX sequence and send ACK (ack_num = next expected frame) - this->increment_rx_sequence_(); - this->send_ack_frame_(this->rx_sequence_); - - // Extract payload (skip control byte, exclude CRC) - size_t payload_length = this->rx_buffer_index_ > 3 ? this->rx_buffer_index_ - 3 : 0; - const uint8_t *payload = this->rx_buffer_.data() + 1; - - // This path only runs during the boot harvest, where this component is the ASH - // endpoint and consumes frames itself, so they must be derandomized. A subscribed - // client is served by the transparent relay instead, which never reaches here. - if (payload_length > 0) { - ash_randomize(this->rx_buffer_.data() + 1, payload_length); - this->handle_boot_data_frame_(payload, payload_length); - } - break; - } - - case AshFrameType::ACK: - this->handle_ack_num_(ack_num); - break; - - case AshFrameType::NAK: - // A NAK carries valid ACK information like any other frame: ackNum is the - // next frame the NCP expects, so everything before it did arrive. Honour - // that first -- retransmitting an already-acknowledged frame otherwise - // burns all ASH_MAX_RETRIES and drops the link. bellows applies the same - // ACK handling to DATA, ACK and NAK alike. - if (this->handle_ack_num_(ack_num)) { - ESP_LOGW(TAG, "NAK received for frame %d (already acknowledged, not retransmitting)", ack_num); - break; - } - ESP_LOGW(TAG, "NAK received for frame %d, retransmitting", ack_num); - if (this->tx_buffer_pending_) { - this->handle_retransmission_(); - } - break; - - case AshFrameType::RST: { - // An NCP never sends RST in normal operation; treat it as a reset indication - // and run the RSTACK handling to resynchronize state (nothing is transmitted here) - ESP_LOGW(TAG, "Received unexpected RST frame from NCP, resynchronizing"); - uint8_t rstack_data[] = {0x02, 0x01, 0x00}; // Synthesized RSTACK payload - this->handle_rstack_frame_(rstack_data, sizeof(rstack_data)); - break; - } - - case AshFrameType::RSTACK: - this->handle_rstack_frame_(this->rx_buffer_.data() + 1, this->rx_buffer_index_ - 3); - break; - - case AshFrameType::ERROR: - this->handle_error_frame_(this->rx_buffer_.data() + 1, this->rx_buffer_index_ - 3); - break; - } -} - -bool ZigbeeProxy::parse_byte_(uint8_t byte) { - static constexpr uint8_t ASH_CAN_BYTE = 0x1A; - static constexpr uint8_t ASH_XON_BYTE = 0x11; - static constexpr uint8_t ASH_XOFF_BYTE = 0x13; - - // Reserved bytes are only meaningful when they appear *bare* in the stream, so - // they must be filtered here, before unescaping, and never afterwards. A frame - // whose control or data byte happens to equal one of them arrives stuffed (0x11 - // is sent as 7D 31), and unescaping yields the real value -- so filtering after - // unescaping silently eats a valid control byte, shifting the whole frame by one - // and failing CRC on every retransmission. This mirrors bellows, which strips - // flow control from the raw buffer and only then unstuffs. - if (!this->escape_next_byte_) { - if (byte == ASH_CAN_BYTE) { - // Cancel: discard any partial frame - this->rx_buffer_index_ = 0; - this->parsing_state_ = ParsingState::WAIT_FLAG_START; - return false; - } - if (byte == ASH_XON_BYTE || byte == ASH_XOFF_BYTE) { - // Flow control: not part of any frame, may appear anywhere - return false; - } - } - - switch (this->parsing_state_) { - case ParsingState::WAIT_FLAG_START: - // Handle escape sequences - NCP may send escaped control byte at frame start - if (byte == ASH_ESCAPE_BYTE) { - this->escape_next_byte_ = true; - return false; - } - - if (this->escape_next_byte_) { - byte ^= ASH_XOR_BYTE; - this->escape_next_byte_ = false; - } - - if (byte == ASH_FLAG_BYTE) { - // Start of frame with FLAG delimiter - this->rx_buffer_index_ = 0; - this->escape_next_byte_ = false; - this->parsing_state_ = ParsingState::WAIT_CONTROL; - ESP_LOGV(TAG, "Frame start detected (FLAG)"); - } else if (this->ash_state_ == AshState::CONNECTED) { - // When connected, NCP often omits leading FLAG on responses - // Any byte could be a control byte: - // - DATA frames: 0x00-0x7F (bit 7 = 0) - // - ACK frames: 0x80-0x9F (bits 7-6 = 10, bit 5 = 0) - // - NAK frames: 0xA0-0xBF (bits 7-6 = 10, bit 5 = 1) - // - RST/RSTACK/ERROR: 0xC0-0xC2 (bits 7-6 = 11) - // Bare flow-control bytes were already filtered above, so anything - // reaching here is genuine frame content. - this->rx_buffer_index_ = 0; - this->rx_buffer_[this->rx_buffer_index_++] = byte; - this->parsing_state_ = ParsingState::WAIT_DATA; - ESP_LOGV(TAG, "Frame start detected (control byte 0x%02X)", byte); - } else if ((byte & 0x80) != 0) { - // Before connected, only accept control/management frames (bit 7 set) - // This handles RSTACK (0xC1), ACK (0x8X), NAK (0xAX), ERROR (0xC2) - this->rx_buffer_index_ = 0; - this->rx_buffer_[this->rx_buffer_index_++] = byte; - this->parsing_state_ = ParsingState::WAIT_DATA; - ESP_LOGV(TAG, "Frame start detected (control byte 0x%02X)", byte); - } - break; - - case ParsingState::WAIT_CONTROL: - if (byte == ASH_FLAG_BYTE) { - // Empty frame or repeated FLAG - ESP_LOGV(TAG, "Empty frame or repeated FLAG, restarting"); - this->rx_buffer_index_ = 0; - return false; - } - - if (byte == ASH_ESCAPE_BYTE) { - this->escape_next_byte_ = true; - return false; - } - - if (this->escape_next_byte_) { - byte ^= ASH_XOR_BYTE; - this->escape_next_byte_ = false; - } - - // Store control byte - this->rx_buffer_[this->rx_buffer_index_++] = byte; - this->parsing_state_ = ParsingState::WAIT_DATA; - break; - - case ParsingState::WAIT_DATA: - if (byte == ASH_FLAG_BYTE) { - // End of frame - validate and process - ESP_LOGV(TAG, "Frame complete, %u bytes in buffer", this->rx_buffer_index_); - if (this->validate_frame_crc_()) { - this->parse_control_byte_(this->rx_buffer_[0]); - } else { - // CRC failed - WARN logs byte count only; hex dump at VERBOSE (truncated to ASH_MAX_LOG_BYTES) - ESP_LOGW(TAG, "CRC failed (%u bytes)", this->rx_buffer_index_); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(ASH_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "CRC failed frame: %s", - format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_index_)); - this->send_nak_frame_(this->rx_sequence_); - } - this->parsing_state_ = ParsingState::WAIT_FLAG_START; - return true; - } - - if (byte == ASH_ESCAPE_BYTE) { - this->escape_next_byte_ = true; - return false; - } - - if (this->escape_next_byte_) { - byte ^= ASH_XOR_BYTE; - this->escape_next_byte_ = false; - } - - // Check buffer overflow - if (this->rx_buffer_index_ >= MAX_ASH_FRAME_SIZE) { - ESP_LOGE(TAG, "RX buffer overflow, frame too large"); - this->parsing_state_ = ParsingState::WAIT_FLAG_START; - return false; - } - - // Store data byte - this->rx_buffer_[this->rx_buffer_index_++] = byte; - break; - - default: - this->parsing_state_ = ParsingState::WAIT_FLAG_START; - break; - } - - return false; -} - -// Appends a byte with ASH stuffing (reserved: FLAG, ESCAPE, XON, XOFF, SUB, CAN); -// returns false if it would exceed capacity -static bool append_byte_stuffed(uint8_t *output, size_t capacity, size_t &pos, uint8_t byte) { - const bool reserved = byte == ASH_FLAG_BYTE || byte == ASH_ESCAPE_BYTE || byte == 0x11 || byte == 0x13 || - byte == ASH_SUBSTITUTE_BYTE || byte == 0x1A; - if (pos + (reserved ? 2 : 1) > capacity) { - return false; - } - if (reserved) { +// Appends a byte with ASH stuffing; the caller sized `output` for the worst case. +static void append_byte_stuffed(uint8_t *output, size_t &pos, uint8_t byte) { + if (ash_is_reserved(byte)) { output[pos++] = ASH_ESCAPE_BYTE; output[pos++] = byte ^ ASH_XOR_BYTE; } else { output[pos++] = byte; } - return true; } -size_t ZigbeeProxy::build_frame_(uint8_t *output, size_t capacity, const uint8_t *data, size_t length, - AshFrameType type, uint8_t frame_num, uint8_t ack_num, bool retx) { +size_t ash_build_ack_frame(uint8_t *output, uint8_t ack_num) { + // ACK control byte: 100nrPPP, where PPP is the next frame number expected + const uint8_t control = 0x80 | (ack_num & ASH_MAX_SEQUENCE); + const uint16_t crc = ash_crc16(&control, 1); + size_t pos = 0; - - // Start with FLAG - if (capacity < 1) { - return 0; - } output[pos++] = ASH_FLAG_BYTE; - - // Build control byte - uint8_t control = 0; - switch (type) { - case AshFrameType::DATA: - // DATA frame format: 0ffrPPPP - // Bit 7 = 0 (DATA indicator), bits 6-4 = frmNum, bit 3 = reTx, bits 2-0 = ackNum - control = (frame_num << 4) | (retx ? 0x08 : 0x00) | ack_num; - break; - case AshFrameType::ACK: - control = 0x80 | ack_num; - break; - case AshFrameType::NAK: - control = 0xA0 | ack_num; - break; - case AshFrameType::RST: - control = 0xC0; - break; - case AshFrameType::RSTACK: - control = 0xC1; - break; - case AshFrameType::ERROR: - control = 0xC2; - break; - } - - // Add control byte with stuffing - if (!append_byte_stuffed(output, capacity, pos, control)) { - ESP_LOGE(TAG, "Frame too large for buffer (%u byte payload, %u byte buffer)", length, capacity); - return 0; - } - - // Add data payload with stuffing - for (size_t i = 0; i < length; i++) { - if (!append_byte_stuffed(output, capacity, pos, data[i])) { - ESP_LOGE(TAG, "Frame too large for buffer (%u byte payload, %u byte buffer)", length, capacity); - return 0; - } - } - - // Calculate CRC incrementally over control byte then data (avoids a MAX_ASH_FRAME_SIZE stack copy) - uint16_t crc = this->calculate_crc_(&control, 1); - if (length > 0) { - crc = this->calculate_crc_(data, length, crc); - } - - // Add CRC with stuffing (big-endian), then the end FLAG - if (!append_byte_stuffed(output, capacity, pos, (crc >> 8) & 0xFF) || - !append_byte_stuffed(output, capacity, pos, crc & 0xFF) || pos + 1 > capacity) { - ESP_LOGE(TAG, "Frame too large for buffer (%u byte payload, %u byte buffer)", length, capacity); - return 0; - } + append_byte_stuffed(output, pos, control); + append_byte_stuffed(output, pos, (crc >> 8) & 0xFF); + append_byte_stuffed(output, pos, crc & 0xFF); output[pos++] = ASH_FLAG_BYTE; - return pos; } } // namespace esphome::zigbee_proxy - -#endif // USE_ZIGBEE_PROXY diff --git a/esphome/components/zigbee_proxy/ash_protocol.h b/esphome/components/zigbee_proxy/ash_protocol.h index 338cb8b2a2..c3d513c338 100644 --- a/esphome/components/zigbee_proxy/ash_protocol.h +++ b/esphome/components/zigbee_proxy/ash_protocol.h @@ -1,7 +1,9 @@ #pragma once -#include +#include "esphome/core/defines.h" + #include +#include namespace esphome::zigbee_proxy { @@ -41,75 +43,15 @@ static constexpr size_t MAX_ASH_FRAME_SIZE = 1024; // Full buffer on ESP32/RP20 #endif // Protocol limits -static constexpr uint8_t ASH_MAX_SEQUENCE = 7; // 3-bit sequence number (0-7) -static constexpr uint8_t ASH_TX_WINDOW_SIZE = 1; // Only 1 unacknowledged frame allowed -static constexpr uint8_t ASH_MAX_RETRIES = 5; // Maximum retransmission attempts -static constexpr uint16_t ASH_CRC_INIT = 0xFFFF; // CRC-CCITT initial value -static constexpr uint32_t ASH_RESET_TIMEOUT = 3000; // RST/RSTACK timeout in milliseconds +static constexpr uint8_t ASH_MAX_SEQUENCE = 7; // 3-bit sequence number (0-7) +static constexpr uint16_t ASH_CRC_INIT = 0xFFFF; // CRC-CCITT initial value -// IEEE address size -static constexpr size_t ZIGBEE_IEEE_ADDR_SIZE = 8; // 64-bit IEEE address +// An ACK is FLAG, control byte, two CRC bytes, FLAG. Every byte but the delimiters may +// need escaping, so the worst case is 2 + 3 * 2 = 8. +static constexpr size_t ASH_ACK_FRAME_MAX_SIZE = 8; -// ASH data randomization. The Data Field of every DATA frame is XORed with a -// pseudo-random sequence (LFSR seeded at 0x42, polynomial 0xB8) before -// transmission and again after reception; the operation is its own inverse. -// -// Proxied client traffic must NOT be passed through this: the client randomizes -// and the NCP derandomizes, so payloads travel end to end untouched and the -// proxy stays transparent. Apply it only to frames this component originates or -// consumes itself, i.e. the boot-harvest EZSP commands and their responses. -// Sending an unrandomized command makes the NCP derandomize it into garbage and -// answer with an error frame that decodes as a plausible-looking wrong value. -void ash_randomize(uint8_t *data, size_t length); - -// ASH Frame Types (encoded in control byte) -// DATA format: 0ffrPPPP - bit 7=0, bits 6-4=frmNum, bit 3=reTx, bits 2-0=ackNum -// ACK/NAK format: 10XnrPPP - bit 5 distinguishes ACK(0) from NAK(1) -enum class AshFrameType : uint8_t { - DATA = 0x00, // Data frame (bit 7 = 0) - ACK = 0x80, // Acknowledge frame (100nrPPP, bit 5 = 0) - NAK = 0xA0, // Negative acknowledge (101nrPPP, bit 5 = 1) - RST = 0xC0, // Reset request (bits 7-6 = 11, bits 2-0 = 000) - RSTACK = 0xC1, // Reset acknowledgment (bits 7-6 = 11, bits 2-0 = 001) - ERROR = 0xC2, // Error indication (bits 7-6 = 11, bits 2-0 = 010) -}; - -// ASH Connection State -enum class AshState : uint8_t { - DISCONNECTED, // Initial state, no connection - CONNECTING, // Sent RST, waiting for RSTACK - CONNECTED, // Normal operation - FAILED, // Too many errors/timeouts, requires reset -}; - -// Frame Parsing State Machine -enum class ParsingState : uint8_t { - WAIT_FLAG_START, // Looking for frame start FLAG (0x7E) - WAIT_CONTROL, // Reading control byte - WAIT_DATA, // Reading data payload - WAIT_CRC_HIGH, // Reading CRC high byte - WAIT_CRC_LOW, // Reading CRC low byte - WAIT_FLAG_END, // Expecting end FLAG (0x7E) -}; - -// Bootloader detection states -enum class BootloaderState : uint8_t { - NORMAL, // Normal operation - DETECTED, // Bootloader mode detected - MENU, // In bootloader menu -}; - -// EZSP Error Codes (from ERROR frame) -enum class EzspError : uint8_t { - VERSION_NOT_SET = 0x00, - RESET_UNKNOWN = 0x01, - RESET_EXTERNAL = 0x02, - RESET_POWER_ON = 0x03, - RESET_WATCHDOG = 0x04, - RESET_ASSERT = 0x05, - RESET_BOOTLOADER = 0x06, - RESET_SOFTWARE = 0x07, - EXCEEDED_MAXIMUM_ACK_TIMEOUT_COUNT = 0x51, -}; +// Writes an ACK frame for `ack_num` into `output`, which must hold at least +// ASH_ACK_FRAME_MAX_SIZE bytes, and returns its length. +size_t ash_build_ack_frame(uint8_t *output, uint8_t ack_num); } // namespace esphome::zigbee_proxy diff --git a/esphome/components/zigbee_proxy/ezsp_commands.h b/esphome/components/zigbee_proxy/ezsp_commands.h deleted file mode 100644 index b03f995177..0000000000 --- a/esphome/components/zigbee_proxy/ezsp_commands.h +++ /dev/null @@ -1,88 +0,0 @@ -#pragma once - -#include -#include - -namespace esphome::zigbee_proxy { - -// EZSP Protocol Versions -static constexpr uint8_t EZSP_MIN_VERSION = 13; // Minimum supported version -static constexpr uint8_t EZSP_MAX_VERSION = 13; // Maximum version we request - -// EZSP Frame Control bits -static constexpr uint8_t EZSP_FRAME_CONTROL_COMMAND = 0x00; // Host to NCP -static constexpr uint8_t EZSP_FRAME_CONTROL_RESPONSE = 0x80; // NCP to Host -static constexpr uint8_t EZSP_FRAME_CONTROL_CALLBACK = 0x90; // Async callback from NCP - -// High byte of the 16-bit frame control, carrying frameFormatVersion = 1. Every -// command after version negotiation must set this: omitting it leaves the NCP -// reading the frame ID's low byte as frame_control_high, so the command is -// discarded and the reply is an error frame rather than the expected response. -static constexpr uint8_t EZSP_FRAME_CONTROL_EXTENDED = 0x01; - -// Legacy EZSP frame format (v4-v7): [sequence] [frame_control] [frame_id] -// Extended EZSP frame format (v8+): [sequence] [frame_control_low] [frame_control_high] [frame_id_low] [frame_id_high] -// -// Only the `version` command and its response use the legacy format, because the -// NCP starts in legacy mode and has not yet learned the negotiated version. -// Everything after that is extended, with no per-NCP exceptions. - -// EZSP Frame IDs - Callbacks (NCP to host, async) -static constexpr uint16_t EZSP_STACK_STATUS_HANDLER = 0x0019; // Stack up/down notification - -// EZSP Frame IDs - Commands (host to NCP) -static constexpr uint16_t EZSP_VERSION = 0x0000; // Version negotiation -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_GET_TOKEN_DATA = 0x0102; // Read an NVM3 token - -// Extended EZSP header: [sequence] [frame_control_lo] [frame_control_hi] [id_lo] [id_hi] -static constexpr size_t EZSP_EXTENDED_HEADER_SIZE = 5; - -// Network metadata comes straight out of NVM3 instead of from a running stack. -// NVM3KEY_STACK_NODE_DATA holds the PAN ID, channel, extended PAN ID and node type of -// the network this radio is commissioned onto, and reading it requires nothing beyond a -// completed version negotiation: no stack configuration, no networkInit, no waiting on -// stackStatusHandler, and above all no joining the network -- so simply plugging the -// device in never brings the radio up. -// -// Note the 0x0001 domain prefix on the NVM3 object key. The bare creator ID -// 0x0000EE64 is a different thing and getTokenData answers FAIL for it. -static constexpr uint32_t NVM3KEY_STACK_NODE_DATA = 0x0001EE64; - -// getTokenData response: [status (4)] [length (4)] [value (length)] -static constexpr size_t TOKEN_DATA_VALUE_OFFSET = 8; - -// NV3StackNodeData value layout (16 bytes, little-endian): -// [panId (2)] [radioTxPower (1)] [radioFreqChannel (1)] [stackProfile (1)] -// [nodeType (1)] [zigbeeNodeId (2)] [extendedPanId (8)] -static constexpr size_t NV3_NODE_DATA_SIZE = 16; -static constexpr size_t NV3_NODE_DATA_PAN_ID_OFFSET = 0; -static constexpr size_t NV3_NODE_DATA_CHANNEL_OFFSET = 3; -static constexpr size_t NV3_NODE_DATA_NODE_TYPE_OFFSET = 5; -static constexpr size_t NV3_NODE_DATA_EXT_PAN_ID_OFFSET = 8; - -// A radio with no network still has the token, holding a sentinel rather than being -// absent: panId reads 0xFFFF and nodeType reads UNKNOWN_DEVICE. Detecting "no network" -// therefore means inspecting nodeType, not treating the read as failed. -static constexpr uint8_t NV3_NODE_TYPE_UNKNOWN_DEVICE = 0x00; - -// Status codes (subset). EZSP v13+ / EmberZNet 8.x report sl_status_t, not the -// legacy 8-bit EmberStatus. -enum class SlStatus : uint8_t { - OK = 0x00, - NETWORK_UP = 0x15, - NETWORK_DOWN = 0x16, -}; - -// getNetworkParameters response layout, used when sniffing a client's own traffic. This -// is a different shape from the NV3 token the boot harvest reads: 25 bytes of -// [status (4)] [nodeType (1)] [extendedPanId (8)] [panId (2)] [radioTxPower (1)] -// [radioChannel (1)] [joinMethod (1)] [nwkManagerId (2)] [nwkUpdateId (1)] [channels (4)] -static constexpr size_t NETWORK_PARAMS_RESPONSE_SIZE = 25; -static constexpr size_t NETWORK_PARAMS_STATUS_OFFSET = 0; -static constexpr size_t NETWORK_PARAMS_EXT_PAN_ID_OFFSET = 5; -static constexpr size_t NETWORK_PARAMS_PAN_ID_OFFSET = 13; -static constexpr size_t NETWORK_PARAMS_CHANNEL_OFFSET = 16; - -} // namespace esphome::zigbee_proxy diff --git a/esphome/components/zigbee_proxy/zigbee_proxy.cpp b/esphome/components/zigbee_proxy/zigbee_proxy.cpp index cc18bbd08d..4f9260a684 100644 --- a/esphome/components/zigbee_proxy/zigbee_proxy.cpp +++ b/esphome/components/zigbee_proxy/zigbee_proxy.cpp @@ -3,129 +3,44 @@ #ifdef USE_ZIGBEE_PROXY #include "esphome/core/log.h" -#include "esphome/components/api/api_server.h" -#include "ezsp_commands.h" - -#ifdef USE_WIFI -#include "esphome/components/wifi/wifi_component.h" -#ifdef USE_ESP32 -#include -#endif -#endif namespace esphome::zigbee_proxy { static const char *const TAG = "zigbee_proxy"; -// 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) +void ZigbeeProxy::setup() { this->parent_->set_tap(this); } -ZigbeeProxy *global_zigbee_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - -ZigbeeProxy::ZigbeeProxy() { global_zigbee_proxy = this; } - -void ZigbeeProxy::setup() { - this->setup_time_ = millis(); - - // 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; - this->parsing_state_ = ParsingState::WAIT_FLAG_START; - this->tx_sequence_ = 0; - this->rx_sequence_ = 0; - - // Send RST frame to initialize NCP - this->reset_ash_protocol_(); -} - -void ZigbeeProxy::loop() { - // 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; - } - - // 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; - } - - // Check for ACK timeout and handle retransmission - if (this->tx_buffer_pending_ && this->check_ack_timeout_()) { - this->handle_retransmission_(); - } - - this->check_boot_timeouts_(); +void ZigbeeProxy::dump_config() { + ESP_LOGCONFIG(TAG, + "Zigbee Proxy:\n" + " Port: %s\n" + " Buffer Size: %u bytes", + this->parent_->get_name(), MAX_ASH_FRAME_SIZE); } 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); - - 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; - } + this->detector_.from_ncp(data[i]); 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); + uint8_t frame[ASH_ACK_FRAME_MAX_SIZE]; + this->parent_->write_from_tap(frame, ash_build_ack_frame(frame, ack_num)); + ESP_LOGV(TAG, "Sent ACK for frame %u", ack_num); } } + + const bool armed = this->detector_.armed(); + if (armed != this->was_armed_) { + this->was_armed_ = armed; + ESP_LOGD(TAG, "ASH session %s", + armed ? LOG_STR_LITERAL("detected, acknowledging frames") + : LOG_STR_LITERAL("lost, no longer acknowledging frames")); + } } void ZigbeeProxy::on_client_tx(const uint8_t *data, size_t len) { @@ -140,960 +55,11 @@ void ZigbeeProxy::on_client_tx(const uint8_t *data, size_t len) { } } -void ZigbeeProxy::check_boot_timeouts_() { - uint32_t total_elapsed = millis() - this->boot_start_time_; - if (total_elapsed > BOOT_SEQUENCE_TIMEOUT_MS) { - ESP_LOGE(TAG, "Boot sequence timeout (state: %d)", static_cast(this->boot_state_)); - this->boot_state_ = BootState::FAILED; - this->boot_sequence_active_ = false; - // Still mark as connected so proxy can work without network info - if (this->ash_state_ != AshState::CONNECTED) { - this->ash_state_ = AshState::FAILED; - } - } else if ((this->boot_state_ == BootState::WAIT_RSTACK || this->boot_state_ == BootState::WAIT_FINAL_RSTACK) && - (millis() - this->setup_time_) > ASH_RESET_TIMEOUT) { - // RST was sent before USB device finished enumeration — retry - ESP_LOGD(TAG, "No RSTACK received within %u ms, retrying RST", ASH_RESET_TIMEOUT); - this->setup_time_ = millis(); - this->send_rst_frame_(); - } -} - -void ZigbeeProxy::dump_config() { - ESP_LOGCONFIG(TAG, - "Zigbee Proxy:\n" - " Buffer Size: %u bytes\n" - " Initial Timeout: %u ms\n" - " Min Timeout: %u ms\n" - " Max Timeout: %u ms", - MAX_ASH_FRAME_SIZE, this->timeout_config_.initial_timeout_ms, this->timeout_config_.min_timeout_ms, - this->timeout_config_.max_timeout_ms); - - if (this->network_info_.valid) { - ESP_LOGCONFIG(TAG, - " IEEE Address: %02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X\n" - " PAN ID: 0x%04X\n" - " Channel: %u", - this->network_info_.ieee_address[7], this->network_info_.ieee_address[6], - this->network_info_.ieee_address[5], this->network_info_.ieee_address[4], - this->network_info_.ieee_address[3], this->network_info_.ieee_address[2], - this->network_info_.ieee_address[1], this->network_info_.ieee_address[0], this->network_info_.pan_id, - this->network_info_.channel); - } - - if (this->ash_state_ == AshState::FAILED) { - ESP_LOGCONFIG(TAG, " Status: Failed (NCP communication error)"); - } else if (this->ash_state_ == AshState::CONNECTED) { - ESP_LOGCONFIG(TAG, " Status: Connected"); - } else { - ESP_LOGCONFIG(TAG, " Status: Connecting..."); - } -} - -float ZigbeeProxy::get_setup_priority() const { return setup_priority::AFTER_WIFI; } - -bool ZigbeeProxy::can_proceed() { - // Block setup only while the boot harvest is running so network info (IEEE address, - // PAN ID) is ready when the API starts. check_boot_timeouts_() guarantees forward - // progress: a dead NCP flips the sequence to FAILED after BOOT_SEQUENCE_TIMEOUT_MS and - // the device boots normally (recovery then happens from loop()). - if (!this->boot_sequence_active_) { - return true; - } - - // loop() is not called while setup is blocked, so run the boot machinery here - this->parent_->tap_pump(); - if (this->tx_buffer_pending_ && this->check_ack_timeout_()) { - this->handle_retransmission_(); - } - this->check_boot_timeouts_(); - - return !this->boot_sequence_active_; -} - -void ZigbeeProxy::api_connection_authenticated(api::APIConnection *conn) { - // Notify client of network info if available - if (this->network_info_.valid) { - this->send_network_info_changed_msg_(conn); - } -} - -void ZigbeeProxy::zigbee_proxy_request(api::APIConnection *api_connection, const api::ZigbeeProxyRequest &msg) { - switch (msg.type) { - case api::enums::ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO: - this->send_network_info_changed_msg_(api_connection); - break; - - default: - ESP_LOGW(TAG, "Unknown request type: %d", static_cast(msg.type)); - break; - } -} - -uint64_t ZigbeeProxy::get_ieee_address() const { - uint64_t addr = 0; - for (size_t i = 0; i < ZIGBEE_IEEE_ADDR_SIZE; i++) { - addr |= static_cast(this->network_info_.ieee_address[i]) << (i * 8); - } - return addr; -} - -void ZigbeeProxy::set_timeout_config(uint32_t initial_ms, uint32_t min_ms, uint32_t max_ms) { - this->timeout_config_.initial_timeout_ms = initial_ms; - this->timeout_config_.min_timeout_ms = min_ms; - this->timeout_config_.max_timeout_ms = max_ms; - this->timeout_config_.current_timeout_ms = initial_ms; - ESP_LOGV(TAG, "Timeout config updated: initial=%u, min=%u, max=%u", initial_ms, min_ms, max_ms); -} - -// ASH Protocol State Machine -void ZigbeeProxy::reset_ash_protocol_() { - ESP_LOGV(TAG, "Resetting ASH protocol"); - this->ash_state_ = AshState::CONNECTING; - 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->setup_time_ = millis(); - this->boot_start_time_ = this->setup_time_; - - // An NCP reset drops it back to legacy framing, so the extended-format version - // handshake has to be redone before any other command is accepted. - this->ezsp_version_ = 0; - this->ezsp_version_confirmed_ = false; - - // Start boot sequence - this->boot_state_ = BootState::WAIT_RSTACK; - this->boot_sequence_active_ = true; - this->ezsp_sequence_ = 0; - - this->send_rst_frame_(); -} - -void ZigbeeProxy::send_rst_frame_() { - // Build a combined buffer: 32 CAN bytes followed immediately by the RST frame, - // 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; - static constexpr size_t MAX_RST_FRAME_SIZE = 8; - uint8_t combined[CAN_COUNT + MAX_RST_FRAME_SIZE]; - memset(combined, ASH_CAN_BYTE, CAN_COUNT); - size_t rst_len = this->build_frame_(combined + CAN_COUNT, MAX_RST_FRAME_SIZE, nullptr, 0, AshFrameType::RST); - -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - 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->parent_->write_from_tap(combined, CAN_COUNT + rst_len); - ESP_LOGV(TAG, "Sent RST frame (with %u CAN bytes prefix)", CAN_COUNT); -} - -void ZigbeeProxy::handle_rstack_frame_(const uint8_t *data, size_t length) { - // Reset sequence numbers on any RSTACK - this->tx_sequence_ = 0; - this->rx_sequence_ = 0; - this->clear_tx_buffer_(); - - 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; - - // 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_(); - } else if (this->boot_state_ == BootState::WAIT_FINAL_RSTACK) { - // Final RSTACK after harvesting - boot complete - ESP_LOGV(TAG, "Boot sequence complete, NCP reset to clean state"); - this->ash_state_ = AshState::CONNECTED; - this->boot_state_ = BootState::COMPLETE; - this->boot_sequence_active_ = false; - - // Now check for WiFi/Zigbee channel conflicts - this->check_wifi_zigbee_conflict_(); - } else { - // 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; - } -} - -void ZigbeeProxy::handle_error_frame_(const uint8_t *data, size_t length) { - if (length < 1) { - ESP_LOGE(TAG, "Frame too short"); - return; - } - - uint8_t error_code = data[0]; - const char *error_str = "Unknown error"; - - switch (static_cast(error_code)) { - case EzspError::VERSION_NOT_SET: - error_str = "Version not set"; - break; - case EzspError::RESET_UNKNOWN: - error_str = "Reset (unknown)"; - break; - case EzspError::RESET_EXTERNAL: - error_str = "External reset"; - break; - case EzspError::RESET_POWER_ON: - error_str = "Power-on reset"; - break; - case EzspError::RESET_WATCHDOG: - error_str = "Watchdog reset"; - break; - case EzspError::RESET_ASSERT: - error_str = "Assert reset"; - break; - case EzspError::RESET_BOOTLOADER: - error_str = "Bootloader reset"; - break; - case EzspError::RESET_SOFTWARE: - error_str = "Software reset"; - break; - case EzspError::EXCEEDED_MAXIMUM_ACK_TIMEOUT_COUNT: - error_str = "Exceeded maximum ACK timeout count"; - 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); -} - -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->parent_->write_from_tap(frame, length); - this->last_ack_sent_ = ack_num; - ESP_LOGV(TAG, "Sent ACK for frame %d", ack_num); - return true; -} - -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->parent_->write_from_tap(frame, length); - ESP_LOGW(TAG, "Sent NAK for frame %d", ack_num); - return true; -} - -bool ZigbeeProxy::send_data_frame_(const uint8_t *data, size_t length, bool retransmit) { - // Build frame (returns 0 if the stuffed frame would exceed the buffer) - size_t frame_length = this->build_frame_(this->tx_buffer_.data(), this->tx_buffer_.size(), data, length, - AshFrameType::DATA, this->tx_sequence_, this->rx_sequence_, retransmit); - if (frame_length == 0) { - return false; - } - - // Store for potential retransmission - if (!retransmit) { - memcpy(this->tx_pending_buffer_.data(), this->tx_buffer_.data(), frame_length); - this->tx_pending_length_ = frame_length; - this->tx_pending_frame_num_ = this->tx_sequence_; - } - - // Debug: log exact bytes being sent (truncated to ZIGBEE_MAX_LOG_BYTES) -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(ZIGBEE_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "TX DATA frame (%u bytes): %s", frame_length, - format_hex_pretty_to(hex_buf, this->tx_buffer_.data(), frame_length)); - - // Send frame - this->parent_->write_from_tap(this->tx_buffer_.data(), frame_length); - - // Start ACK timer - this->tx_buffer_pending_ = true; - this->start_ack_timer_(); - - ESP_LOGV(TAG, "Sent DATA frame %d (%s), length: %u", this->tx_sequence_, retransmit ? "retransmit" : "new", length); - - // Increment TX sequence for next frame (only for new frames) - if (!retransmit) { - this->increment_tx_sequence_(); - } - - return true; -} - -// Timeout management -void ZigbeeProxy::update_adaptive_timeout_(uint32_t measured_rtt_ms) { - // Formula: new_timeout = (7/8) * current + (1/2) * measured_rtt - uint32_t new_timeout = (this->timeout_config_.current_timeout_ms * 7 + measured_rtt_ms * 4) / 8; - - // Clamp to configured bounds - if (new_timeout < this->timeout_config_.min_timeout_ms) { - new_timeout = this->timeout_config_.min_timeout_ms; - } else if (new_timeout > this->timeout_config_.max_timeout_ms) { - new_timeout = this->timeout_config_.max_timeout_ms; - } - - this->timeout_config_.current_timeout_ms = new_timeout; - this->last_rtt_ms_ = measured_rtt_ms; - - ESP_LOGV(TAG, "Updated timeout: %u ms (RTT: %u ms)", new_timeout, measured_rtt_ms); -} - -bool ZigbeeProxy::check_ack_timeout_() { - if (!this->tx_buffer_pending_) { - return false; - } - - uint32_t elapsed = millis() - this->ack_timer_start_; - return elapsed >= this->timeout_config_.current_timeout_ms; -} - -// Retransmission -void ZigbeeProxy::handle_retransmission_() { - if (!this->tx_buffer_pending_) { - return; - } - - this->tx_retry_count_++; - - if (this->tx_retry_count_ > ASH_MAX_RETRIES) { - ESP_LOGE(TAG, "Max retries exceeded"); - this->ash_state_ = AshState::FAILED; - this->clear_tx_buffer_(); - return; - } - - ESP_LOGW(TAG, "Retransmitting frame %d (attempt %d/%d)", this->tx_pending_frame_num_, this->tx_retry_count_, - ASH_MAX_RETRIES); - - // Resend the pending frame - this->parent_->write_from_tap(this->tx_pending_buffer_.data(), this->tx_pending_length_); - this->start_ack_timer_(); -} - -// Boot-time NCP metadata harvest -// Sequence: RST -> RSTACK -> version() -> getTokenData(NVM3KEY_STACK_NODE_DATA) -> -// getEui64() -> RST -> RSTACK -// -// Both values are read with the stack left down. The alternative -- set the stack -// profile, call networkInit, wait for a stackStatusHandler callback announcing -// NETWORK_UP, then call getNetworkParameters -- does work, but it joins the network -// just to read four fields, so merely powering the device on brings the radio up. -// Reading the NVM3 token needs none of it, and getEui64 answers with the stack down -// as well, so nothing in this sequence starts the stack. - -void ZigbeeProxy::advance_boot_state_() { - switch (this->boot_state_) { - case BootState::SEND_VERSION: - this->send_ezsp_version_(); - this->boot_state_ = BootState::WAIT_VERSION; - break; - - case BootState::SEND_TOKEN_DATA: - this->send_get_token_data_(); - this->boot_state_ = BootState::WAIT_TOKEN_DATA; - break; - - case BootState::SEND_GET_EUI64: - this->send_get_eui64_(); - this->boot_state_ = BootState::WAIT_EUI64; - break; - - case BootState::SEND_FINAL_RST: - ESP_LOGV(TAG, "Sending final RST to reset NCP to clean state"); - this->boot_state_ = BootState::WAIT_FINAL_RSTACK; - this->send_rst_frame_(); - break; - - default: - break; - } -} - -void ZigbeeProxy::handle_boot_data_frame_(const uint8_t *data, size_t length) { - // EZSP frame format is decided solely by whether version negotiation has - // completed, never by the frame's length: - // Legacy: [sequence] [frame_control] [frame_id] [data...] (3-byte header) - // Extended (v13+): [sequence] [frame_control_low] [frame_control_high] [frame_id_low] [frame_id_high] [data...] - // - // Only the `version` response arrives in legacy format, before ezsp_version_ is - // set. Inferring the format from the length instead misparses a short extended - // frame -- an error reply, say -- as a legacy one, which then surfaces as a - // plausible but wrong payload rather than as a protocol error. - - if (length < 3) { - ESP_LOGW(TAG, "Boot frame too short: %u bytes", length); - return; - } - - uint8_t frame_control; - uint16_t frame_id; - const uint8_t *payload; - size_t payload_length; - - bool use_extended = this->ezsp_version_ >= EZSP_MIN_VERSION; - - if (use_extended && length < 5) { - ESP_LOGW(TAG, "Extended EZSP frame too short: %u bytes", length); - return; - } - - if (use_extended) { - frame_control = data[1]; - frame_id = data[3] | (static_cast(data[4]) << 8); - payload = data + 5; - payload_length = length - 5; - } else { - frame_control = data[1]; - frame_id = data[2]; - payload = data + 3; - payload_length = length - 3; - } - - // Check if this is a response (not a callback) - bool is_response = (frame_control & 0x80) != 0; - bool is_callback = (frame_control & 0x10) != 0; - - ESP_LOGV(TAG, "Boot EZSP frame (%s): id=0x%04X, response=%d, callback=%d, payload_len=%u", - use_extended ? "extended" : "legacy", frame_id, is_response, is_callback, payload_length); - - // Handle based on current boot state - switch (this->boot_state_) { - case BootState::WAIT_VERSION: - if (frame_id == EZSP_VERSION && is_response) { - this->handle_version_response_(payload, payload_length); - } - break; - - case BootState::WAIT_EUI64: - if (frame_id == EZSP_GET_EUI64 && is_response) { - this->handle_eui64_response_(payload, payload_length); - } - break; - - case BootState::WAIT_TOKEN_DATA: - if (frame_id == EZSP_GET_TOKEN_DATA && is_response) { - this->handle_token_data_response_(payload, payload_length); - } - break; - - default: - break; - } -} - -void ZigbeeProxy::send_ezsp_version_() { - // EZSP version command must use LEGACY format (v4-v7) for initial handshake - // because NCP starts in legacy mode until version negotiation completes. - // Legacy format: [sequence] [frame_control] [frame_id] [desiredProtocolVersion] - this->ezsp_requested_version_ = EZSP_MAX_VERSION; - uint8_t cmd[] = { - this->ezsp_sequence_++, // Sequence - EZSP_FRAME_CONTROL_COMMAND, // Frame control (command, no callback) - 0x00, // Frame ID (version command = 0x00) - EZSP_MAX_VERSION // Desired protocol version - }; - - ash_randomize(cmd, sizeof(cmd)); - ESP_LOGV(TAG, "Sending EZSP version command (legacy format, requesting v%d)", EZSP_MAX_VERSION); - this->send_data_frame_(cmd, sizeof(cmd), false); -} - -void ZigbeeProxy::send_get_eui64_() { - // Extended format: version negotiation has completed, so the NCP now requires - // the 16-bit frame control and 16-bit frame ID. - uint8_t cmd[] = { - this->ezsp_sequence_++, // Sequence - EZSP_FRAME_CONTROL_COMMAND, // Frame control (low) - EZSP_FRAME_CONTROL_EXTENDED, // Frame control (high) - EZSP_GET_EUI64 & 0xFF, // Frame ID (low) - (EZSP_GET_EUI64 >> 8) & 0xFF, // Frame ID (high) - }; - ash_randomize(cmd, sizeof(cmd)); - ESP_LOGV(TAG, "Sending EZSP getEui64 command"); - this->send_data_frame_(cmd, sizeof(cmd), false); -} - -void ZigbeeProxy::send_get_token_data_() { - // getTokenData takes a 32-bit NVM3 key and a 32-bit index, both little-endian. - uint8_t cmd[] = { - this->ezsp_sequence_++, // Sequence - EZSP_FRAME_CONTROL_COMMAND, // Frame control (low) - EZSP_FRAME_CONTROL_EXTENDED, // Frame control (high) - EZSP_GET_TOKEN_DATA & 0xFF, // Frame ID (low) - (EZSP_GET_TOKEN_DATA >> 8) & 0xFF, // Frame ID (high) - NVM3KEY_STACK_NODE_DATA & 0xFF, // token - (NVM3KEY_STACK_NODE_DATA >> 8) & 0xFF, // - (NVM3KEY_STACK_NODE_DATA >> 16) & 0xFF, // - (NVM3KEY_STACK_NODE_DATA >> 24) & 0xFF, // - 0x00, // index - 0x00, // - 0x00, // - 0x00, // - }; - ash_randomize(cmd, sizeof(cmd)); - ESP_LOGV(TAG, "Sending EZSP getTokenData(NVM3KEY_STACK_NODE_DATA)"); - this->send_data_frame_(cmd, sizeof(cmd), false); -} - -void ZigbeeProxy::handle_version_response_(const uint8_t *data, size_t length) { - // Version response format depends on whether NCP supports requested version: - // - If supported: [protocolVersion] [stackType] [stackVersion (2 bytes)] - // - If NOT supported: [protocolVersion] only (NCP's supported version) - // - // When NCP doesn't support the requested version, it responds with just - // its supported version, indicating we should re-negotiate. - - if (length < 1) { - ESP_LOGW(TAG, "Version response empty"); - this->boot_state_ = BootState::FAILED; - return; - } - - uint8_t ncp_version = data[0]; - - if (length == 1) { - // NCP responded with just its version - // This happens when: - // 1. We requested a version the NCP doesn't support -> re-negotiate - // 2. We requested the NCP's version and it accepted -> treat as success - - if (ncp_version == this->ezsp_requested_version_) { - // 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_TOKEN_DATA; - this->advance_boot_state_(); - return; - } - - // NCP doesn't support our version - re-negotiate - ESP_LOGV(TAG, "NCP supports EZSP v%d, re-negotiating", ncp_version); - this->ezsp_requested_version_ = ncp_version; - - // Re-send version command with NCP's supported version - // Use legacy format for re-negotiation (NCP stays in legacy until handshake completes) - uint8_t cmd[] = { - this->ezsp_sequence_++, // Sequence - EZSP_FRAME_CONTROL_COMMAND, // Frame control - 0x00, // Frame ID (version) - ncp_version // Use NCP's version - }; - ash_randomize(cmd, sizeof(cmd)); - ESP_LOGV(TAG, "Re-sending EZSP version command (requesting v%d)", ncp_version); - this->send_data_frame_(cmd, sizeof(cmd), false); - // Stay in WAIT_VERSION state - return; - } - - // Full response with stack info - if (length < 4) { - ESP_LOGW(TAG, "Version response too short: %u bytes", length); - this->boot_state_ = BootState::FAILED; - return; - } - - this->ezsp_version_ = data[0]; - uint8_t stack_type = data[1]; - uint16_t stack_version = data[2] | (static_cast(data[3]) << 8); - - ESP_LOGD(TAG, "NCP EZSP version: %d, stack type: %d, stack version: 0x%04X", this->ezsp_version_, stack_type, - stack_version); - - if (this->ezsp_version_ < EZSP_MIN_VERSION) { - ESP_LOGE(TAG, "EZSP version %d not supported (minimum: %d)", this->ezsp_version_, EZSP_MIN_VERSION); - this->boot_state_ = BootState::FAILED; - return; - } - - if (!this->ezsp_version_confirmed_) { - // Repeat `version` in the negotiated extended format. The NCP answers the - // initial legacy command with its own version, but keeps rejecting extended - // frames (error frame 0x0058) until the handshake is completed in that format. - this->ezsp_version_confirmed_ = true; - this->ezsp_requested_version_ = this->ezsp_version_; - - uint8_t cmd[] = { - this->ezsp_sequence_++, // Sequence - EZSP_FRAME_CONTROL_COMMAND, // Frame control (low) - EZSP_FRAME_CONTROL_EXTENDED, // Frame control (high) - EZSP_VERSION & 0xFF, // Frame ID (low) - (EZSP_VERSION >> 8) & 0xFF, // Frame ID (high) - this->ezsp_version_, // desiredProtocolVersion - }; - ash_randomize(cmd, sizeof(cmd)); - ESP_LOGV(TAG, "Confirming EZSP v%d in extended format", this->ezsp_version_); - this->send_data_frame_(cmd, sizeof(cmd), false); - // Stay in WAIT_VERSION for the confirmation response - return; - } - - this->boot_state_ = BootState::SEND_TOKEN_DATA; - this->advance_boot_state_(); -} - -void ZigbeeProxy::handle_eui64_response_(const uint8_t *data, size_t length) { - // getEui64 response: [eui64 (8 bytes, little-endian)] - if (length >= ZIGBEE_IEEE_ADDR_SIZE) { - this->set_ieee_address_(data); - } else { - ESP_LOGW(TAG, "getEui64 response too short: %u bytes", length); - } - // Harvest is done either way; the proxy works without an IEEE address - this->boot_state_ = BootState::SEND_FINAL_RST; - this->advance_boot_state_(); -} - -void ZigbeeProxy::handle_token_data_response_(const uint8_t *data, size_t length) { - // getTokenData response: [status (4)] [length (4)] [value (length)] - // The EUI64 read follows regardless of what happens here: it is a hardware address - // that exists whether or not the radio is commissioned, and it is what lets a client - // tell an unformed radio apart from an unreachable one. - if (length < TOKEN_DATA_VALUE_OFFSET + NV3_NODE_DATA_SIZE) { - ESP_LOGW(TAG, "getTokenData response too short: %u bytes", length); - this->boot_state_ = BootState::SEND_GET_EUI64; - this->advance_boot_state_(); - return; - } - - if (data[0] != static_cast(SlStatus::OK)) { - ESP_LOGW(TAG, "getTokenData(NVM3KEY_STACK_NODE_DATA) failed: 0x%02X", data[0]); - this->boot_state_ = BootState::SEND_GET_EUI64; - this->advance_boot_state_(); - return; - } - - const uint8_t *node_data = data + TOKEN_DATA_VALUE_OFFSET; - - if (node_data[NV3_NODE_DATA_NODE_TYPE_OFFSET] == NV3_NODE_TYPE_UNKNOWN_DEVICE) { - ESP_LOGD(TAG, "NCP has no network configured"); - this->boot_state_ = BootState::SEND_GET_EUI64; - this->advance_boot_state_(); - return; - } - - memcpy(this->network_info_.extended_pan_id.data(), node_data + NV3_NODE_DATA_EXT_PAN_ID_OFFSET, 8); - this->network_info_.pan_id = - node_data[NV3_NODE_DATA_PAN_ID_OFFSET] | (static_cast(node_data[NV3_NODE_DATA_PAN_ID_OFFSET + 1]) << 8); - this->network_info_.channel = node_data[NV3_NODE_DATA_CHANNEL_OFFSET]; - this->network_info_.valid = true; - this->send_network_info_changed_msg_(); - - ESP_LOGD(TAG, - "Network info:\n" - " Extended PAN ID: %02X%02X%02X%02X%02X%02X%02X%02X\n" - " PAN ID: 0x%04X\n" - " Channel: %u", - this->network_info_.extended_pan_id[7], this->network_info_.extended_pan_id[6], - this->network_info_.extended_pan_id[5], this->network_info_.extended_pan_id[4], - this->network_info_.extended_pan_id[3], this->network_info_.extended_pan_id[2], - this->network_info_.extended_pan_id[1], this->network_info_.extended_pan_id[0], this->network_info_.pan_id, - this->network_info_.channel); - - this->boot_state_ = BootState::SEND_GET_EUI64; - this->advance_boot_state_(); -} - -bool ZigbeeProxy::set_ieee_address_(const uint8_t *new_address) { - bool changed = memcmp(this->network_info_.ieee_address.data(), new_address, ZIGBEE_IEEE_ADDR_SIZE) != 0; - - if (changed) { - memcpy(this->network_info_.ieee_address.data(), new_address, ZIGBEE_IEEE_ADDR_SIZE); - this->network_info_.valid = true; - ESP_LOGD(TAG, "IEEE address updated: %02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X", new_address[7], new_address[6], - new_address[5], new_address[4], new_address[3], new_address[2], new_address[1], new_address[0]); - this->send_network_info_changed_msg_(); - return true; - } - - return false; -} - -// Packed network info payload: ieee(8) + extended_pan(8) + pan_id(2) + channel(1), little-endian -static void pack_network_info(const NetworkInfo &info, uint8_t *out) { - memcpy(out, info.ieee_address.data(), ZIGBEE_IEEE_ADDR_SIZE); - memcpy(out + 8, info.extended_pan_id.data(), 8); - out[16] = info.pan_id & 0xFF; - out[17] = (info.pan_id >> 8) & 0xFF; - out[18] = info.channel; -} - -void ZigbeeProxy::send_network_info_changed_msg_(api::APIConnection *conn) { - uint8_t payload[NETWORK_INFO_PAYLOAD_SIZE]; - pack_network_info(this->network_info_, payload); - api::ZigbeeProxyRequest msg; - msg.type = api::enums::ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO; - msg.data = payload; - msg.data_len = sizeof(payload); - if (conn != nullptr) { - conn->send_message(msg); - } else if (api::global_api_server != nullptr) { - // Very infrequent and small - send to all clients rather than tracking a subscription - api::global_api_server->on_zigbee_proxy_request(msg); - } -} - -// WiFi/Zigbee channel conflict detection -namespace { - -// 802.11b/g/n in the 2.4 GHz band: channels 1-13 sit 5 MHz apart starting at 2412 MHz, -// with channel 14 an outlier. Returns 0 for anything not in the band. -uint16_t wifi_center_mhz(uint8_t channel) { - if (channel == 14) { - return 2484; - } - if (channel >= 1 && channel <= 13) { - return static_cast(2412 + 5 * (channel - 1)); - } - return 0; -} - -// 802.15.4 in the 2.4 GHz band: channels 11-26, 5 MHz apart starting at 2405 MHz. -uint16_t zigbee_center_mhz(uint8_t channel) { - if (channel >= 11 && channel <= 26) { - return static_cast(2405 + 5 * (channel - 11)); - } - return 0; -} - -// 802.15.4 O-QPSK occupies about 2 MHz. WiFi is not fixed: the ESP32 supports HT40 as -// well as HT20, and an HT40 link is both twice as wide and re-centred 10 MHz towards its -// secondary channel, so assuming 20 MHz would mispredict overlap at both edges. -constexpr uint16_t ZIGBEE_BANDWIDTH_MHZ = 2; -constexpr uint16_t WIFI_BANDWIDTH_HT20_MHZ = 20; -constexpr uint16_t WIFI_BANDWIDTH_HT40_MHZ = 40; - -struct WifiOccupancy { - uint16_t center_mhz; - uint16_t bandwidth_mhz; -}; - -// Two carriers clash when their halves meet: |f1 - f2| < (bw1 + bw2) / 2. -bool channels_overlap(const WifiOccupancy &wifi, uint16_t zigbee_mhz) { - const uint16_t separation = - wifi.center_mhz > zigbee_mhz ? wifi.center_mhz - zigbee_mhz : zigbee_mhz - wifi.center_mhz; - return separation * 2 < wifi.bandwidth_mhz + ZIGBEE_BANDWIDTH_MHZ; -} - -// Resolve what the radio is actually using, rather than assuming. Falls back to HT20, -// which is what every non-ESP32 target here supports anyway. -WifiOccupancy wifi_occupancy(uint8_t primary_channel) { - WifiOccupancy occupancy{wifi_center_mhz(primary_channel), WIFI_BANDWIDTH_HT20_MHZ}; -#ifdef USE_ESP32 - uint8_t primary = 0; - wifi_second_chan_t second = WIFI_SECOND_CHAN_NONE; - if (occupancy.center_mhz != 0 && esp_wifi_get_channel(&primary, &second) == ESP_OK && - second != WIFI_SECOND_CHAN_NONE) { - occupancy.bandwidth_mhz = WIFI_BANDWIDTH_HT40_MHZ; - // The 40 MHz block spans the primary and its neighbour, so its centre sits half a - // 20 MHz channel away from the primary's, on the secondary's side. - occupancy.center_mhz = - static_cast(second == WIFI_SECOND_CHAN_ABOVE ? occupancy.center_mhz + 10 : occupancy.center_mhz - 10); - } -#endif - return occupancy; -} - -} // namespace - -void ZigbeeProxy::check_wifi_zigbee_conflict_() { -#ifdef USE_WIFI - if (wifi::global_wifi_component == nullptr || !this->network_info_.valid || this->network_info_.channel == 0) { - return; - } - - const uint8_t wifi_channel = wifi::global_wifi_component->get_wifi_channel(); - const WifiOccupancy wifi = wifi_occupancy(wifi_channel); - if (wifi.center_mhz == 0) { - return; // Not connected yet, or a 5 GHz channel that cannot clash by definition - } - - const uint8_t zigbee_channel = this->network_info_.channel; - const uint16_t zigbee_mhz = zigbee_center_mhz(zigbee_channel); - if (zigbee_mhz == 0) { - return; - } - - if (!channels_overlap(wifi, zigbee_mhz)) { - ESP_LOGV(TAG, "No WiFi/Zigbee channel conflict (WiFi %u @ %u MHz/%u MHz wide, Zigbee %u @ %u MHz)", wifi_channel, - wifi.center_mhz, wifi.bandwidth_mhz, zigbee_channel, zigbee_mhz); - return; - } - - // Naming the channels that are actually clear beats a fixed suggestion: which ones those - // are depends entirely on where WiFi happens to be, and an auto-channel AP is rarely on - // 1, 6 or 11. - char clear[64]; - size_t offset = 0; - for (uint8_t candidate = 11; candidate <= 26; candidate++) { - if (channels_overlap(wifi, zigbee_center_mhz(candidate))) { - continue; - } - const int written = snprintf(clear + offset, sizeof(clear) - offset, offset == 0 ? "%u" : ", %u", candidate); - if (written <= 0 || static_cast(written) >= sizeof(clear) - offset) { - break; - } - offset += static_cast(written); - } - - ESP_LOGW(TAG, - "WiFi/Zigbee channel conflict detected\n" - " WiFi channel %u (%u MHz, %u MHz wide) overlaps Zigbee channel %u (%u MHz)\n" - " Zigbee channels clear of this WiFi channel: %s", - wifi_channel, wifi.center_mhz, wifi.bandwidth_mhz, zigbee_channel, zigbee_mhz, offset > 0 ? clear : "none"); -#endif -} - -// 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) { - // 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 detected\n" - " Flash NCP firmware via the serial proxy, or power cycle the device"); - this->bootloader_state_ = BootloaderState::MENU; - } - return; - } - - // 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; - } -} - -// 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. - if (length < EZSP_EXTENDED_HEADER_SIZE + NETWORK_PARAMS_RESPONSE_SIZE) { - return; - } - - // 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)); - ash_randomize(decoded, sizeof(decoded)); - - const uint16_t frame_id = decoded[3] | (static_cast(decoded[4]) << 8); - if (frame_id != EZSP_GET_NETWORK_PARAMETERS || (decoded[1] & EZSP_FRAME_CONTROL_RESPONSE) == 0) { - return; - } - - const uint8_t *params = decoded + EZSP_EXTENDED_HEADER_SIZE; - if (params[NETWORK_PARAMS_STATUS_OFFSET] != static_cast(SlStatus::OK)) { - return; - } - - const uint16_t pan_id = - params[NETWORK_PARAMS_PAN_ID_OFFSET] | (static_cast(params[NETWORK_PARAMS_PAN_ID_OFFSET + 1]) << 8); - const uint8_t channel = params[NETWORK_PARAMS_CHANNEL_OFFSET]; - const bool changed = - pan_id != this->network_info_.pan_id || channel != this->network_info_.channel || - memcmp(this->network_info_.extended_pan_id.data(), params + NETWORK_PARAMS_EXT_PAN_ID_OFFSET, 8) != 0; - if (!changed) { - return; - } - - memcpy(this->network_info_.extended_pan_id.data(), params + NETWORK_PARAMS_EXT_PAN_ID_OFFSET, 8); - this->network_info_.pan_id = pan_id; - this->network_info_.channel = channel; - this->network_info_.valid = true; - ESP_LOGD(TAG, "Network info from proxied traffic: PAN 0x%04X, channel %u", pan_id, channel); - this->send_network_info_changed_msg_(); -} - -void ZigbeeProxy::sniff_stack_status_(const uint8_t *frame, size_t length) { - // stackStatusHandler: [status (4)]. It carries no network parameters, so it can only - // invalidate, never refresh. Worth acting on anyway: a client leaving a network need not - // read parameters afterwards, and without this the old PAN would be reported forever. - if (length < EZSP_EXTENDED_HEADER_SIZE + 1) { - return; - } - - uint8_t decoded[EZSP_EXTENDED_HEADER_SIZE + 1]; - memcpy(decoded, frame, sizeof(decoded)); - ash_randomize(decoded, sizeof(decoded)); - - const uint16_t frame_id = decoded[3] | (static_cast(decoded[4]) << 8); - // A callback sets both the response direction and the callback bit, so match on both - if (frame_id != EZSP_STACK_STATUS_HANDLER || - (decoded[1] & EZSP_FRAME_CONTROL_CALLBACK) != EZSP_FRAME_CONTROL_CALLBACK) { - return; - } - - if (decoded[EZSP_EXTENDED_HEADER_SIZE] != static_cast(SlStatus::NETWORK_DOWN)) { - return; // NETWORK_UP and everything else leaves what we have standing - } - - if (this->network_info_.pan_id == 0 && this->network_info_.channel == 0) { - return; // Already reporting no network - } - - ESP_LOGD(TAG, "Stack reported NETWORK_DOWN, dropping stale network info"); - this->network_info_.pan_id = 0; - this->network_info_.channel = 0; - this->network_info_.extended_pan_id.fill(0); - // The IEEE address is a hardware property and survives leaving a network, so it stays. - this->send_network_info_changed_msg_(); + // A client turning protocol handling off is usually about to reflash the radio, so the + // handshake we saw says nothing about what will be on the wire next. Forget it: a real + // ASH session announces itself again with an RSTACK. + this->detector_.reset(); } } // namespace esphome::zigbee_proxy diff --git a/esphome/components/zigbee_proxy/zigbee_proxy.h b/esphome/components/zigbee_proxy/zigbee_proxy.h index 1175104211..63d13698c0 100644 --- a/esphome/components/zigbee_proxy/zigbee_proxy.h +++ b/esphome/components/zigbee_proxy/zigbee_proxy.h @@ -3,247 +3,47 @@ #include "esphome/core/defines.h" #ifdef USE_ZIGBEE_PROXY -#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 "ash_protocol.h" #include "ash_detector.h" -#include - namespace esphome::zigbee_proxy { -// Timeout configuration structure -struct TimeoutConfig { - uint32_t initial_timeout_ms{1600}; // Initial ACK timeout - uint32_t min_timeout_ms{400}; // Minimum adaptive timeout - uint32_t max_timeout_ms{3200}; // Maximum adaptive timeout - uint32_t current_timeout_ms{1600}; // Current adaptive timeout -}; - -// Network information structure -struct NetworkInfo { - std::array ieee_address{}; - uint16_t pan_id{0}; - std::array extended_pan_id{}; - uint8_t channel{0}; - bool valid{false}; -}; - -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 -enum class BootState : uint8_t { - IDLE, // Not initializing - WAIT_RSTACK, // Sent RST, waiting for RSTACK - SEND_VERSION, // Send EZSP version command - WAIT_VERSION, // Waiting for version response - SEND_TOKEN_DATA, // Send getTokenData(NVM3KEY_STACK_NODE_DATA) - WAIT_TOKEN_DATA, // Waiting for token data response - SEND_GET_EUI64, // Send getEui64 command - WAIT_EUI64, // Waiting for EUI64 response - SEND_FINAL_RST, // Send final RST to reset NCP - WAIT_FINAL_RSTACK, // Waiting for final RSTACK - COMPLETE, // Boot sequence complete - FAILED, // Boot sequence failed -}; - -// 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. +// Acknowledges the ASH frames of an EZSP NCP on behalf of a remote client, so the NCP's +// ack timeout is measured against this device rather than against the network round trip +// to the client. The client suppresses its own acknowledgements, making these the only +// ones the NCP sees. +// +// It never carries client traffic: the serial proxy owns the port and the bytes, and this +// component only observes them. The sole exception is the acknowledgement itself, and it +// is sent only once the handshake has proven the port really is carrying ASH. class ZigbeeProxy : public serial_proxy::SerialProxyTap, public Component { public: - ZigbeeProxy(); + explicit ZigbeeProxy(serial_proxy::SerialProxy *parent) : parent_(parent) {} void setup() override; - void loop() override; void dump_config() override; - 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. + // Acknowledging is only ever useful on a client's behalf, so with nobody subscribed + // there is nothing to do and the port need not be read. + bool tap_needs_port() const override { return false; } 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); - - // Feature flags - 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_; } - uint64_t get_ieee_address() const; - - // Timeout configuration (callable from Python/API) - void set_timeout_config(uint32_t initial_ms, uint32_t min_ms, uint32_t max_ms); - void set_initial_timeout(uint32_t timeout_ms) { this->timeout_config_.initial_timeout_ms = timeout_ms; } - 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; } - protected: - // ASH Protocol State Machine - void reset_ash_protocol_(); - 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 - // acknowledged one. Valid on DATA, ACK and NAK frames alike. - bool handle_ack_num_(uint8_t ack_num); - bool send_ack_frame_(uint8_t ack_num); - bool send_nak_frame_(uint8_t ack_num); - bool send_data_frame_(const uint8_t *data, size_t length, bool retransmit = false); - - // Frame parsing and building (implemented in ash_protocol.cpp) - bool parse_byte_(uint8_t byte); - void parse_control_byte_(uint8_t control); - bool validate_frame_crc_(); - // Builds a stuffed frame into output; returns 0 if the frame (worst case 2*length + 8 - // bytes after byte stuffing) would exceed capacity. - size_t build_frame_(uint8_t *output, size_t capacity, const uint8_t *data, size_t length, AshFrameType type, - uint8_t frame_num = 0, uint8_t ack_num = 0, bool retx = false); - uint16_t calculate_crc_(const uint8_t *data, size_t length, uint16_t init = ASH_CRC_INIT); - - // Sequence number management - void increment_tx_sequence_() { this->tx_sequence_ = (this->tx_sequence_ + 1) & ASH_MAX_SEQUENCE; } - void increment_rx_sequence_() { this->rx_sequence_ = (this->rx_sequence_ + 1) & ASH_MAX_SEQUENCE; } - - // Timeout management - void update_adaptive_timeout_(uint32_t measured_rtt_ms); - void start_ack_timer_() { this->ack_timer_start_ = millis(); } - bool check_ack_timeout_(); - - // Retransmission - void handle_retransmission_(); - void clear_tx_buffer_() { - this->tx_buffer_pending_ = false; - this->tx_retry_count_ = 0; - } - - // Boot-time NCP initialization - void advance_boot_state_(); - void check_boot_timeouts_(); - void handle_boot_data_frame_(const uint8_t *data, size_t length); - void send_ezsp_version_(); - void send_get_eui64_(); - void send_get_token_data_(); - void handle_version_response_(const uint8_t *data, size_t length); - void handle_eui64_response_(const uint8_t *data, size_t length); - void handle_token_data_response_(const uint8_t *data, size_t length); - - // IEEE address and network info - bool set_ieee_address_(const uint8_t *new_address); - void send_network_info_changed_msg_(api::APIConnection *conn = nullptr); - - // WiFi/Zigbee channel conflict detection - void check_wifi_zigbee_conflict_(); - - // Bootloader detection (fed consecutive raw byte pairs while not CONNECTED) - void check_bootloader_mode_(uint8_t prev_byte, uint8_t byte); - - // 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); - - // NCP-side ASH buffers - std::array rx_buffer_; - std::array tx_buffer_; - std::array tx_pending_buffer_; // For retransmission - - // Network information - NetworkInfo network_info_; - - // Timeout configuration - TimeoutConfig timeout_config_; - // The port this component observes. Owns the UART and the bytes; every write we make // goes through it. - serial_proxy::SerialProxy *parent_{nullptr}; - - 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 - - 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 - - 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 - uint8_t tx_pending_frame_num_{0}; // Frame number of pending TX frame - uint8_t last_ack_sent_{0}; // Last ACK number sent - uint8_t last_rx_byte_{0}; // Previous raw RX byte (bootloader detection) - - AshState ash_state_{AshState::DISCONNECTED}; - ParsingState parsing_state_{ParsingState::WAIT_FLAG_START}; - BootloaderState bootloader_state_{BootloaderState::NORMAL}; - BootState boot_state_{BootState::IDLE}; - - 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) - // The NCP keeps using legacy framing until `version` is repeated in the - // negotiated (extended) format; until then it rejects every extended command - // 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 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}; + serial_proxy::SerialProxy *parent_; // 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_; -}; -extern ZigbeeProxy *global_zigbee_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + // Previous armed state, for logging the transitions + bool was_armed_{false}; +}; } // namespace esphome::zigbee_proxy