From 917af8ff31559c68d5f407acc43920787d7b2e3d Mon Sep 17 00:00:00 2001 From: kbx81 Date: Thu, 19 Feb 2026 14:34:29 -0600 Subject: [PATCH] [zigbee_proxy] New component --- esphome/components/api/api.proto | 30 + esphome/components/api/api_connection.cpp | 22 + esphome/components/api/api_connection.h | 6 + esphome/components/api/api_pb2.cpp | 58 ++ esphome/components/api/api_pb2.h | 55 +- esphome/components/api/api_pb2_dump.cpp | 33 + esphome/components/api/api_pb2_service.cpp | 21 +- esphome/components/api/api_pb2_service.h | 6 + esphome/components/zigbee_proxy/__init__.py | 69 ++ .../components/zigbee_proxy/ash_protocol.cpp | 413 +++++++++ .../components/zigbee_proxy/ash_protocol.h | 90 ++ .../components/zigbee_proxy/ezsp_commands.h | 58 ++ .../components/zigbee_proxy/zigbee_proxy.cpp | 866 ++++++++++++++++++ .../components/zigbee_proxy/zigbee_proxy.h | 200 ++++ esphome/core/defines.h | 1 + tests/components/zigbee_proxy/common.yaml | 18 + .../zigbee_proxy/test.esp32-idf.yaml | 14 + .../zigbee_proxy/test.esp8266-ard.yaml | 11 + .../zigbee_proxy/test.rp2040-ard.yaml | 8 + 19 files changed, 1973 insertions(+), 6 deletions(-) create mode 100644 esphome/components/zigbee_proxy/__init__.py create mode 100644 esphome/components/zigbee_proxy/ash_protocol.cpp create mode 100644 esphome/components/zigbee_proxy/ash_protocol.h create mode 100644 esphome/components/zigbee_proxy/ezsp_commands.h create mode 100644 esphome/components/zigbee_proxy/zigbee_proxy.cpp create mode 100644 esphome/components/zigbee_proxy/zigbee_proxy.h create mode 100644 tests/components/zigbee_proxy/common.yaml create mode 100644 tests/components/zigbee_proxy/test.esp32-idf.yaml create mode 100644 tests/components/zigbee_proxy/test.esp8266-ard.yaml create mode 100644 tests/components/zigbee_proxy/test.rp2040-ard.yaml diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 18dac6a2d17..0dbac1f0641 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -260,6 +260,10 @@ message DeviceInfoResponse { // Indicates if Z-Wave proxy support is available and features supported uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"]; uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + + // Indicates if Zigbee proxy support is available and features supported + uint32 zigbee_proxy_feature_flags = 25 [(field_ifdef) = "USE_ZIGBEE_PROXY"]; + uint64 zigbee_ieee_address = 26 [(field_ifdef) = "USE_ZIGBEE_PROXY"]; } message ListEntitiesRequest { @@ -2443,6 +2447,32 @@ message ZWaveProxyRequest { bytes data = 2; } +// ==================== ZIGBEE ==================== + +message ZigbeeProxyFrame { + option (id) = 130; + option (source) = SOURCE_BOTH; + option (ifdef) = "USE_ZIGBEE_PROXY"; + option (no_delay) = true; + + bytes data = 1; +} + +enum ZigbeeProxyRequestType { + ZIGBEE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; + ZIGBEE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; + ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 2; +} + +message ZigbeeProxyRequest { + option (id) = 131; + option (source) = SOURCE_BOTH; + option (ifdef) = "USE_ZIGBEE_PROXY"; + + ZigbeeProxyRequestType type = 1; + bytes data = 2; +} + // ==================== INFRARED ==================== // Note: Feature and capability flag enums are defined in // esphome/components/infrared/infrared.h diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4d564af9e2a..e8236dbacaf 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -43,6 +43,9 @@ #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_WATER_HEATER #include "esphome/components/water_heater/water_heater.h" #endif @@ -1267,6 +1270,16 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { } #endif +#ifdef USE_ZIGBEE_PROXY +void APIConnection::on_zigbee_proxy_frame(const ZigbeeProxyFrame &msg) { + zigbee_proxy::global_zigbee_proxy->zigbee_proxy_frame(this, msg); +} + +void APIConnection::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) { + zigbee_proxy::global_zigbee_proxy->zigbee_proxy_request(this, msg); +} +#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, @@ -1502,6 +1515,11 @@ 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) { @@ -1627,6 +1645,10 @@ bool APIConnection::send_device_info_response_() { resp.zwave_proxy_feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags(); resp.zwave_home_id = zwave_proxy::global_zwave_proxy->get_home_id(); #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; #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index e34bed8ada0..0c2c92f74f2 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -167,6 +167,12 @@ class APIConnection final : public APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override; #endif +#ifdef USE_ZIGBEE_PROXY + void on_zigbee_proxy_frame(const ZigbeeProxyFrame &msg) override; + void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) override; + void send_zigbee_proxy_frame(const ZigbeeProxyFrame &msg) { this->send_message(msg, ZigbeeProxyFrame::MESSAGE_TYPE); } +#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) override; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 743f51dac77..52e56866537 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -119,6 +119,12 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { #ifdef USE_ZWAVE_PROXY buffer.encode_uint32(24, this->zwave_home_id); #endif +#ifdef USE_ZIGBEE_PROXY + buffer.encode_uint32(25, this->zigbee_proxy_feature_flags); +#endif +#ifdef USE_ZIGBEE_PROXY + buffer.encode_uint64(26, this->zigbee_ieee_address); +#endif } void DeviceInfoResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->name.size()); @@ -174,6 +180,12 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_ZWAVE_PROXY size.add_uint32(2, this->zwave_home_id); #endif +#ifdef USE_ZIGBEE_PROXY + size.add_uint32(2, this->zigbee_proxy_feature_flags); +#endif +#ifdef USE_ZIGBEE_PROXY + size.add_uint64(2, this->zigbee_ieee_address); +#endif } #ifdef USE_BINARY_SENSOR void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { @@ -3347,6 +3359,52 @@ void ZWaveProxyRequest::calculate_size(ProtoSize &size) const { size.add_length(1, this->data_len); } #endif +#ifdef USE_ZIGBEE_PROXY +bool ZigbeeProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 1: { + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; + } + return true; +} +void ZigbeeProxyFrame::encode(ProtoWriteBuffer buffer) const { buffer.encode_bytes(1, this->data, this->data_len); } +void ZigbeeProxyFrame::calculate_size(ProtoSize &size) const { size.add_length(1, this->data_len); } +bool ZigbeeProxyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->type = static_cast(value.as_uint32()); + 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; +} +void ZigbeeProxyRequest::encode(ProtoWriteBuffer buffer) const { + buffer.encode_uint32(1, static_cast(this->type)); + buffer.encode_bytes(2, this->data, this->data_len); +} +void ZigbeeProxyRequest::calculate_size(ProtoSize &size) const { + size.add_uint32(1, static_cast(this->type)); + size.add_length(1, this->data_len); +} +#endif #ifdef USE_INFRARED void ListEntitiesInfraredResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->object_id); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d001f869c50..187406f4900 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -311,6 +311,13 @@ enum ZWaveProxyRequestType : uint32_t { ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 2, }; #endif +#ifdef USE_ZIGBEE_PROXY +enum ZigbeeProxyRequestType : uint32_t { + ZIGBEE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0, + ZIGBEE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1, + ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 2, +}; +#endif } // namespace enums @@ -474,7 +481,7 @@ class DeviceInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint8_t ESTIMATED_SIZE = 255; + static constexpr uint16_t ESTIMATED_SIZE = 265; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "device_info_response"; } #endif @@ -526,6 +533,12 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_ZWAVE_PROXY uint32_t zwave_home_id{0}; +#endif +#ifdef USE_ZIGBEE_PROXY + uint32_t zigbee_proxy_feature_flags{0}; +#endif +#ifdef USE_ZIGBEE_PROXY + uint64_t zigbee_ieee_address{0}; #endif void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; @@ -2960,6 +2973,46 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif +#ifdef USE_ZIGBEE_PROXY +class ZigbeeProxyFrame final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 130; + static constexpr uint8_t ESTIMATED_SIZE = 19; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "zigbee_proxy_frame"; } +#endif + const uint8_t *data{nullptr}; + uint16_t data_len{0}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; +}; +class ZigbeeProxyRequest final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 131; + static constexpr uint8_t ESTIMATED_SIZE = 21; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "zigbee_proxy_request"; } +#endif + enums::ZigbeeProxyRequestType type{}; + const uint8_t *data{nullptr}; + uint16_t data_len{0}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#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, ProtoVarInt value) override; +}; +#endif #ifdef USE_INFRARED class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 73690610ede..2cc96afd966 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -736,6 +736,20 @@ template<> const char *proto_enum_to_string(enums: } } #endif +#ifdef USE_ZIGBEE_PROXY +template<> const char *proto_enum_to_string(enums::ZigbeeProxyRequestType value) { + switch (value) { + case enums::ZIGBEE_PROXY_REQUEST_TYPE_SUBSCRIBE: + return "ZIGBEE_PROXY_REQUEST_TYPE_SUBSCRIBE"; + case enums::ZIGBEE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: + return "ZIGBEE_PROXY_REQUEST_TYPE_UNSUBSCRIBE"; + case enums::ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO: + return "ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO"; + default: + return "UNKNOWN"; + } +} +#endif const char *HelloRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "HelloRequest"); @@ -845,6 +859,12 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif #ifdef USE_ZWAVE_PROXY dump_field(out, "zwave_home_id", this->zwave_home_id); +#endif +#ifdef USE_ZIGBEE_PROXY + dump_field(out, "zigbee_proxy_feature_flags", this->zigbee_proxy_feature_flags); +#endif +#ifdef USE_ZIGBEE_PROXY + dump_field(out, "zigbee_ieee_address", this->zigbee_ieee_address); #endif return out.c_str(); } @@ -2422,6 +2442,19 @@ const char *ZWaveProxyRequest::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_ZIGBEE_PROXY +const char *ZigbeeProxyFrame::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "ZigbeeProxyFrame"); + dump_bytes_field(out, "data", this->data, this->data_len); + return out.c_str(); +} +const char *ZigbeeProxyRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "ZigbeeProxyRequest"); + dump_field(out, "type", static_cast(this->type)); + dump_bytes_field(out, "data", this->data, this->data_len); + return out.c_str(); +} +#endif #ifdef USE_INFRARED const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "ListEntitiesInfraredResponse"); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index f9151ae3b46..2fd4e9a2800 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -602,14 +602,25 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #endif -#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES - case HomeassistantActionResponse::MESSAGE_TYPE: { - HomeassistantActionResponse msg; +#ifdef USE_ZIGBEE_PROXY + case ZigbeeProxyFrame::MESSAGE_TYPE: { + ZigbeeProxyFrame msg; msg.decode(msg_data, msg_size); #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_homeassistant_action_response"), msg); + this->log_receive_message_(LOG_STR("on_zigbee_proxy_frame"), msg); #endif - this->on_homeassistant_action_response(msg); + this->on_zigbee_proxy_frame(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 diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 1441507406d..ba8f077c0f5 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -219,6 +219,12 @@ class APIServerConnectionBase : public ProtoService { #ifdef USE_ZWAVE_PROXY virtual void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif +#ifdef USE_ZIGBEE_PROXY + virtual void on_zigbee_proxy_frame(const ZigbeeProxyFrame &value){}; +#endif +#ifdef USE_ZIGBEE_PROXY + virtual void on_zigbee_proxy_request(const ZigbeeProxyRequest &value){}; +#endif #ifdef USE_IR_RF virtual void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){}; diff --git a/esphome/components/zigbee_proxy/__init__.py b/esphome/components/zigbee_proxy/__init__.py new file mode 100644 index 00000000000..310781e73ba --- /dev/null +++ b/esphome/components/zigbee_proxy/__init__.py @@ -0,0 +1,69 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI +import esphome.final_validate as fv + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["api", "uart"] + +CONF_BUFFER_SIZE = "buffer_size" +CONF_INITIAL_TIMEOUT = "initial_timeout" +CONF_MIN_TIMEOUT = "min_timeout" +CONF_MAX_TIMEOUT = "max_timeout" + +zigbee_proxy_ns = cg.esphome_ns.namespace("zigbee_proxy") +ZigbeeProxy = zigbee_proxy_ns.class_("ZigbeeProxy", cg.Component, uart.UARTDevice) + + +def final_validate(config): + full_config = fv.full_config.get() + if (wifi_conf := full_config.get(CONF_WIFI)) and ( + wifi_conf.get(CONF_POWER_SAVE_MODE, "").lower() != "none" + ): + raise cv.Invalid( + f"{CONF_WIFI} {CONF_POWER_SAVE_MODE} must be set to 'none' when using Zigbee proxy" + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ZigbeeProxy), + cv.Optional(CONF_BUFFER_SIZE): cv.SplitDefault( + cv.int_range(min=256, max=2048), + esp8266=512, + default=1024, + ), + cv.Optional(CONF_INITIAL_TIMEOUT, default=1600): cv.int_range( + min=100, max=10000 + ), + cv.Optional(CONF_MIN_TIMEOUT, default=400): cv.int_range(min=100, max=5000), + cv.Optional(CONF_MAX_TIMEOUT, default=3200): cv.int_range( + min=500, max=10000 + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), +) + +FINAL_VALIDATE_SCHEMA = final_validate + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + cg.add_define("USE_ZIGBEE_PROXY") + + # 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]) + + # Set timeout values + cg.add(var.set_initial_timeout(config[CONF_INITIAL_TIMEOUT])) + cg.add(var.set_min_timeout(config[CONF_MIN_TIMEOUT])) + cg.add(var.set_max_timeout(config[CONF_MAX_TIMEOUT])) diff --git a/esphome/components/zigbee_proxy/ash_protocol.cpp b/esphome/components/zigbee_proxy/ash_protocol.cpp new file mode 100644 index 00000000000..4b7ab81fe8d --- /dev/null +++ b/esphome/components/zigbee_proxy/ash_protocol.cpp @@ -0,0 +1,413 @@ +#include "zigbee_proxy.h" + +#ifdef USE_ZIGBEE_PROXY + +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace zigbee_proxy { + +static const char *const TAG = "zigbee_proxy"; + +// 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, + 0xE1CE, 0xF1EF, 0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6, 0x9339, 0x8318, 0xB37B, 0xA35A, + 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE, 0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485, 0xA56A, 0xB54B, + 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D, 0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4, + 0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC, 0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, + 0x2802, 0x3823, 0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B, 0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, + 0x1A71, 0x0A50, 0x3A33, 0x2A12, 0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A, 0x6CA6, 0x7C87, + 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41, 0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49, + 0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70, 0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, + 0x9F59, 0x8F78, 0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F, 0x1080, 0x00A1, 0x30C2, 0x20E3, + 0x5004, 0x4025, 0x7046, 0x6067, 0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E, 0x02B1, 0x1290, + 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256, 0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D, + 0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, 0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, + 0xC71D, 0xD73C, 0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634, 0xD94C, 0xC96D, 0xF90E, 0xE92F, + 0x99C8, 0x89E9, 0xB98A, 0xA9AB, 0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3, 0xCB7D, 0xDB5C, + 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A, 0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92, + 0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9, 0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, + 0x1CE0, 0x0CC1, 0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8, 0x6E17, 0x7E36, 0x4E55, 0x5E74, + 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0}; + +uint16_t ZigbeeProxy::calculate_crc_(const uint8_t *data, size_t length) { + uint16_t crc = ASH_CRC_INIT; + for (size_t i = 0; i < length; i++) { + crc = (crc << 8) ^ CRC_TABLE[(crc >> 8) ^ data[i]]; + } + return crc; +} + +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; +} + +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: { + // Check sequence number + if (frame_num != this->rx_sequence_) { + 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; + } + + // Check for ACK in DATA frame (piggybacked ACK) BEFORE processing + // This must happen first because the handler may send new frames + if (this->tx_buffer_pending_ && ack_num == ((this->tx_pending_frame_num_ + 1) & ASH_MAX_SEQUENCE)) { + // ackNum means "I expect frame N next" = "I received up to N-1" + // So if ackNum == pending+1, our pending frame was received + uint32_t rtt = millis() - this->ack_timer_start_; + this->update_adaptive_timeout_(rtt); + this->clear_tx_buffer_(); + ESP_LOGD(TAG, "ACK received (piggybacked in DATA), RTT: %u ms", rtt); + } + + // Send ACK immediately + this->send_ack_frame_(frame_num); + + // Increment RX sequence for next frame + this->increment_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; + + // During boot sequence, route to boot handler + if (this->boot_sequence_active_ && payload_length > 0) { + this->handle_boot_data_frame_(payload, payload_length); + } else if (this->api_connection_ != nullptr && payload_length > 0) { + // Forward EZSP payload to API client + this->outgoing_proto_msg_.data = payload; + this->outgoing_proto_msg_.data_len = payload_length; + this->api_connection_->send_zigbee_proxy_frame(this->outgoing_proto_msg_); + } + break; + } + + case AshFrameType::ACK: + // Check if this ACKs our pending frame + // ackNum means "I expect frame N next" = "I received all frames up to N-1" + // So if ackNum == pending+1, our pending frame was acknowledged + if (this->tx_buffer_pending_ && ack_num == ((this->tx_pending_frame_num_ + 1) & ASH_MAX_SEQUENCE)) { + uint32_t rtt = millis() - this->ack_timer_start_; + this->update_adaptive_timeout_(rtt); + this->clear_tx_buffer_(); + ESP_LOGD(TAG, "ACK received for frame %d, RTT: %u ms", this->tx_pending_frame_num_, rtt); + } + break; + + case AshFrameType::NAK: + ESP_LOGW(TAG, "NAK received for frame %d, retransmitting", ack_num); + if (this->tx_buffer_pending_) { + this->handle_retransmission_(); + } + break; + + case AshFrameType::RST: { + ESP_LOGW(TAG, "Received RST frame from NCP, sending RSTACK"); + // Send RSTACK response + uint8_t rstack_data[] = {0x02, 0x01, 0x00}; // RSTACK with reset code + 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) { + // ASH_CAN (0x1A) resets the parser state - discard any partial frame + static constexpr uint8_t ASH_CAN_BYTE = 0x1A; + if (byte == ASH_CAN_BYTE) { + this->rx_buffer_index_ = 0; + this->escape_next_byte_ = false; + this->parsing_state_ = ParsingState::WAIT_FLAG_START; + 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; + // After unescaping, check if it's a CAN byte (0x1A) + if (byte == ASH_CAN_BYTE) { + this->rx_buffer_index_ = 0; + return 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) + // Skip reserved bytes that cannot be valid control bytes + if (byte != 0x11 && byte != 0x13) { + 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); + } + // Check for bootloader patterns + this->check_bootloader_mode_(&byte, 1); + 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 - log frame contents for debugging + ESP_LOGW(TAG, "CRC failed, frame (%u bytes): %s", this->rx_buffer_index_, + format_hex_pretty(this->rx_buffer_.data(), this->rx_buffer_index_).c_str()); + 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; +} + +size_t ZigbeeProxy::build_frame_(uint8_t *output, const uint8_t *data, size_t length, AshFrameType type, + uint8_t frame_num, uint8_t ack_num, bool retx) { + size_t pos = 0; + + // Start with FLAG + 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 (control == ASH_FLAG_BYTE || control == ASH_ESCAPE_BYTE || control == 0x11 || control == 0x13 || control == 0x93 || + control == 0xA3) { + output[pos++] = ASH_ESCAPE_BYTE; + output[pos++] = control ^ ASH_XOR_BYTE; + } else { + output[pos++] = control; + } + + // Prepare CRC calculation buffer (control + data) + uint8_t crc_buffer[MAX_ASH_FRAME_SIZE]; + crc_buffer[0] = control; + if (length > 0) { + memcpy(crc_buffer + 1, data, length); + } + + // Add data payload with stuffing + for (size_t i = 0; i < length; i++) { + uint8_t byte = data[i]; + if (byte == ASH_FLAG_BYTE || byte == ASH_ESCAPE_BYTE || byte == 0x11 || byte == 0x13 || byte == 0x93 || + byte == 0xA3) { + output[pos++] = ASH_ESCAPE_BYTE; + output[pos++] = byte ^ ASH_XOR_BYTE; + } else { + output[pos++] = byte; + } + } + + // Calculate CRC + uint16_t crc = this->calculate_crc_(crc_buffer, 1 + length); + + // Add CRC with stuffing (big-endian) + uint8_t crc_high = (crc >> 8) & 0xFF; + uint8_t crc_low = crc & 0xFF; + + if (crc_high == ASH_FLAG_BYTE || crc_high == ASH_ESCAPE_BYTE || crc_high == 0x11 || crc_high == 0x13 || + crc_high == 0x93 || crc_high == 0xA3) { + output[pos++] = ASH_ESCAPE_BYTE; + output[pos++] = crc_high ^ ASH_XOR_BYTE; + } else { + output[pos++] = crc_high; + } + + if (crc_low == ASH_FLAG_BYTE || crc_low == ASH_ESCAPE_BYTE || crc_low == 0x11 || crc_low == 0x13 || crc_low == 0x93 || + crc_low == 0xA3) { + output[pos++] = ASH_ESCAPE_BYTE; + output[pos++] = crc_low ^ ASH_XOR_BYTE; + } else { + output[pos++] = crc_low; + } + + // End with FLAG + output[pos++] = ASH_FLAG_BYTE; + + return pos; +} + +} // namespace zigbee_proxy +} // namespace esphome + +#endif // USE_ZIGBEE_PROXY diff --git a/esphome/components/zigbee_proxy/ash_protocol.h b/esphome/components/zigbee_proxy/ash_protocol.h new file mode 100644 index 00000000000..890ff2064af --- /dev/null +++ b/esphome/components/zigbee_proxy/ash_protocol.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include + +namespace esphome { +namespace zigbee_proxy { + +// ASH Protocol Constants +static constexpr uint8_t ASH_FLAG_BYTE = 0x7E; // Frame delimiter +static constexpr uint8_t ASH_ESCAPE_BYTE = 0x7D; // Escape/substitution byte +static constexpr uint8_t ASH_XOR_BYTE = 0x20; // XOR mask for escaped bytes +static constexpr uint8_t ASH_SUBSTITUTE_BYTE = 0x18; // Substitution for invalid bytes + +// Reserved bytes that must be escaped +static constexpr uint8_t ASH_RESERVED_BYTES[] = {0x7E, 0x7D, 0x11, 0x13, 0x93, 0xA3}; + +// Buffer size configuration +#ifdef ZIGBEE_PROXY_BUFFER_SIZE +static constexpr size_t MAX_ASH_FRAME_SIZE = ZIGBEE_PROXY_BUFFER_SIZE; +#else +#ifdef USE_ESP8266 +static constexpr size_t MAX_ASH_FRAME_SIZE = 512; // Limited RAM on ESP8266 +#else +static constexpr size_t MAX_ASH_FRAME_SIZE = 1024; // Full buffer on ESP32/RP2040 +#endif +#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 + +// IEEE address size +static constexpr size_t ZIGBEE_IEEE_ADDR_SIZE = 8; // 64-bit IEEE address + +// 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, +}; + +} // namespace zigbee_proxy +} // namespace esphome diff --git a/esphome/components/zigbee_proxy/ezsp_commands.h b/esphome/components/zigbee_proxy/ezsp_commands.h new file mode 100644 index 00000000000..3131d0bad5c --- /dev/null +++ b/esphome/components/zigbee_proxy/ezsp_commands.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +namespace esphome { +namespace zigbee_proxy { + +// EZSP Protocol Versions +static constexpr uint8_t EZSP_MIN_VERSION = 8; // 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 + +// 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] + +// EZSP Frame IDs - Commands (host to NCP) +static constexpr uint16_t EZSP_VERSION = 0x0000; // Version negotiation +static constexpr uint16_t EZSP_NETWORK_INIT = 0x0017; // Initialize network +static constexpr uint16_t EZSP_NETWORK_STATE = 0x0018; // Get network state +static constexpr uint16_t EZSP_GET_EUI64 = 0x0026; // Get IEEE address +static constexpr uint16_t EZSP_GET_NETWORK_PARAMETERS = 0x0028; // Get network parameters + +// EZSP Frame IDs - Callbacks (NCP to host, async) +static constexpr uint16_t EZSP_STACK_STATUS_HANDLER = 0x0019; // Stack status callback + +// EZSP Network Status +enum class EzspNetworkStatus : uint8_t { + NO_NETWORK = 0x00, + JOINING_NETWORK = 0x01, + JOINED_NETWORK = 0x02, + JOINED_NETWORK_NO_PARENT = 0x03, + LEAVING_NETWORK = 0x04, +}; + +// Ember Status codes (subset) +enum class EmberStatus : uint8_t { + SUCCESS = 0x00, + NETWORK_UP = 0x90, + NETWORK_DOWN = 0x91, + NOT_JOINED = 0x93, +}; + +// Network parameters structure offsets (in getNetworkParameters response) +// Response format: [status] [nodeType] [parameters...] +// Parameters: [extendedPanId (8)] [panId (2)] [radioTxPower] [radioChannel] [joinMethod] ... +static constexpr size_t NETWORK_PARAMS_STATUS_OFFSET = 0; +static constexpr size_t NETWORK_PARAMS_NODE_TYPE_OFFSET = 1; +static constexpr size_t NETWORK_PARAMS_EXT_PAN_ID_OFFSET = 2; +static constexpr size_t NETWORK_PARAMS_PAN_ID_OFFSET = 10; +static constexpr size_t NETWORK_PARAMS_CHANNEL_OFFSET = 13; + +} // namespace zigbee_proxy +} // namespace esphome diff --git a/esphome/components/zigbee_proxy/zigbee_proxy.cpp b/esphome/components/zigbee_proxy/zigbee_proxy.cpp new file mode 100644 index 00000000000..76f8414e949 --- /dev/null +++ b/esphome/components/zigbee_proxy/zigbee_proxy.cpp @@ -0,0 +1,866 @@ +#include "zigbee_proxy.h" + +#ifdef USE_ZIGBEE_PROXY + +#include "esphome/core/log.h" +#include "esphome/core/application.h" +#include "esphome/components/api/api_server.h" +#include "ezsp_commands.h" + +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif + +namespace esphome { +namespace zigbee_proxy { + +static const char *const TAG = "zigbee_proxy"; + +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(); + + // 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() { + // Process incoming UART data + this->process_uart_(); + + // Check for ACK timeout and handle retransmission + if (this->tx_buffer_pending_ && this->check_ack_timeout_()) { + this->handle_retransmission_(); + } + + // Check for boot sequence timeout + if (this->boot_sequence_active_) { + uint32_t elapsed = millis() - this->setup_time_; + if (elapsed > 10000) { // 10 second timeout for entire boot sequence + 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; + } + } + } + + // Check if we're stuck in CONNECTING state (before boot sequence starts) + if (this->ash_state_ == AshState::CONNECTING && !this->boot_sequence_active_) { + if (millis() - this->setup_time_ > ASH_RESET_TIMEOUT) { + ESP_LOGE(TAG, "RSTACK timeout, NCP not responding"); + this->ash_state_ = AshState::FAILED; + } + } +} + +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() { return this->ash_state_ == AshState::CONNECTED; } + +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_SUBSCRIBE: + if (this->api_connection_ != nullptr) { + ESP_LOGW(TAG, "Another client is already subscribed"); + return; + } + ESP_LOGI(TAG, "Client subscribed"); + this->api_connection_ = api_connection; + break; + + case api::enums::ZIGBEE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: + if (this->api_connection_ == api_connection) { + ESP_LOGI(TAG, "Client unsubscribed"); + this->api_connection_ = nullptr; + } + break; + + default: + ESP_LOGW(TAG, "Unknown request type: %d", static_cast(msg.type)); + break; + } +} + +void ZigbeeProxy::zigbee_proxy_frame(api::APIConnection *api_connection, const api::ZigbeeProxyFrame &msg) { + if (this->api_connection_ != api_connection) { + ESP_LOGW(TAG, "Frame received from non-subscribed client"); + return; + } + + if (this->ash_state_ != AshState::CONNECTED) { + ESP_LOGW(TAG, "Cannot send frame, NCP not connected"); + return; + } + + // Forward EZSP payload to NCP + this->send_frame(msg.data, msg.data_len); +} + +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::send_frame(const uint8_t *data, size_t length) { + if (this->ash_state_ != AshState::CONNECTED) { + ESP_LOGW(TAG, "Cannot send frame, not connected"); + return; + } + + if (this->tx_buffer_pending_) { + ESP_LOGW(TAG, "Cannot send frame, previous frame still pending"); + return; + } + + // Validate frame size + if (length + 4 > MAX_ASH_FRAME_SIZE) { // +4 for control, CRC, FLAGS + ESP_LOGE(TAG, "Frame too large: %u bytes (max %u)", length, MAX_ASH_FRAME_SIZE - 4); + return; + } + + this->send_data_frame_(data, length, false); +} + +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_LOGI(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_LOGI(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(); + + // 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_() { + // Send CANCEL bytes (0x1A) to clear NCP's receive buffer before RST + // This is required by ASH protocol to ensure NCP ignores any partial frames + // Note: 0x1A is ASH_CAN (cancel), NOT 0x18 which is ASH_SUB (substitute) + static constexpr uint8_t ASH_CAN_BYTE = 0x1A; + for (int i = 0; i < 32; i++) { + this->write_byte(ASH_CAN_BYTE); + } + this->flush(); + + // Small delay to allow NCP to process CANCEL bytes + delay(10); + + // Now send the RST frame + uint8_t frame[8]; + size_t length = this->build_frame_(frame, nullptr, 0, AshFrameType::RST); + + // Debug: log exact bytes being sent + ESP_LOGV(TAG, "RST frame bytes (%u): %s", length, format_hex_pretty(frame, length).c_str()); + + this->write_array(frame, length); + this->flush(); + ESP_LOGD(TAG, "Sent RST frame (with 32 CAN bytes prefix)"); +} + +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_LOGI(TAG, "Received RSTACK, starting EZSP initialization"); + this->ash_state_ = AshState::CONNECTED; + + // Small delay to let NCP settle and clear any pending data + delay(50); + + // Drain any garbage bytes from UART buffer + while (this->available()) { + uint8_t byte; + this->read_byte(&byte); + ESP_LOGV(TAG, "Draining post-RSTACK byte: 0x%02X", byte); + } + + 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_LOGI(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 if (this->ash_state_ == AshState::CONNECTING) { + // Unexpected but valid RSTACK during connecting + ESP_LOGI(TAG, "Received RSTACK, NCP ready"); + this->ash_state_ = AshState::CONNECTED; + } else { + ESP_LOGW(TAG, "Unexpected RSTACK received (boot_state=%d)", static_cast(this->boot_state_)); + } +} + +void ZigbeeProxy::handle_error_frame_(const uint8_t *data, size_t length) { + if (length < 1) { + ESP_LOGE(TAG, "ERROR 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; + } + + ESP_LOGE(TAG, "NCP ERROR: %s (0x%02X)", error_str, error_code); + + // Attempt recovery + ESP_LOGI(TAG, "Attempting recovery..."); + this->reset_ash_protocol_(); +} + +bool ZigbeeProxy::send_ack_frame_(uint8_t ack_num) { + uint8_t frame[8]; + size_t length = this->build_frame_(frame, nullptr, 0, AshFrameType::ACK, 0, ack_num); + this->write_array(frame, length); + this->flush(); + this->last_ack_sent_ = ack_num; + ESP_LOGV(TAG, "Sent ACK for frame %d", ack_num); + return true; +} + +bool ZigbeeProxy::send_nak_frame_(uint8_t ack_num) { + uint8_t frame[8]; + size_t length = this->build_frame_(frame, nullptr, 0, AshFrameType::NAK, 0, ack_num); + this->write_array(frame, length); + this->flush(); + ESP_LOGW(TAG, "Sent NAK for frame %d", ack_num); + return true; +} + +bool ZigbeeProxy::send_data_frame_(const uint8_t *data, size_t length, bool retransmit) { + // Validate frame size + if (length + 4 > MAX_ASH_FRAME_SIZE) { + ESP_LOGE(TAG, "Frame too large: %u bytes", length); + return false; + } + + // Build frame + size_t frame_length = this->build_frame_(this->tx_buffer_.data(), data, length, AshFrameType::DATA, + this->tx_sequence_, this->rx_sequence_, retransmit); + + // 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 + ESP_LOGV(TAG, "TX DATA frame (%u bytes): %s", frame_length, + format_hex_pretty(this->tx_buffer_.data(), frame_length).c_str()); + + // Send frame + this->write_array(this->tx_buffer_.data(), frame_length); + this->flush(); + + // 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, entering FAILED state"); + 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->write_array(this->tx_pending_buffer_.data(), this->tx_pending_length_); + this->flush(); + this->start_ack_timer_(); +} + +// Boot-time NCP initialization sequence +// Sequence: RST -> RSTACK -> version() -> networkInit() -> stackStatus -> getNetworkParameters() -> RST -> RSTACK + +void ZigbeeProxy::start_boot_sequence_() { + ESP_LOGI(TAG, "Starting boot sequence to harvest network info"); + this->boot_state_ = BootState::WAIT_RSTACK; + this->boot_sequence_active_ = true; + this->ezsp_sequence_ = 0; + this->reset_ash_protocol_(); +} + +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_NETWORK_INIT: + this->send_network_init_(); + this->boot_state_ = BootState::WAIT_STACK_STATUS; + break; + + case BootState::SEND_GET_NETWORK_PARAMS: + this->send_get_network_params_(); + this->boot_state_ = BootState::WAIT_NETWORK_PARAMS; + break; + + case BootState::SEND_FINAL_RST: + ESP_LOGI(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 depends on negotiated version: + // Legacy (v4-v7): [sequence] [frame_control] [frame_id] [data...] (3-byte header) + // Extended (v8+): [sequence] [frame_control_low] [frame_control_high] [frame_id_low] [frame_id_high] [data...] + // + // Note: Some NCPs may respond in legacy format for a few frames after version negotiation + // before fully switching to extended format. We handle this by falling back to legacy + // parsing if the frame is too short for extended format. + + 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; + + // Determine format: prefer extended for v8+, but fall back to legacy if frame is too short + bool use_extended = (this->ezsp_version_ >= 8) && (length >= 5); + + 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_STACK_STATUS: + if (frame_id == EZSP_STACK_STATUS_HANDLER && is_callback) { + this->handle_stack_status_(payload, payload_length); + } else if (frame_id == EZSP_NETWORK_INIT && is_response) { + // networkInit response contains EmberStatus + // Some NCPs proceed directly without stackStatusHandler callback + if (payload_length >= 1) { + uint8_t status = payload[0]; + ESP_LOGD(TAG, "networkInit response: status=0x%02X", status); + // Proceed to getNetworkParameters regardless of status + // getNetworkParameters will tell us if there's actually a network + ESP_LOGI(TAG, "networkInit complete, querying network parameters"); + this->boot_state_ = BootState::SEND_GET_NETWORK_PARAMS; + this->advance_boot_state_(); + } + } + break; + + case BootState::WAIT_NETWORK_PARAMS: + if (frame_id == EZSP_GET_NETWORK_PARAMETERS && is_response) { + this->handle_network_params_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 + }; + + ESP_LOGD(TAG, "Sending EZSP version command (legacy format, requesting v%d)", EZSP_MAX_VERSION); + this->send_data_frame_(cmd, sizeof(cmd), false); +} + +void ZigbeeProxy::send_network_init_() { + // networkInit command - use legacy format for compatibility + // Some NCPs need a command or two in legacy format after version negotiation + // before fully switching to extended format. + // networkInitStruct: [bitmask (2 bytes)] - use 0x0000 for default + uint8_t cmd[] = { + this->ezsp_sequence_++, // Sequence + EZSP_FRAME_CONTROL_COMMAND, // Frame control + EZSP_NETWORK_INIT & 0xFF, // Frame ID + 0x00, 0x00 // networkInitStruct bitmask (default) + }; + ESP_LOGD(TAG, "Sending EZSP networkInit command (legacy format)"); + this->send_data_frame_(cmd, sizeof(cmd), false); +} + +void ZigbeeProxy::send_get_network_params_() { + // getNetworkParameters command - use legacy format for boot sequence compatibility + uint8_t cmd[] = { + this->ezsp_sequence_++, // Sequence + EZSP_FRAME_CONTROL_COMMAND, // Frame control + EZSP_GET_NETWORK_PARAMETERS & 0xFF // Frame ID + }; + ESP_LOGD(TAG, "Sending EZSP getNetworkParameters command (legacy format)"); + 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_LOGI(TAG, "NCP accepted EZSP v%d", ncp_version); + this->ezsp_version_ = ncp_version; + // Proceed to networkInit + this->boot_state_ = BootState::SEND_NETWORK_INIT; + this->advance_boot_state_(); + return; + } + + // NCP doesn't support our version - re-negotiate + ESP_LOGI(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 + }; + ESP_LOGD(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_LOGI(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; + } + + // Proceed to networkInit + this->boot_state_ = BootState::SEND_NETWORK_INIT; + this->advance_boot_state_(); +} + +void ZigbeeProxy::handle_stack_status_(const uint8_t *data, size_t length) { + // stackStatusHandler callback: [status] + if (length < 1) { + ESP_LOGW(TAG, "Stack status too short"); + return; + } + + uint8_t status = data[0]; + ESP_LOGD(TAG, "Stack status: 0x%02X", status); + + // Check for network up status + if (status == static_cast(EmberStatus::NETWORK_UP) || status == static_cast(EmberStatus::SUCCESS)) { + ESP_LOGI(TAG, "Network is up, querying parameters"); + this->boot_state_ = BootState::SEND_GET_NETWORK_PARAMS; + this->advance_boot_state_(); + } else if (status == static_cast(EmberStatus::NOT_JOINED)) { + // No network configured - that's fine, we just won't have network info + ESP_LOGI(TAG, "No network configured on NCP"); + this->boot_state_ = BootState::SEND_FINAL_RST; + this->advance_boot_state_(); + } else { + ESP_LOGW(TAG, "Unexpected stack status: 0x%02X, continuing anyway", status); + // Try to get network params anyway + this->boot_state_ = BootState::SEND_GET_NETWORK_PARAMS; + this->advance_boot_state_(); + } +} + +void ZigbeeProxy::handle_network_params_response_(const uint8_t *data, size_t length) { + // getNetworkParameters response: + // [status] [nodeType] [extendedPanId (8)] [panId (2)] [radioTxPower] [radioChannel] ... + if (length < 14) { + ESP_LOGW(TAG, "Network params response too short: %u bytes", length); + this->boot_state_ = BootState::SEND_FINAL_RST; + this->advance_boot_state_(); + return; + } + + uint8_t status = data[NETWORK_PARAMS_STATUS_OFFSET]; + if (status != static_cast(EmberStatus::SUCCESS)) { + ESP_LOGW(TAG, "getNetworkParameters failed with status: 0x%02X", status); + this->boot_state_ = BootState::SEND_FINAL_RST; + this->advance_boot_state_(); + return; + } + + // Extract Extended PAN ID (8 bytes, little-endian) + memcpy(this->network_info_.extended_pan_id.data(), data + NETWORK_PARAMS_EXT_PAN_ID_OFFSET, 8); + + // Extract PAN ID (2 bytes, little-endian) + this->network_info_.pan_id = + data[NETWORK_PARAMS_PAN_ID_OFFSET] | (static_cast(data[NETWORK_PARAMS_PAN_ID_OFFSET + 1]) << 8); + + // Extract channel + this->network_info_.channel = data[NETWORK_PARAMS_CHANNEL_OFFSET]; + + this->network_info_.valid = true; + + ESP_LOGI(TAG, + "Network info harvested:\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); + + // Now reset NCP to clean state + this->boot_state_ = BootState::SEND_FINAL_RST; + this->advance_boot_state_(); +} + +bool ZigbeeProxy::set_ieee_address_(const uint8_t *new_address) { + bool changed = false; + + for (size_t i = 0; i < ZIGBEE_IEEE_ADDR_SIZE; i++) { + if (this->network_info_.ieee_address[i] != new_address[i]) { + changed = true; + break; + } + } + + if (changed) { + memcpy(this->network_info_.ieee_address.data(), new_address, ZIGBEE_IEEE_ADDR_SIZE); + this->network_info_.valid = true; + ESP_LOGI(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; +} + +void ZigbeeProxy::send_network_info_changed_msg_(api::APIConnection *conn) { + // This would send network info to the API client + // For now, we'll log it + ESP_LOGD(TAG, "Network info changed notification"); +} + +// WiFi/Zigbee channel conflict detection +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; + } + + uint8_t wifi_channel = wifi::global_wifi_component->get_wifi_channel(); + if (wifi_channel == 0) { + return; // WiFi not connected yet + } + + uint8_t zigbee_channel = this->network_info_.channel; + + // Check for overlap + bool conflict = false; + const char *recommendation = ""; + + if (zigbee_channel >= 11 && zigbee_channel <= 14) { + // Zigbee 11-14 overlaps WiFi 1 + if (wifi_channel == 1) { + conflict = true; + recommendation = "Use Zigbee channel 25-26 or WiFi channel 6/11"; + } + } else if (zigbee_channel >= 15 && zigbee_channel <= 18) { + // Zigbee 15-18 overlaps WiFi 6 + if (wifi_channel == 6) { + conflict = true; + recommendation = "Use Zigbee channel 25-26 or WiFi channel 1/11"; + } + } else if (zigbee_channel >= 19 && zigbee_channel <= 22) { + // Zigbee 19-22 overlaps WiFi 11 + if (wifi_channel == 11) { + conflict = true; + recommendation = "Use Zigbee channel 25-26 or WiFi channel 1/6"; + } + } else if (zigbee_channel >= 23 && zigbee_channel <= 26) { + // Zigbee 23-26 overlaps WiFi 13-14 + if (wifi_channel >= 13) { + conflict = true; + recommendation = "Use Zigbee channel 15-20 or WiFi channel 1/6/11"; + } + } + + if (conflict) { + ESP_LOGW(TAG, + "WiFi/Zigbee channel conflict detected\n" + " WiFi channel: %u, Zigbee channel: %u\n" + " Recommendation: %s", + wifi_channel, zigbee_channel, recommendation); + } else { + ESP_LOGI(TAG, "No WiFi/Zigbee channel conflict (WiFi: %u, Zigbee: %u)", wifi_channel, zigbee_channel); + } +#endif +} + +// Bootloader detection +void ZigbeeProxy::check_bootloader_mode_(const uint8_t *data, size_t length) { + if (length < 2) { + return; + } + + // Check for Silicon Labs bootloader menu prompt (0xC1 0x0D) + if (data[0] == 0xC1 && data[1] == 0x0D) { + if (this->bootloader_state_ != BootloaderState::MENU) { + ESP_LOGW(TAG, "NCP in bootloader menu mode detected\n" + "Please flash NCP firmware or power cycle the device"); + this->bootloader_state_ = BootloaderState::MENU; + } + return; + } + + // Check for upload begin (0x43) + if (data[0] == 0x43) { + if (this->bootloader_state_ != BootloaderState::DETECTED) { + ESP_LOGW(TAG, "NCP bootloader upload mode detected"); + this->bootloader_state_ = BootloaderState::DETECTED; + } + return; + } + + // Reset bootloader state if we see normal traffic + if (this->bootloader_state_ != BootloaderState::NORMAL && this->ash_state_ == AshState::CONNECTED) { + ESP_LOGI(TAG, "NCP returned to normal operation"); + this->bootloader_state_ = BootloaderState::NORMAL; + } +} + +// UART processing +void ZigbeeProxy::process_uart_() { + while (this->available()) { + uint8_t byte; + this->read_byte(&byte); + + // Verbose logging for debugging (ESP_LOGV already checks log level) + ESP_LOGV(TAG, "RX: 0x%02X", byte); + + this->parse_byte_(byte); + } +} + +} // namespace zigbee_proxy +} // namespace esphome + +#endif // USE_ZIGBEE_PROXY diff --git a/esphome/components/zigbee_proxy/zigbee_proxy.h b/esphome/components/zigbee_proxy/zigbee_proxy.h new file mode 100644 index 00000000000..af0a076bb69 --- /dev/null +++ b/esphome/components/zigbee_proxy/zigbee_proxy.h @@ -0,0 +1,200 @@ +#pragma once + +#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/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/uart/uart.h" +#include "ash_protocol.h" + +#include + +namespace esphome { +namespace 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, +}; + +// 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_NETWORK_INIT, // Send networkInit command + WAIT_STACK_STATUS, // Waiting for stackStatusHandler callback + SEND_GET_NETWORK_PARAMS, // Send getNetworkParameters command + WAIT_NETWORK_PARAMS, // Waiting for network parameters 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 +}; + +class ZigbeeProxy : public uart::UARTDevice, public Component { + public: + ZigbeeProxy(); + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + bool can_proceed() override; + + // API integration + void api_connection_authenticated(api::APIConnection *conn); + void zigbee_proxy_request(api::APIConnection *api_connection, const api::ZigbeeProxyRequest &msg); + void zigbee_proxy_frame(api::APIConnection *api_connection, const api::ZigbeeProxyFrame &msg); + api::APIConnection *get_api_connection() { return this->api_connection_; } + + // Feature flags + uint32_t get_feature_flags() const { return ZigbeeProxyFeature::FEATURE_ZIGBEE_PROXY_ENABLED; } + + // Network information accessors + const NetworkInfo &get_network_info() const { return this->network_info_; } + uint64_t get_ieee_address() const; + + // Frame sending (from API client to NCP) + void send_frame(const uint8_t *data, size_t length); + + // 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); + 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_(); + size_t build_frame_(uint8_t *output, const uint8_t *data, size_t length, AshFrameType type, uint8_t frame_num = 0, + uint8_t ack_num = 0, bool retx = false); + uint16_t calculate_crc_(const uint8_t *data, size_t length); + + // 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 start_boot_sequence_(); + void advance_boot_state_(); + void handle_boot_data_frame_(const uint8_t *data, size_t length); + void send_ezsp_version_(); + void send_network_init_(); + void send_get_network_params_(); + void handle_version_response_(const uint8_t *data, size_t length); + void handle_stack_status_(const uint8_t *data, size_t length); + void handle_network_params_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 + void check_bootloader_mode_(const uint8_t *data, size_t length); + + // UART processing + void process_uart_(); + + // Pre-allocated message - always ready to send + api::ZigbeeProxyFrame outgoing_proto_msg_; + + // Fixed-size buffers for incoming and outgoing data + 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_; + + // Pointers (aligned together) + api::APIConnection *api_connection_{nullptr}; // Current subscribed client + + // 32-bit values + uint32_t setup_time_{0}; // Time when setup() was called + uint32_t ack_timer_start_{0}; // Time when ACK timer started + uint32_t last_rtt_ms_{0}; // Last measured round-trip time + + // 16-bit values + uint16_t rx_buffer_index_{0}; // Index for populating rx_buffer_ + uint16_t tx_pending_length_{0}; // Length of pending TX frame for retransmission + uint16_t calculated_crc_{0}; // CRC calculated during frame reception + + // 8-bit values (grouped together to minimize padding) + 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 + + // Enums and booleans + AshState ash_state_{AshState::DISCONNECTED}; + ParsingState parsing_state_{ParsingState::WAIT_FLAG_START}; + BootloaderState bootloader_state_{BootloaderState::NORMAL}; + BootState boot_state_{BootState::IDLE}; + + 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) + + bool tx_buffer_pending_{false}; // True if waiting for ACK + bool escape_next_byte_{false}; // True if next byte should be unescaped + bool network_info_ready_{false}; // True when network info retrieved + bool boot_sequence_active_{false}; // True during boot-time init +}; + +extern ZigbeeProxy *global_zigbee_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace zigbee_proxy +} // namespace esphome + +#endif // USE_ZIGBEE_PROXY diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0d6c1a42e83..0b61828bced 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -122,6 +122,7 @@ #define USE_VALVE #define USE_WATER_HEATER #define USE_WATER_HEATER_VISUAL_OVERRIDES +#define USE_ZIGBEE_PROXY #define USE_ZWAVE_PROXY // Feature flags which do not work for zephyr diff --git a/tests/components/zigbee_proxy/common.yaml b/tests/components/zigbee_proxy/common.yaml new file mode 100644 index 00000000000..22d6aba890c --- /dev/null +++ b/tests/components/zigbee_proxy/common.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +wifi: + ssid: test + password: password + power_save_mode: none + +api: + +uart: + - id: zigbee_uart + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 115200 + +zigbee_proxy: + uart_id: zigbee_uart diff --git a/tests/components/zigbee_proxy/test.esp32-idf.yaml b/tests/components/zigbee_proxy/test.esp32-idf.yaml new file mode 100644 index 00000000000..9b06a7a8ac3 --- /dev/null +++ b/tests/components/zigbee_proxy/test.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +esp32: + board: esp32dev + +<<: !include common.yaml + +zigbee_proxy: + buffer_size: 1024 + initial_timeout: 1600 + min_timeout: 400 + max_timeout: 3200 diff --git a/tests/components/zigbee_proxy/test.esp8266-ard.yaml b/tests/components/zigbee_proxy/test.esp8266-ard.yaml new file mode 100644 index 00000000000..afdb7027806 --- /dev/null +++ b/tests/components/zigbee_proxy/test.esp8266-ard.yaml @@ -0,0 +1,11 @@ +substitutions: + tx_pin: GPIO1 + rx_pin: GPIO3 + +esp8266: + board: nodemcuv2 + +<<: !include common.yaml + +zigbee_proxy: + buffer_size: 512 diff --git a/tests/components/zigbee_proxy/test.rp2040-ard.yaml b/tests/components/zigbee_proxy/test.rp2040-ard.yaml new file mode 100644 index 00000000000..84583b0d1eb --- /dev/null +++ b/tests/components/zigbee_proxy/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +rp2040: + board: rpipico + +<<: !include common.yaml