Strip out Zigbee proxy's EZSP, leaving only enough for ASH auto-ACKIng

This commit is contained in:
puddly
2026-09-08 19:04:02 +00:00
parent 590a4a6268
commit 80254f8689
19 changed files with 88 additions and 2069 deletions
-21
View File
@@ -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;
}
-18
View File
@@ -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
-4
View File
@@ -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);
-48
View File
@@ -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<enums::ZigbeeProxyRequestType>(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<uint32_t>(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
+1 -34
View File
@@ -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
+2
View File
@@ -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
-24
View File
@@ -893,16 +893,6 @@ template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::Seria
}
}
#endif
#ifdef USE_ZIGBEE_PROXY
template<> const char *proto_enum_to_string<enums::ZigbeeProxyRequestType>(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<enums::ZigbeeProxyRequestType>(this->type));
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
return out.c_str();
}
#endif
} // namespace esphome::api
@@ -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;
-4
View File
@@ -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
-8
View File
@@ -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<int32_t> *timings) {
-3
View File
@@ -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<int32_t> *timings);
#endif
+20 -44
View File
@@ -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)
@@ -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;
+1 -15
View File
@@ -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
+12 -397
View File
@@ -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<uint8_t>((rand >> 1) ^ 0xB8) : static_cast<uint8_t>(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<uint16_t>(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<int>(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
+11 -69
View File
@@ -1,7 +1,9 @@
#pragma once
#include <cstdint>
#include "esphome/core/defines.h"
#include <cstddef>
#include <cstdint>
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
@@ -1,88 +0,0 @@
#pragma once
#include <cstddef>
#include <cstdint>
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
File diff suppressed because it is too large Load Diff
+16 -216
View File
@@ -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 <array>
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<uint8_t, ZIGBEE_IEEE_ADDR_SIZE> ieee_address{};
uint16_t pan_id{0};
std::array<uint8_t, 8> 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<uint8_t, MAX_ASH_FRAME_SIZE> rx_buffer_;
std::array<uint8_t, MAX_ASH_FRAME_SIZE> tx_buffer_;
std::array<uint8_t, MAX_ASH_FRAME_SIZE> 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