mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[ezsp_proxy_tap] Add SiLabs EZSP auto-ACK implementation (#20304)
Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
co-authored by
J. Nick Koston
J. Nick Koston
parent
1c315c3bf2
commit
c5baae35d5
@@ -202,6 +202,7 @@ esphome/components/exponential_moving_average/* @clydebarrow
|
||||
esphome/components/exposure_notifications/* @OttoWinter
|
||||
esphome/components/ezo/* @ssieb
|
||||
esphome/components/ezo_pmp/* @carlos-sarmiento
|
||||
esphome/components/ezsp_proxy_tap/* @kbx81 @puddly
|
||||
esphome/components/factory_reset/* @anatoly-savchenkov
|
||||
esphome/components/fastled_base/* @OttoWinter
|
||||
esphome/components/feedback/* @ianchi
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import serial_proxy
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kbx81", "@puddly"]
|
||||
DEPENDENCIES = ["serial_proxy"]
|
||||
DOMAIN = "ezsp_proxy_tap"
|
||||
|
||||
CONF_SERIAL_PROXY_ID = "serial_proxy_id"
|
||||
|
||||
ezsp_proxy_tap_ns = cg.esphome_ns.namespace("ezsp_proxy_tap")
|
||||
EzspProxyTap = ezsp_proxy_tap_ns.class_(
|
||||
"EzspProxyTap", cg.Component, serial_proxy.SerialProxyTap
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(EzspProxyTap),
|
||||
cv.Required(CONF_SERIAL_PROXY_ID): cv.use_id(serial_proxy.SerialProxy),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID])
|
||||
var = cg.new_Pvariable(config[CONF_ID], sp)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add_define("USE_EZSP_PROXY_TAP")
|
||||
# Compiles the tap interface into serial_proxy; without it the port is a plain byte pipe
|
||||
cg.add_define("USE_SERIAL_PROXY_TAP")
|
||||
@@ -0,0 +1,126 @@
|
||||
#include "ash_acknowledger.h"
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::ezsp_proxy_tap {
|
||||
|
||||
// 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
|
||||
// A DATA frame carries an EZSP frame of at least 3 bytes after its control byte
|
||||
static constexpr size_t ASH_MIN_DATA_BODY_SIZE = 1 + 3;
|
||||
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;
|
||||
|
||||
void AshFrameScanner::reset() {
|
||||
this->index_ = 0;
|
||||
this->crc_ = ASH_CRC_INIT;
|
||||
this->escaped_ = false;
|
||||
this->poisoned_ = false;
|
||||
this->discarding_ = false;
|
||||
}
|
||||
|
||||
bool AshFrameScanner::feed(uint8_t byte) {
|
||||
if (byte == ASH_FLAG_BYTE) {
|
||||
// Running the CRC over the body and its trailing CRC bytes leaves zero when correct
|
||||
const bool valid = !this->discarding_ && !this->poisoned_ && !this->escaped_ &&
|
||||
this->index_ >= ASH_MIN_FRAME_SIZE && this->crc_ == 0;
|
||||
if (valid) {
|
||||
this->frame_length_ = this->index_ - ASH_CRC_SIZE;
|
||||
}
|
||||
this->reset();
|
||||
return valid;
|
||||
}
|
||||
|
||||
// CANCEL also ends a SUBSTITUTE's discard, so the bytes after it start a new frame
|
||||
if (byte == ASH_CANCEL_BYTE) {
|
||||
this->reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->discarding_ || (byte == ASH_WAKE_BYTE && this->index_ == 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (byte) {
|
||||
case ASH_SUBSTITUTE_BYTE:
|
||||
this->discarding_ = true;
|
||||
return false;
|
||||
|
||||
case ASH_XON_BYTE:
|
||||
case ASH_XOFF_BYTE:
|
||||
return false;
|
||||
|
||||
case ASH_ESCAPE_BYTE:
|
||||
this->escaped_ = true;
|
||||
return false;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
uint8_t value = byte;
|
||||
if (this->escaped_) {
|
||||
this->escaped_ = false;
|
||||
value = byte ^ ASH_XOR_BYTE;
|
||||
}
|
||||
|
||||
if (this->index_ >= MAX_ASH_FRAME_SIZE) {
|
||||
this->poisoned_ = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->index_ < ASH_HEADER_SIZE) {
|
||||
this->header_[this->index_] = value;
|
||||
}
|
||||
this->index_++;
|
||||
this->crc_ = crc16be(&value, 1, this->crc_);
|
||||
return false;
|
||||
}
|
||||
|
||||
void AshAcknowledger::reset() {
|
||||
this->scanner_.reset();
|
||||
this->rx_sequence_ = 0;
|
||||
this->synced_ = false;
|
||||
}
|
||||
|
||||
bool AshAcknowledger::feed(uint8_t byte) { return this->scanner_.feed(byte) && this->handle_frame_(); }
|
||||
|
||||
bool AshAcknowledger::handle_frame_() {
|
||||
const uint8_t *body = this->scanner_.header();
|
||||
const uint8_t control = body[0];
|
||||
|
||||
// An RSTACK restarts the NCP's frame numbering
|
||||
if (control == ASH_RSTACK_CONTROL) {
|
||||
if (this->scanner_.length() == ASH_RSTACK_BODY_SIZE && body[1] == ASH_PROTOCOL_VERSION) {
|
||||
this->rx_sequence_ = 0;
|
||||
this->synced_ = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((control & 0x80) != 0) {
|
||||
return false; // ACK/NAK/RST/ERROR: nothing is owed for these
|
||||
}
|
||||
|
||||
if (this->scanner_.length() < ASH_MIN_DATA_BODY_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t frame_num = (control >> 4) & ASH_MAX_SEQUENCE;
|
||||
const bool re_tx = (control & 0x08) != 0;
|
||||
// Without an RSTACK, the session began before we were watching: follow the NCP's numbering
|
||||
if (!this->synced_) {
|
||||
this->rx_sequence_ = frame_num;
|
||||
this->synced_ = true;
|
||||
}
|
||||
if (frame_num == this->rx_sequence_) {
|
||||
this->rx_sequence_ = (this->rx_sequence_ + 1) & ASH_MAX_SEQUENCE;
|
||||
return true;
|
||||
}
|
||||
return re_tx;
|
||||
}
|
||||
|
||||
} // namespace esphome::ezsp_proxy_tap
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include "ash_protocol.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ezsp_proxy_tap {
|
||||
|
||||
// Reassembles one direction of the byte stream into unstuffed, CRC-checked frames. Only
|
||||
// the first ASH_HEADER_SIZE bytes of a frame are kept, as the CRC is checked as bytes
|
||||
// arrive and acknowledging needs nothing past the header.
|
||||
class AshFrameScanner {
|
||||
public:
|
||||
// Returns true when the byte completes a frame with a valid CRC
|
||||
bool feed(uint8_t byte);
|
||||
void reset();
|
||||
|
||||
// The first min(length(), ASH_HEADER_SIZE) bytes of the last completed frame's body.
|
||||
// Valid only until the next feed() call, which begins overwriting them.
|
||||
const uint8_t *header() const { return this->header_; }
|
||||
size_t length() const { return this->frame_length_; }
|
||||
|
||||
private:
|
||||
uint8_t header_[ASH_HEADER_SIZE];
|
||||
size_t index_{0}; // body bytes read so far in 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};
|
||||
};
|
||||
|
||||
// Works out which acknowledgements an NCP is owed while a client has handed them to this
|
||||
// device. Whether to acknowledge at all is the client's decision, made by putting the port
|
||||
// in PROTOCOL mode, so this only follows the NCP's frame numbering.
|
||||
class AshAcknowledger {
|
||||
public:
|
||||
// Forgets the NCP's frame numbering. The next RSTACK or DATA frame sets it again.
|
||||
void reset();
|
||||
|
||||
// Feed a byte from the NCP. Returns true when it completes a frame that is owed ack_num().
|
||||
bool feed(uint8_t byte);
|
||||
uint8_t ack_num() const { return this->rx_sequence_; }
|
||||
|
||||
protected:
|
||||
bool handle_frame_();
|
||||
|
||||
AshFrameScanner scanner_;
|
||||
uint8_t rx_sequence_{0};
|
||||
bool synced_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::ezsp_proxy_tap
|
||||
@@ -0,0 +1,19 @@
|
||||
#include "ash_protocol.h"
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::ezsp_proxy_tap {
|
||||
|
||||
void ash_build_ack_frame(uint8_t *output, uint8_t ack_num) {
|
||||
// ACK control byte: 100nrPPP, where PPP is the next frame number expected
|
||||
const uint8_t control = 0x80 | (ack_num & ASH_MAX_SEQUENCE);
|
||||
const uint16_t crc = crc16be(&control, 1, ASH_CRC_INIT);
|
||||
|
||||
output[0] = ASH_FLAG_BYTE;
|
||||
output[1] = control;
|
||||
output[2] = crc >> 8;
|
||||
output[3] = crc & 0xFF;
|
||||
output[4] = ASH_FLAG_BYTE;
|
||||
}
|
||||
|
||||
} // namespace esphome::ezsp_proxy_tap
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ezsp_proxy_tap {
|
||||
|
||||
// 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
|
||||
// Sent between frames by sleepy NCPs to signal pending callbacks. No control byte is 0xFF.
|
||||
static constexpr uint8_t ASH_WAKE_BYTE = 0xFF;
|
||||
|
||||
static constexpr size_t ASH_MAX_DATA_FIELD_SIZE = 220;
|
||||
static constexpr size_t MAX_ASH_FRAME_SIZE = 1 + ASH_MAX_DATA_FIELD_SIZE + 2;
|
||||
|
||||
// The control byte, then the first Data Field byte (an RSTACK's version)
|
||||
static constexpr size_t ASH_HEADER_SIZE = 2;
|
||||
|
||||
// Protocol limits
|
||||
static constexpr uint8_t ASH_MAX_SEQUENCE = 7; // 3-bit sequence number (0-7)
|
||||
|
||||
// The frame CRC is CRC-CCITT: crc16be() with this initial value, transmitted big-endian
|
||||
static constexpr uint16_t ASH_CRC_INIT = 0xFFFF;
|
||||
|
||||
// FLAG, control byte, two CRC bytes, FLAG. None of the eight ACKs has a byte that needs
|
||||
// escaping, so the size is fixed.
|
||||
static constexpr size_t ASH_ACK_FRAME_SIZE = 5;
|
||||
|
||||
// Writes the ACK frame for `ack_num` into `output`
|
||||
void ash_build_ack_frame(uint8_t *output, uint8_t ack_num);
|
||||
|
||||
} // namespace esphome::ezsp_proxy_tap
|
||||
@@ -0,0 +1,38 @@
|
||||
#include "ezsp_proxy_tap.h"
|
||||
|
||||
#ifdef USE_EZSP_PROXY_TAP
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ezsp_proxy_tap {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ezsp_proxy_tap");
|
||||
|
||||
void EzspProxyTap::setup() { this->parent_->set_tap(this); }
|
||||
|
||||
void EzspProxyTap::dump_config() { ESP_LOGCONFIG(TAG, "EZSP Proxy Tap:\n Port: %s", this->parent_->get_name()); }
|
||||
|
||||
void EzspProxyTap::on_device_rx(const uint8_t *data, size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (this->acknowledger_.feed(data[i])) {
|
||||
uint8_t frame[ASH_ACK_FRAME_SIZE];
|
||||
ash_build_ack_frame(frame, this->acknowledger_.ack_num());
|
||||
this->parent_->write_from_tap(frame, sizeof(frame));
|
||||
ESP_LOGV(TAG, "Sent ACK %u", this->acknowledger_.ack_num());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EzspProxyTap::on_protocol_enabled() {
|
||||
// Numbering from an earlier session is stale
|
||||
this->acknowledger_.reset();
|
||||
}
|
||||
|
||||
void EzspProxyTap::on_device_disconnected() {
|
||||
// The frame numbering ended with the NCP's power; whatever boots next starts over
|
||||
this->acknowledger_.reset();
|
||||
}
|
||||
|
||||
} // namespace esphome::ezsp_proxy_tap
|
||||
|
||||
#endif // USE_EZSP_PROXY_TAP
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_EZSP_PROXY_TAP
|
||||
|
||||
#include "esphome/components/serial_proxy/serial_proxy.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "ash_acknowledger.h"
|
||||
|
||||
namespace esphome::ezsp_proxy_tap {
|
||||
|
||||
// Acknowledges the ASH frames of an EZSP NCP on behalf of a remote client, so the NCP's
|
||||
// ack timeout is measured against this device rather than against the network round trip
|
||||
// to the client. The client suppresses its own acknowledgements, making these the only
|
||||
// ones the NCP sees.
|
||||
//
|
||||
// It never carries client traffic: the serial proxy owns the port and the bytes, and this
|
||||
// component only observes them. The sole exception is the acknowledgement itself, sent
|
||||
// only while a client has put the port in PROTOCOL mode, which only an EZSP client does.
|
||||
class EzspProxyTap : public serial_proxy::SerialProxyTap, public Component {
|
||||
public:
|
||||
explicit EzspProxyTap(serial_proxy::SerialProxy *parent) : parent_(parent) {}
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
// SerialProxyTap
|
||||
void on_device_rx(const uint8_t *data, size_t len) override;
|
||||
// ACKs depend only on what the NCP sends
|
||||
void on_client_tx(const uint8_t *data, size_t len) override {}
|
||||
// Acknowledging is only ever useful on a client's behalf, so with nobody subscribed
|
||||
// there is nothing to do and the port need not be read.
|
||||
bool tap_needs_port() const override { return false; }
|
||||
void on_protocol_enabled() override;
|
||||
// Nothing is acknowledged outside PROTOCOL mode, and entering it starts afresh
|
||||
void on_protocol_disabled() override {}
|
||||
void on_device_disconnected() override;
|
||||
|
||||
protected:
|
||||
// The port this component observes. Owns the UART and the bytes; every write we make
|
||||
// goes through it.
|
||||
serial_proxy::SerialProxy *parent_;
|
||||
|
||||
AshAcknowledger acknowledger_;
|
||||
};
|
||||
|
||||
} // namespace esphome::ezsp_proxy_tap
|
||||
|
||||
#endif // USE_EZSP_PROXY_TAP
|
||||
@@ -277,10 +277,16 @@ SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_conn
|
||||
ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_,
|
||||
mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW"));
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
const bool entering_protocol_mode =
|
||||
this->mode_ != api::enums::SERIAL_PROXY_MODE_PROTOCOL && mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL;
|
||||
const bool leaving_protocol_mode =
|
||||
this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW;
|
||||
this->mode_ = mode;
|
||||
|
||||
if (entering_protocol_mode && this->tap_ != nullptr) {
|
||||
this->tap_->on_protocol_enabled();
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -426,6 +432,14 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api:
|
||||
void SerialProxy::on_usb_connection_changed_(bool connected) {
|
||||
ESP_LOGD(TAG, "USB device %s serial proxy [%" PRIu32 "]",
|
||||
connected ? LOG_STR_LITERAL("attached to") : LOG_STR_LITERAL("removed from"), this->instance_index_);
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// Before telling clients, so a tap never acknowledges a frame from the old device after
|
||||
// a client has been told it is gone. The subscriber and the mode stay: both belong to
|
||||
// the client's session, and only the client knows whether that session is over.
|
||||
if (!connected && this->tap_ != nullptr) {
|
||||
this->tap_->on_device_disconnected();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API
|
||||
if (api::global_api_server == nullptr) {
|
||||
return;
|
||||
|
||||
@@ -89,11 +89,20 @@ class SerialProxyTap {
|
||||
/// subscriber holds the port; with one attached, the port mode alone decides.
|
||||
virtual bool tap_needs_port() const = 0;
|
||||
|
||||
/// A client turned protocol handling on for this port. The tap has seen none of the
|
||||
/// traffic since it was last on, so anything it remembers from then is stale.
|
||||
virtual void on_protocol_enabled() = 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;
|
||||
|
||||
/// The device behind the port went away -- unplugged, or its power was cut. Whatever
|
||||
/// session the tap had observed ended with it; the device that appears next starts from
|
||||
/// scratch and may not even be the same one. Only a USB UART can report this.
|
||||
virtual void on_device_disconnected() = 0;
|
||||
};
|
||||
#endif
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
#define USE_ESP32_HOSTED_HTTP_UPDATE
|
||||
#define USE_ESP_NOW_HOSTED
|
||||
#define USE_EVENT
|
||||
#define USE_EZSP_PROXY_TAP
|
||||
#define USE_FAN
|
||||
#define USE_FEEDBACK_COVER_OVERSHOOT
|
||||
#define USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/ezsp_proxy_tap/ash_acknowledger.h"
|
||||
#include "esphome/components/ezsp_proxy_tap/ash_protocol.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::ezsp_proxy_tap::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
// Whole frames as they appear on the wire, each ending in its FLAG. RSTACK and ACK(1)
|
||||
// are the examples in Silicon Labs UG101.
|
||||
const std::vector<uint8_t> RSTACK = {0xC1, 0x02, 0x02, 0x9B, 0x7B, 0x7E};
|
||||
const std::vector<uint8_t> DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA8, 0x50, 0x9B, 0x98, 0x7E};
|
||||
const std::vector<uint8_t> DATA_FRAME_1 = {0x10, 0x43, 0x21, 0xA8, 0x50, 0x9F, 0xC2, 0x7E};
|
||||
const std::vector<uint8_t> DATA_FRAME_0_RETRANSMITTED = {0x08, 0x43, 0x21, 0xA8, 0x50, 0x99, 0xB5, 0x7E};
|
||||
const std::vector<uint8_t> DATA_FRAME_5 = {0x50, 0x43, 0x21, 0xA8, 0x50, 0x8E, 0xAA, 0x7E};
|
||||
const std::vector<uint8_t> DATA_FRAME_5_RETRANSMITTED = {0x58, 0x43, 0x21, 0xA8, 0x50, 0x8C, 0x87, 0x7E};
|
||||
const std::vector<uint8_t> DATA_FRAME_6 = {0x60, 0x43, 0x21, 0xA8, 0x50, 0x82, 0x44, 0x7E};
|
||||
// Data frame 0 with a 2-byte and a 3-byte data field, below and at the minimum
|
||||
const std::vector<uint8_t> SHORT_DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA0, 0x40, 0x7E};
|
||||
const std::vector<uint8_t> MIN_DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA8, 0xC1, 0x08, 0x7E};
|
||||
// Body {0x00, 0x7E, 0x7D, 0x11, 0x13, 0x18, 0x1A}: every reserved byte, each escaped
|
||||
const std::vector<uint8_t> ESCAPED_FRAME = {0x00, 0x7D, 0x5E, 0x7D, 0x5D, 0x7D, 0x31, 0x7D,
|
||||
0x33, 0x7D, 0x38, 0x7D, 0x3A, 0x46, 0x8C, 0x7E};
|
||||
|
||||
const std::vector<uint8_t> RESERVED_BYTES = {ASH_FLAG_BYTE, ASH_ESCAPE_BYTE, ASH_XON_BYTE,
|
||||
ASH_XOFF_BYTE, ASH_SUBSTITUTE_BYTE, ASH_CANCEL_BYTE};
|
||||
|
||||
// Data frame 0 with `data_size` bytes of 0x42 and a valid CRC, none of which needs escaping
|
||||
std::vector<uint8_t> unstuffed_frame(size_t data_size) {
|
||||
std::vector<uint8_t> frame(1 + data_size, 0x42);
|
||||
frame[0] = 0x00;
|
||||
const uint16_t crc = crc16be(frame.data(), frame.size(), ASH_CRC_INIT);
|
||||
frame.push_back(crc >> 8);
|
||||
frame.push_back(crc & 0xFF);
|
||||
frame.push_back(ASH_FLAG_BYTE);
|
||||
return frame;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> concat(std::vector<uint8_t> a, const std::vector<uint8_t> &b) {
|
||||
a.insert(a.end(), b.begin(), b.end());
|
||||
return a;
|
||||
}
|
||||
|
||||
// Returns whether the last byte completed a valid frame
|
||||
bool feed(AshFrameScanner &scanner, const std::vector<uint8_t> &bytes) {
|
||||
bool complete = false;
|
||||
for (uint8_t byte : bytes)
|
||||
complete = scanner.feed(byte);
|
||||
return complete;
|
||||
}
|
||||
|
||||
// Feeds whole frames and returns the ACK owed after the last, or -1 if none is
|
||||
int feed(AshAcknowledger &acknowledger, const std::vector<uint8_t> &bytes) {
|
||||
bool owed = false;
|
||||
for (uint8_t byte : bytes)
|
||||
owed = acknowledger.feed(byte);
|
||||
return owed ? acknowledger.ack_num() : -1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(AshProtocol, AckFrameMatchesReference) {
|
||||
uint8_t frame[ASH_ACK_FRAME_SIZE];
|
||||
ash_build_ack_frame(frame, 1);
|
||||
EXPECT_EQ(std::vector<uint8_t>(frame, frame + sizeof(frame)), (std::vector<uint8_t>{0x7E, 0x81, 0x60, 0x59, 0x7E}));
|
||||
}
|
||||
|
||||
TEST(AshProtocol, AckFramesNeedNoEscaping) {
|
||||
for (uint8_t ack_num = 0; ack_num <= ASH_MAX_SEQUENCE; ack_num++) {
|
||||
uint8_t frame[ASH_ACK_FRAME_SIZE];
|
||||
ash_build_ack_frame(frame, ack_num);
|
||||
EXPECT_EQ(frame[0], ASH_FLAG_BYTE);
|
||||
EXPECT_EQ(frame[4], ASH_FLAG_BYTE);
|
||||
for (size_t i = 1; i < 4; i++) {
|
||||
EXPECT_EQ(std::count(RESERVED_BYTES.begin(), RESERVED_BYTES.end(), frame[i]), 0)
|
||||
<< "ack_num " << int(ack_num) << " byte " << i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(AshFrameScanner, UnescapesReservedBytes) {
|
||||
AshFrameScanner scanner;
|
||||
// The CRC covers the unescaped body, so a valid frame proves every byte was restored
|
||||
ASSERT_TRUE(feed(scanner, ESCAPED_FRAME));
|
||||
EXPECT_EQ(scanner.length(), 7u);
|
||||
EXPECT_EQ(std::vector<uint8_t>(scanner.header(), scanner.header() + ASH_HEADER_SIZE),
|
||||
(std::vector<uint8_t>{0x00, 0x7E}));
|
||||
}
|
||||
|
||||
TEST(AshFrameScanner, RejectsBadCrc) {
|
||||
AshFrameScanner scanner;
|
||||
std::vector<uint8_t> corrupt = DATA_FRAME_0;
|
||||
corrupt[corrupt.size() - 2] ^= 0x01;
|
||||
EXPECT_FALSE(feed(scanner, corrupt));
|
||||
EXPECT_TRUE(feed(scanner, DATA_FRAME_0));
|
||||
}
|
||||
|
||||
TEST(AshFrameScanner, CancelDiscardsPartialFrame) {
|
||||
AshFrameScanner scanner;
|
||||
EXPECT_TRUE(feed(scanner, concat({0x10, 0x43, ASH_CANCEL_BYTE}, DATA_FRAME_0)));
|
||||
EXPECT_EQ(scanner.length(), 5u);
|
||||
}
|
||||
|
||||
TEST(AshFrameScanner, SubstituteDiscardsUntilFlag) {
|
||||
AshFrameScanner scanner;
|
||||
std::vector<uint8_t> substituted = DATA_FRAME_0;
|
||||
substituted.insert(substituted.begin() + 2, ASH_SUBSTITUTE_BYTE);
|
||||
EXPECT_FALSE(feed(scanner, substituted));
|
||||
EXPECT_TRUE(feed(scanner, DATA_FRAME_0));
|
||||
}
|
||||
|
||||
TEST(AshFrameScanner, CancelEndsSubstitute) {
|
||||
AshFrameScanner scanner;
|
||||
EXPECT_TRUE(feed(scanner, concat({0x10, ASH_SUBSTITUTE_BYTE, 0x43, ASH_CANCEL_BYTE}, DATA_FRAME_0)));
|
||||
}
|
||||
|
||||
TEST(AshFrameScanner, IgnoresWakeBetweenFrames) {
|
||||
AshFrameScanner scanner;
|
||||
EXPECT_TRUE(feed(scanner, concat({ASH_WAKE_BYTE, ASH_FLAG_BYTE, ASH_WAKE_BYTE, ASH_WAKE_BYTE}, DATA_FRAME_0)));
|
||||
EXPECT_EQ(scanner.length(), 5u);
|
||||
}
|
||||
|
||||
TEST(AshFrameScanner, IgnoresFlowControlInsideFrame) {
|
||||
AshFrameScanner scanner;
|
||||
std::vector<uint8_t> interrupted = DATA_FRAME_0;
|
||||
interrupted.insert(interrupted.begin() + 3, ASH_XOFF_BYTE);
|
||||
interrupted.insert(interrupted.begin() + 1, ASH_XON_BYTE);
|
||||
ASSERT_TRUE(feed(scanner, interrupted));
|
||||
EXPECT_EQ(scanner.length(), 5u);
|
||||
EXPECT_EQ(std::vector<uint8_t>(scanner.header(), scanner.header() + ASH_HEADER_SIZE),
|
||||
(std::vector<uint8_t>{0x00, 0x43}));
|
||||
}
|
||||
|
||||
TEST(AshFrameScanner, RejectsOversizedFrame) {
|
||||
AshFrameScanner scanner;
|
||||
EXPECT_TRUE(feed(scanner, unstuffed_frame(ASH_MAX_DATA_FIELD_SIZE)));
|
||||
EXPECT_EQ(scanner.length(), 1 + ASH_MAX_DATA_FIELD_SIZE);
|
||||
EXPECT_FALSE(feed(scanner, unstuffed_frame(ASH_MAX_DATA_FIELD_SIZE + 1)));
|
||||
EXPECT_TRUE(feed(scanner, DATA_FRAME_0));
|
||||
}
|
||||
|
||||
TEST(AshAcknowledger, AcknowledgesFramesInSequence) {
|
||||
AshAcknowledger acknowledger;
|
||||
EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1);
|
||||
// A repeat not marked as a retransmission is out of sequence
|
||||
EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), -1);
|
||||
// A retransmission means the NCP missed our ACK, so it gets the same one again
|
||||
EXPECT_EQ(feed(acknowledger, DATA_FRAME_0_RETRANSMITTED), 1);
|
||||
EXPECT_EQ(feed(acknowledger, DATA_FRAME_1), 2);
|
||||
}
|
||||
|
||||
TEST(AshAcknowledger, RstackRestartsNumbering) {
|
||||
AshAcknowledger acknowledger;
|
||||
EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1);
|
||||
EXPECT_EQ(feed(acknowledger, RSTACK), -1);
|
||||
EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1);
|
||||
}
|
||||
|
||||
TEST(AshAcknowledger, IgnoresShortDataFrames) {
|
||||
AshAcknowledger acknowledger;
|
||||
EXPECT_EQ(feed(acknowledger, SHORT_DATA_FRAME_0), -1);
|
||||
EXPECT_EQ(feed(acknowledger, MIN_DATA_FRAME_0), 1);
|
||||
}
|
||||
|
||||
TEST(AshAcknowledger, SyncsToFirstFrameMidSession) {
|
||||
AshAcknowledger acknowledger;
|
||||
// The NCP is retransmitting a frame nobody acknowledged before the tap was watching
|
||||
EXPECT_EQ(feed(acknowledger, DATA_FRAME_5_RETRANSMITTED), 6);
|
||||
EXPECT_EQ(feed(acknowledger, DATA_FRAME_6), 7);
|
||||
}
|
||||
|
||||
TEST(AshAcknowledger, ResetForgetsNumbering) {
|
||||
AshAcknowledger acknowledger;
|
||||
EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1);
|
||||
acknowledger.reset();
|
||||
EXPECT_EQ(feed(acknowledger, DATA_FRAME_5), 6);
|
||||
}
|
||||
|
||||
} // namespace esphome::ezsp_proxy_tap::testing
|
||||
@@ -0,0 +1,15 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
|
||||
# The port owns the UART and carries every byte; ezsp_proxy_tap only taps it
|
||||
serial_proxy:
|
||||
- id: ezsp_serial
|
||||
name: Zigbee
|
||||
port_type: TTL
|
||||
|
||||
ezsp_proxy_tap:
|
||||
serial_proxy_id: ezsp_serial
|
||||
@@ -0,0 +1,27 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
|
||||
usb_host:
|
||||
|
||||
usb_uart:
|
||||
- type: CDC_ACM
|
||||
vid: 0x303A
|
||||
pid: 0x831A
|
||||
channels:
|
||||
- id: ezsp_usb_channel
|
||||
baud_rate: 460800
|
||||
|
||||
# The tapped port may be a USB CDC ACM channel just as well as a hardware UART:
|
||||
# ezsp_proxy_tap never touches the UART itself, so it does not care which it is.
|
||||
serial_proxy:
|
||||
- id: ezsp_usb_serial
|
||||
uart_id: ezsp_usb_channel
|
||||
name: Zigbee
|
||||
port_type: USB_SERIAL
|
||||
|
||||
ezsp_proxy_tap:
|
||||
serial_proxy_id: ezsp_usb_serial
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
ezsp_proxy_tap: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
|
||||
ezsp_proxy_tap: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
|
||||
ezsp_proxy_tap: !include common.yaml
|
||||
Reference in New Issue
Block a user