mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
890f0408a5 | ||
|
|
3e0f81bf1e | ||
|
|
6111791706 | ||
|
|
7a05749231 | ||
|
|
997e218376 | ||
|
|
973da47da6 | ||
|
|
e80aa9579b | ||
|
|
a060db1251 | ||
|
|
9c4016a871 | ||
|
|
5846977cf6 | ||
|
|
ca42862742 | ||
|
|
e0a054dbcb | ||
|
|
5e822b828e | ||
|
|
ef646a9303 | ||
|
|
f8bec0813d | ||
|
|
84762e6ae0 | ||
|
|
2edf313ee3 | ||
|
|
ae9c999052 | ||
|
|
7d2f6fbf55 | ||
|
|
608bef86cc | ||
|
|
6514dc2fe1 | ||
|
|
240afd23b3 | ||
|
|
156c2a8cb0 | ||
|
|
908c47bb5e | ||
|
|
6df3a30740 | ||
|
|
0aaf59dbed | ||
|
|
249c5bb724 | ||
|
|
54ea8dd207 | ||
|
|
4cfb794b62 | ||
|
|
917af8ff31 |
@@ -69,6 +69,8 @@ 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) {}
|
||||
@@ -76,6 +78,7 @@ service APIConnection {
|
||||
rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {}
|
||||
rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {}
|
||||
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
|
||||
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -315,6 +318,10 @@ 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"];
|
||||
}
|
||||
|
||||
message ListEntitiesRequest {
|
||||
@@ -2731,6 +2738,23 @@ message SerialProxyRequestResponse {
|
||||
string error_message = 4; // Additional detail on failure (optional)
|
||||
}
|
||||
|
||||
// How a port treats the bytes passing through it. RAW is a plain byte pipe; EZSP_ASH lets
|
||||
// a protocol-aware tap acknowledge NCP frames and read network metadata. A client that is
|
||||
// about to flash firmware selects RAW first, which definitively disables that injection.
|
||||
enum SerialProxyMode {
|
||||
SERIAL_PROXY_MODE_RAW = 0;
|
||||
SERIAL_PROXY_MODE_EZSP_ASH = 1;
|
||||
}
|
||||
|
||||
message SerialProxySetModeRequest {
|
||||
option (id) = 151;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_SERIAL_PROXY";
|
||||
|
||||
uint32 instance = 1;
|
||||
SerialProxyMode mode = 2;
|
||||
}
|
||||
|
||||
// ==================== BLUETOOTH CONNECTION PARAMS ====================
|
||||
message BluetoothSetConnectionParamsRequest {
|
||||
option (id) = 145;
|
||||
@@ -2752,3 +2776,18 @@ message BluetoothSetConnectionParamsResponse {
|
||||
uint64 address = 1;
|
||||
int32 error = 2;
|
||||
}
|
||||
|
||||
// ==================== ZIGBEE ====================
|
||||
|
||||
enum ZigbeeProxyRequestType {
|
||||
ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0;
|
||||
}
|
||||
|
||||
message ZigbeeProxyRequest {
|
||||
option (id) = 150;
|
||||
option (source) = SOURCE_BOTH;
|
||||
option (ifdef) = "USE_ZIGBEE_PROXY";
|
||||
|
||||
ZigbeeProxyRequestType type = 1;
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,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
|
||||
@@ -1377,6 +1380,12 @@ 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,
|
||||
@@ -1615,6 +1624,15 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
if (msg.instance >= proxies.size()) {
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
return;
|
||||
}
|
||||
proxies[msg.instance]->set_mode(this, msg.mode);
|
||||
}
|
||||
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); }
|
||||
#endif
|
||||
|
||||
@@ -1722,6 +1740,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) {
|
||||
@@ -1871,6 +1894,10 @@ bool APIConnection::send_device_info_response_() {
|
||||
info.port_type = proxy->get_port_type();
|
||||
}
|
||||
#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
|
||||
|
||||
@@ -218,6 +218,10 @@ 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);
|
||||
@@ -239,6 +243,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
|
||||
void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
|
||||
void on_serial_proxy_request(const SerialProxyRequest &msg);
|
||||
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg);
|
||||
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
|
||||
#endif
|
||||
|
||||
|
||||
@@ -173,6 +173,12 @@ 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;
|
||||
}
|
||||
@@ -238,6 +244,12 @@ 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;
|
||||
}
|
||||
@@ -4144,6 +4156,19 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
|
||||
size += ProtoSize::calc_length(1, this->error_message.size());
|
||||
return size;
|
||||
}
|
||||
bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
this->instance = value;
|
||||
break;
|
||||
case 2:
|
||||
this->mode = static_cast<enums::SerialProxyMode>(value);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
@@ -4181,5 +4206,41 @@ 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
|
||||
|
||||
@@ -351,6 +351,15 @@ enum SerialProxyStatus : uint32_t {
|
||||
SERIAL_PROXY_STATUS_TIMEOUT = 3,
|
||||
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4,
|
||||
};
|
||||
enum SerialProxyMode : uint32_t {
|
||||
SERIAL_PROXY_MODE_RAW = 0,
|
||||
SERIAL_PROXY_MODE_EZSP_ASH = 1,
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
enum ZigbeeProxyRequestType : uint32_t {
|
||||
ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0,
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace enums
|
||||
@@ -533,7 +542,7 @@ class SerialProxyInfo final : public ProtoMessage {
|
||||
class DeviceInfoResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 10;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 312;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 322;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
|
||||
#endif
|
||||
@@ -591,6 +600,12 @@ 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;
|
||||
@@ -3289,6 +3304,22 @@ class SerialProxyRequestResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
class SerialProxySetModeRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 151;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 6;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); }
|
||||
#endif
|
||||
uint32_t instance{0};
|
||||
enums::SerialProxyMode mode{};
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
|
||||
@@ -3328,5 +3359,27 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage {
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
class ZigbeeProxyRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 150;
|
||||
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
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifndef USE_API_VARINT64
|
||||
#define USE_API_VARINT64
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace esphome::api {} // namespace esphome::api
|
||||
|
||||
@@ -856,6 +856,26 @@ template<> const char *proto_enum_to_string<enums::SerialProxyStatus>(enums::Ser
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::SerialProxyMode value) {
|
||||
switch (value) {
|
||||
case enums::SERIAL_PROXY_MODE_RAW:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW");
|
||||
case enums::SERIAL_PROXY_MODE_EZSP_ASH:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_MODE_EZSP_ASH");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
template<> const char *proto_enum_to_string<enums::ZigbeeProxyRequestType>(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 {
|
||||
@@ -985,6 +1005,12 @@ 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();
|
||||
}
|
||||
@@ -2714,6 +2740,12 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("error_message"), this->error_message);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
|
||||
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
|
||||
@@ -2732,6 +2764,14 @@ 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
|
||||
|
||||
|
||||
@@ -704,6 +704,28 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
this->on_bluetooth_set_connection_params_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
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
case SerialProxySetModeRequest::MESSAGE_TYPE: {
|
||||
SerialProxySetModeRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg);
|
||||
#endif
|
||||
this->on_serial_proxy_set_mode_request(msg);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -233,9 +233,16 @@ class APIServerConnectionBase {
|
||||
void on_serial_proxy_request(const SerialProxyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
void on_zigbee_proxy_request(const ZigbeeProxyRequest &value){};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -399,6 +399,14 @@ 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) {
|
||||
|
||||
@@ -186,6 +186,9 @@ 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
|
||||
|
||||
@@ -18,7 +18,7 @@ from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_NAME
|
||||
from esphome.const import CONF_ID, CONF_MODE, CONF_NAME
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.coroutine import CoroPriority
|
||||
|
||||
@@ -29,6 +29,7 @@ MULTI_CONF = True
|
||||
|
||||
serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy")
|
||||
SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice)
|
||||
SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap")
|
||||
|
||||
api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums")
|
||||
SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType")
|
||||
@@ -38,6 +39,15 @@ SERIAL_PROXY_PORT_TYPES = {
|
||||
"RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485,
|
||||
}
|
||||
|
||||
SerialProxyMode = api_enums_ns.enum("SerialProxyMode")
|
||||
# The mode a port starts in. `raw` is a plain byte pipe; `ezsp_ash` lets a tap
|
||||
# acknowledge NCP frames and read network metadata off the wire. Clients may change
|
||||
# it at runtime, so this only decides what the device boots into.
|
||||
SERIAL_PROXY_MODES = {
|
||||
"RAW": SerialProxyMode.SERIAL_PROXY_MODE_RAW,
|
||||
"EZSP_ASH": SerialProxyMode.SERIAL_PROXY_MODE_EZSP_ASH,
|
||||
}
|
||||
|
||||
CONF_DTR_PIN = "dtr_pin"
|
||||
CONF_PORT_TYPE = "port_type"
|
||||
CONF_RTS_PIN = "rts_pin"
|
||||
@@ -62,6 +72,9 @@ CONFIG_SCHEMA = (
|
||||
cv.GenerateID(): cv.declare_id(SerialProxy),
|
||||
cv.Required(CONF_NAME): cv.string_strict,
|
||||
cv.Required(CONF_PORT_TYPE): cv.enum(SERIAL_PROXY_PORT_TYPES, upper=True),
|
||||
cv.Optional(CONF_MODE, default="RAW"): cv.enum(
|
||||
SERIAL_PROXY_MODES, upper=True
|
||||
),
|
||||
cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema,
|
||||
cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema,
|
||||
}
|
||||
@@ -86,6 +99,7 @@ async def to_code(config):
|
||||
cg.add(cg.App.register_serial_proxy(var))
|
||||
cg.add(var.set_name(config[CONF_NAME]))
|
||||
cg.add(var.set_port_type(config[CONF_PORT_TYPE]))
|
||||
cg.add(var.set_mode(config[CONF_MODE]))
|
||||
cg.add_define("USE_SERIAL_PROXY")
|
||||
|
||||
# Track instance count for the FINAL priority define
|
||||
|
||||
@@ -29,26 +29,55 @@ void SerialProxy::setup() {
|
||||
#ifdef USE_API
|
||||
// instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data
|
||||
this->outgoing_msg_.instance = this->instance_index_;
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// A tap sets itself up before this runs (its setup priority is higher), so it may
|
||||
// already be waiting on the port -- a boot-time handshake with the device, say. Leaving
|
||||
// the loop enabled is what lets that finish; without it the tap would stall until a
|
||||
// client happened to subscribe.
|
||||
if (this->tap_ != nullptr && this->tap_->tap_needs_port()) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// No subscriber at startup; disable loop until a client subscribes
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void SerialProxy::loop() {
|
||||
#ifdef USE_API
|
||||
// Safety check — loop should only run when subscribed, but guard against races
|
||||
if (this->api_connection_ == nullptr) [[unlikely]] {
|
||||
this->disable_loop();
|
||||
void SerialProxy::reset_mode_() {
|
||||
// The mode belongs to a session, not to the port. Carrying a departed client's choice
|
||||
// over to the next one would inject protocol bytes into a stream that never asked for
|
||||
// them -- a firmware upload, or any client built before this request existed and so
|
||||
// unable to turn it off. Guessing RAW is the safe direction: a client that wanted
|
||||
// protocol handling and did not ask for it merely sends its own acknowledgements.
|
||||
if (this->mode_ == api::enums::SERIAL_PROXY_MODE_RAW) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_);
|
||||
this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW;
|
||||
}
|
||||
|
||||
void SerialProxy::loop() {
|
||||
#ifdef USE_API
|
||||
// Detect subscriber disconnect
|
||||
if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() ||
|
||||
!api_is_connected()) {
|
||||
if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() ||
|
||||
!this->api_connection_->is_connection_setup() || !api_is_connected())) {
|
||||
ESP_LOGW(TAG, "Subscriber disconnected");
|
||||
this->api_connection_ = nullptr;
|
||||
this->reset_mode_();
|
||||
}
|
||||
|
||||
// With no subscriber there is normally nothing to do, but a tap may still need the port
|
||||
// read -- it does its protocol work precisely while nobody else is listening.
|
||||
if (this->api_connection_ == nullptr) [[unlikely]] {
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) {
|
||||
this->disable_loop();
|
||||
return;
|
||||
}
|
||||
#else
|
||||
this->disable_loop();
|
||||
return;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Read available data from UART and forward to subscribed client
|
||||
@@ -69,22 +98,64 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) {
|
||||
if (!this->read_array(buffer, to_read))
|
||||
return;
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// Before forwarding, so a tap that answers the device (an acknowledgement, say) is not
|
||||
// waiting on the network round trip to a subscriber that may not even exist.
|
||||
if (this->tap_observing_()) {
|
||||
this->tap_->on_device_rx(buffer, to_read);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (this->api_connection_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
this->outgoing_msg_.set_data(buffer, to_read);
|
||||
this->api_connection_->send_serial_proxy_data(this->outgoing_msg_);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
|
||||
bool SerialProxy::tap_observing_() const {
|
||||
if (this->tap_ == nullptr) {
|
||||
return false;
|
||||
}
|
||||
// A tap that needs the port is mid-protocol-work of its own -- the boot-time handshake
|
||||
// with the device, which runs before any client has connected and so before anyone could
|
||||
// have chosen a mode. Withholding bytes from it there would strand it, so it is served
|
||||
// regardless of mode.
|
||||
if (this->tap_->tap_needs_port()) {
|
||||
return true;
|
||||
}
|
||||
// Otherwise the mode decides. RAW must be inert: a client that flips to RAW before
|
||||
// flashing firmware is entitled to a byte pipe with nothing injecting protocol bytes
|
||||
// into it, and "the tap turned out not to recognise the stream" is not good enough.
|
||||
return this->mode_ == api::enums::SERIAL_PROXY_MODE_EZSP_ASH;
|
||||
}
|
||||
|
||||
void SerialProxy::tap_pump() {
|
||||
#ifdef USE_API
|
||||
const size_t available = this->available();
|
||||
if (available > 0) {
|
||||
this->read_and_send_(available);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
void SerialProxy::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Serial Proxy [%" PRIu32 "]:\n"
|
||||
" Name: %s\n"
|
||||
" Port Type: %s\n"
|
||||
" Mode: %s\n"
|
||||
" RTS Pin: %s\n"
|
||||
" DTR Pin: %s",
|
||||
this->instance_index_, this->name_ != nullptr ? this->name_ : "",
|
||||
this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? "RS485"
|
||||
: this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? "RS232"
|
||||
: "TTL",
|
||||
this->mode_ == api::enums::SERIAL_PROXY_MODE_EZSP_ASH ? "EZSP_ASH" : "RAW",
|
||||
this->rts_pin_ != nullptr ? "configured" : "not configured",
|
||||
this->dtr_pin_ != nullptr ? "configured" : "not configured");
|
||||
}
|
||||
@@ -126,17 +197,26 @@ void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrat
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply validated parameters
|
||||
uart_comp->set_baud_rate(baudrate);
|
||||
uart_comp->set_stop_bits(stop_bits);
|
||||
uart_comp->set_data_bits(data_size);
|
||||
|
||||
// Map parity value to UARTParityOptions
|
||||
// Skip a no-op reconfigure. Clients routinely re-send identical settings on every
|
||||
// port open, and on a USB UART each apply is a CDC SET_LINE_CODING control transfer.
|
||||
// Some bridges watch line-coding changes as a signalling channel (a magic baud
|
||||
// sequence to enter a bootloader, say), so redundant applies are not harmless.
|
||||
static const uart::UARTParityOptions PARITY_MAP[] = {
|
||||
uart::UART_CONFIG_PARITY_NONE,
|
||||
uart::UART_CONFIG_PARITY_EVEN,
|
||||
uart::UART_CONFIG_PARITY_ODD,
|
||||
};
|
||||
if (uart_comp->get_baud_rate() == baudrate && uart_comp->get_stop_bits() == stop_bits &&
|
||||
uart_comp->get_data_bits() == data_size && uart_comp->get_parity() == PARITY_MAP[parity]) {
|
||||
ESP_LOGV(TAG, "Settings unchanged, skipping reconfigure [%" PRIu32 "]", this->instance_index_);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply validated parameters
|
||||
uart_comp->set_baud_rate(baudrate);
|
||||
uart_comp->set_stop_bits(stop_bits);
|
||||
uart_comp->set_data_bits(data_size);
|
||||
|
||||
uart_comp->set_parity(PARITY_MAP[parity]);
|
||||
|
||||
// load_settings() is available on ESP8266 and ESP32 platforms
|
||||
@@ -149,6 +229,29 @@ void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrat
|
||||
}
|
||||
}
|
||||
|
||||
void SerialProxy::set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) {
|
||||
#ifdef USE_API
|
||||
if (this->port_claimed_by_other_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring mode request from client without port access [%" PRIu32 "]", this->instance_index_);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_,
|
||||
mode == api::enums::SERIAL_PROXY_MODE_EZSP_ASH ? "EZSP_ASH" : "RAW");
|
||||
const bool leaving_protocol_mode =
|
||||
this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW;
|
||||
this->mode_ = mode;
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// Only for an explicit client request, not for reset_mode_() at the end of a session:
|
||||
// an ordinary disconnect says nothing about the device, whereas a client deliberately
|
||||
// asking for raw bytes usually precedes changing what the device is.
|
||||
if (leaving_protocol_mode && this->tap_ != nullptr) {
|
||||
this->tap_->on_protocol_disabled();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {
|
||||
#ifdef USE_API
|
||||
// Bytes from a client other than the live subscriber would interleave with the
|
||||
@@ -161,6 +264,13 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui
|
||||
if (data == nullptr || len == 0)
|
||||
return;
|
||||
this->write_array(data, len);
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// After the write, so the tap observes the same ordering the device does
|
||||
if (this->tap_observing_()) {
|
||||
this->tap_->on_client_tx(data, len);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
|
||||
@@ -227,6 +337,7 @@ void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::
|
||||
return;
|
||||
}
|
||||
this->api_connection_ = nullptr;
|
||||
this->reset_mode_();
|
||||
this->disable_loop();
|
||||
ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_);
|
||||
break;
|
||||
|
||||
@@ -26,6 +26,7 @@ class APIConnection;
|
||||
namespace enums {
|
||||
enum SerialProxyPortType : uint32_t;
|
||||
enum SerialProxyRequestType : uint32_t;
|
||||
enum SerialProxyMode : uint32_t;
|
||||
} // namespace enums
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -41,6 +42,35 @@ enum SerialProxyLineStateFlag : uint32_t {
|
||||
/// Maximum bytes to read from UART in a single loop iteration
|
||||
inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256;
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// Observes a port's traffic without owning it, and may inject bytes of its own.
|
||||
///
|
||||
/// This exists so protocol-aware behaviour can be layered onto a plain byte pipe without
|
||||
/// the pipe knowing anything about the protocol: the tap is compiled in only when some
|
||||
/// component asks for one, so a proxy carrying an RS485 meter pays nothing for it.
|
||||
///
|
||||
/// A tap is an observer, never a gatekeeper -- it cannot suppress or alter the bytes
|
||||
/// flowing in either direction, so a misbehaving tap cannot corrupt the stream.
|
||||
class SerialProxyTap {
|
||||
public:
|
||||
/// Bytes read from the device, before they are forwarded to any subscriber.
|
||||
virtual void on_device_rx(const uint8_t *data, size_t len) = 0;
|
||||
|
||||
/// Bytes a subscriber sent towards the device, after they have been written.
|
||||
virtual void on_client_tx(const uint8_t *data, size_t len) = 0;
|
||||
|
||||
/// True when the port must keep reading even with no subscriber attached, so a tap can
|
||||
/// do its own protocol work while nobody is listening.
|
||||
virtual bool tap_needs_port() const = 0;
|
||||
|
||||
/// A client explicitly turned protocol handling off for this port. Distinct from the
|
||||
/// automatic reset when a session ends: this one means a client intends to do something
|
||||
/// else with the device -- reflash it, most likely -- so anything the tap believes about
|
||||
/// it should be treated as suspect.
|
||||
virtual void on_protocol_disabled() = 0;
|
||||
};
|
||||
#endif
|
||||
|
||||
class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
@@ -66,6 +96,15 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Get the port type
|
||||
api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; }
|
||||
|
||||
/// Set the initial mode (from YAML configuration)
|
||||
void set_mode(api::enums::SerialProxyMode mode) { this->mode_ = mode; }
|
||||
|
||||
/// Get the current mode
|
||||
api::enums::SerialProxyMode get_mode() const { return this->mode_; }
|
||||
|
||||
/// Handle a mode change requested by an API client
|
||||
void set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode);
|
||||
|
||||
/// Configure UART parameters and apply them
|
||||
/// @param api_connection The API connection requesting the change
|
||||
/// @param baudrate Baud rate in bits per second
|
||||
@@ -103,15 +142,48 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Set the DTR GPIO pin (from YAML configuration)
|
||||
void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; }
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// Attach a traffic observer. At most one, set once at setup time.
|
||||
void set_tap(SerialProxyTap *tap) { this->tap_ = tap; }
|
||||
|
||||
/// Write bytes originating from the tap rather than from a client. Bypasses the
|
||||
/// subscriber ownership check, since the tap is part of the device, not a client of it.
|
||||
void write_from_tap(const uint8_t *data, size_t len) { this->write_array(data, len); }
|
||||
|
||||
/// Resume reading after a tap's needs change. loop() disables itself when there is
|
||||
/// neither a subscriber nor a tap that wants the port, so a tap starting fresh work
|
||||
/// must ask for it back.
|
||||
void tap_request_port() { this->enable_loop(); }
|
||||
|
||||
/// Whether the underlying device is present. On a USB UART this tracks enumeration, so
|
||||
/// a tap can notice the device being unplugged and plugged back in.
|
||||
bool is_device_connected() const { return this->parent_->is_connected(); }
|
||||
|
||||
/// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the
|
||||
/// main loop is running -- during setup, for instance, while a component is still
|
||||
/// blocking on can_proceed().
|
||||
void tap_pump();
|
||||
#endif
|
||||
|
||||
protected:
|
||||
#ifdef USE_API
|
||||
/// Read from UART and send to API client (slow path with 256-byte stack buffer)
|
||||
/// Read from UART, hand the bytes to any tap, and forward them to a subscriber
|
||||
/// (slow path with a 256-byte stack buffer)
|
||||
void read_and_send_(size_t available);
|
||||
|
||||
/// True when a live subscriber other than the given connection holds the port
|
||||
bool port_claimed_by_other_(api::APIConnection *api_connection) const;
|
||||
#endif
|
||||
|
||||
/// Return the port to RAW when a subscriber goes away, so the mode never outlives it.
|
||||
/// Not tap-gated: the mode is a client-visible property whether or not a tap acts on it.
|
||||
void reset_mode_();
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// True when the tap should be shown the traffic passing through this port
|
||||
bool tap_observing_() const;
|
||||
#endif
|
||||
|
||||
/// Instance index for identifying this proxy in API messages
|
||||
uint32_t instance_index_{0};
|
||||
|
||||
@@ -129,6 +201,9 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Port type
|
||||
api::enums::SerialProxyPortType port_type_{};
|
||||
|
||||
/// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW
|
||||
api::enums::SerialProxyMode mode_{};
|
||||
|
||||
/// Optional GPIO pins for modem control
|
||||
GPIOPin *rts_pin_{nullptr};
|
||||
GPIOPin *dtr_pin_{nullptr};
|
||||
@@ -136,6 +211,10 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Current modem pin states
|
||||
bool rts_state_{false};
|
||||
bool dtr_state_{false};
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
SerialProxyTap *tap_{nullptr};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::serial_proxy
|
||||
|
||||
@@ -16,7 +16,7 @@ from esphome.const import (
|
||||
CONF_DUMMY_RECEIVER,
|
||||
CONF_ID,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_types import Component
|
||||
|
||||
AUTO_LOAD = ["uart", "usb_host", "bytebuffer"]
|
||||
@@ -26,6 +26,21 @@ usb_uart_ns = cg.esphome_ns.namespace("usb_uart")
|
||||
USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component)
|
||||
USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent)
|
||||
|
||||
|
||||
def is_usb_uart_channel(uart_id: ID) -> bool:
|
||||
"""Return True if the given ID refers to a channel of a configured usb_uart device.
|
||||
|
||||
Lets UART device components detect that their uart_id points at a USB UART
|
||||
channel (use_id resolution does not narrow the ID's type) so they can enable
|
||||
USB-specific features such as the RX callback.
|
||||
"""
|
||||
return any(
|
||||
channel[CONF_ID] == uart_id
|
||||
for device in CORE.config.get("usb_uart") or []
|
||||
for channel in device[CONF_CHANNELS]
|
||||
)
|
||||
|
||||
|
||||
UARTParityOptions = usb_uart_ns.enum("UARTParityOptions")
|
||||
UART_PARITY_OPTIONS = {
|
||||
"NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import esphome.codegen as cg
|
||||
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
|
||||
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
DEPENDENCIES = ["api", "serial_proxy"]
|
||||
|
||||
CONF_INITIAL_TIMEOUT = "initial_timeout"
|
||||
CONF_MIN_TIMEOUT = "min_timeout"
|
||||
CONF_MAX_TIMEOUT = "max_timeout"
|
||||
CONF_SERIAL_PROXY_ID = "serial_proxy_id"
|
||||
|
||||
# Default ACK timeout values 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):
|
||||
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.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),
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validate
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID])
|
||||
cg.add(var.set_serial_proxy(sp))
|
||||
|
||||
cg.add_define("USE_ZIGBEE_PROXY")
|
||||
# Compiles the tap interface into serial_proxy; without it the port is a plain byte pipe
|
||||
cg.add_define("USE_SERIAL_PROXY_TAP")
|
||||
|
||||
# Set buffer size via define for compile-time allocation
|
||||
if CONF_BUFFER_SIZE in config:
|
||||
cg.add_define("ZIGBEE_PROXY_BUFFER_SIZE", config[CONF_BUFFER_SIZE])
|
||||
|
||||
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]))
|
||||
@@ -0,0 +1,256 @@
|
||||
#include "ash_detector.h"
|
||||
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
|
||||
namespace esphome::zigbee_proxy {
|
||||
|
||||
// Control byte of an RSTACK, and the only ASH version byte that can follow it
|
||||
static constexpr uint8_t ASH_RSTACK_CONTROL = 0xC1;
|
||||
static constexpr uint8_t ASH_PROTOCOL_VERSION = 0x02;
|
||||
static constexpr size_t ASH_RSTACK_BODY_SIZE = 3; // control, version, reset code
|
||||
static constexpr size_t ASH_CRC_SIZE = 2;
|
||||
// Smallest legal frame on the wire: a bare control byte plus its CRC
|
||||
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.
|
||||
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
|
||||
// retransmit an unacknowledged frame, so the absence of one is the positive evidence
|
||||
// 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) {
|
||||
switch (code) {
|
||||
case 0x00: // RESET_UNKNOWN
|
||||
case 0x01: // RESET_EXTERNAL
|
||||
case 0x02: // RESET_POWER_ON
|
||||
case 0x03: // RESET_WATCHDOG
|
||||
case 0x06: // RESET_ASSERT
|
||||
case 0x09: // RESET_BOOTLOADER
|
||||
case 0x0B: // RESET_SOFTWARE
|
||||
case 0x51: // ERROR_EXCEEDED_MAXIMUM_ACK_TIMEOUT_COUNT
|
||||
case 0x80: // ERROR_CHIP_SPECIFIC
|
||||
case 0x81: // RESET_CHIP_SPECIFIC
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void AshFrameScanner::begin_frame_() {
|
||||
this->index_ = 0;
|
||||
this->crc_ = ASH_CRC_INIT;
|
||||
this->escaped_ = false;
|
||||
this->poisoned_ = false;
|
||||
}
|
||||
|
||||
void AshFrameScanner::reset() {
|
||||
this->begin_frame_();
|
||||
this->frame_length_ = 0;
|
||||
this->discarding_ = false;
|
||||
}
|
||||
|
||||
ScanResult AshFrameScanner::feed(uint8_t byte) {
|
||||
if (byte == ASH_FLAG_BYTE) {
|
||||
// Snapshot everything the verdict depends on: begin_frame_() clears all of it.
|
||||
const bool discarding = this->discarding_;
|
||||
const bool poisoned = this->poisoned_;
|
||||
const bool escaped = this->escaped_;
|
||||
const size_t index = this->index_;
|
||||
const uint16_t crc = this->crc_;
|
||||
// A FLAG always starts the next frame afresh, whatever preceded it
|
||||
this->begin_frame_();
|
||||
this->discarding_ = false;
|
||||
|
||||
if (discarding || index == 0) {
|
||||
// Consecutive delimiters carry no frame at all, so there is nothing to judge
|
||||
this->frame_length_ = 0;
|
||||
return ScanResult::NONE;
|
||||
}
|
||||
// Running the CRC over the body *and* its trailing CRC bytes leaves zero when
|
||||
// correct, so validity needs no second pass over the frame.
|
||||
if (poisoned || escaped || index < ASH_MIN_FRAME_SIZE || crc != 0) {
|
||||
this->frame_length_ = 0;
|
||||
return ScanResult::INVALID;
|
||||
}
|
||||
this->frame_length_ = index - ASH_CRC_SIZE;
|
||||
return ScanResult::FRAME;
|
||||
}
|
||||
|
||||
if (this->discarding_) {
|
||||
return ScanResult::NONE;
|
||||
}
|
||||
|
||||
switch (byte) {
|
||||
case ASH_CANCEL_BYTE:
|
||||
// Everything received since the last FLAG is to be ignored
|
||||
this->begin_frame_();
|
||||
return ScanResult::NONE;
|
||||
|
||||
case ASH_SUBSTITUTE_BYTE:
|
||||
// A low-level error was flagged; ignore everything up to the next FLAG
|
||||
this->discarding_ = true;
|
||||
return ScanResult::NONE;
|
||||
|
||||
case ASH_XON_BYTE:
|
||||
case ASH_XOFF_BYTE:
|
||||
// Transport flow control, not frame content: skip it without disturbing the frame
|
||||
return ScanResult::NONE;
|
||||
|
||||
case ASH_ESCAPE_BYTE:
|
||||
this->escaped_ = true;
|
||||
return ScanResult::NONE;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
uint8_t value = byte;
|
||||
if (this->escaped_) {
|
||||
this->escaped_ = false;
|
||||
value = byte ^ ASH_XOR_BYTE;
|
||||
// An escape must decode to a reserved byte; anything else is not ASH framing at all
|
||||
if (!ash_is_reserved(value)) {
|
||||
this->poisoned_ = true;
|
||||
return ScanResult::NONE;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->index_ >= sizeof(this->buffer_)) {
|
||||
this->poisoned_ = true;
|
||||
return ScanResult::NONE;
|
||||
}
|
||||
|
||||
this->buffer_[this->index_++] = value;
|
||||
this->crc_ = ash_crc16(&value, 1, this->crc_);
|
||||
return ScanResult::NONE;
|
||||
}
|
||||
|
||||
void AshDetector::reset() {
|
||||
this->ncp_scanner_.reset();
|
||||
this->host_scanner_.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_();
|
||||
break;
|
||||
case ScanResult::INVALID:
|
||||
// A delimited chunk that is not a frame. While armed this may be a corrupted ASH
|
||||
// frame, which the peer will retransmit, or a sign the peer stopped speaking ASH.
|
||||
// reject_() distinguishes the two by whether a retransmission ever arrives.
|
||||
this->reject_();
|
||||
break;
|
||||
case ScanResult::NONE:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void AshDetector::handle_ncp_frame_() {
|
||||
const uint8_t *body = this->ncp_scanner_.frame();
|
||||
const size_t length = this->ncp_scanner_.length();
|
||||
const uint8_t control = body[0];
|
||||
|
||||
// RSTACK is the only way into the handshake, and the only way back after a firmware
|
||||
// swap: a Spinel or bootloader NCP never emits one, so those stay unarmed forever.
|
||||
if (control == ASH_RSTACK_CONTROL) {
|
||||
if (length == ASH_RSTACK_BODY_SIZE && body[1] == ASH_PROTOCOL_VERSION && ash_reset_code_is_known(body[2])) {
|
||||
this->state_ = AshDetectState::SAW_RSTACK;
|
||||
this->rx_sequence_ = 0;
|
||||
this->ack_owed_ = false;
|
||||
this->unconfirmed_rejects_ = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->state_ != AshDetectState::ARMED) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((control & 0x80) != 0) {
|
||||
return; // ACK/NAK/RST/ERROR: nothing is owed for these
|
||||
}
|
||||
|
||||
const uint8_t frame_num = (control >> 4) & ASH_MAX_SEQUENCE;
|
||||
const bool re_tx = (control & 0x08) != 0;
|
||||
|
||||
if (frame_num != this->rx_sequence_) {
|
||||
// A retransmission still proves the peer is speaking ASH even though we cannot use
|
||||
// this copy, so it clears the suspicion without being acknowledged.
|
||||
if (re_tx) {
|
||||
this->unconfirmed_rejects_ = 0;
|
||||
} else {
|
||||
this->reject_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void AshDetector::reject_() {
|
||||
if (this->state_ != AshDetectState::ARMED) {
|
||||
return;
|
||||
}
|
||||
if (++this->unconfirmed_rejects_ >= MAX_UNCONFIRMED_REJECTS) {
|
||||
this->state_ = AshDetectState::IDLE;
|
||||
this->unconfirmed_rejects_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void AshDetector::from_host(uint8_t byte) {
|
||||
if (this->host_scanner_.feed(byte) != ScanResult::FRAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->state_ != AshDetectState::SAW_RSTACK) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t *body = this->host_scanner_.frame();
|
||||
if (this->host_scanner_.length() != EZSP_VERSION_CMD_SIZE) {
|
||||
return;
|
||||
}
|
||||
for (size_t i = 0; i < sizeof(EZSP_VERSION_CMD_PREFIX); i++) {
|
||||
if (body[i] != EZSP_VERSION_CMD_PREFIX[i]) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this->negotiated_version_ = body[4] ^ EZSP_VERSION_RANDOM_MASK;
|
||||
this->state_ = AshDetectState::ARMED;
|
||||
this->rx_sequence_ = 0;
|
||||
this->ack_owed_ = false;
|
||||
this->unconfirmed_rejects_ = 0;
|
||||
}
|
||||
|
||||
bool AshDetector::take_pending_ack(uint8_t &ack_num) {
|
||||
if (!this->ack_owed_) {
|
||||
return false;
|
||||
}
|
||||
this->ack_owed_ = false;
|
||||
ack_num = this->pending_ack_;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::zigbee_proxy
|
||||
|
||||
#endif // USE_ZIGBEE_PROXY
|
||||
@@ -0,0 +1,118 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
|
||||
#include "ash_protocol.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::zigbee_proxy {
|
||||
|
||||
// Decides when it is safe to acknowledge NCP frames on a client's behalf.
|
||||
//
|
||||
// The client suppresses its own ACKs, so nobody else will send them, and injecting ASH
|
||||
// bytes into a stream that is not ASH would corrupt it. Detection is therefore one-sided:
|
||||
// arm only on the session handshake, which is a fixed byte string, and never on frame
|
||||
// validity, which non-ASH traffic can satisfy by luck.
|
||||
//
|
||||
// RSTACK (NCP -> host) c1 02 <reset_code> <crc> 7e
|
||||
// version (host -> NCP) 00 42 21 a8 <version^0x54> <crc> 7e
|
||||
//
|
||||
// Requiring both, in that order, in opposite directions cannot be satisfied by a
|
||||
// unidirectional byte stream whatever it contains -- which is exactly the situation
|
||||
// during a firmware upload. Verified against real .gbl images and real Spinel traffic:
|
||||
// zero false arms, and neither pattern occurs even as a substring.
|
||||
//
|
||||
// Getting it wrong in the other direction is cheap: a frame we decline to acknowledge is
|
||||
// retransmitted by the NCP, so we see a clean copy and lose only the ack timeout. That
|
||||
// asymmetry is why this errs towards silence everywhere.
|
||||
|
||||
enum class AshDetectState : uint8_t {
|
||||
IDLE, // Not ASH, or not yet proven to be
|
||||
SAW_RSTACK, // Handshake half-complete; watching for the version command
|
||||
ARMED, // Session confirmed; acknowledging on the client's behalf
|
||||
};
|
||||
|
||||
enum class ScanResult : uint8_t {
|
||||
NONE, // Mid-frame, or a delimiter that carried nothing
|
||||
FRAME, // frame()/length() hold a complete body with a verified CRC
|
||||
INVALID, // A delimited chunk arrived but was not a well-formed ASH frame
|
||||
};
|
||||
|
||||
// Reassembles one direction of the byte stream into unstuffed, CRC-checked frames.
|
||||
// Mirrors bellows' AshProtocol.data_received: FLAG ends a frame, CANCEL discards what
|
||||
// precedes it, SUBSTITUTE poisons everything up to the next FLAG, and XON/XOFF are
|
||||
// transport flow control removed without disturbing the frame around them.
|
||||
class AshFrameScanner {
|
||||
public:
|
||||
ScanResult feed(uint8_t byte);
|
||||
void reset();
|
||||
|
||||
// Valid only until the next feed() call, which begins overwriting the buffer.
|
||||
const uint8_t *frame() const { return this->buffer_; }
|
||||
size_t length() const { return this->frame_length_; }
|
||||
|
||||
private:
|
||||
void begin_frame_();
|
||||
|
||||
// Frames are bounded by the ASH maximum, so a stream carrying no delimiters cannot
|
||||
// grow the buffer without limit; it just keeps failing.
|
||||
uint8_t buffer_[MAX_ASH_FRAME_SIZE];
|
||||
size_t index_{0}; // accumulation position for the frame being read
|
||||
size_t frame_length_{0}; // body length of the last completed frame
|
||||
uint16_t crc_{ASH_CRC_INIT};
|
||||
bool escaped_{false};
|
||||
bool discarding_{false};
|
||||
bool poisoned_{false};
|
||||
};
|
||||
|
||||
class AshDetector {
|
||||
public:
|
||||
void reset();
|
||||
|
||||
// Feed observed traffic. Neither call gates forwarding: the detector only watches.
|
||||
void from_ncp(uint8_t byte);
|
||||
void from_host(uint8_t byte);
|
||||
|
||||
bool armed() const { return this->state_ == AshDetectState::ARMED; }
|
||||
|
||||
// True only while the host direction can affect the state machine, i.e. while waiting
|
||||
// for the version command. Lets the caller skip scanning that direction entirely the
|
||||
// rest of the time -- it is the one carrying firmware uploads.
|
||||
bool needs_host_scan() const { return this->state_ == AshDetectState::SAW_RSTACK; }
|
||||
|
||||
// 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_();
|
||||
|
||||
AshFrameScanner ncp_scanner_;
|
||||
AshFrameScanner host_scanner_;
|
||||
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};
|
||||
};
|
||||
|
||||
} // namespace esphome::zigbee_proxy
|
||||
|
||||
#endif // USE_ZIGBEE_PROXY
|
||||
@@ -0,0 +1,444 @@
|
||||
#include "zigbee_proxy.h"
|
||||
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/helpers.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,
|
||||
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};
|
||||
|
||||
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++) {
|
||||
crc = (crc << 8) ^ CRC_TABLE[(crc >> 8) ^ data[i]];
|
||||
}
|
||||
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) {
|
||||
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 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;
|
||||
}
|
||||
output[pos++] = ASH_FLAG_BYTE;
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
} // namespace esphome::zigbee_proxy
|
||||
|
||||
#endif // USE_ZIGBEE_PROXY
|
||||
@@ -0,0 +1,115 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
|
||||
namespace esphome::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
|
||||
|
||||
static constexpr uint8_t ASH_XON_BYTE = 0x11; // Resume transmission
|
||||
static constexpr uint8_t ASH_XOFF_BYTE = 0x13; // Pause transmission
|
||||
static constexpr uint8_t ASH_CANCEL_BYTE = 0x1A; // Discards the partial frame before it
|
||||
|
||||
// A reserved byte can never appear literally inside a frame; it is escaped as
|
||||
// ESCAPE followed by the byte XOR 0x20. Rejecting frames that contain one is what
|
||||
// eliminates most non-ASH traffic before its CRC is ever computed: real firmware
|
||||
// images and Spinel payloads are dense in 0x11/0x13/0x18/0x1A.
|
||||
inline bool ash_is_reserved(uint8_t byte) {
|
||||
return byte == ASH_FLAG_BYTE || byte == ASH_ESCAPE_BYTE || byte == ASH_XON_BYTE || byte == ASH_XOFF_BYTE ||
|
||||
byte == ASH_SUBSTITUTE_BYTE || byte == ASH_CANCEL_BYTE;
|
||||
}
|
||||
|
||||
// CRC-CCITT (init 0xFFFF, polynomial 0x1021, transmitted big-endian). Note this is a
|
||||
// different variant from the Kermit FCS that Spinel/HDLC-lite uses over the same
|
||||
// 0x7E framing, so Spinel frames systematically fail this check.
|
||||
uint16_t ash_crc16(const uint8_t *data, size_t length, uint16_t init = 0xFFFF);
|
||||
|
||||
// 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 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,
|
||||
};
|
||||
|
||||
} // namespace esphome::zigbee_proxy
|
||||
@@ -0,0 +1,88 @@
|
||||
#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
@@ -0,0 +1,250 @@
|
||||
#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/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.
|
||||
class ZigbeeProxy : public serial_proxy::SerialProxyTap, public Component {
|
||||
public:
|
||||
ZigbeeProxy();
|
||||
|
||||
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.
|
||||
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};
|
||||
|
||||
// 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)
|
||||
|
||||
} // namespace esphome::zigbee_proxy
|
||||
|
||||
#endif // USE_ZIGBEE_PROXY
|
||||
@@ -169,6 +169,7 @@
|
||||
#define USE_SENSOR
|
||||
#define USE_SENSOR_FILTER
|
||||
#define USE_SERIAL_PROXY
|
||||
#define USE_SERIAL_PROXY_TAP
|
||||
#define USE_SETUP_PRIORITY_OVERRIDE
|
||||
#define USE_STATUS_LED
|
||||
#define USE_STATUS_SENSOR
|
||||
@@ -184,6 +185,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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
|
||||
# The port owns the UART and carries every byte; zigbee_proxy only taps it
|
||||
serial_proxy:
|
||||
- id: zigbee_serial
|
||||
uart_id: zigbee_uart
|
||||
name: Zigbee
|
||||
port_type: TTL
|
||||
mode: ezsp_ash
|
||||
@@ -0,0 +1,28 @@
|
||||
wifi:
|
||||
ssid: test
|
||||
password: password
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
|
||||
usb_host:
|
||||
|
||||
usb_uart:
|
||||
- type: CDC_ACM
|
||||
vid: 0x303A
|
||||
pid: 0x831A
|
||||
channels:
|
||||
- id: zigbee_usb_channel
|
||||
baud_rate: 460800
|
||||
|
||||
# The tapped port may be a USB CDC ACM channel just as well as a hardware UART:
|
||||
# zigbee_proxy never touches the UART itself, so it does not care which it is.
|
||||
serial_proxy:
|
||||
- id: zigbee_usb_serial
|
||||
uart_id: zigbee_usb_channel
|
||||
name: Zigbee
|
||||
port_type: TTL
|
||||
mode: ezsp_ash
|
||||
|
||||
zigbee_proxy:
|
||||
serial_proxy_id: zigbee_usb_serial
|
||||
@@ -0,0 +1,15 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
zigbee_proxy:
|
||||
serial_proxy_id: zigbee_serial
|
||||
buffer_size: 1024
|
||||
initial_timeout: 1600
|
||||
min_timeout: 400
|
||||
max_timeout: 3200
|
||||
@@ -0,0 +1,12 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO1
|
||||
rx_pin: GPIO3
|
||||
|
||||
esp8266:
|
||||
board: nodemcuv2
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
zigbee_proxy:
|
||||
serial_proxy_id: zigbee_serial
|
||||
buffer_size: 512
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO0
|
||||
rx_pin: GPIO1
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
zigbee_proxy:
|
||||
serial_proxy_id: zigbee_serial
|
||||
Reference in New Issue
Block a user