mirror of
https://github.com/esphome/esphome.git
synced 2026-10-21 13:34:12 +00:00
Compare commits
92
Commits
@@ -186,6 +186,7 @@ esphome/components/ens160_base/* @latonita @vincentscode
|
||||
esphome/components/ens160_i2c/* @latonita
|
||||
esphome/components/ens160_spi/* @latonita
|
||||
esphome/components/ens210/* @itn3rd77
|
||||
esphome/components/entity_transport/* @clydebarrow
|
||||
esphome/components/epaper_spi/* @esphome/core
|
||||
esphome/components/es7210/* @kahrendt
|
||||
esphome/components/es7243e/* @kbx81
|
||||
@@ -457,6 +458,8 @@ esphome/components/openthread/* @mrene
|
||||
esphome/components/opt3001/* @ccutrer
|
||||
esphome/components/ota/* @esphome/core
|
||||
esphome/components/output/* @esphome/core
|
||||
esphome/components/packet_interface/* @clydebarrow
|
||||
esphome/components/packet_interface/packet_transport/* @clydebarrow
|
||||
esphome/components/packet_transport/* @clydebarrow
|
||||
esphome/components/pca6416a/* @Mat931
|
||||
esphome/components/pca9505/* @ajfriesen @ssieb
|
||||
@@ -618,7 +621,9 @@ esphome/components/sun_gtil2/* @Mat931
|
||||
esphome/components/switch/* @esphome/core
|
||||
esphome/components/switch/binary_sensor/* @ssieb
|
||||
esphome/components/sx126x/* @swoboda1337
|
||||
esphome/components/sx126x/packet_interface/* @clydebarrow
|
||||
esphome/components/sx127x/* @swoboda1337
|
||||
esphome/components/sx127x/packet_interface/* @clydebarrow
|
||||
esphome/components/sy6970/* @linkedupbits
|
||||
esphome/components/syslog/* @clydebarrow
|
||||
esphome/components/systa_bus/* @Mat931
|
||||
@@ -630,6 +635,8 @@ esphome/components/tca8418/* @zebble
|
||||
esphome/components/tca9548a/* @andreashergert1984
|
||||
esphome/components/tca9555/* @mobrembski
|
||||
esphome/components/tcl112/* @glmnet
|
||||
esphome/components/tcp/* @clydebarrow
|
||||
esphome/components/tcp/packet_interface/* @clydebarrow
|
||||
esphome/components/tcp_uart/* @Bascht74
|
||||
esphome/components/tee501/* @Stock-M
|
||||
esphome/components/teleinfo/* @0hax @FredM67
|
||||
@@ -673,10 +680,12 @@ esphome/components/tuya/water_heater/* @iago-veiga
|
||||
esphome/components/uart/* @esphome/core
|
||||
esphome/components/uart/button/* @ssieb
|
||||
esphome/components/uart/event/* @eoasmxd
|
||||
esphome/components/uart/packet_interface/* @clydebarrow
|
||||
esphome/components/uart/packet_transport/* @clydebarrow
|
||||
esphome/components/uart_mux/* @kbx81
|
||||
esphome/components/uart_tcp/* @Bascht74
|
||||
esphome/components/udp/* @clydebarrow
|
||||
esphome/components/udp/packet_interface/* @clydebarrow
|
||||
esphome/components/ufire_ec/* @pvizeli
|
||||
esphome/components/ufire_ise/* @pvizeli
|
||||
esphome/components/ufm01/* @ljungqvist
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Send sensor and binary sensor states to other devices over a packet interface."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import packet_interface
|
||||
from esphome.components.binary_sensor import BinarySensor
|
||||
from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID
|
||||
from esphome.components.packet_interface import (
|
||||
CONF_PACKET_INTERFACE,
|
||||
final_validate_interface,
|
||||
interface_layers,
|
||||
)
|
||||
from esphome.components.packet_interface.layers import CONF_SESSION
|
||||
from esphome.components.sensor import Sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_ID,
|
||||
CONF_SENSORS,
|
||||
CONF_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DEPENDENCIES = ["packet_interface"]
|
||||
DOMAIN = "entity_transport"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_BROADCAST_ID = "broadcast_id"
|
||||
CONF_ENTITY_TRANSPORT_ID = "entity_transport_id"
|
||||
|
||||
entity_transport_ns = cg.esphome_ns.namespace("entity_transport")
|
||||
EntityTransport = entity_transport_ns.class_("EntityTransport", cg.Component)
|
||||
|
||||
_request_remote_sensor_slot = cg.slot_counter("ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT")
|
||||
_request_remote_binary_sensor_slot = cg.slot_counter(
|
||||
"ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT"
|
||||
)
|
||||
|
||||
|
||||
def _advertised_id(conf: ConfigType) -> str:
|
||||
return conf.get(CONF_BROADCAST_ID, conf[CONF_ID].id)
|
||||
|
||||
|
||||
def _unique_advertised_ids(entities: list[ConfigType]) -> list[ConfigType]:
|
||||
"""A receiver tells entities of one type apart by their advertised ids, so each must be different."""
|
||||
seen: set[str] = set()
|
||||
for index, conf in enumerate(entities):
|
||||
if (advertised := _advertised_id(conf)) in seen:
|
||||
raise cv.Invalid(
|
||||
f"The id '{advertised}' is sent for two entities", path=[index]
|
||||
)
|
||||
seen.add(advertised)
|
||||
return entities
|
||||
|
||||
|
||||
def _local_entity(cls: MockObjClass) -> Callable[[Any], Any]:
|
||||
return cv.maybe_simple_value(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(cls),
|
||||
cv.Optional(CONF_BROADCAST_ID): cv.All(
|
||||
cv.validate_id_name, cv.Length(max=255)
|
||||
),
|
||||
}
|
||||
),
|
||||
key=CONF_ID,
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(EntityTransport),
|
||||
cv.Required(CONF_PACKET_INTERFACE): cv.use_id(packet_interface.PacketInterface),
|
||||
# Defaults to a periodic resend unless the interface has session and reliability layers
|
||||
cv.Optional(CONF_UPDATE_INTERVAL): cv.All(
|
||||
cv.update_interval, cv.Range(min=cv.TimePeriod(seconds=1))
|
||||
),
|
||||
# Remote entities lose their state when none arrives for this long
|
||||
cv.Optional(CONF_TIMEOUT): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(min=cv.TimePeriod(seconds=1)),
|
||||
),
|
||||
# Entities are numbered with one byte
|
||||
cv.Optional(CONF_SENSORS): cv.All(
|
||||
cv.ensure_list(_local_entity(Sensor)),
|
||||
cv.Length(max=255),
|
||||
_unique_advertised_ids,
|
||||
),
|
||||
cv.Optional(CONF_BINARY_SENSORS): cv.All(
|
||||
cv.ensure_list(_local_entity(BinarySensor)),
|
||||
cv.Length(max=255),
|
||||
_unique_advertised_ids,
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
"""With a session layer, which clears the states when the peer is lost, nothing would refresh an unchanged state."""
|
||||
layers = interface_layers(config[CONF_PACKET_INTERFACE])
|
||||
if CONF_TIMEOUT in config and layers is not None and CONF_SESSION in layers:
|
||||
raise cv.Invalid(
|
||||
f"Not allowed with a '{CONF_SESSION}' layer, which already clears the states when the other device "
|
||||
"cannot be reached; unchanged states may never be sent again, so they would time out on a working link",
|
||||
path=[CONF_TIMEOUT],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
final_validate_interface(CONF_PACKET_INTERFACE), _final_validate
|
||||
)
|
||||
|
||||
|
||||
def _validate_remote_id(config: ConfigType) -> ConfigType:
|
||||
if CONF_REMOTE_ID not in config and not config[CONF_ID].is_manual:
|
||||
raise cv.Invalid(f"'{CONF_REMOTE_ID}' is required unless '{CONF_ID}' is given")
|
||||
return config
|
||||
|
||||
|
||||
def remote_entity_schema(base_schema: cv.Schema) -> cv.Schema:
|
||||
"""Schema for an entity whose state comes from another device."""
|
||||
return cv.All(
|
||||
base_schema.extend(
|
||||
{
|
||||
cv.GenerateID(CONF_ENTITY_TRANSPORT_ID): cv.use_id(EntityTransport),
|
||||
cv.Required(CONF_PROVIDER): cv.valid_name,
|
||||
cv.Optional(CONF_REMOTE_ID): cv.All(
|
||||
cv.string_strict, cv.Length(min=1, max=255)
|
||||
),
|
||||
}
|
||||
),
|
||||
_validate_remote_id,
|
||||
)
|
||||
|
||||
|
||||
def _remote_id(config: ConfigType) -> str:
|
||||
return config.get(CONF_REMOTE_ID) or config[CONF_ID].id
|
||||
|
||||
|
||||
async def register_remote_sensor(config: ConfigType, var: MockObj) -> None:
|
||||
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
|
||||
_request_remote_sensor_slot(str(hub))
|
||||
cg.add(hub.add_remote_sensor(config[CONF_PROVIDER], _remote_id(config), var))
|
||||
|
||||
|
||||
async def register_remote_binary_sensor(config: ConfigType, var: MockObj) -> None:
|
||||
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
|
||||
_request_remote_binary_sensor_slot(str(hub))
|
||||
cg.add(hub.add_remote_binary_sensor(config[CONF_PROVIDER], _remote_id(config), var))
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
interface = await cg.get_variable(config[CONF_PACKET_INTERFACE])
|
||||
var = cg.new_Pvariable(config[CONF_ID], interface)
|
||||
await cg.register_component(var, config)
|
||||
if (interval := config.get(CONF_UPDATE_INTERVAL)) is not None:
|
||||
cg.add(var.set_update_interval(interval))
|
||||
if (timeout := config.get(CONF_TIMEOUT)) is not None:
|
||||
cg.add(var.set_remote_timeout(timeout))
|
||||
sensors = config.get(CONF_SENSORS, [])
|
||||
binary_sensors = config.get(CONF_BINARY_SENSORS, [])
|
||||
if count := len(sensors) + len(binary_sensors):
|
||||
cg.add(var.set_entity_count(count))
|
||||
for conf in sensors:
|
||||
sensor = await cg.get_variable(conf[CONF_ID])
|
||||
cg.add(var.add_sensor(_advertised_id(conf), sensor))
|
||||
for conf in binary_sensors:
|
||||
sensor = await cg.get_variable(conf[CONF_ID])
|
||||
cg.add(var.add_binary_sensor(_advertised_id(conf), sensor))
|
||||
@@ -0,0 +1,11 @@
|
||||
from esphome.components import binary_sensor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import register_remote_binary_sensor, remote_entity_schema
|
||||
|
||||
CONFIG_SCHEMA = remote_entity_schema(binary_sensor.binary_sensor_schema())
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
await register_remote_binary_sensor(config, var)
|
||||
@@ -0,0 +1,683 @@
|
||||
#include "entity_transport.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::entity_transport {
|
||||
|
||||
using packet_interface::PacketBuffer;
|
||||
using packet_interface::PacketMetaData;
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "entity_transport");
|
||||
|
||||
// The packet type byte holds the format version in its high nibble
|
||||
static constexpr uint8_t FORMAT_VERSION = 1;
|
||||
static constexpr uint8_t PACKET_DICTIONARY = 0x11;
|
||||
static constexpr uint8_t PACKET_DATA = 0x12;
|
||||
static constexpr uint8_t PACKET_REQUEST = 0x13;
|
||||
// Entity types, in dictionary sections and data records; 0 is not used
|
||||
static constexpr uint8_t TYPE_SENSOR = 1;
|
||||
static constexpr uint8_t TYPE_BINARY_SENSOR = 2;
|
||||
static constexpr uint8_t MAX_TYPE = 31;
|
||||
// Packet type and dictionary id
|
||||
static constexpr size_t HEADER_SIZE = 5;
|
||||
// A dictionary packet's part byte follows the header: the part's index in bits 0-6, and on the last part, bit 7
|
||||
static constexpr uint8_t PART_INDEX_MASK = 0x7F;
|
||||
static constexpr uint8_t PART_LAST = 0x80;
|
||||
static constexpr uint8_t PART_NONE = 0xFF; // no dictionary is being received in order
|
||||
// A data record starts with a 16-bit tag: the entity's number in bits 0-7, its type in bits 8-12, the payload form in
|
||||
// bits 13-14, and with PAYLOAD_NONE, the state in bit 15. Any record can be skipped without knowing its type.
|
||||
static constexpr size_t TAG_SIZE = 2;
|
||||
static constexpr uint8_t TAG_TYPE_SHIFT = 8;
|
||||
static constexpr uint8_t TAG_PAYLOAD_SHIFT = 13;
|
||||
static constexpr uint16_t TAG_VALUE_BIT = 0x8000;
|
||||
static constexpr uint8_t PAYLOAD_NONE = 0; // the state is in the tag's value bit
|
||||
static constexpr uint8_t PAYLOAD_WORD = 1; // 4 bytes follow
|
||||
static constexpr uint8_t PAYLOAD_LENGTH = 2; // a length byte follows, then that many bytes
|
||||
static constexpr uint8_t PAYLOAD_NO_STATE = 3; // the entity has no state
|
||||
static constexpr size_t WORD_SIZE = 4;
|
||||
static constexpr size_t MAX_RECORD_SIZE = TAG_SIZE + WORD_SIZE;
|
||||
// A dictionary section starts with the entity type, first number and count
|
||||
static constexpr size_t SECTION_HEADER_SIZE = 3;
|
||||
static constexpr size_t MAX_STRING_LENGTH = 255;
|
||||
static constexpr uint32_t DEFAULT_UPDATE_INTERVAL_MS = 15000;
|
||||
static constexpr uint32_t REQUEST_INTERVAL_MS = 5000;
|
||||
static constexpr uint32_t RETRY_DELAY_MS = 1000;
|
||||
static constexpr uint32_t RETRY_TIMER_ID = 0;
|
||||
static constexpr uint32_t TIMEOUT_CHECK_INTERVAL_MS = 1000;
|
||||
static constexpr uint32_t WARNING_INTERVAL_MS = 60000;
|
||||
// How long the interface may refuse every packet before it is reported
|
||||
static constexpr uint32_t REFUSED_WARNING_MS = 60000;
|
||||
|
||||
static void put_uint32(uint8_t *buf, uint32_t value) {
|
||||
for (size_t i = 0; i != 4; i++)
|
||||
buf[i] = value >> (i * 8);
|
||||
}
|
||||
|
||||
// True if the NUL terminated str is the same as the len bytes at data
|
||||
static bool matches(const char *str, const char *data, size_t len) {
|
||||
return strlen(str) == len && memcmp(str, data, len) == 0;
|
||||
}
|
||||
|
||||
struct Record {
|
||||
uint8_t number;
|
||||
uint8_t type;
|
||||
uint8_t payload;
|
||||
bool value;
|
||||
const uint8_t *data; // what follows the tag
|
||||
};
|
||||
|
||||
static Record parse_record(std::span<const uint8_t> packet, size_t pos) {
|
||||
uint16_t tag = encode_uint16(packet[pos + 1], packet[pos]);
|
||||
return {static_cast<uint8_t>(tag), static_cast<uint8_t>((tag >> TAG_TYPE_SHIFT) & MAX_TYPE),
|
||||
static_cast<uint8_t>((tag >> TAG_PAYLOAD_SHIFT) & 3), (tag & TAG_VALUE_BIT) != 0,
|
||||
packet.data() + pos + TAG_SIZE};
|
||||
}
|
||||
|
||||
// The size of the data record at pos, or 0 if it does not fit in the packet
|
||||
static size_t record_size(std::span<const uint8_t> packet, size_t pos) {
|
||||
if (pos + TAG_SIZE > packet.size())
|
||||
return 0;
|
||||
size_t size = TAG_SIZE;
|
||||
uint8_t payload = parse_record(packet, pos).payload;
|
||||
if (payload == PAYLOAD_WORD) {
|
||||
size += WORD_SIZE;
|
||||
} else if (payload == PAYLOAD_LENGTH) {
|
||||
if (pos + TAG_SIZE == packet.size())
|
||||
return 0;
|
||||
size += 1 + packet[pos + TAG_SIZE];
|
||||
}
|
||||
return pos + size <= packet.size() ? size : 0;
|
||||
}
|
||||
|
||||
// Checks every record of a data packet, so that a damaged or forged packet changes no state at all
|
||||
static bool data_well_formed(std::span<const uint8_t> packet) {
|
||||
for (size_t pos = HEADER_SIZE; pos != packet.size();) {
|
||||
size_t size = record_size(packet, pos);
|
||||
if (size == 0)
|
||||
return false;
|
||||
Record record = parse_record(packet, pos);
|
||||
if (record.type == 0 || (record.value && record.payload != PAYLOAD_NONE))
|
||||
return false;
|
||||
// Types this device knows must use their own payload form; others are skipped
|
||||
if (record.type == TYPE_SENSOR && record.payload != PAYLOAD_WORD && record.payload != PAYLOAD_NO_STATE)
|
||||
return false;
|
||||
if (record.type == TYPE_BINARY_SENSOR && record.payload != PAYLOAD_NONE && record.payload != PAYLOAD_NO_STATE)
|
||||
return false;
|
||||
pos += size;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Checks the device name and every section of a dictionary packet before anything is learnt from it
|
||||
static bool dictionary_well_formed(std::span<const uint8_t> packet) {
|
||||
size_t pos = HEADER_SIZE + 1;
|
||||
if (pos >= packet.size())
|
||||
return false;
|
||||
pos += 1 + packet[pos];
|
||||
if (pos > packet.size())
|
||||
return false;
|
||||
while (pos != packet.size()) {
|
||||
if (pos + SECTION_HEADER_SIZE > packet.size())
|
||||
return false;
|
||||
uint8_t type = packet[pos];
|
||||
size_t first = packet[pos + 1];
|
||||
size_t count = packet[pos + 2];
|
||||
pos += SECTION_HEADER_SIZE;
|
||||
// Numbers are one byte, and a data record can name types up to MAX_TYPE only
|
||||
if (type == 0 || type > MAX_TYPE || count == 0 || first + count > 256)
|
||||
return false;
|
||||
for (size_t i = 0; i != count; i++) {
|
||||
if (pos == packet.size())
|
||||
return false;
|
||||
pos += 1 + packet[pos];
|
||||
if (pos > packet.size())
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void EntityTransport::setup() {
|
||||
this->interface_ = this->interface_->top();
|
||||
this->max_packet_size_ = this->interface_->get_max_packet_size();
|
||||
const auto &name = App.get_name();
|
||||
size_t longest_id = 0;
|
||||
for (const auto &entity : this->entities_)
|
||||
longest_id = std::max(longest_id, strlen(entity.id));
|
||||
// A dictionary packet must hold the device name and any one id, which also leaves room for a data record
|
||||
if (name.size() > MAX_STRING_LENGTH || longest_id > MAX_STRING_LENGTH ||
|
||||
HEADER_SIZE + 2 + name.size() + SECTION_HEADER_SIZE + 1 + longest_id > this->max_packet_size_) {
|
||||
this->mark_failed(LOG_STR("max_packet_size too small for the device name and entity ids"));
|
||||
return;
|
||||
}
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
this->buffer_ = allocator.allocate(this->max_packet_size_);
|
||||
if (this->buffer_ == nullptr) {
|
||||
this->mark_failed(LOG_STR("Not enough memory for the packet buffer"));
|
||||
return;
|
||||
}
|
||||
// The dictionary id changes when the device name or any entity changes
|
||||
uint32_t hash = fnv1a_hash(name.c_str());
|
||||
for (const auto &entity : this->entities_) {
|
||||
hash = fnv1a_hash_extend(hash, entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR);
|
||||
hash = fnv1a_hash_extend(hash, entity.id);
|
||||
}
|
||||
this->dictionary_id_ = hash;
|
||||
|
||||
// [&entity] is safe: FixedVector never reallocates, and the parent pointer keeps the capture to one pointer
|
||||
for (auto &entity : this->entities_) {
|
||||
#ifdef USE_SENSOR
|
||||
if (entity.is_sensor) {
|
||||
static_cast<sensor::Sensor *>(entity.entity)->add_on_state_callback([&entity](float) {
|
||||
entity.parent->mark_updated_(entity);
|
||||
});
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (!entity.is_sensor) {
|
||||
// The full state callback also fires for the first state
|
||||
static_cast<binary_sensor::BinarySensor *>(entity.entity)
|
||||
->add_full_state_callback(
|
||||
[&entity](optional<bool>, optional<bool>) { entity.parent->mark_updated_(entity); });
|
||||
}
|
||||
#endif
|
||||
}
|
||||
this->interface_->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &) { this->receive_(data); });
|
||||
|
||||
bool session = this->interface_->has_capability(packet_interface::CAPABILITY_SESSION);
|
||||
if (session)
|
||||
this->interface_->add_on_session_state_callback([this](bool connected) { this->on_session_(connected); });
|
||||
// Without a session and reliable delivery, a lost dictionary or state is only replaced by a periodic resend
|
||||
if (this->update_interval_ms_ == 0) {
|
||||
this->update_interval_ms_ = session && this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)
|
||||
? SCHEDULER_DONT_RUN
|
||||
: DEFAULT_UPDATE_INTERVAL_MS;
|
||||
}
|
||||
if (this->update_interval_ms_ != SCHEDULER_DONT_RUN)
|
||||
this->set_interval(this->update_interval_ms_, [this] { this->send_all_(); });
|
||||
if (this->remote_timeout_ms_ != 0)
|
||||
this->set_interval(TIMEOUT_CHECK_INTERVAL_MS, [this] { this->clear_remote_states_(true); });
|
||||
this->send_all_();
|
||||
}
|
||||
|
||||
void EntityTransport::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Entity Transport:\n"
|
||||
" Packet Interface: %s\n"
|
||||
" Max Packet Size: %zu\n"
|
||||
" Encryption: %s\n"
|
||||
" Reliability: %s\n"
|
||||
" Session: %s\n"
|
||||
" Dictionary ID: %08" PRIX32,
|
||||
this->interface_->get_name(), this->max_packet_size_,
|
||||
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_ENCRYPTION)),
|
||||
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)),
|
||||
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_SESSION)), this->dictionary_id_);
|
||||
if (this->update_interval_ms_ == SCHEDULER_DONT_RUN) {
|
||||
ESP_LOGCONFIG(TAG, " Update Interval: never");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Update Interval: %" PRIu32 "ms", this->update_interval_ms_);
|
||||
}
|
||||
if (this->remote_timeout_ms_ != 0) {
|
||||
ESP_LOGCONFIG(TAG, " Remote Timeout: %" PRIu32 "ms", this->remote_timeout_ms_);
|
||||
}
|
||||
for (const auto &entity : this->entities_) {
|
||||
ESP_LOGCONFIG(TAG, " %s %u: %s", entity.is_sensor ? LOG_STR_LITERAL("Sensor") : LOG_STR_LITERAL("Binary Sensor"),
|
||||
entity.number, entity.id);
|
||||
}
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
for (const auto &remote : this->remote_sensors_) {
|
||||
ESP_LOGCONFIG(TAG, " Remote Sensor: %s from %s", remote.remote_id, remote.provider);
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
for (const auto &remote : this->remote_binary_sensors_) {
|
||||
ESP_LOGCONFIG(TAG, " Remote Binary Sensor: %s from %s", remote.remote_id, remote.provider);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void EntityTransport::send_all_() {
|
||||
this->dictionary_pending_ = true;
|
||||
this->dictionary_next_ = 0;
|
||||
this->dictionary_part_ = 0;
|
||||
for (auto &entity : this->entities_)
|
||||
entity.updated = true;
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
void EntityTransport::mark_updated_(LocalEntity &entity) {
|
||||
entity.updated = true;
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
void EntityTransport::loop() {
|
||||
this->disable_loop();
|
||||
// States are only sent once the dictionary that names them has been accepted
|
||||
if ((this->dictionary_pending_ && !this->send_dictionary_()) || !this->send_updated_()) {
|
||||
this->sending_refused_();
|
||||
this->set_timeout(RETRY_TIMER_ID, RETRY_DELAY_MS, [this] { this->enable_loop(); });
|
||||
return;
|
||||
}
|
||||
this->refusing_ = false;
|
||||
if (this->status_has_warning()) {
|
||||
ESP_LOGI(TAG, "Packets are accepted again");
|
||||
this->status_clear_warning();
|
||||
}
|
||||
}
|
||||
|
||||
// A link that is down or full refuses packets for a while; one that refuses them for long sends nothing at all
|
||||
void EntityTransport::sending_refused_() {
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (!this->refusing_) {
|
||||
this->refusing_ = true;
|
||||
this->refused_since_ms_ = now;
|
||||
return;
|
||||
}
|
||||
if (!this->status_has_warning() && now - this->refused_since_ms_ >= REFUSED_WARNING_MS) {
|
||||
ESP_LOGW(TAG, "The packet interface has accepted no packets for %" PRIu32 "s, so no states are sent",
|
||||
(now - this->refused_since_ms_) / 1000);
|
||||
this->status_set_warning();
|
||||
}
|
||||
}
|
||||
|
||||
size_t EntityTransport::write_header_(uint8_t type) const {
|
||||
this->buffer_[0] = type;
|
||||
put_uint32(this->buffer_ + 1, this->dictionary_id_);
|
||||
return HEADER_SIZE;
|
||||
}
|
||||
|
||||
bool EntityTransport::send_dictionary_() {
|
||||
uint8_t *buf = this->buffer_;
|
||||
const auto &name = App.get_name();
|
||||
size_t header = this->write_header_(PACKET_DICTIONARY);
|
||||
size_t part_offset = header++;
|
||||
buf[header++] = name.size();
|
||||
memcpy(buf + header, name.c_str(), name.size());
|
||||
header += name.size();
|
||||
size_t length = header;
|
||||
size_t count_offset = 0;
|
||||
uint8_t section_type = 0; // none
|
||||
// A dictionary in several packets resumes after the last packet accepted, so a refused one does not resend the rest
|
||||
for (size_t i = this->dictionary_next_; i != this->entities_.size(); i++) {
|
||||
const auto &entity = this->entities_[i];
|
||||
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
|
||||
size_t id_length = strlen(entity.id);
|
||||
bool new_section = type != section_type;
|
||||
if (length + (new_section ? SECTION_HEADER_SIZE : 0) + 1 + id_length > this->max_packet_size_) {
|
||||
buf[part_offset] = this->dictionary_part_ & PART_INDEX_MASK;
|
||||
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
|
||||
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
|
||||
return false;
|
||||
}
|
||||
this->dictionary_next_ = i;
|
||||
this->dictionary_part_++;
|
||||
length = header;
|
||||
new_section = true;
|
||||
}
|
||||
if (new_section) {
|
||||
buf[length++] = type;
|
||||
buf[length++] = entity.number;
|
||||
count_offset = length;
|
||||
buf[length++] = 0;
|
||||
section_type = type;
|
||||
}
|
||||
buf[count_offset]++;
|
||||
buf[length++] = id_length;
|
||||
memcpy(buf + length, entity.id, id_length);
|
||||
length += id_length;
|
||||
}
|
||||
if (length != header) {
|
||||
buf[part_offset] = (this->dictionary_part_ & PART_INDEX_MASK) | PART_LAST;
|
||||
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
|
||||
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this->dictionary_pending_ = false;
|
||||
this->dictionary_next_ = 0;
|
||||
this->dictionary_part_ = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Writes the entity's data record, and returns its size
|
||||
size_t EntityTransport::write_record_(const LocalEntity &entity, uint8_t *record) const {
|
||||
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
|
||||
uint8_t payload = PAYLOAD_NO_STATE;
|
||||
bool value = false;
|
||||
size_t size = TAG_SIZE;
|
||||
#ifdef USE_SENSOR
|
||||
if (entity.is_sensor) {
|
||||
float state = static_cast<sensor::Sensor *>(entity.entity)->get_state();
|
||||
if (!std::isnan(state)) {
|
||||
payload = PAYLOAD_WORD;
|
||||
uint32_t bits;
|
||||
memcpy(&bits, &state, sizeof(bits));
|
||||
put_uint32(record + TAG_SIZE, bits);
|
||||
size += WORD_SIZE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (!entity.is_sensor) {
|
||||
auto *sensor = static_cast<binary_sensor::BinarySensor *>(entity.entity);
|
||||
if (sensor->has_state()) {
|
||||
payload = PAYLOAD_NONE;
|
||||
value = sensor->state;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
uint16_t tag =
|
||||
entity.number | (type << TAG_TYPE_SHIFT) | (payload << TAG_PAYLOAD_SHIFT) | (value ? TAG_VALUE_BIT : 0);
|
||||
record[0] = tag;
|
||||
record[1] = tag >> 8;
|
||||
return size;
|
||||
}
|
||||
|
||||
// Sends every updated entity, in as few packets as fit; returns false if a packet was refused
|
||||
bool EntityTransport::send_updated_() {
|
||||
size_t length = this->write_header_(PACKET_DATA);
|
||||
size_t first = 0;
|
||||
for (size_t i = 0; i != this->entities_.size(); i++) {
|
||||
auto &entity = this->entities_[i];
|
||||
if (!entity.updated)
|
||||
continue;
|
||||
uint8_t record[MAX_RECORD_SIZE];
|
||||
size_t size = this->write_record_(entity, record);
|
||||
if (length + size > this->max_packet_size_) {
|
||||
if (!this->flush_(length, first, i))
|
||||
return false;
|
||||
length = HEADER_SIZE;
|
||||
first = i;
|
||||
}
|
||||
memcpy(this->buffer_ + length, record, size);
|
||||
length += size;
|
||||
}
|
||||
return this->flush_(length, first, this->entities_.size());
|
||||
}
|
||||
|
||||
// Sends the packet holding the updated entities from first up to last, and clears their flags if it was accepted
|
||||
bool EntityTransport::flush_(size_t length, size_t first, size_t last) {
|
||||
if (length == HEADER_SIZE)
|
||||
return true;
|
||||
if (!this->interface_->send_to_interface(PacketBuffer(this->buffer_, length), {})) {
|
||||
ESP_LOGV(TAG, "Packet of %zu bytes not accepted, will retry", length);
|
||||
return false;
|
||||
}
|
||||
for (size_t i = first; i != last; i++)
|
||||
this->entities_[i].updated = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void EntityTransport::receive_(const PacketBuffer &data) {
|
||||
// Packet interface layers deliver packets in one piece
|
||||
if (!data.is_contiguous()) {
|
||||
ESP_LOGW(TAG, "Packet split across buffers is not supported");
|
||||
return;
|
||||
}
|
||||
auto packet = data.as_span();
|
||||
if (packet.size() < HEADER_SIZE) {
|
||||
if (this->warning_due_()) {
|
||||
ESP_LOGW(TAG, "Packet of %zu bytes is too short", packet.size());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (packet[0] >> 4 != FORMAT_VERSION) {
|
||||
if (this->warning_due_()) {
|
||||
ESP_LOGW(TAG, "Packet format version %u not supported; the sender may run another ESPHome version",
|
||||
packet[0] >> 4);
|
||||
}
|
||||
return;
|
||||
}
|
||||
uint32_t dictionary_id = encode_uint32(packet[4], packet[3], packet[2], packet[1]);
|
||||
if (dictionary_id == this->dictionary_id_) {
|
||||
// Either a request for our dictionary, or our own packet heard on a broadcast channel
|
||||
if (packet[0] == PACKET_REQUEST && packet.size() == HEADER_SIZE) {
|
||||
ESP_LOGD(TAG, "Dictionary requested");
|
||||
this->send_all_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (packet[0] == PACKET_DICTIONARY) {
|
||||
this->receive_dictionary_(packet, dictionary_id);
|
||||
} else if (packet[0] == PACKET_DATA) {
|
||||
this->receive_data_(packet, dictionary_id);
|
||||
} else if (packet[0] != PACKET_REQUEST) {
|
||||
ESP_LOGV(TAG, "Unknown packet type 0x%02X", packet[0]);
|
||||
}
|
||||
}
|
||||
|
||||
void EntityTransport::receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
|
||||
if (!dictionary_well_formed(packet)) {
|
||||
if (this->warning_due_()) {
|
||||
ESP_LOGW(TAG, "Dictionary packet of %zu bytes is malformed", packet.size());
|
||||
}
|
||||
return;
|
||||
}
|
||||
size_t pos = HEADER_SIZE;
|
||||
uint8_t part = packet[pos++];
|
||||
uint8_t index = part & PART_INDEX_MASK;
|
||||
// The dictionary is complete, and need not be asked for again, once its last part follows all the others in order
|
||||
bool in_order = index == 0 || (this->rx_dictionary_id_ == dictionary_id && index == this->rx_next_part_);
|
||||
this->rx_dictionary_id_ = dictionary_id;
|
||||
this->rx_next_part_ = in_order ? (index + 1) & PART_INDEX_MASK : PART_NONE;
|
||||
bool completed = in_order && (part & PART_LAST) != 0;
|
||||
bool first_time = completed && !this->dictionary_seen_(dictionary_id);
|
||||
if (first_time) {
|
||||
this->seen_dictionaries_[this->seen_next_] = dictionary_id;
|
||||
this->seen_next_ = (this->seen_next_ + 1) % this->seen_dictionaries_.size();
|
||||
}
|
||||
size_t provider_length = packet[pos++];
|
||||
const auto *provider = reinterpret_cast<const char *>(packet.data() + pos);
|
||||
pos += provider_length;
|
||||
while (pos != packet.size()) {
|
||||
uint8_t type = packet[pos];
|
||||
uint8_t number = packet[pos + 1];
|
||||
uint8_t count = packet[pos + 2];
|
||||
pos += SECTION_HEADER_SIZE;
|
||||
for (uint8_t i = 0; i != count; i++) {
|
||||
size_t id_length = packet[pos++];
|
||||
this->learn_(provider, provider_length, type, number + i, reinterpret_cast<const char *>(packet.data() + pos),
|
||||
id_length, dictionary_id);
|
||||
pos += id_length;
|
||||
}
|
||||
}
|
||||
// A provider may go back to a dictionary already seen, so every complete one is checked
|
||||
if (completed)
|
||||
this->forget_unnamed_(provider, provider_length, dictionary_id, first_time);
|
||||
}
|
||||
|
||||
void EntityTransport::forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id,
|
||||
bool first_time) {
|
||||
// A remote entity that its provider's whole dictionary does not name would otherwise keep no or a stale state
|
||||
// silently
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
for (auto &remote : this->remote_sensors_) {
|
||||
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
|
||||
continue;
|
||||
if (remote.known || first_time) {
|
||||
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
|
||||
}
|
||||
remote.known = false;
|
||||
if (remote.has_state) {
|
||||
remote.has_state = false;
|
||||
remote.entity->publish_state(NAN);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
for (auto &remote : this->remote_binary_sensors_) {
|
||||
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
|
||||
continue;
|
||||
if (remote.known || first_time) {
|
||||
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
|
||||
}
|
||||
remote.known = false;
|
||||
if (remote.has_state) {
|
||||
remote.has_state = false;
|
||||
remote.entity->invalidate_state();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void EntityTransport::learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
|
||||
size_t id_length, uint32_t dictionary_id) {
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
if (type == TYPE_SENSOR) {
|
||||
for (auto &remote : this->remote_sensors_) {
|
||||
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
|
||||
remote.dictionary_id = dictionary_id;
|
||||
remote.number = number;
|
||||
remote.known = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
if (type == TYPE_BINARY_SENSOR) {
|
||||
for (auto &remote : this->remote_binary_sensors_) {
|
||||
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
|
||||
remote.dictionary_id = dictionary_id;
|
||||
remote.number = number;
|
||||
remote.known = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool EntityTransport::dictionary_seen_(uint32_t dictionary_id) const {
|
||||
return std::find(this->seen_dictionaries_.begin(), this->seen_dictionaries_.end(), dictionary_id) !=
|
||||
this->seen_dictionaries_.end();
|
||||
}
|
||||
|
||||
void EntityTransport::receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
|
||||
bool known = false;
|
||||
bool has_remotes = false;
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
for (const auto &remote : this->remote_sensors_) {
|
||||
known |= remote.known && remote.dictionary_id == dictionary_id;
|
||||
has_remotes = true;
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
for (const auto &remote : this->remote_binary_sensors_) {
|
||||
known |= remote.known && remote.dictionary_id == dictionary_id;
|
||||
has_remotes = true;
|
||||
}
|
||||
#endif
|
||||
if (!has_remotes)
|
||||
return;
|
||||
// Checked first, so a damaged packet does not make the provider resend everything
|
||||
if (!data_well_formed(packet)) {
|
||||
if (this->warning_due_()) {
|
||||
ESP_LOGW(TAG, "Data packet of %zu bytes is malformed", packet.size());
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A dictionary not yet received in full may name our entities, or be a provider's new one; one received in full
|
||||
// named all of them it could, so asking again would only make that provider resend everything
|
||||
if (!this->dictionary_seen_(dictionary_id))
|
||||
this->request_dictionary_(dictionary_id);
|
||||
if (!known)
|
||||
return;
|
||||
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
|
||||
for (size_t pos = HEADER_SIZE; pos != packet.size(); pos += record_size(packet, pos)) {
|
||||
[[maybe_unused]] Record record = parse_record(packet, pos);
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
if (record.type == TYPE_SENSOR) {
|
||||
float value = NAN;
|
||||
if (record.payload == PAYLOAD_WORD) {
|
||||
uint32_t bits = encode_uint32(record.data[3], record.data[2], record.data[1], record.data[0]);
|
||||
memcpy(&value, &bits, sizeof(value));
|
||||
}
|
||||
for (auto &remote : this->remote_sensors_) {
|
||||
if (remote.known && remote.dictionary_id == dictionary_id && remote.number == record.number) {
|
||||
remote.last_state_ms = now;
|
||||
remote.has_state = true;
|
||||
remote.entity->publish_state(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
if (record.type == TYPE_BINARY_SENSOR) {
|
||||
for (auto &remote : this->remote_binary_sensors_) {
|
||||
if (!remote.known || remote.dictionary_id != dictionary_id || remote.number != record.number)
|
||||
continue;
|
||||
remote.last_state_ms = now;
|
||||
remote.has_state = true;
|
||||
if (record.payload == PAYLOAD_NO_STATE) {
|
||||
remote.entity->invalidate_state();
|
||||
} else {
|
||||
remote.entity->publish_state(record.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
void EntityTransport::request_dictionary_(uint32_t dictionary_id) {
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (this->request_sent_ && now - this->last_request_ms_ < REQUEST_INTERVAL_MS)
|
||||
return;
|
||||
uint8_t packet[HEADER_SIZE] = {PACKET_REQUEST};
|
||||
put_uint32(packet + 1, dictionary_id);
|
||||
// A refused request is tried again with the next data packet under that dictionary id
|
||||
if (!this->interface_->send_to_interface(PacketBuffer(packet, sizeof(packet)), {})) {
|
||||
ESP_LOGV(TAG, "Request for dictionary %08" PRIX32 " not accepted", dictionary_id);
|
||||
return;
|
||||
}
|
||||
this->request_sent_ = true;
|
||||
this->last_request_ms_ = now;
|
||||
ESP_LOGD(TAG, "Requested dictionary %08" PRIX32, dictionary_id);
|
||||
}
|
||||
|
||||
// Dropped packets point to a device with other firmware or a fault, and may arrive steadily, so warn only now and then
|
||||
bool EntityTransport::warning_due_() {
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (this->warned_ && now - this->last_warning_ms_ < WARNING_INTERVAL_MS)
|
||||
return false;
|
||||
this->warned_ = true;
|
||||
this->last_warning_ms_ = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
// A newly connected or restarted peer needs the dictionary and every state; while it is lost, its entities have no
|
||||
// state
|
||||
void EntityTransport::on_session_(bool connected) {
|
||||
if (connected) {
|
||||
this->send_all_();
|
||||
return;
|
||||
}
|
||||
this->clear_remote_states_(false);
|
||||
}
|
||||
|
||||
// Clears the state of every remote entity, or with timed_out_only, of those whose state is older than the timeout
|
||||
void EntityTransport::clear_remote_states_(bool timed_out_only) {
|
||||
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
for (auto &remote : this->remote_sensors_) {
|
||||
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
|
||||
continue;
|
||||
remote.has_state = false;
|
||||
remote.entity->publish_state(NAN);
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
for (auto &remote : this->remote_binary_sensors_) {
|
||||
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
|
||||
continue;
|
||||
remote.has_state = false;
|
||||
remote.entity->invalidate_state();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::entity_transport
|
||||
@@ -0,0 +1,171 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <span>
|
||||
|
||||
#include "esphome/components/packet_interface/packet_interface.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::entity_transport {
|
||||
|
||||
/**
|
||||
* Sends the states of local sensors and binary sensors over a packet interface, and publishes the states received
|
||||
* from other devices to remote entities.
|
||||
*
|
||||
* Each device numbers its sensors and its binary sensors from 0, and sends a dictionary that gives the id for each
|
||||
* number. Data packets then refer to an entity by its number. Every packet carries the sender's dictionary id, a hash
|
||||
* of its device name and dictionary, so that a receiver knows which device sent it and which dictionary applies.
|
||||
* All values are little endian.
|
||||
*
|
||||
* Dictionary packet:
|
||||
* - type: 1 byte, PACKET_DICTIONARY;
|
||||
* - dictionary id: 4 bytes;
|
||||
* - part: 1 byte, the packet's index in the dictionary in bits 0-6, counting from 0 and wrapping, and bit 7 set on
|
||||
* the last packet;
|
||||
* - sender's device name: length (1 byte), then the name;
|
||||
* - one or more sections, each: entity type (1 byte), first number (1 byte), count (1 byte), then for each entity
|
||||
* its id: length (1 byte), then the id.
|
||||
*
|
||||
* Data packet:
|
||||
* - type: 1 byte, PACKET_DATA;
|
||||
* - dictionary id: 4 bytes;
|
||||
* - records, to the end, each a 16-bit tag, then its payload. The tag holds the entity's number in bits 0-7, its type
|
||||
* in bits 8-12, and the payload form in bits 13-14: none, with the state in bit 15 (binary sensors); 4 bytes (a
|
||||
* sensor's float); a length byte, then that many bytes; or no state, with no payload. The form gives the record's
|
||||
* size, so a receiver skips records of types it does not know.
|
||||
*
|
||||
* Every packet is checked in full before anything in it is used, so a damaged or forged packet changes no state.
|
||||
*
|
||||
* Dictionary request packet, sent by a device that receives data with a dictionary id it has not received in full,
|
||||
* with every part in order:
|
||||
* - type: 1 byte, PACKET_REQUEST;
|
||||
* - the dictionary id wanted: 4 bytes.
|
||||
*
|
||||
* Changed states are sent from loop(). The dictionary and all states are sent at boot, when a session layer reports
|
||||
* the peer connected, when the dictionary is requested, and every update interval. Unless the interface has both a
|
||||
* session and a reliability layer, the update interval defaults to DEFAULT_UPDATE_INTERVAL_MS. When the interface
|
||||
* refuses a packet, sending is tried again after a delay, and a warning is set once it has refused every packet for a
|
||||
* minute. With a remote timeout, a remote entity loses its state when
|
||||
* none arrives for it within the timeout.
|
||||
*/
|
||||
class EntityTransport : public Component {
|
||||
public:
|
||||
explicit EntityTransport(packet_interface::PacketInterface *interface) : interface_(interface) {}
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// 0 chooses the default for the interface's layers; SCHEDULER_DONT_RUN never resends.
|
||||
void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; }
|
||||
/// Remote entities lose their state when none arrives for this long; 0 keeps the last state.
|
||||
void set_remote_timeout(uint32_t timeout_ms) { this->remote_timeout_ms_ = timeout_ms; }
|
||||
void set_entity_count(size_t count) { this->entities_.init(count); }
|
||||
// Sensors must be added before binary sensors, so that data packets hold sensor records first
|
||||
#ifdef USE_SENSOR
|
||||
void add_sensor(const char *id, sensor::Sensor *sensor) {
|
||||
this->entities_.push_back({sensor, id, this, this->sensor_count_++, true, true});
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
void add_binary_sensor(const char *id, binary_sensor::BinarySensor *sensor) {
|
||||
this->entities_.push_back({sensor, id, this, this->binary_sensor_count_++, false, true});
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
void add_remote_sensor(const char *provider, const char *remote_id, sensor::Sensor *sensor) {
|
||||
this->remote_sensors_.push_back({provider, remote_id, sensor});
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
void add_remote_binary_sensor(const char *provider, const char *remote_id, binary_sensor::BinarySensor *sensor) {
|
||||
this->remote_binary_sensors_.push_back({provider, remote_id, sensor});
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
struct LocalEntity {
|
||||
EntityBase *entity; // a sensor::Sensor or a binary_sensor::BinarySensor, as is_sensor says
|
||||
const char *id;
|
||||
EntityTransport *parent;
|
||||
uint8_t number;
|
||||
bool is_sensor;
|
||||
bool updated;
|
||||
};
|
||||
template<typename T> struct RemoteEntity {
|
||||
const char *provider;
|
||||
const char *remote_id;
|
||||
T *entity;
|
||||
uint32_t dictionary_id{0}; // learnt from the provider's dictionary
|
||||
uint32_t last_state_ms{0};
|
||||
uint8_t number{0};
|
||||
bool known{false};
|
||||
bool has_state{false}; // a state arrived, and has not yet timed out
|
||||
};
|
||||
|
||||
void send_all_();
|
||||
void mark_updated_(LocalEntity &entity);
|
||||
bool send_dictionary_();
|
||||
bool send_updated_();
|
||||
size_t write_record_(const LocalEntity &entity, uint8_t *record) const;
|
||||
bool flush_(size_t length, size_t first, size_t last);
|
||||
size_t write_header_(uint8_t type) const;
|
||||
void receive_(const packet_interface::PacketBuffer &data);
|
||||
void receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id);
|
||||
void receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id);
|
||||
void learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
|
||||
size_t id_length, uint32_t dictionary_id);
|
||||
void request_dictionary_(uint32_t dictionary_id);
|
||||
bool dictionary_seen_(uint32_t dictionary_id) const;
|
||||
/// Clears each remote entity of \p provider that its complete dictionary does not name, warning if the entity was
|
||||
/// known or the dictionary is \p first_time seen, so a resent dictionary does not repeat the warning.
|
||||
void forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id, bool first_time);
|
||||
bool warning_due_();
|
||||
void sending_refused_();
|
||||
void on_session_(bool connected);
|
||||
void clear_remote_states_(bool timed_out_only);
|
||||
|
||||
packet_interface::PacketInterface *interface_;
|
||||
FixedVector<LocalEntity> entities_;
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
StaticVector<RemoteEntity<sensor::Sensor>, ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT> remote_sensors_;
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
StaticVector<RemoteEntity<binary_sensor::BinarySensor>, ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT>
|
||||
remote_binary_sensors_;
|
||||
#endif
|
||||
// Packets are built here, allocated in setup()
|
||||
uint8_t *buffer_{nullptr};
|
||||
size_t max_packet_size_{0};
|
||||
uint32_t update_interval_ms_{0};
|
||||
uint32_t remote_timeout_ms_{0};
|
||||
uint32_t dictionary_id_{0};
|
||||
uint32_t last_request_ms_{0};
|
||||
uint32_t last_warning_ms_{0};
|
||||
uint32_t refused_since_ms_{0}; // when the interface began refusing every packet
|
||||
uint32_t rx_dictionary_id_{0}; // the dictionary whose parts are arriving
|
||||
// The last dictionary ids received in full, so a provider whose dictionary names none of our entities is not asked
|
||||
// again
|
||||
std::array<uint32_t, 8> seen_dictionaries_{};
|
||||
uint8_t seen_next_{0};
|
||||
uint8_t rx_next_part_{0}; // the index of its next part, or PART_NONE if one was missed
|
||||
uint8_t dictionary_part_{0}; // the index of the next dictionary packet to send
|
||||
uint8_t sensor_count_{0};
|
||||
uint8_t binary_sensor_count_{0};
|
||||
size_t dictionary_next_{0}; // the first entity of the next dictionary packet to send
|
||||
bool dictionary_pending_{true};
|
||||
bool request_sent_{false};
|
||||
bool warned_{false};
|
||||
bool refusing_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::entity_transport
|
||||
@@ -0,0 +1,11 @@
|
||||
from esphome.components import sensor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import register_remote_sensor, remote_entity_schema
|
||||
|
||||
CONFIG_SCHEMA = remote_entity_schema(sensor.sensor_schema())
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await register_remote_sensor(config, var)
|
||||
@@ -0,0 +1,196 @@
|
||||
from collections.abc import Callable
|
||||
import importlib
|
||||
import pkgutil
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_MAX_PACKET_SIZE
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import layers
|
||||
from .layers import (
|
||||
ADDRESSING,
|
||||
BROADCAST_ADDRESS,
|
||||
CONF_ADDRESSING,
|
||||
CONF_DESTINATION,
|
||||
CONF_PEERS,
|
||||
CONF_SESSION,
|
||||
STACK_ORDER,
|
||||
PacketInterface,
|
||||
build_stack,
|
||||
check_packet_sizes,
|
||||
check_session,
|
||||
check_single_peer,
|
||||
layer_needing_session,
|
||||
packet_interface_ns,
|
||||
stack_auto_load,
|
||||
stack_schema,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DOMAIN = "packet_interface"
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
|
||||
CONF_PACKET_INTERFACE = "packet_interface"
|
||||
|
||||
# Each module in layers registers a layer type; import them all before building the schemas from the registry
|
||||
for module_info in pkgutil.iter_modules(layers.__path__):
|
||||
importlib.import_module(f".layers.{module_info.name}", package=__package__)
|
||||
|
||||
LINK_LAYER_SCHEMAS = {
|
||||
cv.Optional(CONF_ADDRESSING): ADDRESSING.schema(),
|
||||
**stack_schema(),
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"CONF_MAX_PACKET_SIZE",
|
||||
"CONF_PACKET_INTERFACE",
|
||||
"interface_layers",
|
||||
"PacketInterface",
|
||||
"final_validate_interface",
|
||||
"new_packet_interface",
|
||||
"packet_interface_ns",
|
||||
"packet_interface_schema",
|
||||
]
|
||||
|
||||
|
||||
def AUTO_LOAD(config: list[ConfigType] | None = None) -> list[str]:
|
||||
"""Load only what the configured layers use, on the link and in each addressing peer.
|
||||
|
||||
Without a config, as tooling that lists dependencies calls it, everything a layer could load.
|
||||
"""
|
||||
if config is None:
|
||||
return ["binary_sensor", "xxtea"]
|
||||
loads: set[str] = set()
|
||||
for interface in config:
|
||||
loads |= stack_auto_load(interface)
|
||||
for peer in interface.get(CONF_ADDRESSING, {}).get(CONF_PEERS, []):
|
||||
loads |= stack_auto_load(peer)
|
||||
return sorted(loads)
|
||||
|
||||
|
||||
def _validate_layers(config: ConfigType) -> ConfigType:
|
||||
"""With peers, the layers go in each peer; otherwise check them against the one destination."""
|
||||
check_session(config)
|
||||
size = config[CONF_MAX_PACKET_SIZE]
|
||||
if (addressing := config.get(CONF_ADDRESSING)) is None:
|
||||
check_packet_sizes(config, size)
|
||||
return config
|
||||
size = ADDRESSING.get_type(addressing).packet_size(addressing, size)
|
||||
if CONF_PEERS in addressing:
|
||||
for kind in STACK_ORDER:
|
||||
if kind.key in config:
|
||||
raise cv.Invalid(
|
||||
f"With '{CONF_PEERS}', '{kind.key}' goes in each peer",
|
||||
path=[kind.key],
|
||||
)
|
||||
for index, peer in enumerate(addressing[CONF_PEERS]):
|
||||
try:
|
||||
check_packet_sizes(peer, size)
|
||||
except cv.Invalid as err:
|
||||
err.prepend([CONF_ADDRESSING, CONF_PEERS, index])
|
||||
raise
|
||||
return config
|
||||
addressing.setdefault(CONF_DESTINATION, BROADCAST_ADDRESS)
|
||||
check_single_peer(config, addressing[CONF_DESTINATION])
|
||||
check_packet_sizes(config, size)
|
||||
return config
|
||||
|
||||
|
||||
def interface_layers(interface_id: ID) -> ConfigType | None:
|
||||
"""During final validation, the configuration holding the layers of the packet interface with this id.
|
||||
|
||||
The id is a link's, or an addressing peer's.
|
||||
"""
|
||||
for interface in fv.full_config.get().get(DOMAIN, []):
|
||||
if interface[CONF_ID].id == interface_id.id:
|
||||
return interface
|
||||
for peer in interface.get(CONF_ADDRESSING, {}).get(CONF_PEERS, []):
|
||||
if peer[CONF_ID].id == interface_id.id:
|
||||
return peer
|
||||
return None
|
||||
|
||||
|
||||
def final_validate_interface(key: str) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Final validation for an application: a link with peers must be used through one of the peers."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
interface_id: ID = config[key]
|
||||
interface = interface_layers(interface_id)
|
||||
if interface is not None and (
|
||||
peers := interface.get(CONF_ADDRESSING, {}).get(CONF_PEERS)
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{interface_id.id}' has peers, so use the id of one of them, such as '{peers[0][CONF_ID].id}'",
|
||||
path=[key],
|
||||
)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def _check_provided_session(config: ConfigType) -> ConfigType:
|
||||
"""A link's own session does not send packets through the layers, so none can need them from the peer."""
|
||||
if key := layer_needing_session(config):
|
||||
raise cv.Invalid(
|
||||
f"This '{key}' needs packets from the peer, which this link does not send through its layers, "
|
||||
"so it cannot be used on this link",
|
||||
path=[key],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def packet_interface_schema(
|
||||
class_: MockObjClass,
|
||||
default_max_packet_size: int,
|
||||
max_packet_size_limit: int = 4096,
|
||||
provides: tuple[str, ...] = (),
|
||||
) -> cv.Schema:
|
||||
"""Schema for a link platform; provides names the layer keys, such as reliability, that the link does itself."""
|
||||
link_layers = {
|
||||
key: (
|
||||
cv.invalid(f"This link provides '{key}' itself, so it cannot be configured")
|
||||
if key in provides
|
||||
else schema
|
||||
)
|
||||
for key, schema in LINK_LAYER_SCHEMAS.items()
|
||||
}
|
||||
schema = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(class_),
|
||||
cv.Optional(
|
||||
CONF_MAX_PACKET_SIZE, default=default_max_packet_size
|
||||
): cv.int_range(16, max_packet_size_limit),
|
||||
**link_layers,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
if CONF_SESSION in provides:
|
||||
schema = schema.add_extra(_check_provided_session)
|
||||
return schema.add_extra(_validate_layers)
|
||||
|
||||
|
||||
async def new_packet_interface(config: ConfigType, *args: Any) -> MockObj:
|
||||
"""Create the link layer and the layers configured on it, from the bottom up.
|
||||
|
||||
The link's constructor takes its maximum packet size, usually as a std::span over a buffer that code generation
|
||||
sized from the configuration, so the two cannot differ.
|
||||
|
||||
Returns the link layer; applications reach the top layer with PacketInterface::top(). With several addressing
|
||||
peers, applications use the peers' ids instead.
|
||||
"""
|
||||
var = cg.new_Pvariable(config[CONF_ID], *args)
|
||||
cg.add(var.set_name(config[CONF_ID].id))
|
||||
await cg.register_component(var, config)
|
||||
if (addressing := config.get(CONF_ADDRESSING)) is None:
|
||||
await build_stack(config, var)
|
||||
return var
|
||||
for port, layers_config in await ADDRESSING.get_type(addressing).build(
|
||||
addressing, var
|
||||
):
|
||||
await build_stack(config if layers_config is None else layers_config, port)
|
||||
return var
|
||||
@@ -0,0 +1,97 @@
|
||||
#include "basic_addressing.h"
|
||||
#ifdef USE_PACKET_INTERFACE_BASIC_ADDRESSING
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "packet_interface.addressing");
|
||||
|
||||
void BasicAddressing::setup() {
|
||||
this->link_->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
|
||||
}
|
||||
|
||||
void BasicAddressing::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Basic Addressing on '%s':\n"
|
||||
" Address: 0x%04X",
|
||||
this->link_->get_name(), this->address_);
|
||||
}
|
||||
|
||||
bool BasicAddressing::send(const PacketBuffer &data, const PacketMetaData &meta_data, uint16_t destination) {
|
||||
if (meta_data.destination_address != 0)
|
||||
destination = meta_data.destination_address;
|
||||
uint8_t header[HEADER_SIZE] = {static_cast<uint8_t>(destination), static_cast<uint8_t>(destination >> 8),
|
||||
static_cast<uint8_t>(this->address_), static_cast<uint8_t>(this->address_ >> 8)};
|
||||
PacketBuffer packet(header, HEADER_SIZE, &data, nullptr, 0);
|
||||
return this->link_->send_to_interface(packet, meta_data);
|
||||
}
|
||||
|
||||
void BasicAddressing::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
// Lower layers deliver packets in one piece; the payload is passed up without copying
|
||||
if (!data.is_contiguous()) {
|
||||
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->link_->get_name());
|
||||
return;
|
||||
}
|
||||
auto packet = data.as_span();
|
||||
if (packet.size() < HEADER_SIZE) {
|
||||
ESP_LOGV(TAG, "%s: Packet of %zu bytes is shorter than the address header, dropped", this->link_->get_name(),
|
||||
packet.size());
|
||||
return;
|
||||
}
|
||||
uint16_t destination = encode_uint16(packet[1], packet[0]);
|
||||
uint16_t source = encode_uint16(packet[3], packet[2]);
|
||||
if (source == this->address_ || (destination != this->address_ && destination != BROADCAST_ADDRESS))
|
||||
return;
|
||||
AddressingPort *source_port = nullptr;
|
||||
AddressingPort *broadcast_port = nullptr;
|
||||
for (auto *port : this->ports_) {
|
||||
if (port->destination_ == source) {
|
||||
source_port = port;
|
||||
} else if (port->destination_ == BROADCAST_ADDRESS) {
|
||||
broadcast_port = port;
|
||||
}
|
||||
}
|
||||
// A broadcast goes to the broadcast port, even from a device with a port of its own, as that port's layers expect
|
||||
// only its point-to-point packets; anything else goes to the source's port, or to the broadcast port if it has none
|
||||
AddressingPort *target = source_port;
|
||||
if (broadcast_port != nullptr && (destination == BROADCAST_ADDRESS || source_port == nullptr))
|
||||
target = broadcast_port;
|
||||
if (target == nullptr) {
|
||||
ESP_LOGV(TAG, "%s: Packet from 0x%04X dropped", this->link_->get_name(), source);
|
||||
return;
|
||||
}
|
||||
PacketMetaData received = meta_data;
|
||||
received.source_address = source;
|
||||
received.destination_address = destination;
|
||||
target->on_receive_from_interface_(PacketBuffer(packet.data() + HEADER_SIZE, packet.size() - HEADER_SIZE), received);
|
||||
}
|
||||
|
||||
AddressingPort::AddressingPort(BasicAddressing *addressing, uint16_t destination, bool only_port)
|
||||
: PacketInterface(
|
||||
remaining_packet_size(addressing->get_link()->get_max_packet_size(), BasicAddressing::HEADER_SIZE)),
|
||||
addressing_(addressing),
|
||||
destination_(destination) {
|
||||
if (only_port) {
|
||||
this->stack_on_(addressing->get_link(), CAPABILITY_ADDRESSING);
|
||||
} else {
|
||||
this->inherit_from_(addressing->get_link(), CAPABILITY_ADDRESSING);
|
||||
}
|
||||
// A link with no room leaves the layers above none either, so they fail their setup
|
||||
addressing->add_port(this);
|
||||
}
|
||||
|
||||
void AddressingPort::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Addressing Port '%s':\n"
|
||||
" Max Packet Size: %zu",
|
||||
this->name_, this->max_packet_size_);
|
||||
if (this->destination_ == BasicAddressing::BROADCAST_ADDRESS) {
|
||||
ESP_LOGCONFIG(TAG, " Destination: broadcast");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Destination: 0x%04X", this->destination_);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_PACKET_INTERFACE_BASIC_ADDRESSING
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "packet_interface.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
class AddressingPort;
|
||||
|
||||
/**
|
||||
* An addressing layer for channels shared by several devices, such as an RS485 bus or a LoRa radio.
|
||||
*
|
||||
* Each packet starts with a 16-bit destination address and a 16-bit source address, little endian.
|
||||
* BROADCAST_ADDRESS sends to every device. A packet is dropped unless it is addressed to this device or broadcast, and
|
||||
* a packet from this device's own address, such as an echo on a half-duplex bus, is dropped too.
|
||||
*
|
||||
* Above this layer are one or more ports, each the bottom of its own stack of layers. A port with a destination
|
||||
* address is a point-to-point link to that device: it sends to it and receives only its packets, so the reliability
|
||||
* and session layers and the rolling code, which work between two devices only, can be used above it. A port with
|
||||
* the broadcast destination sends to every device, and receives every broadcast, as well as packets from devices
|
||||
* that have no port of their own. Without a broadcast port, a device's broadcasts go to its own port.
|
||||
*/
|
||||
class BasicAddressing : public Component {
|
||||
public:
|
||||
static constexpr uint16_t BROADCAST_ADDRESS = 0xFFFF;
|
||||
/// Destination and source addresses.
|
||||
static constexpr size_t HEADER_SIZE = 4;
|
||||
|
||||
BasicAddressing(PacketInterface *link, uint16_t address) : link_(link), address_(address) {}
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
PacketInterface *get_link() const { return this->link_; }
|
||||
void add_port(AddressingPort *port) { this->ports_.push_back(port); }
|
||||
/// Sends \p data to \p destination, or to the destination in \p meta_data if set.
|
||||
bool send(const PacketBuffer &data, const PacketMetaData &meta_data, uint16_t destination);
|
||||
|
||||
protected:
|
||||
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
|
||||
|
||||
PacketInterface *link_;
|
||||
const uint16_t address_;
|
||||
StaticVector<AddressingPort *, PACKET_INTERFACE_ADDRESSING_PORT_COUNT> ports_;
|
||||
};
|
||||
|
||||
/// The bottom of the stack of layers for one destination, or for broadcast, on an addressed channel.
|
||||
class AddressingPort : public PacketInterface {
|
||||
public:
|
||||
/// With \p only_port, the port is the top of the link, so top() on the link reaches this port's stack.
|
||||
AddressingPort(BasicAddressing *addressing, uint16_t destination, bool only_port);
|
||||
|
||||
void dump_config() override;
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override {
|
||||
return this->addressing_->send(data, meta_data, this->destination_);
|
||||
}
|
||||
uint16_t get_destination() const { return this->destination_; }
|
||||
|
||||
protected:
|
||||
friend class BasicAddressing;
|
||||
|
||||
BasicAddressing *addressing_;
|
||||
const uint16_t destination_;
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,160 @@
|
||||
#include "basic_fragmentation.h"
|
||||
#ifdef USE_PACKET_INTERFACE_BASIC_FRAGMENTATION
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "packet_interface.fragmentation");
|
||||
|
||||
static constexpr uint8_t LAST_FRAGMENT = 0x80;
|
||||
static constexpr uint8_t INDEX_MASK = 0x7F;
|
||||
|
||||
BasicFragmentation::BasicFragmentation(PacketInterface *lower, size_t max_packet_size)
|
||||
: PacketInterface(max_packet_size), lower_(lower) {
|
||||
this->stack_on_(lower, CAPABILITY_FRAGMENTATION);
|
||||
}
|
||||
|
||||
size_t BasicFragmentation::fragments_for_(size_t size) const {
|
||||
size_t fragment_size = this->fragment_size_();
|
||||
if (fragment_size == 0)
|
||||
return SIZE_MAX;
|
||||
return std::max<size_t>(1, (size + fragment_size - 1) / fragment_size);
|
||||
}
|
||||
|
||||
void BasicFragmentation::setup() {
|
||||
if (this->fragment_size_() == 0) {
|
||||
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
|
||||
return;
|
||||
}
|
||||
if (this->fragments_for_(this->max_packet_size_) > MAX_FRAGMENTS) {
|
||||
this->mark_failed(LOG_STR("max_packet_size needs more than 128 fragments"));
|
||||
return;
|
||||
}
|
||||
// Checked now, while the layer below holds nothing; a failed layer below reports its own failure instead
|
||||
this->queue_too_short_ =
|
||||
!this->lower_->is_failed() && this->fragments_for_(this->max_packet_size_) > this->lower_->get_send_capacity();
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
this->tx_buffer_ = allocator.allocate(this->fragment_size_() + this->max_packet_size_);
|
||||
if (this->tx_buffer_ == nullptr) {
|
||||
this->mark_failed(LOG_STR("Not enough memory for the packet buffers"));
|
||||
return;
|
||||
}
|
||||
this->rx_buffer_ = this->tx_buffer_ + this->fragment_size_();
|
||||
// Numbering from a random start makes it unlikely that a packet joins fragments from before a restart
|
||||
this->tx_number_ = random_uint32();
|
||||
this->lower_->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
|
||||
}
|
||||
|
||||
void BasicFragmentation::dump_config() {
|
||||
// A link with no room for fragments fails setup, which core reports below
|
||||
size_t fragments = this->fragment_size_() == 0 ? 0 : this->fragments_for_(this->max_packet_size_);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Basic Fragmentation on '%s':\n"
|
||||
" Max Packet Size: %zu\n"
|
||||
" Fragment Size: %zu\n"
|
||||
" Fragments in the Largest Packet: %zu",
|
||||
this->name_, this->max_packet_size_, this->fragment_size_(), fragments);
|
||||
if (this->queue_too_short_) {
|
||||
ESP_LOGW(TAG, " The reliability queue is too short for the largest packets, which cannot be sent");
|
||||
}
|
||||
}
|
||||
|
||||
bool BasicFragmentation::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
if (this->tx_buffer_ == nullptr)
|
||||
return false;
|
||||
size_t size = data.size();
|
||||
if (size > this->max_packet_size_) {
|
||||
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
|
||||
return false;
|
||||
}
|
||||
size_t fragments = this->fragments_for_(size);
|
||||
if (fragments > this->lower_->get_send_capacity()) {
|
||||
ESP_LOGV(TAG, "%s: No room for %zu fragments", this->name_, fragments);
|
||||
return false;
|
||||
}
|
||||
uint8_t header[HEADER_SIZE] = {this->tx_number_++, 0};
|
||||
if (fragments == 1) {
|
||||
header[1] = LAST_FRAGMENT;
|
||||
return this->lower_->send_to_interface(PacketBuffer(header, HEADER_SIZE, &data, nullptr, 0), meta_data);
|
||||
}
|
||||
size_t fragment_size = this->fragment_size_();
|
||||
for (size_t index = 0; index != fragments; index++) {
|
||||
size_t length = data.copy_to(this->tx_buffer_, fragment_size, index * fragment_size);
|
||||
header[1] = index | (index + 1 == fragments ? LAST_FRAGMENT : 0);
|
||||
PacketBuffer body(this->tx_buffer_, length);
|
||||
if (!this->lower_->send_to_interface(PacketBuffer(header, HEADER_SIZE, &body, nullptr, 0), meta_data)) {
|
||||
// The peer drops the part already sent when the next packet starts
|
||||
ESP_LOGV(TAG, "%s: Fragment %zu of %zu not accepted", this->name_, index, fragments);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void BasicFragmentation::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
// Lower layers deliver packets in one piece; a packet in one fragment is passed up without copying
|
||||
if (!data.is_contiguous()) {
|
||||
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->name_);
|
||||
return;
|
||||
}
|
||||
auto fragment = data.as_span();
|
||||
if (fragment.size() < HEADER_SIZE) {
|
||||
ESP_LOGV(TAG, "%s: Fragment of %zu bytes is shorter than its header, dropped", this->name_, fragment.size());
|
||||
return;
|
||||
}
|
||||
uint8_t number = fragment[0];
|
||||
uint8_t index = fragment[1] & INDEX_MASK;
|
||||
bool last = (fragment[1] & LAST_FRAGMENT) != 0;
|
||||
const uint8_t *payload = fragment.data() + HEADER_SIZE;
|
||||
size_t length = fragment.size() - HEADER_SIZE;
|
||||
if (index == 0 && last) {
|
||||
// A packet in one fragment leaves a packet being joined from another device intact
|
||||
if (this->rx_joining_ && meta_data.source_address == this->rx_source_) {
|
||||
ESP_LOGV(TAG, "%s: Incomplete packet %u dropped", this->name_, this->rx_number_);
|
||||
this->rx_joining_ = false;
|
||||
}
|
||||
if (length > this->max_packet_size_) {
|
||||
ESP_LOGV(TAG, "%s: Packet %u is larger than %zu, dropped", this->name_, number, this->max_packet_size_);
|
||||
return;
|
||||
}
|
||||
this->on_receive_from_interface_(PacketBuffer(payload, length), meta_data);
|
||||
return;
|
||||
}
|
||||
if (index == 0) {
|
||||
if (this->rx_joining_) {
|
||||
ESP_LOGV(TAG, "%s: Incomplete packet %u dropped", this->name_, this->rx_number_);
|
||||
}
|
||||
this->rx_joining_ = true;
|
||||
this->rx_number_ = number;
|
||||
this->rx_source_ = meta_data.source_address;
|
||||
this->rx_next_index_ = 0;
|
||||
this->rx_length_ = 0;
|
||||
} else if (this->rx_joining_ && meta_data.source_address != this->rx_source_) {
|
||||
// Only this fragment is lost, so of two devices sending at once, the one that started last still gets through
|
||||
ESP_LOGV(TAG, "%s: Fragment %u from 0x%04X dropped", this->name_, index, meta_data.source_address);
|
||||
return;
|
||||
} else if (!this->rx_joining_ || number != this->rx_number_ || index != this->rx_next_index_) {
|
||||
ESP_LOGV(TAG, "%s: Fragment %u of packet %u dropped", this->name_, index, number);
|
||||
this->rx_joining_ = false;
|
||||
return;
|
||||
}
|
||||
if (this->rx_length_ + length > this->max_packet_size_) {
|
||||
ESP_LOGV(TAG, "%s: Packet %u is larger than %zu, dropped", this->name_, number, this->max_packet_size_);
|
||||
this->rx_joining_ = false;
|
||||
return;
|
||||
}
|
||||
memcpy(this->rx_buffer_ + this->rx_length_, payload, length);
|
||||
this->rx_length_ += length;
|
||||
this->rx_next_index_++;
|
||||
if (last) {
|
||||
this->rx_joining_ = false;
|
||||
this->on_receive_from_interface_(PacketBuffer(this->rx_buffer_, this->rx_length_), meta_data);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_PACKET_INTERFACE_BASIC_FRAGMENTATION
|
||||
|
||||
#include "packet_interface.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/**
|
||||
* A fragmentation layer: packets up to this layer's max_packet_size are split into fragments that fit the layer
|
||||
* below, and joined again at the other end.
|
||||
*
|
||||
* Each fragment starts with a 2 byte header: an 8-bit packet number, then the fragment's index in bits 0-6, with bit
|
||||
* 7 set on the last fragment. A packet that fits in one fragment still has the header, and is sent and passed up
|
||||
* without being copied.
|
||||
*
|
||||
* A packet is started only if the layer below can take all of its fragments now (get_send_capacity()), so a full
|
||||
* reliability queue does not leave the peer with part of a packet. The receiver joins one packet at a time, and drops
|
||||
* it when a fragment is missing or arrives with another packet number, or when the same source, or another one,
|
||||
* starts a new packet of several fragments. Other fragments from another source address are dropped, leaving the
|
||||
* packet being joined intact. Above a reliability layer, fragments arrive in order and none are missing.
|
||||
*/
|
||||
class BasicFragmentation : public PacketInterface {
|
||||
public:
|
||||
/// Packet number, then index and last flag.
|
||||
static constexpr size_t HEADER_SIZE = 2;
|
||||
/// The index has 7 bits.
|
||||
static constexpr size_t MAX_FRAGMENTS = 128;
|
||||
|
||||
BasicFragmentation(PacketInterface *lower, size_t max_packet_size);
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
|
||||
|
||||
protected:
|
||||
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
|
||||
size_t fragment_size_() const {
|
||||
size_t lower_size = this->lower_->get_max_packet_size();
|
||||
return lower_size > HEADER_SIZE ? lower_size - HEADER_SIZE : 0;
|
||||
}
|
||||
size_t fragments_for_(size_t size) const;
|
||||
|
||||
PacketInterface *lower_;
|
||||
// One fragment being sent, and the packet being joined; one block allocated in setup(), and tx_buffer_ stays null
|
||||
// if that fails
|
||||
uint8_t *tx_buffer_{nullptr};
|
||||
uint8_t *rx_buffer_{nullptr};
|
||||
size_t rx_length_{0};
|
||||
uint16_t rx_source_{0};
|
||||
uint8_t tx_number_{0};
|
||||
uint8_t rx_number_{0};
|
||||
uint8_t rx_next_index_{0};
|
||||
bool rx_joining_{false};
|
||||
bool queue_too_short_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "basic_session.h"
|
||||
#ifdef USE_PACKET_INTERFACE_BASIC_SESSION
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "packet_interface.session");
|
||||
|
||||
static constexpr uint8_t PACKET_TYPE_DATA = 0x00;
|
||||
static constexpr uint8_t PACKET_TYPE_KEEP_ALIVE = 0x01;
|
||||
// Type byte and the 32-bit boot id, little endian
|
||||
static constexpr size_t KEEP_ALIVE_SIZE = 5;
|
||||
|
||||
BasicSession::BasicSession(PacketInterface *lower)
|
||||
: PacketInterface(remaining_packet_size(lower->get_max_packet_size(), HEADER_SIZE)), lower_(lower) {
|
||||
this->stack_on_(lower, CAPABILITY_SESSION);
|
||||
// Data lost by a reliability layer below must be sent again, as after a restart of the peer
|
||||
if (lower->has_capability(CAPABILITY_RELIABILITY)) {
|
||||
lower->add_on_session_state_callback([this](bool connected) {
|
||||
if (!connected && this->connected_)
|
||||
this->set_connected_(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void BasicSession::setup() {
|
||||
if (this->lower_->get_max_packet_size() < KEEP_ALIVE_SIZE) {
|
||||
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
|
||||
return;
|
||||
}
|
||||
this->boot_id_ = random_uint32();
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_sensor_ != nullptr)
|
||||
this->connected_sensor_->publish_initial_state(false);
|
||||
#endif
|
||||
this->lower_->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
|
||||
// A reliability layer below queues this until its link is up, so the peer hears of a restart straight away
|
||||
this->send_keep_alive_();
|
||||
this->set_interval(this->keep_alive_ms_, [this] { this->on_interval_(); });
|
||||
}
|
||||
|
||||
void BasicSession::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Basic Session on '%s':\n"
|
||||
" Max Packet Size: %zu\n"
|
||||
" Keep Alive: %" PRIu32 "ms\n"
|
||||
" Timeout: %" PRIu32 "ms",
|
||||
this->name_, this->max_packet_size_, this->keep_alive_ms_, this->timeout_ms_);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
|
||||
#endif
|
||||
}
|
||||
|
||||
bool BasicSession::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
if (this->is_failed())
|
||||
return false;
|
||||
uint8_t header = PACKET_TYPE_DATA;
|
||||
PacketBuffer packet(&header, HEADER_SIZE, &data, nullptr, 0);
|
||||
return this->lower_->send_to_interface(packet, meta_data);
|
||||
}
|
||||
|
||||
void BasicSession::send_keep_alive_() {
|
||||
uint8_t packet[KEEP_ALIVE_SIZE] = {
|
||||
PACKET_TYPE_KEEP_ALIVE, static_cast<uint8_t>(this->boot_id_), static_cast<uint8_t>(this->boot_id_ >> 8),
|
||||
static_cast<uint8_t>(this->boot_id_ >> 16), static_cast<uint8_t>(this->boot_id_ >> 24)};
|
||||
if (!this->lower_->send_to_interface(PacketBuffer(packet, sizeof(packet)), {}))
|
||||
ESP_LOGV(TAG, "%s: Lower layer did not accept keep-alive", this->name_);
|
||||
}
|
||||
|
||||
void BasicSession::on_interval_() {
|
||||
this->send_keep_alive_();
|
||||
if (this->connected_ && App.get_loop_component_start_time() - this->last_receive_ms_ > this->timeout_ms_) {
|
||||
ESP_LOGW(TAG, "%s: Peer not heard for %" PRIu32 "ms", this->name_, this->timeout_ms_);
|
||||
this->set_connected_(false);
|
||||
}
|
||||
}
|
||||
|
||||
void BasicSession::set_connected_(bool connected) {
|
||||
this->connected_ = connected;
|
||||
ESP_LOGD(TAG, "%s: Session %s", this->name_, connected ? LOG_STR_LITERAL("connected") : LOG_STR_LITERAL("lost"));
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_sensor_ != nullptr)
|
||||
this->connected_sensor_->publish_state(connected);
|
||||
#endif
|
||||
this->session_callback_.call(connected);
|
||||
}
|
||||
|
||||
void BasicSession::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
// Lower layers deliver packets in one piece; the payload is passed up without copying
|
||||
if (!data.is_contiguous()) {
|
||||
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->name_);
|
||||
return;
|
||||
}
|
||||
auto packet = data.as_span();
|
||||
if (packet.empty()) {
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
uint8_t type = packet[0];
|
||||
if (type == PACKET_TYPE_KEEP_ALIVE) {
|
||||
if (packet.size() != KEEP_ALIVE_SIZE) {
|
||||
ESP_LOGV(TAG, "%s: Keep-alive of %zu bytes dropped", this->name_, packet.size());
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
uint32_t boot_id = encode_uint32(packet[4], packet[3], packet[2], packet[1]);
|
||||
if (!this->peer_known_ || boot_id != this->peer_boot_id_) {
|
||||
// Data may arrive before the first keep-alive, so only a changed id means a restart
|
||||
if (this->connected_ && this->peer_known_) {
|
||||
ESP_LOGD(TAG, "%s: Peer has restarted", this->name_);
|
||||
this->set_connected_(false);
|
||||
}
|
||||
this->peer_boot_id_ = boot_id;
|
||||
this->peer_known_ = true;
|
||||
// Answer a new peer at once; it answers only if our id is new to it, so this cannot repeat
|
||||
this->send_keep_alive_();
|
||||
}
|
||||
} else if (type != PACKET_TYPE_DATA) {
|
||||
ESP_LOGV(TAG, "%s: Unknown packet type 0x%02X dropped", this->name_, type);
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
this->note_received_(true);
|
||||
this->last_receive_ms_ = App.get_loop_component_start_time();
|
||||
if (!this->connected_)
|
||||
this->set_connected_(true);
|
||||
if (type == PACKET_TYPE_DATA)
|
||||
this->on_receive_from_interface_(PacketBuffer(packet.data() + HEADER_SIZE, packet.size() - HEADER_SIZE), meta_data);
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_PACKET_INTERFACE_BASIC_SESSION
|
||||
#include "packet_interface.h"
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/**
|
||||
* A session layer that reports whether the peer can be reached.
|
||||
*
|
||||
* Every packet starts with a type byte. A data packet carries the payload of the layer above. A keep-alive packet
|
||||
* carries a random number chosen at boot, so that a restart of the peer is noticed even if it is quicker than the
|
||||
* timeout. A keep-alive is sent every keep_alive interval, and at once when a new peer is heard. The peer is
|
||||
* considered gone when nothing has been received from it for the timeout; this is checked every keep_alive interval.
|
||||
* A restart of the peer is reported as a disconnect followed by a connect. So is a reset of a reliability layer below,
|
||||
* which discards data: the session is reported lost, and connected again when the peer is next heard.
|
||||
*/
|
||||
class BasicSession : public PacketInterface {
|
||||
public:
|
||||
/// The packet type byte.
|
||||
static constexpr size_t HEADER_SIZE = 1;
|
||||
|
||||
explicit BasicSession(PacketInterface *lower);
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
|
||||
|
||||
void set_keep_alive(uint32_t keep_alive_ms) { this->keep_alive_ms_ = keep_alive_ms; }
|
||||
void set_session_timeout(uint32_t timeout_ms) { this->timeout_ms_ = timeout_ms; }
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; }
|
||||
#endif
|
||||
|
||||
bool is_connected() const { return this->connected_; }
|
||||
|
||||
protected:
|
||||
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
|
||||
void on_interval_();
|
||||
void send_keep_alive_();
|
||||
void set_connected_(bool connected);
|
||||
|
||||
PacketInterface *lower_;
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
binary_sensor::BinarySensor *connected_sensor_{nullptr};
|
||||
#endif
|
||||
uint32_t keep_alive_ms_{10000};
|
||||
uint32_t timeout_ms_{30000};
|
||||
uint32_t last_receive_ms_{0};
|
||||
uint32_t boot_id_{0};
|
||||
uint32_t peer_boot_id_{0};
|
||||
bool peer_known_{false};
|
||||
bool connected_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,365 @@
|
||||
#include "hdlc_reliability.h"
|
||||
#ifdef USE_PACKET_INTERFACE_HDLC
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "packet_interface.hdlc");
|
||||
|
||||
static constexpr uint32_t RETRANSMIT_TIMER_ID = 0;
|
||||
static constexpr uint32_t ACK_TIMER_ID = 1;
|
||||
static constexpr uint32_t SEND_RETRY_TIMER_ID = 2;
|
||||
// A frame or acknowledgement the lower layer refused, for example while a radio was receiving, is sent again after this
|
||||
static constexpr uint32_t SEND_RETRY_MS = 50;
|
||||
// SABM is repeated at this interval, or the timeout if longer, until the peer answers
|
||||
static constexpr uint32_t RESET_INTERVAL_MS = 1000;
|
||||
|
||||
// Control byte formats, with the poll/final bit (0x10) ignored
|
||||
static constexpr uint8_t POLL_FINAL_BIT = 0x10;
|
||||
static constexpr uint8_t SUPERVISORY_FRAME = 0x01;
|
||||
static constexpr uint8_t SUPERVISORY_RR = 0x00;
|
||||
static constexpr uint8_t SUPERVISORY_REJ = 0x02;
|
||||
static constexpr uint8_t UNNUMBERED_SABM = 0x2F;
|
||||
static constexpr uint8_t UNNUMBERED_UA = 0x63;
|
||||
|
||||
static uint16_t frame_crc(uint8_t control, const uint8_t *payload, size_t length) {
|
||||
uint16_t crc = crc16(&control, 1);
|
||||
return crc16(payload, length, crc);
|
||||
}
|
||||
|
||||
HdlcReliability::HdlcReliability(PacketInterface *lower)
|
||||
: PacketInterface(remaining_packet_size(lower->get_max_packet_size(), FRAME_OVERHEAD)), lower_(lower) {
|
||||
this->stack_on_(lower, CAPABILITY_RELIABILITY);
|
||||
}
|
||||
|
||||
void HdlcReliability::setup() {
|
||||
if (this->max_packet_size_ == 0) {
|
||||
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
|
||||
return;
|
||||
}
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
this->tx_slots_ = allocator.allocate(size_t{this->max_queue_length_} * this->max_packet_size_);
|
||||
if (this->tx_slots_ == nullptr) {
|
||||
this->mark_failed(LOG_STR("Not enough memory for the send queue"));
|
||||
return;
|
||||
}
|
||||
this->lower_->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
|
||||
}
|
||||
|
||||
void HdlcReliability::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"HDLC Reliability on '%s':\n"
|
||||
" Max Packet Size: %zu\n"
|
||||
" Timeout: %" PRIu32 "ms\n"
|
||||
" Ack Delay: %" PRIu32 "ms\n"
|
||||
" Max Queue Length: %u\n"
|
||||
" Max Retries: %u",
|
||||
this->name_, this->max_packet_size_, this->timeout_ms_, this->ack_delay_ms_, this->max_queue_length_,
|
||||
this->max_retries_);
|
||||
}
|
||||
|
||||
void HdlcReliability::loop() {
|
||||
// Disabled first, as sending may deliver a reply to this layer and request the loop again
|
||||
this->disable_loop();
|
||||
if (this->state_ == LinkState::LINK_STATE_RESET_PENDING) {
|
||||
this->state_ = LinkState::LINK_STATE_RESETTING;
|
||||
this->start_timer_();
|
||||
this->send_frame_(UNNUMBERED_SABM, nullptr, 0);
|
||||
}
|
||||
if (this->state_ != LinkState::LINK_STATE_CONNECTED)
|
||||
return;
|
||||
// Queued frames go first, as each carries the acknowledgement
|
||||
if (this->send_pending_() && this->ack_due_ && !this->send_supervisory_(SUPERVISORY_RR))
|
||||
this->retry_send_();
|
||||
}
|
||||
|
||||
void HdlcReliability::retry_send_() {
|
||||
this->set_timeout(SEND_RETRY_TIMER_ID, SEND_RETRY_MS, [this]() { this->request_loop_(); });
|
||||
}
|
||||
|
||||
void HdlcReliability::owe_ack_(bool now) {
|
||||
if (now || this->ack_delay_ms_ == 0) {
|
||||
this->ack_due_ = true;
|
||||
this->request_loop_();
|
||||
} else if (!this->ack_pending_) {
|
||||
// Waiting lets one acknowledgement cover several frames, or a reply carry it
|
||||
this->set_timeout(ACK_TIMER_ID, this->ack_delay_ms_, [this]() {
|
||||
this->ack_due_ = true;
|
||||
this->request_loop_();
|
||||
});
|
||||
}
|
||||
this->ack_pending_ = true;
|
||||
}
|
||||
|
||||
void HdlcReliability::ack_sent_() {
|
||||
this->ack_pending_ = false;
|
||||
this->ack_due_ = false;
|
||||
this->cancel_timeout(ACK_TIMER_ID);
|
||||
}
|
||||
|
||||
void HdlcReliability::request_loop_() { this->enable_loop(); }
|
||||
|
||||
bool HdlcReliability::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
if (this->tx_slots_ == nullptr)
|
||||
return false;
|
||||
size_t size = data.size();
|
||||
if (size > this->max_packet_size_) {
|
||||
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
|
||||
return false;
|
||||
}
|
||||
uint8_t outstanding = this->outstanding_();
|
||||
if (outstanding >= this->max_queue_length_) {
|
||||
if (this->state_ == LinkState::LINK_STATE_CONNECTED) {
|
||||
ESP_LOGV(TAG, "%s: Send queue full", this->name_);
|
||||
} else if (!this->reset_wait_logged_) {
|
||||
// Once for each reset; the layers above keep trying while the peer is away
|
||||
this->reset_wait_logged_ = true;
|
||||
ESP_LOGD(TAG, "%s: Send queue full, waiting for the peer to answer the link reset", this->name_);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
uint8_t seq = this->vs_;
|
||||
data.copy_to(this->slot_(seq), size);
|
||||
this->tx_lengths_[this->slot_index_(seq)] = size;
|
||||
this->vs_ = (seq + 1) & 7;
|
||||
// While the link is being reset, the frame waits in the queue and is sent when UA arrives
|
||||
if (this->state_ == LinkState::LINK_STATE_CONNECTED) {
|
||||
this->send_pending_();
|
||||
if (outstanding == 0)
|
||||
this->start_timer_();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HdlcReliability::send_frame_(uint8_t control, const uint8_t *payload, size_t length) {
|
||||
uint16_t crc = frame_crc(control, payload, length);
|
||||
uint8_t trailer[2] = {static_cast<uint8_t>(crc), static_cast<uint8_t>(crc >> 8)};
|
||||
PacketBuffer body(payload, length);
|
||||
PacketBuffer frame(&control, 1, length != 0 ? &body : nullptr, trailer, sizeof(trailer));
|
||||
if (this->lower_->send_to_interface(frame, {}))
|
||||
return true;
|
||||
ESP_LOGV(TAG, "%s: Lower layer did not accept frame 0x%02X", this->name_, control);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool HdlcReliability::send_supervisory_(uint8_t type) {
|
||||
if (!this->send_frame_(SUPERVISORY_FRAME | (type << 2) | (this->vr_ << 5), nullptr, 0))
|
||||
return false;
|
||||
this->ack_sent_();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sends the next queued frame not yet sent, and returns true if none are left. One frame per loop pass lets other
|
||||
// components run between frames: on a radio, sending blocks, and the peer's frames are received in between. If the
|
||||
// lower layer refuses the frame, it is sent again shortly.
|
||||
bool HdlcReliability::send_pending_() {
|
||||
if (this->vn_ == this->vs_)
|
||||
return true;
|
||||
uint8_t control = (this->vn_ << 1) | (this->vr_ << 5);
|
||||
if (!this->send_frame_(control, this->slot_(this->vn_), this->tx_lengths_[this->slot_index_(this->vn_)])) {
|
||||
this->retry_send_();
|
||||
return false;
|
||||
}
|
||||
// Every I-frame carries an acknowledgement, so a pending RR is no longer needed
|
||||
this->ack_sent_();
|
||||
this->vn_ = (this->vn_ + 1) & 7;
|
||||
if (this->vn_ == this->vs_)
|
||||
return true;
|
||||
this->request_loop_();
|
||||
return false;
|
||||
}
|
||||
|
||||
void HdlcReliability::resend_outstanding_() {
|
||||
this->vn_ = this->va_;
|
||||
this->send_pending_();
|
||||
}
|
||||
|
||||
void HdlcReliability::start_timer_() {
|
||||
uint32_t interval = this->timeout_ms_;
|
||||
if (this->state_ != LinkState::LINK_STATE_CONNECTED && interval < RESET_INTERVAL_MS)
|
||||
interval = RESET_INTERVAL_MS;
|
||||
// Up to a quarter longer at random, so devices whose frames collided on a shared channel do not resend together
|
||||
interval += random_uint32() % (interval / 4 + 1);
|
||||
this->set_timeout(RETRANSMIT_TIMER_ID, interval, [this]() { this->on_timeout_(); });
|
||||
}
|
||||
|
||||
void HdlcReliability::on_timeout_() {
|
||||
if (this->state_ != LinkState::LINK_STATE_CONNECTED) {
|
||||
// Counted up to max_retries_ only, so the warning is logged once until the link comes up
|
||||
if (this->retries_ < this->max_retries_) {
|
||||
this->retries_++;
|
||||
if (this->retries_ == this->max_retries_) {
|
||||
ESP_LOGW(TAG, "%s: No answer to the link reset after %u tries; check that the peer has a reliability layer",
|
||||
this->name_, this->max_retries_);
|
||||
}
|
||||
}
|
||||
this->send_frame_(UNNUMBERED_SABM, nullptr, 0);
|
||||
this->start_timer_();
|
||||
return;
|
||||
}
|
||||
if (this->outstanding_() == 0)
|
||||
return;
|
||||
if (this->retries_ == this->max_retries_) {
|
||||
ESP_LOGW(TAG, "%s: No acknowledgement after %u retries, resetting link", this->name_, this->max_retries_);
|
||||
this->start_reset_();
|
||||
return;
|
||||
}
|
||||
this->retries_++;
|
||||
ESP_LOGV(TAG, "%s: Timeout, resending %u frames", this->name_, this->outstanding_());
|
||||
this->resend_outstanding_();
|
||||
this->start_timer_();
|
||||
}
|
||||
|
||||
// Unacknowledged frames are discarded; frames queued from now on are numbered from 0 and sent once the link is up.
|
||||
// Resetting a connected link can lose data both ways, which is reported to the layers above.
|
||||
void HdlcReliability::start_reset_() {
|
||||
bool was_connected = this->state_ == LinkState::LINK_STATE_CONNECTED;
|
||||
this->state_ = LinkState::LINK_STATE_RESET_PENDING;
|
||||
this->va_ = this->vs_ = this->vn_ = 0;
|
||||
this->head_ = 0;
|
||||
this->vr_ = 0;
|
||||
this->retries_ = 0;
|
||||
this->rej_sent_ = false;
|
||||
this->ack_sent_();
|
||||
this->cancel_timeout(RETRANSMIT_TIMER_ID);
|
||||
this->request_loop_();
|
||||
if (was_connected)
|
||||
this->session_callback_.call(false);
|
||||
}
|
||||
|
||||
void HdlcReliability::link_up_() {
|
||||
ESP_LOGD(TAG, "%s: Link connected", this->name_);
|
||||
this->state_ = LinkState::LINK_STATE_CONNECTED;
|
||||
this->reset_wait_logged_ = false;
|
||||
this->peer_frame_seen_ = false;
|
||||
this->retries_ = 0;
|
||||
this->cancel_timeout(RETRANSMIT_TIMER_ID);
|
||||
if (this->outstanding_() != 0) {
|
||||
this->resend_outstanding_();
|
||||
this->start_timer_();
|
||||
}
|
||||
}
|
||||
|
||||
void HdlcReliability::process_ack_(uint8_t nr) {
|
||||
uint8_t acked = (nr - this->va_) & 7;
|
||||
if (acked > this->outstanding_()) {
|
||||
ESP_LOGV(TAG, "%s: Ignoring invalid N(R) %u", this->name_, nr);
|
||||
return;
|
||||
}
|
||||
if (acked == 0)
|
||||
return;
|
||||
// The peer may have had frames that were refused when last resent
|
||||
if (acked > ((this->vn_ - this->va_) & 7))
|
||||
this->vn_ = nr;
|
||||
this->head_ = (this->head_ + acked) % this->max_queue_length_;
|
||||
this->va_ = nr;
|
||||
this->retries_ = 0;
|
||||
if (this->outstanding_() == 0) {
|
||||
this->cancel_timeout(RETRANSMIT_TIMER_ID);
|
||||
} else {
|
||||
this->start_timer_();
|
||||
}
|
||||
}
|
||||
|
||||
void HdlcReliability::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
// Lower layers deliver packets in one piece; the payload is passed up without copying
|
||||
if (!data.is_contiguous()) {
|
||||
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->name_);
|
||||
return;
|
||||
}
|
||||
auto frame = data.as_span();
|
||||
if (frame.size() < FRAME_OVERHEAD) {
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
size_t length = frame.size() - FRAME_OVERHEAD;
|
||||
uint8_t control = frame[0];
|
||||
const uint8_t *payload = frame.data() + 1;
|
||||
uint16_t crc = frame[length + 1] | (frame[length + 2] << 8);
|
||||
if (frame_crc(control, payload, length) != crc) {
|
||||
ESP_LOGV(TAG, "%s: CRC error, frame dropped", this->name_);
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
// The peer's packets may only be larger if it has a larger max_packet_size, which the layers above cannot take
|
||||
if (length > this->max_packet_size_) {
|
||||
if (!this->oversize_warned_) {
|
||||
this->oversize_warned_ = true;
|
||||
ESP_LOGW(TAG,
|
||||
"%s: Frame of %zu bytes is larger than %zu, dropped; check that both devices use the same "
|
||||
"max_packet_size",
|
||||
this->name_, length, this->max_packet_size_);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "%s: Frame of %zu bytes is larger than %zu, dropped", this->name_, length, this->max_packet_size_);
|
||||
}
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
this->note_received_(true);
|
||||
|
||||
uint8_t command = control & ~POLL_FINAL_BIT;
|
||||
if (command == UNNUMBERED_SABM) {
|
||||
// A peer that has not heard our UA repeats its SABM, and cannot have sent a numbered frame meanwhile; answering
|
||||
// again keeps this side connected, and the queued frames, instead of treating the repeat as a restart.
|
||||
if (this->state_ == LinkState::LINK_STATE_CONNECTED && this->peer_frame_seen_) {
|
||||
// The peer has restarted, so frames it has not acknowledged are lost
|
||||
ESP_LOGD(TAG, "%s: Link reset by peer", this->name_);
|
||||
this->start_reset_();
|
||||
}
|
||||
this->vr_ = 0;
|
||||
this->rej_sent_ = false;
|
||||
this->send_frame_(UNNUMBERED_UA, nullptr, 0);
|
||||
// Both sides now start from 0, so the link is up even if our own SABM is unanswered
|
||||
this->link_up_();
|
||||
return;
|
||||
}
|
||||
if (command == UNNUMBERED_UA) {
|
||||
if (this->state_ == LinkState::LINK_STATE_RESETTING)
|
||||
this->link_up_();
|
||||
return;
|
||||
}
|
||||
// Numbered frames are ignored until the link is reset; the peer answers our SABM first
|
||||
if (this->state_ != LinkState::LINK_STATE_CONNECTED)
|
||||
return;
|
||||
this->peer_frame_seen_ = true;
|
||||
|
||||
uint8_t nr = control >> 5;
|
||||
if ((control & 1) == 0) {
|
||||
this->process_ack_(nr);
|
||||
this->receive_info_(control, payload, length, meta_data);
|
||||
return;
|
||||
}
|
||||
if ((control & 3) != SUPERVISORY_FRAME)
|
||||
return;
|
||||
this->process_ack_(nr);
|
||||
if (((control >> 2) & 3) == SUPERVISORY_REJ && this->outstanding_() != 0) {
|
||||
ESP_LOGV(TAG, "%s: Peer rejected from %u", this->name_, nr);
|
||||
this->resend_outstanding_();
|
||||
this->start_timer_();
|
||||
}
|
||||
}
|
||||
|
||||
void HdlcReliability::receive_info_(uint8_t control, const uint8_t *payload, size_t length,
|
||||
const PacketMetaData &meta_data) {
|
||||
uint8_t ns = (control >> 1) & 7;
|
||||
if (ns != this->vr_) {
|
||||
// A gap or a duplicate; ask once for a resend from the expected frame, then just acknowledge at once
|
||||
if (!this->rej_sent_) {
|
||||
this->rej_sent_ = this->send_supervisory_(SUPERVISORY_REJ);
|
||||
if (this->rej_sent_)
|
||||
return;
|
||||
}
|
||||
this->owe_ack_(true);
|
||||
return;
|
||||
}
|
||||
this->vr_ = (this->vr_ + 1) & 7;
|
||||
this->rej_sent_ = false;
|
||||
// The acknowledgement is sent from loop(), so a reply sent from the callback can carry it instead
|
||||
this->owe_ack_(false);
|
||||
this->on_receive_from_interface_(PacketBuffer(payload, length), meta_data);
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,99 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_PACKET_INTERFACE_HDLC
|
||||
#include <array>
|
||||
|
||||
#include "packet_interface.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/**
|
||||
* A reliability layer that delivers packets in order, without loss or duplicates, between two peers.
|
||||
*
|
||||
* It follows HDLC asynchronous balanced mode, with sequence numbers modulo 8 and go-back-N retransmission:
|
||||
* - I-frames carry a payload, the sequence number N(S) and an acknowledgement N(R) of the peer's frames.
|
||||
* - RR frames acknowledge without a payload; REJ frames ask the peer to resend from N(R).
|
||||
* - SABM resets both peers' sequence numbers, and UA answers it.
|
||||
*
|
||||
* Each frame is the HDLC control byte, the payload, then a CRC-16 of both in little endian order.
|
||||
* Unacknowledged frames are resent after the timeout; after max_retries the link is reset.
|
||||
*/
|
||||
class HdlcReliability : public PacketInterface {
|
||||
public:
|
||||
/// Most frames that can be unacknowledged at once, set by the 3 bit sequence numbers.
|
||||
static constexpr uint8_t MAX_WINDOW = 7;
|
||||
/// Control byte plus CRC-16.
|
||||
static constexpr size_t FRAME_OVERHEAD = 3;
|
||||
|
||||
explicit HdlcReliability(PacketInterface *lower);
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
|
||||
/// No room while the layer has no send queue: before setup(), or when setup() failed.
|
||||
size_t get_send_capacity() const override {
|
||||
return this->tx_slots_ == nullptr ? 0 : this->max_queue_length_ - this->outstanding_();
|
||||
}
|
||||
|
||||
void set_ack_timeout(uint32_t timeout_ms) { this->timeout_ms_ = timeout_ms; }
|
||||
void set_ack_delay(uint32_t ack_delay_ms) { this->ack_delay_ms_ = ack_delay_ms; }
|
||||
void set_max_queue_length(uint8_t length) { this->max_queue_length_ = length; }
|
||||
void set_max_retries(uint8_t retries) { this->max_retries_ = retries; }
|
||||
|
||||
bool is_connected() const { return this->state_ == LinkState::LINK_STATE_CONNECTED; }
|
||||
|
||||
protected:
|
||||
enum class LinkState : uint8_t {
|
||||
LINK_STATE_RESET_PENDING, // SABM to be sent from loop()
|
||||
LINK_STATE_RESETTING, // SABM sent, waiting for UA
|
||||
LINK_STATE_CONNECTED,
|
||||
};
|
||||
|
||||
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
|
||||
void receive_info_(uint8_t control, const uint8_t *payload, size_t length, const PacketMetaData &meta_data);
|
||||
bool send_frame_(uint8_t control, const uint8_t *payload, size_t length);
|
||||
bool send_supervisory_(uint8_t type);
|
||||
bool send_pending_();
|
||||
void resend_outstanding_();
|
||||
void retry_send_();
|
||||
/// An RR is owed: at once, or after the ack delay unless an I-frame carries it first.
|
||||
void owe_ack_(bool now);
|
||||
void ack_sent_();
|
||||
void process_ack_(uint8_t nr);
|
||||
void on_timeout_();
|
||||
void start_reset_();
|
||||
void link_up_();
|
||||
void start_timer_();
|
||||
void request_loop_();
|
||||
uint8_t outstanding_() const { return (this->vs_ - this->va_) & 7; }
|
||||
uint8_t slot_index_(uint8_t seq) const { return (this->head_ + ((seq - this->va_) & 7)) % this->max_queue_length_; }
|
||||
uint8_t *slot_(uint8_t seq) { return this->tx_slots_ + this->slot_index_(seq) * this->max_packet_size_; }
|
||||
|
||||
PacketInterface *lower_;
|
||||
// max_queue_length_ slots of max_packet_size_ bytes, a ring starting at head_ which holds frame va_; allocated in
|
||||
// setup() and never freed
|
||||
uint8_t *tx_slots_{nullptr};
|
||||
std::array<uint16_t, MAX_WINDOW> tx_lengths_{}; // indexed like the slots
|
||||
uint32_t timeout_ms_{200};
|
||||
uint32_t ack_delay_ms_{0};
|
||||
uint8_t max_queue_length_{4};
|
||||
uint8_t max_retries_{5};
|
||||
uint8_t retries_{0};
|
||||
uint8_t head_{0};
|
||||
uint8_t vs_{0}; // sequence number of the next new frame
|
||||
uint8_t vn_{0}; // next frame to send; frames from va_ up to it have been sent
|
||||
uint8_t va_{0}; // oldest unacknowledged frame
|
||||
uint8_t vr_{0}; // next frame expected from the peer
|
||||
LinkState state_{LinkState::LINK_STATE_RESET_PENDING};
|
||||
bool ack_pending_{false}; // an RR is owed
|
||||
bool ack_due_{false}; // and should be sent now
|
||||
bool rej_sent_{false};
|
||||
bool peer_frame_seen_{false}; // a numbered frame has arrived since the link came up
|
||||
bool oversize_warned_{false};
|
||||
bool reset_wait_logged_{false}; // the queue filled while waiting for this reset to be answered
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,243 @@
|
||||
"""The kinds of layer that can be stacked on a packet interface, and the registry of their implementations.
|
||||
|
||||
Each module in this package implements one layer type and registers it with its kind, for example:
|
||||
|
||||
@RELIABILITY.register("hdlc", schema=HDLC_SCHEMA, single_peer=lambda config: True)
|
||||
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
|
||||
...
|
||||
|
||||
The packet_interface component imports every module here before it builds its schemas, so a new layer type needs
|
||||
only a new module. The kinds, and the order in which they are stacked, are fixed here.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENCRYPTION, CONF_FRAGMENTATION, CONF_KEY, CONF_TYPE
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
packet_interface_ns = cg.esphome_ns.namespace("packet_interface")
|
||||
PacketInterface = packet_interface_ns.class_("PacketInterface", cg.Component)
|
||||
|
||||
CONF_ADDRESSING = "addressing"
|
||||
CONF_DESTINATION = "destination"
|
||||
CONF_PEERS = "peers"
|
||||
CONF_RELIABILITY = "reliability"
|
||||
CONF_SESSION = "session"
|
||||
|
||||
BROADCAST_ADDRESS = 0xFFFF
|
||||
|
||||
# Builds a layer above lower and returns it. An addressing layer instead returns a list of (port, config) pairs, one
|
||||
# for each stack it carries, where config holds the layers for that stack, or is None for the layers on the link.
|
||||
LayerBuilder = Callable[[ConfigType, MockObj], Awaitable[Any]]
|
||||
|
||||
|
||||
def _never(config: ConfigType) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _nothing(config: ConfigType) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _same_size(config: ConfigType, size: int) -> int:
|
||||
return size
|
||||
|
||||
|
||||
def _no_limit(config: ConfigType) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _fits(config: ConfigType, size: int, capacity: int | None) -> None:
|
||||
"""Accept any layer below."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LayerType:
|
||||
"""One implementation of a kind of layer."""
|
||||
|
||||
# Returns the schema; called once every layer type is registered, as some schemas contain other layers
|
||||
schema_factory: Callable[[], Any]
|
||||
build: LayerBuilder
|
||||
# True if this configuration keeps state for one peer, so cannot be used with broadcast addressing
|
||||
single_peer: Callable[[ConfigType], bool]
|
||||
# True if this configuration works only when the peer sends packets back, which a session layer makes sure of
|
||||
needs_session: Callable[[ConfigType], bool]
|
||||
# The components this configuration needs loaded
|
||||
auto_load: Callable[[ConfigType], list[str]]
|
||||
# The largest packet this configuration takes, from the largest that the layer below takes, as the C++ works it out
|
||||
packet_size: Callable[[ConfigType, int], int]
|
||||
# How many packets this configuration takes at once, or None for no limit
|
||||
send_capacity: Callable[[ConfigType], int | None]
|
||||
# Raises cv.Invalid if this configuration cannot work above a layer with this packet size and send capacity
|
||||
check_lower: Callable[[ConfigType, int, int | None], None]
|
||||
|
||||
|
||||
class LayerKind:
|
||||
"""A kind of layer, such as encryption, with the types that implement it."""
|
||||
|
||||
def __init__(
|
||||
self, key: str, default_type: str, shorthand: str | None = None
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.default_type = default_type
|
||||
# The option that a plain string value sets, as in `encryption: my key`
|
||||
self.shorthand = shorthand
|
||||
self.types: dict[str, LayerType] = {}
|
||||
# Set once the schema is built, as types registered after that would not be in it
|
||||
self.frozen = False
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
schema: Any = None,
|
||||
schema_factory: Callable[[], Any] | None = None,
|
||||
single_peer: Callable[[ConfigType], bool] = _never,
|
||||
needs_session: Callable[[ConfigType], bool] = _never,
|
||||
auto_load: Callable[[ConfigType], list[str]] = _nothing,
|
||||
packet_size: Callable[[ConfigType, int], int] = _same_size,
|
||||
send_capacity: Callable[[ConfigType], int | None] = _no_limit,
|
||||
check_lower: Callable[[ConfigType, int, int | None], None] = _fits,
|
||||
) -> Callable[[LayerBuilder], LayerBuilder]:
|
||||
"""Register a type with its schema, or with a function returning a schema that contains other layers."""
|
||||
|
||||
def fixed_schema() -> Any:
|
||||
return schema
|
||||
|
||||
def decorator(build: LayerBuilder) -> LayerBuilder:
|
||||
if self.frozen:
|
||||
raise ValueError(
|
||||
f"{self.key} layer type '{name}' is registered after the schemas were built; register it "
|
||||
"before the packet_interface component is imported"
|
||||
)
|
||||
if name in self.types:
|
||||
raise ValueError(
|
||||
f"{self.key} layer type '{name}' is already registered"
|
||||
)
|
||||
self.types[name] = LayerType(
|
||||
schema_factory or fixed_schema,
|
||||
build,
|
||||
single_peer,
|
||||
needs_session,
|
||||
auto_load,
|
||||
packet_size,
|
||||
send_capacity,
|
||||
check_lower,
|
||||
)
|
||||
return build
|
||||
|
||||
return decorator
|
||||
|
||||
def _expand(self, value: Any) -> Any:
|
||||
"""Allow a layer to be enabled with no options, as in `reliability:`, or with its shorthand."""
|
||||
if value is None:
|
||||
return {}
|
||||
if self.shorthand is not None and isinstance(value, str):
|
||||
return {self.shorthand: value}
|
||||
return value
|
||||
|
||||
def schema(self) -> Callable[[Any], ConfigType]:
|
||||
"""The schema for this kind; no type can be registered after this."""
|
||||
self.frozen = True
|
||||
return cv.All(
|
||||
self._expand,
|
||||
cv.typed_schema(
|
||||
{name: layer.schema_factory() for name, layer in self.types.items()},
|
||||
default_type=self.default_type,
|
||||
),
|
||||
)
|
||||
|
||||
def get_type(self, config: ConfigType) -> LayerType:
|
||||
return self.types[config[CONF_TYPE]]
|
||||
|
||||
|
||||
# An addressing type's configuration has either a destination or a list of peers, each with an id, a destination
|
||||
# and its own layers.
|
||||
ADDRESSING = LayerKind(CONF_ADDRESSING, "basic")
|
||||
ENCRYPTION = LayerKind(CONF_ENCRYPTION, "xxtea", shorthand=CONF_KEY)
|
||||
RELIABILITY = LayerKind(CONF_RELIABILITY, "hdlc")
|
||||
FRAGMENTATION = LayerKind(CONF_FRAGMENTATION, "basic")
|
||||
SESSION = LayerKind(CONF_SESSION, "basic")
|
||||
# The kinds stacked above the link, or above each port of an addressing layer, from the bottom up
|
||||
STACK_ORDER = (ENCRYPTION, RELIABILITY, FRAGMENTATION, SESSION)
|
||||
|
||||
|
||||
def stack_schema() -> dict:
|
||||
"""Schema entries for the layers of one stack; call only once all layer types are registered."""
|
||||
return {cv.Optional(kind.key): kind.schema() for kind in STACK_ORDER}
|
||||
|
||||
|
||||
def layer_needing_session(config: ConfigType) -> str | None:
|
||||
"""The key of the first layer in this stack that needs packets back from the peer, if any."""
|
||||
for kind in STACK_ORDER:
|
||||
if (layer := config.get(kind.key)) is not None and kind.get_type(
|
||||
layer
|
||||
).needs_session(layer):
|
||||
return kind.key
|
||||
return None
|
||||
|
||||
|
||||
def check_session(config: ConfigType) -> None:
|
||||
"""Layers that need packets back from the peer need a session layer, whose keep-alives send them."""
|
||||
if CONF_SESSION not in config and (key := layer_needing_session(config)):
|
||||
raise cv.Invalid(
|
||||
f"This '{key}' needs packets from the peer, so it needs a '{CONF_SESSION}' layer, whose "
|
||||
"keep-alives make sure they are sent",
|
||||
path=[key],
|
||||
)
|
||||
|
||||
|
||||
def check_single_peer(config: ConfigType, destination: int) -> None:
|
||||
"""Layers that keep state for one peer need a destination other than broadcast."""
|
||||
if destination != BROADCAST_ADDRESS:
|
||||
return
|
||||
for kind in STACK_ORDER:
|
||||
if (layer := config.get(kind.key)) is not None and kind.get_type(
|
||||
layer
|
||||
).single_peer(layer):
|
||||
raise cv.Invalid(
|
||||
f"This '{kind.key}' works between two devices only, so it needs a '{CONF_DESTINATION}' other than "
|
||||
"broadcast",
|
||||
path=[kind.key],
|
||||
)
|
||||
|
||||
|
||||
def remaining_packet_size(size: int, used: int) -> int:
|
||||
return max(size - used, 0)
|
||||
|
||||
|
||||
def check_packet_sizes(config: ConfigType, size: int) -> None:
|
||||
"""Check each layer in config against the layer below it; size is the largest packet that the stack sits on takes."""
|
||||
capacity = None
|
||||
for kind in STACK_ORDER:
|
||||
if (layer := config.get(kind.key)) is None:
|
||||
continue
|
||||
layer_type = kind.get_type(layer)
|
||||
try:
|
||||
layer_type.check_lower(layer, size, capacity)
|
||||
except cv.Invalid as err:
|
||||
err.prepend([kind.key])
|
||||
raise
|
||||
size = layer_type.packet_size(layer, size)
|
||||
capacity = layer_type.send_capacity(layer)
|
||||
|
||||
|
||||
def stack_auto_load(config: ConfigType) -> set[str]:
|
||||
"""The components that the layers configured in config need loaded."""
|
||||
loads: set[str] = set()
|
||||
for kind in STACK_ORDER:
|
||||
if (layer := config.get(kind.key)) is not None:
|
||||
loads.update(kind.get_type(layer).auto_load(layer))
|
||||
return loads
|
||||
|
||||
|
||||
async def build_stack(config: ConfigType, lower: MockObj) -> None:
|
||||
"""Create the layers configured in config, from the bottom up, above lower."""
|
||||
for kind in STACK_ORDER:
|
||||
if (layer := config.get(kind.key)) is not None:
|
||||
lower = await kind.get_type(layer).build(layer, lower)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Addressing layer with 16-bit addresses, carrying one stack of layers or one for each of several peers."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import (
|
||||
ADDRESSING,
|
||||
BROADCAST_ADDRESS,
|
||||
CONF_DESTINATION,
|
||||
CONF_PEERS,
|
||||
PacketInterface,
|
||||
check_session,
|
||||
check_single_peer,
|
||||
packet_interface_ns,
|
||||
remaining_packet_size,
|
||||
stack_schema,
|
||||
)
|
||||
|
||||
CONF_PORT_ID = "port_id"
|
||||
|
||||
# The destination and source addresses
|
||||
HEADER_SIZE = 4
|
||||
|
||||
BasicAddressing = packet_interface_ns.class_("BasicAddressing", cg.Component)
|
||||
AddressingPort = packet_interface_ns.class_("AddressingPort", PacketInterface)
|
||||
|
||||
_request_port_slot = cg.slot_counter("PACKET_INTERFACE_ADDRESSING_PORT_COUNT")
|
||||
|
||||
|
||||
def _destination(value: Any) -> int:
|
||||
"""A device address, or broadcast."""
|
||||
if isinstance(value, str) and value.lower() == "broadcast":
|
||||
return BROADCAST_ADDRESS
|
||||
return cv.hex_int_range(1, BROADCAST_ADDRESS)(value)
|
||||
|
||||
|
||||
def _validate_peer(config: ConfigType) -> ConfigType:
|
||||
check_session(config)
|
||||
check_single_peer(config, config[CONF_DESTINATION])
|
||||
return config
|
||||
|
||||
|
||||
def _unique_destinations(peers: list[ConfigType]) -> list[ConfigType]:
|
||||
destinations = [peer[CONF_DESTINATION] for peer in peers]
|
||||
if len(set(destinations)) != len(destinations):
|
||||
raise cv.Invalid("Each peer must have a different destination")
|
||||
return peers
|
||||
|
||||
|
||||
def _not_own_address(config: ConfigType) -> ConfigType:
|
||||
"""Packets from this device's own address are dropped as echoes, so it cannot be a destination."""
|
||||
address = config[CONF_ADDRESS]
|
||||
if config.get(CONF_DESTINATION) == address:
|
||||
raise cv.Invalid(
|
||||
"Must not be this device's own address", path=[CONF_DESTINATION]
|
||||
)
|
||||
for index, peer in enumerate(config.get(CONF_PEERS, [])):
|
||||
if peer[CONF_DESTINATION] == address:
|
||||
raise cv.Invalid(
|
||||
"Must not be this device's own address",
|
||||
path=[CONF_PEERS, index, CONF_DESTINATION],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# A function, as each peer holds the other layers, whose schemas exist only once every layer type is registered
|
||||
def _basic_addressing_schema() -> cv.Schema:
|
||||
peer_schema = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(AddressingPort),
|
||||
cv.Required(CONF_DESTINATION): _destination,
|
||||
**stack_schema(),
|
||||
}
|
||||
),
|
||||
_validate_peer,
|
||||
)
|
||||
return cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BasicAddressing),
|
||||
cv.GenerateID(CONF_PORT_ID): cv.declare_id(AddressingPort),
|
||||
cv.Required(CONF_ADDRESS): cv.hex_int_range(1, BROADCAST_ADDRESS - 1),
|
||||
cv.Optional(CONF_DESTINATION): _destination,
|
||||
cv.Optional(CONF_PEERS): cv.All(
|
||||
cv.ensure_list(peer_schema), cv.Length(min=1), _unique_destinations
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.has_at_most_one_key(CONF_DESTINATION, CONF_PEERS),
|
||||
_not_own_address,
|
||||
)
|
||||
|
||||
|
||||
async def _new_port(
|
||||
port_id: ID, addressing: MockObj, destination: int, only_port: bool
|
||||
) -> MockObj:
|
||||
_request_port_slot(str(addressing))
|
||||
port = cg.new_Pvariable(port_id, addressing, destination, only_port)
|
||||
await cg.register_component(port, {})
|
||||
return port
|
||||
|
||||
|
||||
@ADDRESSING.register(
|
||||
"basic",
|
||||
schema_factory=_basic_addressing_schema,
|
||||
packet_size=lambda config, size: remaining_packet_size(size, HEADER_SIZE),
|
||||
)
|
||||
async def build_basic_addressing(
|
||||
config: ConfigType, link: MockObj
|
||||
) -> list[tuple[MockObj, ConfigType | None]]:
|
||||
cg.add_define("USE_PACKET_INTERFACE_BASIC_ADDRESSING")
|
||||
hub = cg.new_Pvariable(config[CONF_ID], link, config[CONF_ADDRESS])
|
||||
await cg.register_component(hub, config)
|
||||
if (peers := config.get(CONF_PEERS)) is None:
|
||||
port = await _new_port(
|
||||
config[CONF_PORT_ID], hub, config[CONF_DESTINATION], True
|
||||
)
|
||||
return [(port, None)]
|
||||
stacks = []
|
||||
# Each peer has its own stack, named after the peer for logs and saved preferences
|
||||
for peer in peers:
|
||||
port = await _new_port(peer[CONF_ID], hub, peer[CONF_DESTINATION], False)
|
||||
cg.add(port.set_name(peer[CONF_ID].id))
|
||||
stacks.append((port, peer))
|
||||
return stacks
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Fragmentation layer that splits packets larger than the layers below allow, and joins them again."""
|
||||
|
||||
import math
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_MAX_PACKET_SIZE
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import FRAGMENTATION, PacketInterface, packet_interface_ns
|
||||
|
||||
# The packet number, and the fragment's index with the flag on the last fragment
|
||||
HEADER_SIZE = 2
|
||||
MAX_FRAGMENTS = 128
|
||||
|
||||
BasicFragmentation = packet_interface_ns.class_("BasicFragmentation", PacketInterface)
|
||||
|
||||
BASIC_FRAGMENTATION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BasicFragmentation),
|
||||
# The packet being joined is held in a buffer of this size
|
||||
cv.Optional(CONF_MAX_PACKET_SIZE, default=1024): cv.int_range(16, 16384),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
def _check_lower(config: ConfigType, size: int, capacity: int | None) -> None:
|
||||
"""The largest packet must fit in the fragments that the layer below can take at once."""
|
||||
fragment_size = size - HEADER_SIZE
|
||||
if fragment_size <= 0:
|
||||
raise cv.Invalid("The layers below leave no room for fragments")
|
||||
fragments = max(1, math.ceil(config[CONF_MAX_PACKET_SIZE] / fragment_size))
|
||||
limit = MAX_FRAGMENTS if capacity is None else min(MAX_FRAGMENTS, capacity)
|
||||
if fragments > limit:
|
||||
raise cv.Invalid(
|
||||
f"A packet of {config[CONF_MAX_PACKET_SIZE]} bytes needs {fragments} fragments of {fragment_size} bytes, "
|
||||
f"but at most {limit} can be sent at once",
|
||||
path=[CONF_MAX_PACKET_SIZE],
|
||||
)
|
||||
|
||||
|
||||
# Fragments from different devices are told apart by the addressing layer's source address, so of two devices sending
|
||||
# fragmented packets at once, one still gets through; without addressing, the receiver can drop both
|
||||
@FRAGMENTATION.register(
|
||||
"basic",
|
||||
schema=BASIC_FRAGMENTATION_SCHEMA,
|
||||
packet_size=lambda config, size: config[CONF_MAX_PACKET_SIZE],
|
||||
check_lower=_check_lower,
|
||||
)
|
||||
async def build_basic_fragmentation(config: ConfigType, lower: MockObj) -> MockObj:
|
||||
cg.add_define("USE_PACKET_INTERFACE_BASIC_FRAGMENTATION")
|
||||
var = cg.new_Pvariable(config[CONF_ID], lower, config[CONF_MAX_PACKET_SIZE])
|
||||
await cg.register_component(var, config)
|
||||
return var
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Session layer that sends keep-alives and reports whether the peer can be reached."""
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BINARY_SENSOR,
|
||||
CONF_ID,
|
||||
CONF_TIMEOUT,
|
||||
DEVICE_CLASS_CONNECTIVITY,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import SESSION, PacketInterface, packet_interface_ns
|
||||
|
||||
CONF_KEEP_ALIVE = "keep_alive"
|
||||
|
||||
BasicSession = packet_interface_ns.class_("BasicSession", PacketInterface)
|
||||
|
||||
|
||||
def _session_timeout(config: ConfigType) -> ConfigType:
|
||||
"""Default the timeout to three keep-alive intervals, and require it to be longer than one."""
|
||||
keep_alive = config[CONF_KEEP_ALIVE].total_milliseconds
|
||||
if (timeout := config.get(CONF_TIMEOUT)) is None:
|
||||
config[CONF_TIMEOUT] = cv.positive_time_period_milliseconds(
|
||||
f"{keep_alive * 3}ms"
|
||||
)
|
||||
elif timeout.total_milliseconds <= keep_alive:
|
||||
raise cv.Invalid("Must be longer than keep_alive", path=[CONF_TIMEOUT])
|
||||
return config
|
||||
|
||||
|
||||
BASIC_SESSION_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BasicSession),
|
||||
cv.Optional(CONF_KEEP_ALIVE, default="10s"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(hours=1)),
|
||||
),
|
||||
cv.Optional(CONF_TIMEOUT): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(hours=3)),
|
||||
),
|
||||
cv.Optional(CONF_BINARY_SENSOR): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_CONNECTIVITY,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
_session_timeout,
|
||||
)
|
||||
|
||||
|
||||
@SESSION.register(
|
||||
"basic",
|
||||
schema=BASIC_SESSION_SCHEMA,
|
||||
single_peer=lambda config: True,
|
||||
auto_load=lambda config: ["binary_sensor"] if CONF_BINARY_SENSOR in config else [],
|
||||
)
|
||||
async def build_basic_session(config: ConfigType, lower: MockObj) -> MockObj:
|
||||
cg.add_define("USE_PACKET_INTERFACE_BASIC_SESSION")
|
||||
var = cg.new_Pvariable(config[CONF_ID], lower)
|
||||
cg.add(var.set_keep_alive(config[CONF_KEEP_ALIVE]))
|
||||
cg.add(var.set_session_timeout(config[CONF_TIMEOUT]))
|
||||
await cg.register_component(var, config)
|
||||
binary_sensors = binary_sensor.sub_binary_sensors(config)
|
||||
await binary_sensors(CONF_BINARY_SENSOR, var.set_connected_sensor)
|
||||
return var
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Reliability layer using HDLC style acknowledgement and go-back-N retransmission."""
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_TIMEOUT
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import RELIABILITY, PacketInterface, packet_interface_ns, remaining_packet_size
|
||||
|
||||
CONF_ACK_DELAY = "ack_delay"
|
||||
CONF_MAX_QUEUE_LENGTH = "max_queue_length"
|
||||
CONF_MAX_RETRIES = "max_retries"
|
||||
|
||||
# The control byte and the CRC-16
|
||||
FRAME_OVERHEAD = 3
|
||||
|
||||
HdlcReliability = packet_interface_ns.class_("HdlcReliability", PacketInterface)
|
||||
|
||||
|
||||
def _ack_delay_within_timeout(config: ConfigType) -> ConfigType:
|
||||
"""The peer, configured the same, must hear the acknowledgement before its timeout."""
|
||||
if (
|
||||
config[CONF_ACK_DELAY].total_milliseconds
|
||||
>= config[CONF_TIMEOUT].total_milliseconds
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Must be shorter than '{CONF_TIMEOUT}'", path=[CONF_ACK_DELAY]
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
HDLC_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(HdlcReliability),
|
||||
# Retransmitting more often than the main loop runs would exhaust the retries at once
|
||||
cv.Optional(CONF_TIMEOUT, default="200ms"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(min=cv.TimePeriod(milliseconds=50)),
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ACK_DELAY, default="0ms"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_MAX_QUEUE_LENGTH, default=4): cv.int_range(1, 7),
|
||||
cv.Optional(CONF_MAX_RETRIES, default=5): cv.int_range(1, 255),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
_ack_delay_within_timeout,
|
||||
)
|
||||
|
||||
|
||||
@RELIABILITY.register(
|
||||
"hdlc",
|
||||
schema=HDLC_SCHEMA,
|
||||
single_peer=lambda config: True,
|
||||
packet_size=lambda config, size: remaining_packet_size(size, FRAME_OVERHEAD),
|
||||
send_capacity=lambda config: config[CONF_MAX_QUEUE_LENGTH],
|
||||
)
|
||||
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
|
||||
cg.add_define("USE_PACKET_INTERFACE_HDLC")
|
||||
var = cg.new_Pvariable(config[CONF_ID], lower)
|
||||
cg.add(var.set_ack_timeout(config[CONF_TIMEOUT]))
|
||||
cg.add(var.set_ack_delay(config[CONF_ACK_DELAY]))
|
||||
cg.add(var.set_max_queue_length(config[CONF_MAX_QUEUE_LENGTH]))
|
||||
cg.add(var.set_max_retries(config[CONF_MAX_RETRIES]))
|
||||
await cg.register_component(var, config)
|
||||
return var
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Encryption layer using XXTEA, with an optional rolling code."""
|
||||
|
||||
import hashlib
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_KEY
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import ENCRYPTION, PacketInterface, packet_interface_ns, remaining_packet_size
|
||||
|
||||
CONF_ROLLING_CODE_ENABLE = "rolling_code_enable"
|
||||
|
||||
# The check word, and with the rolling code the boot numbers and counter
|
||||
CHECK_SIZE = 4
|
||||
ROLLING_SIZE = 12
|
||||
|
||||
XxteaEncryption = packet_interface_ns.class_("XxteaEncryption", PacketInterface)
|
||||
|
||||
XXTEA_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(XxteaEncryption),
|
||||
cv.Required(CONF_KEY): cv.All(cv.sensitive(cv.string), cv.Length(min=1)),
|
||||
cv.Optional(CONF_ROLLING_CODE_ENABLE, default=False): cv.boolean,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
# The rolling code keeps the last counter of one peer, and accepts a packet only once the peer has sent back this
|
||||
# device's boot number
|
||||
@ENCRYPTION.register(
|
||||
"xxtea",
|
||||
schema=XXTEA_SCHEMA,
|
||||
single_peer=lambda config: config[CONF_ROLLING_CODE_ENABLE],
|
||||
needs_session=lambda config: config[CONF_ROLLING_CODE_ENABLE],
|
||||
auto_load=lambda config: ["xxtea"],
|
||||
# XXTEA works on whole 32-bit words
|
||||
packet_size=lambda config, size: remaining_packet_size(
|
||||
size & ~3,
|
||||
CHECK_SIZE + (ROLLING_SIZE if config[CONF_ROLLING_CODE_ENABLE] else 0),
|
||||
),
|
||||
)
|
||||
async def build_xxtea(config: ConfigType, lower: MockObj) -> MockObj:
|
||||
cg.add_define("USE_PACKET_INTERFACE_XXTEA")
|
||||
var = cg.new_Pvariable(config[CONF_ID], lower, config[CONF_ROLLING_CODE_ENABLE])
|
||||
# A 256-bit key from the SHA-256 of the key text, as 32-bit words in little endian order
|
||||
digest = hashlib.sha256(config[CONF_KEY].encode()).digest()
|
||||
key = [int.from_bytes(digest[i : i + 4], "little") for i in range(0, 32, 4)]
|
||||
cg.add(var.set_key(key))
|
||||
await cg.register_component(var, config)
|
||||
return var
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/**
|
||||
* Decides whether a half-duplex radio link may send while its radio reports that it is receiving a packet.
|
||||
*
|
||||
* Sending is refused while the radio is busy, so the packet being received is not lost. A busy period starts when a
|
||||
* send first finds the radio busy, and ends when a send finds it free or end_busy() is called. Once a busy period has
|
||||
* lasted max_busy_ms, sends go ahead, so a busy flag that never clears cannot stop sending for good, however often
|
||||
* sends are tried. Code generation sets max_busy_ms from the time the radio takes to receive the largest packet.
|
||||
*/
|
||||
class ListenBeforeTalk {
|
||||
public:
|
||||
static constexpr uint32_t DEFAULT_MAX_BUSY_MS = 2000;
|
||||
|
||||
void set_max_busy(uint32_t max_busy_ms) { this->max_busy_ms_ = max_busy_ms; }
|
||||
uint32_t get_max_busy() const { return this->max_busy_ms_; }
|
||||
|
||||
/// True if a packet may be sent at \p now; \p busy says whether the radio is receiving.
|
||||
bool may_send(bool busy, uint32_t now) {
|
||||
if (!busy) {
|
||||
this->busy_ = false;
|
||||
return true;
|
||||
}
|
||||
if (!this->busy_) {
|
||||
this->busy_ = true;
|
||||
this->busy_since_ms_ = now;
|
||||
}
|
||||
return now - this->busy_since_ms_ >= this->max_busy_ms_;
|
||||
}
|
||||
/// Ends the busy period, when the radio is seen to be free other than by a send.
|
||||
void end_busy() { this->busy_ = false; }
|
||||
/// How long the current busy period has lasted at \p now.
|
||||
uint32_t busy_for(uint32_t now) const { return now - this->busy_since_ms_; }
|
||||
|
||||
protected:
|
||||
uint32_t busy_since_ms_{0};
|
||||
uint32_t max_busy_ms_{DEFAULT_MAX_BUSY_MS};
|
||||
bool busy_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
@@ -0,0 +1,173 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/**
|
||||
* A buffer class that efficiently represents a composite buffer with three sections:
|
||||
* - pre_buffer: A pointer to uint8_t data with a length
|
||||
* - mid_buffer: A reference to another PacketBuffer (optional)
|
||||
* - post_buffer: A pointer to uint8_t data with a length
|
||||
*
|
||||
* This allows efficient zero-copy composition of buffers without allocating new memory.
|
||||
*
|
||||
* WARNING: PacketBuffer holds raw pointers to data. The user must ensure that the data remains valid
|
||||
* for the lifetime of the PacketBuffer instance. Storing PacketBuffer instances beyond the scope of the data they
|
||||
* reference will lead to dangling pointers and undefined behavior, so to keep a packet, copy it with copy_to() into a
|
||||
* buffer allocated in setup().
|
||||
*/
|
||||
class PacketBuffer {
|
||||
public:
|
||||
PacketBuffer() = default;
|
||||
|
||||
// Disable copy semantics - PacketBuffer contains pointers and copying would create dangling references
|
||||
PacketBuffer(const PacketBuffer &) = delete;
|
||||
PacketBuffer &operator=(const PacketBuffer &) = delete;
|
||||
|
||||
// Enable move semantics
|
||||
PacketBuffer(PacketBuffer &&) = default;
|
||||
PacketBuffer &operator=(PacketBuffer &&) = default;
|
||||
|
||||
/**
|
||||
* Construct from a single buffer.
|
||||
*/
|
||||
PacketBuffer(const uint8_t *data, size_t length) : pre_buffer_(data), pre_length_(length) {}
|
||||
|
||||
/**
|
||||
* Construct from a vector.
|
||||
*/
|
||||
explicit PacketBuffer(const std::vector<uint8_t> &vec) : pre_buffer_(vec.data()), pre_length_(vec.size()) {}
|
||||
// A temporary vector would leave the buffer dangling
|
||||
explicit PacketBuffer(std::vector<uint8_t> &&) = delete;
|
||||
|
||||
/**
|
||||
* Construct with pre and post buffers.
|
||||
*/
|
||||
PacketBuffer(const uint8_t *pre_data, size_t pre_length, const uint8_t *post_data, size_t post_length)
|
||||
: pre_buffer_(pre_data), pre_length_(pre_length), post_buffer_(post_data), post_length_(post_length) {}
|
||||
|
||||
/**
|
||||
* Construct with pre, mid, and post buffers.
|
||||
*/
|
||||
PacketBuffer(const uint8_t *pre_data, size_t pre_length, const PacketBuffer *mid_buffer, const uint8_t *post_data,
|
||||
size_t post_length)
|
||||
: pre_buffer_(pre_data),
|
||||
pre_length_(pre_length),
|
||||
mid_buffer_(mid_buffer),
|
||||
post_buffer_(post_data),
|
||||
post_length_(post_length) {}
|
||||
|
||||
/**
|
||||
* Get the total size of the buffer.
|
||||
*/
|
||||
size_t size() const {
|
||||
size_t total = this->pre_length_ + this->post_length_;
|
||||
if (this->mid_buffer_ != nullptr) {
|
||||
total += this->mid_buffer_->size();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the buffer is empty.
|
||||
*/
|
||||
bool empty() const { return this->size() == 0; }
|
||||
|
||||
/**
|
||||
* Check if all the data is held in the pre-buffer, so it can be read as one span.
|
||||
*/
|
||||
bool is_contiguous() const { return this->mid_buffer_ == nullptr && this->post_length_ == 0; }
|
||||
|
||||
/**
|
||||
* Get the data as one span. Only valid when is_contiguous() is true.
|
||||
*/
|
||||
std::span<const uint8_t> as_span() const { return {this->pre_buffer_, this->pre_length_}; }
|
||||
|
||||
/**
|
||||
* Access a byte at a specific position.
|
||||
* @param index The position to access
|
||||
* @return The byte at the specified position, or 0 past the end
|
||||
*/
|
||||
uint8_t operator[](size_t index) const {
|
||||
if (index < this->pre_length_) {
|
||||
return this->pre_buffer_[index];
|
||||
}
|
||||
index -= this->pre_length_;
|
||||
|
||||
if (this->mid_buffer_ != nullptr) {
|
||||
size_t mid_size = this->mid_buffer_->size();
|
||||
if (index < mid_size) {
|
||||
return (*this->mid_buffer_)[index];
|
||||
}
|
||||
index -= mid_size;
|
||||
}
|
||||
|
||||
return index < this->post_length_ ? this->post_buffer_[index] : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy data from the buffer to a destination pointer.
|
||||
* @param dest Destination pointer
|
||||
* @param count Number of bytes to copy
|
||||
* @param offset Offset in the source buffer to start copying from
|
||||
* @return Number of bytes actually copied
|
||||
*/
|
||||
size_t copy_to(uint8_t *dest, size_t count, size_t offset = 0) const {
|
||||
size_t total_size = this->size();
|
||||
if (offset >= total_size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t available = total_size - offset;
|
||||
size_t to_copy = count < available ? count : available;
|
||||
size_t copied = 0;
|
||||
|
||||
// Copy from pre_buffer
|
||||
if (offset < this->pre_length_) {
|
||||
size_t pre_offset = offset;
|
||||
size_t pre_available = this->pre_length_ - pre_offset;
|
||||
size_t pre_copy = to_copy < pre_available ? to_copy : pre_available;
|
||||
std::memcpy(dest, this->pre_buffer_ + pre_offset, pre_copy);
|
||||
copied += pre_copy;
|
||||
dest += pre_copy;
|
||||
offset += pre_copy;
|
||||
}
|
||||
|
||||
// Copy from mid_buffer
|
||||
size_t mid_size = this->mid_buffer_ != nullptr ? this->mid_buffer_->size() : 0;
|
||||
if (copied < to_copy && offset < this->pre_length_ + mid_size) {
|
||||
size_t mid_offset = offset - this->pre_length_;
|
||||
size_t mid_available = mid_size - mid_offset;
|
||||
size_t mid_copy = (to_copy - copied) < mid_available ? (to_copy - copied) : mid_available;
|
||||
this->mid_buffer_->copy_to(dest, mid_copy, mid_offset);
|
||||
copied += mid_copy;
|
||||
dest += mid_copy;
|
||||
offset += mid_copy;
|
||||
}
|
||||
|
||||
// Copy from post_buffer
|
||||
if ((copied < to_copy) && (this->post_length_ > 0)) {
|
||||
size_t post_offset = offset - this->pre_length_ - mid_size;
|
||||
size_t post_available = this->post_length_ - post_offset;
|
||||
size_t post_copy = (to_copy - copied) < post_available ? (to_copy - copied) : post_available;
|
||||
std::memcpy(dest, this->post_buffer_ + post_offset, post_copy);
|
||||
copied += post_copy;
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
private:
|
||||
const uint8_t *pre_buffer_{nullptr};
|
||||
size_t pre_length_{0};
|
||||
const PacketBuffer *mid_buffer_{nullptr};
|
||||
const uint8_t *post_buffer_{nullptr};
|
||||
size_t post_length_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "packet_interface.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "packet_interface");
|
||||
|
||||
// Packets in a row that cannot be understood before the mismatch is logged
|
||||
static constexpr uint8_t MISMATCH_COUNT = 10;
|
||||
|
||||
void PacketInterface::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Packet Interface '%s':\n"
|
||||
" Max Packet Size: %zu",
|
||||
this->name_, this->get_max_packet_size());
|
||||
}
|
||||
|
||||
void PacketInterface::on_receive_from_interface_(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
#ifdef ESPHOME_LOG_HAS_VERBOSE
|
||||
static constexpr size_t LOG_BYTES = 24;
|
||||
uint8_t head[LOG_BYTES];
|
||||
size_t len = data.copy_to(head, LOG_BYTES);
|
||||
ESP_LOGV_HEX(TAG, head, len, LOG_BYTES, "%s: received %zu bytes %s", this->name_, data.size());
|
||||
#endif
|
||||
this->callback_.call(data, meta_data);
|
||||
}
|
||||
|
||||
void PacketInterface::note_received_(bool understood) {
|
||||
if (understood) {
|
||||
this->not_understood_count_ = 0;
|
||||
} else if (this->not_understood_count_ < MISMATCH_COUNT) {
|
||||
this->not_understood_count_++;
|
||||
if (this->not_understood_count_ == MISMATCH_COUNT) {
|
||||
ESP_LOGW(TAG, "%s: %u packets in a row could not be understood; check that both ends have the same layers",
|
||||
this->name_, MISMATCH_COUNT);
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace esphome::packet_interface
|
||||
@@ -0,0 +1,149 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <utility>
|
||||
|
||||
#include "esphome/core/string_ref.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "packet_buffer.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/// The room left for a packet of \p size bytes after \p used bytes, or 0 if it is too small.
|
||||
constexpr size_t remaining_packet_size(size_t size, size_t used) { return size > used ? size - used : 0; }
|
||||
|
||||
/// What a stack of layers provides, as bits of PacketInterface::get_capabilities().
|
||||
enum Capability : uint8_t {
|
||||
CAPABILITY_ENCRYPTION = 1 << 0,
|
||||
CAPABILITY_RELIABILITY = 1 << 1,
|
||||
CAPABILITY_SESSION = 1 << 2,
|
||||
CAPABILITY_ADDRESSING = 1 << 3,
|
||||
CAPABILITY_FRAGMENTATION = 1 << 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* A struct to hold metadata about a packet.
|
||||
* @param info Additional information about the packet
|
||||
* @param source_address With an addressing layer, the sender of a received packet; 0 if not known
|
||||
* @param destination_address With an addressing layer, the destination of a packet; when sending, 0 uses the
|
||||
* configured destination
|
||||
* @param rssi The RSSI of the packet
|
||||
* @param snr The SNR of the packet
|
||||
* @param mac_address The MAC address of the sender/receiver
|
||||
*/
|
||||
class PacketMetaData {
|
||||
public:
|
||||
StringRef info{};
|
||||
uint16_t source_address{0};
|
||||
uint16_t destination_address{0};
|
||||
float rssi{NAN};
|
||||
float snr{NAN};
|
||||
uint8_t mac_address[MAC_ADDRESS_SIZE]{};
|
||||
};
|
||||
/**
|
||||
* A class that will transport data to and from an abstract channel.
|
||||
*
|
||||
* The operations defined are:
|
||||
*
|
||||
* Transmit: the function send_to_interface() takes a PacketBuffer of bytes, and returns when all data is
|
||||
* written or buffered for transmission.
|
||||
*
|
||||
* Receive: The callbacks for an instance of this class are called with currently received data.
|
||||
*/
|
||||
class PacketInterface : public Component {
|
||||
public:
|
||||
/**
|
||||
*
|
||||
* Method to be implemented by child classes to send data to the interface.
|
||||
*
|
||||
* @param data The data to be sent
|
||||
* @param meta_data Metadata about the packet to be sent
|
||||
* @return True if the data was queued for transmission, false if it could not be sent
|
||||
*/
|
||||
virtual bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) = 0;
|
||||
|
||||
/// The largest packet this layer accepts; fixed when the layer is constructed.
|
||||
size_t get_max_packet_size() const { return this->max_packet_size_; }
|
||||
/// How many packets send_to_interface() would accept now, so a layer above can tell before sending a series.
|
||||
virtual size_t get_send_capacity() const { return SIZE_MAX; }
|
||||
/// The capabilities of this layer and every layer below it.
|
||||
uint8_t get_capabilities() const { return this->capabilities_; }
|
||||
bool has_capability(Capability capability) const { return (this->capabilities_ & capability) != 0; }
|
||||
/**
|
||||
* With CAPABILITY_SESSION, the callback is called with true when the peer can be reached, including after it
|
||||
* restarts, and with false when it is lost. The session layer is always the top layer, so register on top().
|
||||
* A reliability layer calls it with false when it resets the link and discards data, which the session layer above
|
||||
* reports as the session lost.
|
||||
*/
|
||||
template<typename F> void add_on_session_state_callback(F &&callback) {
|
||||
this->session_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
/// The topmost layer stacked on this one, or this one if nothing is stacked on it.
|
||||
PacketInterface *top() {
|
||||
PacketInterface *layer = this;
|
||||
while (layer->upper_ != nullptr)
|
||||
layer = layer->upper_;
|
||||
return layer;
|
||||
}
|
||||
/// Name shown in log messages; a layer takes the name of the link it is stacked on.
|
||||
void set_name(const char *name) { this->name_ = name; }
|
||||
const char *get_name() const { return this->name_; }
|
||||
|
||||
/**
|
||||
* Add a listener that will be called when data is received.
|
||||
*
|
||||
* @param callback The callback to be called when data is received.
|
||||
*/
|
||||
template<typename F> void add_packet_interface_listener(F &&callback) {
|
||||
this->callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
/// @param max_packet_size The largest packet the layer accepts, set from the layer below or the layer's own buffer
|
||||
explicit PacketInterface(size_t max_packet_size) : max_packet_size_(max_packet_size) {}
|
||||
|
||||
virtual ~PacketInterface() = default;
|
||||
/**
|
||||
* This function should be called by implementing classes with received data to be passed to any listeners.
|
||||
*
|
||||
* @param data The received data
|
||||
* @param meta_data Metadata about the received packet
|
||||
*/
|
||||
void on_receive_from_interface_(const PacketBuffer &data, const PacketMetaData &meta_data);
|
||||
/**
|
||||
* Called by a layer for each received packet it understood, or could not make sense of. A run of packets that
|
||||
* cannot be understood usually means the peer has different layers configured, which is logged once per run.
|
||||
*/
|
||||
void note_received_(bool understood);
|
||||
/// Called by a layer's constructor to record itself as the layer above \p lower, adding its own capability.
|
||||
void stack_on_(PacketInterface *lower, Capability capability) {
|
||||
lower->upper_ = this;
|
||||
this->inherit_from_(lower, capability);
|
||||
}
|
||||
/// Takes the name and capabilities of `lower` without becoming its top, for one of several stacks on a link.
|
||||
void inherit_from_(PacketInterface *lower, Capability capability) {
|
||||
this->name_ = lower->name_;
|
||||
this->capabilities_ = lower->capabilities_ | capability;
|
||||
// A link that has a session of its own, such as TCP, reports it to whatever is stacked above, as does a
|
||||
// reliability layer that discards data; the session layer handles the latter itself
|
||||
if ((lower->has_capability(CAPABILITY_SESSION) || lower->has_capability(CAPABILITY_RELIABILITY)) &&
|
||||
capability != CAPABILITY_SESSION) {
|
||||
lower->add_on_session_state_callback([this](bool connected) { this->session_callback_.call(connected); });
|
||||
}
|
||||
}
|
||||
|
||||
LazyCallbackManager<void(const PacketBuffer &, const PacketMetaData &)> callback_;
|
||||
LazyCallbackManager<void(bool)> session_callback_;
|
||||
PacketInterface *upper_{nullptr};
|
||||
const char *name_{""};
|
||||
const size_t max_packet_size_;
|
||||
uint8_t capabilities_{0};
|
||||
uint8_t not_understood_count_{0};
|
||||
};
|
||||
} // namespace esphome::packet_interface
|
||||
@@ -0,0 +1,35 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.packet_transport import (
|
||||
PacketTransport,
|
||||
new_packet_transport,
|
||||
transport_schema,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_PACKET_INTERFACE,
|
||||
PacketInterface,
|
||||
final_validate_interface,
|
||||
packet_interface_ns,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DEPENDENCIES = ["packet_interface"]
|
||||
|
||||
PacketInterfaceTransport = packet_interface_ns.class_(
|
||||
"PacketInterfaceTransport", PacketTransport
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = transport_schema(PacketInterfaceTransport).extend(
|
||||
{
|
||||
cv.GenerateID(CONF_PACKET_INTERFACE): cv.use_id(PacketInterface),
|
||||
}
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validate_interface(CONF_PACKET_INTERFACE)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
interface = await cg.get_variable(config[CONF_PACKET_INTERFACE])
|
||||
await new_packet_transport(config, interface)
|
||||
@@ -0,0 +1,29 @@
|
||||
#include "packet_interface_transport.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "packet_interface.transport");
|
||||
|
||||
void PacketInterfaceTransport::setup() {
|
||||
PacketTransport::setup();
|
||||
this->interface_ = this->interface_->top();
|
||||
this->interface_->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &) { this->receive_(data); });
|
||||
}
|
||||
|
||||
void PacketInterfaceTransport::receive_(const PacketBuffer &data) {
|
||||
// Packet interface layers deliver packets in one piece
|
||||
if (!data.is_contiguous()) {
|
||||
ESP_LOGW(TAG, "Packet split across buffers is not supported");
|
||||
return;
|
||||
}
|
||||
this->process_(data.as_span());
|
||||
}
|
||||
|
||||
void PacketInterfaceTransport::send_packet(const std::vector<uint8_t> &buf) const {
|
||||
if (!this->interface_->send_to_interface(PacketBuffer(buf), {}))
|
||||
ESP_LOGD(TAG, "Failed to send packet of %zu bytes", buf.size());
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/packet_interface/packet_interface.h"
|
||||
#include "esphome/components/packet_transport/packet_transport.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/**
|
||||
* A transport protocol adapter that bridges PacketInterface to PacketTransport.
|
||||
* This allows packet_transport functionality to be used with any PacketInterface implementation.
|
||||
*/
|
||||
class PacketInterfaceTransport : public packet_transport::PacketTransport {
|
||||
public:
|
||||
explicit PacketInterfaceTransport(PacketInterface *interface) : interface_(interface) {}
|
||||
|
||||
void setup() override;
|
||||
|
||||
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
|
||||
|
||||
protected:
|
||||
// implementing PacketTransport virtual methods
|
||||
size_t get_max_packet_size() override { return this->interface_->top()->get_max_packet_size(); }
|
||||
void send_packet(const std::vector<uint8_t> &buf) const override;
|
||||
void receive_(const PacketBuffer &data);
|
||||
|
||||
PacketInterface *interface_;
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
@@ -0,0 +1,54 @@
|
||||
#include "radio_link.h"
|
||||
#include <cinttypes>
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "packet_interface.radio");
|
||||
|
||||
bool RadioLinkBase::check_size_(size_t size) const {
|
||||
if (size <= this->max_packet_size_)
|
||||
return true;
|
||||
ESP_LOGW(TAG, "Packet of %zu bytes is larger than %zu", size, this->max_packet_size_);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool RadioLinkBase::may_send_(bool busy) {
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (busy)
|
||||
this->enable_loop();
|
||||
if (!this->listen_before_talk_.may_send(busy, now))
|
||||
return false;
|
||||
if (busy) {
|
||||
// The limit is longer than any packet takes, so the radio's busy indication is stuck or the channel is jammed
|
||||
ESP_LOGW(TAG, "Radio busy for %" PRIu32 "ms, sending anyway", this->listen_before_talk_.busy_for(now));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RadioLinkBase::skip_empty_() const {
|
||||
ESP_LOGV(TAG, "Empty packet not sent");
|
||||
return true;
|
||||
}
|
||||
|
||||
void RadioLinkBase::radio_not_ready_(bool failed) {
|
||||
if (!failed || this->failed_warned_)
|
||||
return;
|
||||
this->failed_warned_ = true;
|
||||
ESP_LOGW(TAG, "%s: The radio failed to set up, so nothing can be sent", this->name_);
|
||||
}
|
||||
|
||||
void RadioLinkBase::radio_free_() {
|
||||
this->listen_before_talk_.end_busy();
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void RadioLinkBase::receive_(const std::vector<uint8_t> &packet, float rssi, float snr) {
|
||||
PacketMetaData meta_data;
|
||||
meta_data.rssi = rssi;
|
||||
meta_data.snr = snr;
|
||||
this->on_receive_from_interface_(PacketBuffer(packet.data(), packet.size()), meta_data);
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/packet_interface/listen_before_talk.h"
|
||||
#include "esphome/components/packet_interface/packet_interface.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/// The parts of RadioLink that do not depend on the radio.
|
||||
class RadioLinkBase : public PacketInterface {
|
||||
public:
|
||||
void set_max_busy(uint32_t max_busy_ms) { this->listen_before_talk_.set_max_busy(max_busy_ms); }
|
||||
|
||||
protected:
|
||||
explicit RadioLinkBase(size_t max_packet_size) : PacketInterface(max_packet_size) {}
|
||||
/// False if a packet of this size is too large.
|
||||
bool check_size_(size_t size) const;
|
||||
/// False if sending must wait, as the radio is busy; starts loop() to watch the radio.
|
||||
bool may_send_(bool busy);
|
||||
/// Returns true for an empty packet, which the radios cannot send, so there is nothing to retry.
|
||||
bool skip_empty_() const;
|
||||
/// Warns once when a send finds the radio failed; until its setup has run, sends are refused quietly.
|
||||
void radio_not_ready_(bool failed);
|
||||
/// Ends the busy period when loop() finds the radio free.
|
||||
void radio_free_();
|
||||
void receive_(const std::vector<uint8_t> &packet, float rssi, float snr);
|
||||
|
||||
ListenBeforeTalk listen_before_talk_;
|
||||
bool failed_warned_{false};
|
||||
};
|
||||
|
||||
/**
|
||||
* A packet interface link over a packet radio: each packet is one radio packet, with the RSSI and SNR of received
|
||||
* packets in the metadata. Sending waits for the radio to finish transmitting. A radio cannot hear while it sends, so
|
||||
* sending is refused while the radio is receiving a packet. A reliability layer sends the packet again shortly, and
|
||||
* entity_transport after a delay; other callers drop it, logging at debug level at most. While a busy period lasts,
|
||||
* loop() watches the radio and ends the period as soon as the radio is free, so a later reception starts a new one.
|
||||
*
|
||||
* Radio provides is_receiving(), register_listener(Listener *) and transmit_packet(const uint8_t *, size_t), which
|
||||
* returns Error::NONE on success. Listener declares on_packet(packet, rssi, snr). MaxPacketSize is the radio's limit.
|
||||
*/
|
||||
template<typename Radio, typename Listener, typename Error, size_t MaxPacketSize>
|
||||
class RadioLink : public RadioLinkBase, public Listener {
|
||||
public:
|
||||
RadioLink(Radio *radio, size_t max_packet_size) : RadioLinkBase(max_packet_size), radio_(radio) {}
|
||||
|
||||
void setup() override {
|
||||
this->radio_->register_listener(this);
|
||||
this->disable_loop();
|
||||
}
|
||||
// Runs only during a busy period; a busy flag that never clears keeps it running. A radio that failed meanwhile
|
||||
// is not read again, as reading it may wait on the radio every time
|
||||
void loop() override {
|
||||
if (!this->radio_->is_ready() || !this->radio_->is_receiving())
|
||||
this->radio_free_();
|
||||
}
|
||||
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override {
|
||||
// The radio is set up after the layers above, which may send from their own setup
|
||||
if (!this->radio_->is_ready()) {
|
||||
this->radio_not_ready_(this->radio_->is_failed());
|
||||
return false;
|
||||
}
|
||||
size_t size = data.size();
|
||||
if (size == 0)
|
||||
return this->skip_empty_();
|
||||
if (!this->check_size_(size) || !this->may_send_(this->radio_->is_receiving()))
|
||||
return false;
|
||||
if (data.is_contiguous()) {
|
||||
auto span = data.as_span();
|
||||
return this->radio_->transmit_packet(span.data(), span.size()) == Error::NONE;
|
||||
}
|
||||
uint8_t buf[MaxPacketSize];
|
||||
data.copy_to(buf, size);
|
||||
return this->radio_->transmit_packet(buf, size) == Error::NONE;
|
||||
}
|
||||
|
||||
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override {
|
||||
this->receive_(packet, rssi, snr);
|
||||
}
|
||||
|
||||
protected:
|
||||
Radio *radio_;
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Shared code generation for the packet interface links over packet radios, such as the SX126x and SX127x."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import math
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_MAX_PACKET_SIZE
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import new_packet_interface, packet_interface_schema
|
||||
from .layers import CONF_ADDRESSING, CONF_PEERS, STACK_ORDER
|
||||
|
||||
# The radios' payload length register is 8 bits
|
||||
MAX_PACKET_SIZE = 255
|
||||
|
||||
# Listen before talk holds a send back for at most the airtime of the largest packet, and never less than this
|
||||
MIN_MAX_BUSY_MS = 2000
|
||||
# Room for the preamble detection and the radio's own delays
|
||||
MARGIN = 1.25
|
||||
|
||||
|
||||
def packet_airtime_ms(radio: ConfigType, size: int) -> float:
|
||||
"""The time a radio configured as in radio takes to send a packet of size bytes.
|
||||
|
||||
LoRa follows the Semtech formula for an explicit header; the low data rate optimization is assumed when a symbol
|
||||
takes more than 16 ms, as the drivers enable it. Other modulations count the preamble, sync word, length byte,
|
||||
payload and CRC at the bitrate, treating the preamble size as bytes, which gives the longer time.
|
||||
"""
|
||||
if str(radio["modulation"]) == "LORA":
|
||||
spreading_factor = radio["spreading_factor"]
|
||||
bandwidth_hz = (
|
||||
float(str(radio["bandwidth"]).removesuffix("kHz").replace("_", ".")) * 1000
|
||||
)
|
||||
coding_rate = int(str(radio["coding_rate"])[-1]) - 4
|
||||
symbol_s = (1 << spreading_factor) / bandwidth_hz
|
||||
low_data_rate = 1 if symbol_s > 0.016 else 0
|
||||
crc = 16 if radio.get("crc_enable", False) else 0
|
||||
payload_symbols = 8 + max(
|
||||
math.ceil(
|
||||
(8 * size - 4 * spreading_factor + 28 + crc)
|
||||
/ (4 * (spreading_factor - 2 * low_data_rate))
|
||||
)
|
||||
* (coding_rate + 4),
|
||||
0,
|
||||
)
|
||||
return (radio["preamble_size"] + 4.25 + payload_symbols) * symbol_s * 1000
|
||||
bytes_sent = (
|
||||
radio["preamble_size"] + len(radio.get("sync_value", [])) + 1 + size + 2
|
||||
)
|
||||
return bytes_sent * 8 / radio["bitrate"] * 1000
|
||||
|
||||
|
||||
def max_busy_ms(radio: ConfigType, max_packet_size: int) -> int:
|
||||
"""How long listen before talk may hold a send back."""
|
||||
return max(
|
||||
MIN_MAX_BUSY_MS, math.ceil(packet_airtime_ms(radio, max_packet_size) * MARGIN)
|
||||
)
|
||||
|
||||
|
||||
def radio_link_schema(
|
||||
class_: MockObjClass, radio_id_key: str, radio_class: MockObjClass
|
||||
) -> cv.Schema:
|
||||
"""Schema for a radio link, referring to its radio by radio_id_key."""
|
||||
return packet_interface_schema(class_, MAX_PACKET_SIZE, MAX_PACKET_SIZE).extend(
|
||||
{cv.GenerateID(radio_id_key): cv.use_id(radio_class)}
|
||||
)
|
||||
|
||||
|
||||
def _radio_config(config: ConfigType, radio_id_key: str) -> ConfigType:
|
||||
full_config = fv.full_config.get()
|
||||
return full_config.get_config_for_path(
|
||||
full_config.get_path_for_id(config[radio_id_key])[:-1]
|
||||
)
|
||||
|
||||
|
||||
def _two_way_layer(config: ConfigType) -> str | None:
|
||||
"""The first layer on the link or in a peer that keeps state for one peer, so needs packets back from it."""
|
||||
for stack in (config, *config.get(CONF_ADDRESSING, {}).get(CONF_PEERS, [])):
|
||||
for kind in STACK_ORDER:
|
||||
if (layer := stack.get(kind.key)) is not None and kind.get_type(
|
||||
layer
|
||||
).single_peer(layer):
|
||||
return kind.key
|
||||
return None
|
||||
|
||||
|
||||
def radio_link_final_validate(
|
||||
radio_id_key: str,
|
||||
payload_length_key: str,
|
||||
rx_start_key: str,
|
||||
check: Callable[[ConfigType, ConfigType], None] | None = None,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Final validation for a radio link: packets vary in length, the radio listens after sending when a layer needs
|
||||
replies, and check(link, radio) checks the rest.
|
||||
"""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
radio = _radio_config(config, radio_id_key)
|
||||
if radio[payload_length_key] != 0:
|
||||
raise cv.Invalid(
|
||||
f"Packets vary in length, so the radio's '{payload_length_key}' must be 0",
|
||||
path=[radio_id_key],
|
||||
)
|
||||
if not radio[rx_start_key] and (layer := _two_way_layer(config)) is not None:
|
||||
raise cv.Invalid(
|
||||
f"This '{layer}' needs packets from the peer, but the radio sleeps after sending unless "
|
||||
f"'{rx_start_key}' is true",
|
||||
path=[radio_id_key],
|
||||
)
|
||||
if check is not None:
|
||||
check(config, radio)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
async def new_radio_link(
|
||||
config: ConfigType, radio_id_key: str, radio_domain: str
|
||||
) -> None:
|
||||
"""Create a radio link, with its listen before talk limit worked out from the radio's settings."""
|
||||
radio = await cg.get_variable(config[radio_id_key])
|
||||
var = await new_packet_interface(config, radio, config[CONF_MAX_PACKET_SIZE])
|
||||
radio_config = next(
|
||||
(
|
||||
conf
|
||||
for conf in CORE.config[radio_domain]
|
||||
if conf[CONF_ID] == config[radio_id_key]
|
||||
),
|
||||
None,
|
||||
)
|
||||
if radio_config is None:
|
||||
raise EsphomeError(f"No {radio_domain} radio with id '{config[radio_id_key]}'")
|
||||
cg.add(var.set_max_busy(max_busy_ms(radio_config, config[CONF_MAX_PACKET_SIZE])))
|
||||
@@ -0,0 +1,182 @@
|
||||
#include "xxtea_encryption.h"
|
||||
#ifdef USE_PACKET_INTERFACE_XXTEA
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include "esphome/components/xxtea/xxtea.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "packet_interface.xxtea");
|
||||
|
||||
static constexpr uint32_t PREF_HASH = fnv1a_hash("packet_interface.xxtea");
|
||||
// Different for each mode, so devices set up differently drop each other's packets
|
||||
static constexpr uint8_t CHECK_BYTES[3] = {0x58, 0x54, 0x45};
|
||||
static constexpr uint8_t ROLLING_CHECK_BYTES[3] = {0x58, 0x54, 0x52};
|
||||
static constexpr size_t CHECK_SIZE = 4;
|
||||
// Boot number, counter, and the echo of the receiver's boot number
|
||||
static constexpr size_t ROLLING_SIZE = 12;
|
||||
// XXTEA needs at least two words
|
||||
static constexpr size_t MIN_LENGTH = 8;
|
||||
static constexpr size_t MAX_PADDING = 4;
|
||||
|
||||
static void put_uint32(uint8_t *buf, uint32_t value) {
|
||||
buf[0] = value;
|
||||
buf[1] = value >> 8;
|
||||
buf[2] = value >> 16;
|
||||
buf[3] = value >> 24;
|
||||
}
|
||||
|
||||
static uint32_t get_uint32(const uint8_t *buf) { return encode_uint32(buf[3], buf[2], buf[1], buf[0]); }
|
||||
|
||||
static constexpr size_t trailer_size(bool rolling_code) {
|
||||
return rolling_code ? ROLLING_SIZE + CHECK_SIZE : CHECK_SIZE;
|
||||
}
|
||||
|
||||
XxteaEncryption::XxteaEncryption(PacketInterface *lower, bool rolling_code)
|
||||
// XXTEA works on whole 32-bit words
|
||||
: PacketInterface(remaining_packet_size(lower->get_max_packet_size() & ~size_t{3}, trailer_size(rolling_code))),
|
||||
lower_(lower),
|
||||
trailer_size_(trailer_size(rolling_code)),
|
||||
rolling_code_(rolling_code) {
|
||||
this->stack_on_(lower, CAPABILITY_ENCRYPTION);
|
||||
}
|
||||
|
||||
void XxteaEncryption::set_key(std::initializer_list<uint32_t> key) {
|
||||
std::copy_n(key.begin(), std::min(key.size(), this->key_.size()), this->key_.begin());
|
||||
}
|
||||
|
||||
void XxteaEncryption::setup() {
|
||||
if (this->max_packet_size_ == 0) {
|
||||
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
|
||||
return;
|
||||
}
|
||||
size_t words = this->lower_->get_max_packet_size() / 4;
|
||||
RAMAllocator<uint32_t> allocator;
|
||||
this->tx_buffer_ = allocator.allocate(words * 2);
|
||||
if (this->tx_buffer_ == nullptr) {
|
||||
this->mark_failed(LOG_STR("Not enough memory for the packet buffers"));
|
||||
return;
|
||||
}
|
||||
this->rx_buffer_ = this->tx_buffer_ + words;
|
||||
if (this->rolling_code_) {
|
||||
this->pref_ = global_preferences->make_preference<uint32_t>(fnv1a_hash_extend(PREF_HASH, this->name_), true);
|
||||
this->pref_.load(&this->boot_);
|
||||
this->next_boot_();
|
||||
}
|
||||
this->lower_->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
|
||||
}
|
||||
|
||||
void XxteaEncryption::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"XXTEA Encryption on '%s':\n"
|
||||
" Max Packet Size: %zu\n"
|
||||
" Rolling Code: %s",
|
||||
this->name_, this->max_packet_size_, YESNO(this->rolling_code_));
|
||||
if (this->rolling_code_) {
|
||||
ESP_LOGCONFIG(TAG, " Boot Number: %" PRIu32, this->boot_);
|
||||
}
|
||||
}
|
||||
|
||||
// A new boot number must be saved before it is used, or a crash could make the peer see it again
|
||||
void XxteaEncryption::next_boot_() {
|
||||
this->boot_++;
|
||||
this->counter_ = 0;
|
||||
if (!this->pref_.save(&this->boot_) || !global_preferences->sync()) {
|
||||
ESP_LOGW(TAG, "%s: Could not save the boot number, so a restart could reuse it", this->name_);
|
||||
}
|
||||
}
|
||||
|
||||
bool XxteaEncryption::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
if (this->tx_buffer_ == nullptr)
|
||||
return false;
|
||||
size_t size = data.size();
|
||||
if (size > this->max_packet_size_) {
|
||||
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
|
||||
return false;
|
||||
}
|
||||
size_t padding = (4 - (size + this->trailer_size_) % 4) % 4;
|
||||
if (size + padding + this->trailer_size_ < MIN_LENGTH)
|
||||
padding += 4;
|
||||
size_t length = size + padding + this->trailer_size_;
|
||||
auto *buf = reinterpret_cast<uint8_t *>(this->tx_buffer_);
|
||||
data.copy_to(buf, size);
|
||||
memset(buf + size, 0, padding);
|
||||
uint8_t *check = buf + length - CHECK_SIZE;
|
||||
if (this->rolling_code_) {
|
||||
put_uint32(check - ROLLING_SIZE, this->boot_);
|
||||
put_uint32(check - ROLLING_SIZE + 4, this->counter_);
|
||||
put_uint32(check - ROLLING_SIZE + 8, this->echo_boot_);
|
||||
memcpy(check, ROLLING_CHECK_BYTES, sizeof(ROLLING_CHECK_BYTES));
|
||||
if (++this->counter_ == 0)
|
||||
this->next_boot_();
|
||||
} else {
|
||||
memcpy(check, CHECK_BYTES, sizeof(CHECK_BYTES));
|
||||
}
|
||||
check[CHECK_SIZE - 1] = padding;
|
||||
xxtea::encrypt(this->tx_buffer_, length / 4, this->key_.data());
|
||||
return this->lower_->send_to_interface(PacketBuffer(buf, length), meta_data);
|
||||
}
|
||||
|
||||
bool XxteaEncryption::accept_counter_(uint32_t boot, uint32_t counter) {
|
||||
if (this->peer_seen_ && (boot < this->peer_boot_ || (boot == this->peer_boot_ && counter <= this->peer_counter_)))
|
||||
return false;
|
||||
this->peer_seen_ = true;
|
||||
this->peer_boot_ = boot;
|
||||
this->peer_counter_ = counter;
|
||||
return true;
|
||||
}
|
||||
|
||||
void XxteaEncryption::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
size_t length = data.size();
|
||||
if (length % 4 != 0 || length < std::max(MIN_LENGTH, this->trailer_size_) ||
|
||||
length > this->lower_->get_max_packet_size()) {
|
||||
ESP_LOGV(TAG, "%s: Packet of %zu bytes dropped", this->name_, length);
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
auto *buf = reinterpret_cast<uint8_t *>(this->rx_buffer_);
|
||||
data.copy_to(buf, length);
|
||||
xxtea::decrypt(this->rx_buffer_, length / 4, this->key_.data());
|
||||
const uint8_t *check = buf + length - CHECK_SIZE;
|
||||
const uint8_t *expected = this->rolling_code_ ? ROLLING_CHECK_BYTES : CHECK_BYTES;
|
||||
size_t padding = check[CHECK_SIZE - 1];
|
||||
if (memcmp(check, expected, sizeof(CHECK_BYTES)) != 0 || padding > MAX_PADDING ||
|
||||
padding > length - this->trailer_size_) {
|
||||
ESP_LOGV(TAG, "%s: Packet failed authentication", this->name_);
|
||||
this->note_received_(false);
|
||||
return;
|
||||
}
|
||||
this->note_received_(true);
|
||||
if (this->rolling_code_) {
|
||||
uint32_t boot = get_uint32(check - ROLLING_SIZE);
|
||||
uint32_t counter = get_uint32(check - ROLLING_SIZE + 4);
|
||||
uint32_t echo = get_uint32(check - ROLLING_SIZE + 8);
|
||||
// Learn the peer's boot number even from a packet that is then dropped, so both sides can catch up
|
||||
if (boot > this->echo_boot_)
|
||||
this->echo_boot_ = boot;
|
||||
// A packet recorded before this boot cannot carry this boot's number
|
||||
if (echo != this->boot_) {
|
||||
ESP_LOGV(TAG, "%s: Packet for boot %" PRIu32 " dropped", this->name_, echo);
|
||||
return;
|
||||
}
|
||||
if (!this->accept_counter_(boot, counter)) {
|
||||
// A boot number lower than one already heard means the peer lost its saved boot number, or a replay
|
||||
if (boot < this->peer_boot_ && !this->old_boot_warned_) {
|
||||
this->old_boot_warned_ = true;
|
||||
ESP_LOGW(TAG,
|
||||
"%s: Packets from an older boot %" PRIu32 " of the peer are dropped; if its flash was erased, "
|
||||
"restart this device",
|
||||
this->name_, boot);
|
||||
}
|
||||
ESP_LOGV(TAG, "%s: Old packet %" PRIu32 ":%" PRIu32 " dropped", this->name_, boot, counter);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->on_receive_from_interface_(PacketBuffer(buf, length - this->trailer_size_ - padding), meta_data);
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_PACKET_INTERFACE_XXTEA
|
||||
#include <array>
|
||||
#include <initializer_list>
|
||||
|
||||
#include "esphome/core/preferences.h"
|
||||
#include "packet_interface.h"
|
||||
|
||||
namespace esphome::packet_interface {
|
||||
|
||||
/**
|
||||
* An encryption layer that encrypts and authenticates each packet with XXTEA and a 256-bit shared key.
|
||||
*
|
||||
* The packet is padded with zeros and followed by a trailer, then the whole is encrypted as one block. With the
|
||||
* rolling code enabled, the trailer starts with:
|
||||
* - a 32-bit boot number, saved in flash and increased at every boot;
|
||||
* - a 32-bit packet counter, starting at 0 at every boot;
|
||||
* - the highest boot number heard from the peer, echoed back to it.
|
||||
* The trailer always ends with 3 fixed check bytes and the number of padding bytes. XXTEA mixes the whole block, so
|
||||
* a packet that was changed or encrypted with another key fails the check.
|
||||
* With the rolling code, a packet is dropped as a replay unless it echoes this device's current boot number, and its
|
||||
* boot number and counter are higher than those of the last packet accepted from the peer. A boot number is never
|
||||
* reused, so the echo acts as a fresh challenge after every restart.
|
||||
*/
|
||||
class XxteaEncryption : public PacketInterface {
|
||||
public:
|
||||
XxteaEncryption(PacketInterface *lower, bool rolling_code);
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
|
||||
|
||||
void set_key(std::initializer_list<uint32_t> key);
|
||||
|
||||
protected:
|
||||
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
|
||||
bool accept_counter_(uint32_t boot, uint32_t counter);
|
||||
void next_boot_();
|
||||
|
||||
PacketInterface *lower_;
|
||||
const size_t trailer_size_;
|
||||
std::array<uint32_t, 8> key_{};
|
||||
// Separate buffers, as a layer above may send a reply while a received packet is being passed up; both are in one
|
||||
// block allocated in setup(), and tx_buffer_ stays null if that fails
|
||||
uint32_t *tx_buffer_{nullptr};
|
||||
uint32_t *rx_buffer_{nullptr};
|
||||
ESPPreferenceObject pref_;
|
||||
uint32_t boot_{0};
|
||||
uint32_t counter_{0};
|
||||
uint32_t peer_boot_{0};
|
||||
uint32_t peer_counter_{0};
|
||||
uint32_t echo_boot_{0}; // highest boot number heard from the peer
|
||||
bool peer_seen_{false};
|
||||
bool old_boot_warned_{false};
|
||||
const bool rolling_code_;
|
||||
};
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -205,8 +205,10 @@ async def register_packet_transport(var: MockObj, config: ConfigType) -> set[str
|
||||
return providers
|
||||
|
||||
|
||||
async def new_packet_transport(config: ConfigType) -> tuple[MockObj, set[str]]:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
async def new_packet_transport(
|
||||
config: ConfigType, *args: Any
|
||||
) -> tuple[MockObj, set[str]]:
|
||||
var = cg.new_Pvariable(config[CONF_ID], *args)
|
||||
cg.add(var.set_platform_name(config[CONF_PLATFORM]))
|
||||
providers = await register_packet_transport(var, config)
|
||||
return var, providers
|
||||
|
||||
@@ -547,9 +547,11 @@ void PacketTransport::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Packet Transport:\n"
|
||||
" Platform: %s\n"
|
||||
" Max Packet Size: %zu\n"
|
||||
" Encrypted: %s\n"
|
||||
" Ping-pong: %s",
|
||||
this->platform_name_, YESNO(this->is_encrypted_()), YESNO(this->ping_pong_enable_));
|
||||
this->platform_name_, this->get_max_packet_size(), YESNO(this->is_encrypted_()),
|
||||
YESNO(this->ping_pong_enable_));
|
||||
#ifdef USE_SENSOR
|
||||
for (const auto &sensor : this->sensors_) {
|
||||
ESP_LOGCONFIG(TAG, " Sensor: %s", sensor.id);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from esphome.components import packet_interface
|
||||
from esphome.components.packet_interface.radio_link import (
|
||||
new_radio_link,
|
||||
radio_link_final_validate,
|
||||
radio_link_schema,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_MODULATION,
|
||||
CONF_PAYLOAD_LENGTH,
|
||||
CONF_RX_START,
|
||||
CONF_SX126X_ID,
|
||||
CONF_SYNC_VALUE,
|
||||
DOMAIN,
|
||||
SX126x,
|
||||
SX126xListener,
|
||||
sx126x_ns,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DEPENDENCIES = ["sx126x"]
|
||||
|
||||
SX126xPacketInterface = sx126x_ns.class_(
|
||||
"SX126xPacketInterface", packet_interface.PacketInterface, SX126xListener
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = radio_link_schema(SX126xPacketInterface, CONF_SX126X_ID, SX126x)
|
||||
|
||||
|
||||
def _check_fsk(config: ConfigType, radio: ConfigType) -> None:
|
||||
"""Without LoRa, the radio can tell that a packet is arriving only from its sync word, which listen before talk
|
||||
needs.
|
||||
"""
|
||||
if radio[CONF_MODULATION] != "LORA" and not radio[CONF_SYNC_VALUE]:
|
||||
raise cv.Invalid(
|
||||
f"Without LoRa modulation the radio needs a '{CONF_SYNC_VALUE}', so that the link can tell when a packet "
|
||||
"is arriving and not send over it",
|
||||
path=[CONF_SX126X_ID],
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = radio_link_final_validate(
|
||||
CONF_SX126X_ID, CONF_PAYLOAD_LENGTH, CONF_RX_START, _check_fsk
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
await new_radio_link(config, CONF_SX126X_ID, DOMAIN)
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/packet_interface/radio_link.h"
|
||||
#include "esphome/components/sx126x/sx126x.h"
|
||||
|
||||
namespace esphome::sx126x {
|
||||
|
||||
using SX126xPacketInterface = packet_interface::RadioLink<SX126x, SX126xListener, SX126xError, SX126X_MAX_PACKET_SIZE>;
|
||||
|
||||
} // namespace esphome::sx126x
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "sx126x.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::sx126x {
|
||||
@@ -402,6 +403,15 @@ void SX126x::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
bool SX126x::is_receiving() {
|
||||
// DIO1 signals a received packet, which sending would overwrite in the radio's buffer
|
||||
if (this->dio1_pin_->digital_read())
|
||||
return true;
|
||||
uint8_t buf[2];
|
||||
this->read_opcode_(RADIO_GET_IRQSTATUS, buf, 2);
|
||||
return (encode_uint16(buf[0], buf[1]) & (IRQ_HEADER_VALID | IRQ_SYNCWORD_VALID)) != 0;
|
||||
}
|
||||
|
||||
void SX126x::run_image_cal() {
|
||||
// the following values were taken from section 9.2.1 table 9-2
|
||||
// in rev 2.1 of the datasheet
|
||||
@@ -430,10 +440,11 @@ void SX126x::run_image_cal() {
|
||||
void SX126x::set_mode_rx() {
|
||||
uint8_t buf[8];
|
||||
|
||||
// configure irq params
|
||||
// configure irq params; a valid header or sync word is flagged for is_receiving(), but not signalled on DIO1
|
||||
uint16_t irq = IRQ_RX_DONE | IRQ_RX_TX_TIMEOUT | IRQ_CRC_ERROR;
|
||||
buf[0] = (irq >> 8) & 0xFF;
|
||||
buf[1] = (irq >> 0) & 0xFF;
|
||||
uint16_t flags = irq | IRQ_HEADER_VALID | IRQ_SYNCWORD_VALID;
|
||||
buf[0] = (flags >> 8) & 0xFF;
|
||||
buf[1] = (flags >> 0) & 0xFF;
|
||||
buf[2] = (irq >> 8) & 0xFF;
|
||||
buf[3] = (irq >> 0) & 0xFF;
|
||||
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
|
||||
|
||||
@@ -104,6 +104,8 @@ class SX126x final : public Component,
|
||||
return this->transmit_packet(packet.data(), packet.size());
|
||||
}
|
||||
void register_listener(SX126xListener *listener) { this->listeners_.push_back(listener); }
|
||||
/// True while a packet is being received, or has been received and not yet read; sending would lose it.
|
||||
bool is_receiving();
|
||||
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
|
||||
|
||||
protected:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from esphome.components import packet_interface
|
||||
from esphome.components.packet_interface import CONF_MAX_PACKET_SIZE
|
||||
from esphome.components.packet_interface.radio_link import (
|
||||
new_radio_link,
|
||||
radio_link_final_validate,
|
||||
radio_link_schema,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_MODULATION,
|
||||
CONF_PACKET_MODE,
|
||||
CONF_PAYLOAD_LENGTH,
|
||||
CONF_RX_START,
|
||||
CONF_SX127X_ID,
|
||||
CONF_SYNC_VALUE,
|
||||
DOMAIN,
|
||||
SX127x,
|
||||
SX127xListener,
|
||||
sx127x_ns,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DEPENDENCIES = ["sx127x"]
|
||||
|
||||
# The FIFO limit outside LoRa mode
|
||||
FSK_MAX_PACKET_SIZE = 64
|
||||
|
||||
SX127xPacketInterface = sx127x_ns.class_(
|
||||
"SX127xPacketInterface", packet_interface.PacketInterface, SX127xListener
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = radio_link_schema(SX127xPacketInterface, CONF_SX127X_ID, SX127x)
|
||||
|
||||
|
||||
def _check_fsk(config: ConfigType, radio: ConfigType) -> None:
|
||||
"""Without LoRa, the radio must send packets, not a continuous bit stream, and holds fewer bytes.
|
||||
|
||||
It can tell that a packet is arriving only from its sync word, which listen before talk needs.
|
||||
"""
|
||||
if radio[CONF_MODULATION] == "LORA":
|
||||
return
|
||||
if not radio.get(CONF_PACKET_MODE, False):
|
||||
raise cv.Invalid(
|
||||
f"Without LoRa modulation the radio's '{CONF_PACKET_MODE}' must be true, as a link sends packets",
|
||||
path=[CONF_SX127X_ID],
|
||||
)
|
||||
if not radio.get(CONF_SYNC_VALUE):
|
||||
raise cv.Invalid(
|
||||
f"Without LoRa modulation the radio needs a '{CONF_SYNC_VALUE}', so that the link can tell when a "
|
||||
"packet is arriving and not send over it",
|
||||
path=[CONF_SX127X_ID],
|
||||
)
|
||||
if config[CONF_MAX_PACKET_SIZE] > FSK_MAX_PACKET_SIZE:
|
||||
raise cv.Invalid(
|
||||
f"Without LoRa modulation the radio sends at most {FSK_MAX_PACKET_SIZE} bytes",
|
||||
path=[CONF_MAX_PACKET_SIZE],
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = radio_link_final_validate(
|
||||
CONF_SX127X_ID, CONF_PAYLOAD_LENGTH, CONF_RX_START, _check_fsk
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
await new_radio_link(config, CONF_SX127X_ID, DOMAIN)
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/packet_interface/radio_link.h"
|
||||
#include "esphome/components/sx127x/sx127x.h"
|
||||
|
||||
namespace esphome::sx127x {
|
||||
|
||||
using SX127xPacketInterface = packet_interface::RadioLink<SX127x, SX127xListener, SX127xError, SX127X_MAX_PACKET_SIZE>;
|
||||
|
||||
} // namespace esphome::sx127x
|
||||
@@ -354,6 +354,18 @@ void SX127x::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
bool SX127x::is_receiving() {
|
||||
// In receive mode DIO0 signals a received packet; the transmit FIFO would overwrite it
|
||||
if (this->dio0_pin_ != nullptr && this->dio0_pin_->digital_read())
|
||||
return true;
|
||||
if (this->modulation_ != MOD_LORA) {
|
||||
// Set from the sync word until the packet has been read; the preamble flag stays set when no sync word follows
|
||||
return (this->read_register_(REG_IRQ_FLAGS_1) & IRQ_FLAGS_1_SYNC_ADDRESS_MATCH) != 0;
|
||||
}
|
||||
return (this->read_register_(REG_MODEM_STAT) &
|
||||
(MODEM_STAT_SIGNAL_DETECTED | MODEM_STAT_SIGNAL_SYNCHRONIZED | MODEM_STAT_HEADER_VALID)) != 0;
|
||||
}
|
||||
|
||||
void SX127x::run_image_cal() {
|
||||
if (this->modulation_ == MOD_LORA) {
|
||||
this->set_mode_(MOD_FSK, MODE_SLEEP);
|
||||
|
||||
@@ -88,6 +88,8 @@ class SX127x final : public Component,
|
||||
return this->transmit_packet(packet.data(), packet.size());
|
||||
}
|
||||
void register_listener(SX127xListener *listener) { this->listeners_.push_back(listener); }
|
||||
/// True while a packet is being received, or has been received and not yet read; sending would lose it.
|
||||
bool is_receiving();
|
||||
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
|
||||
|
||||
protected:
|
||||
|
||||
@@ -45,6 +45,7 @@ enum SX127xReg : uint8_t {
|
||||
REG_PAYLOAD_LENGTH_LSB = 0x32,
|
||||
REG_FIFO_THRESH = 0x35,
|
||||
REG_IMAGE_CAL = 0x3B,
|
||||
REG_IRQ_FLAGS_1 = 0x3E,
|
||||
// LoRa registers
|
||||
REG_FIFO_ADDR_PTR = 0x0D,
|
||||
REG_FIFO_TX_BASE_ADDR = 0x0E,
|
||||
@@ -259,6 +260,17 @@ enum SX127xIrqFlags : uint8_t {
|
||||
CAD_DETECTED = 0x01,
|
||||
};
|
||||
|
||||
enum SX127xIrqFlags1 : uint8_t {
|
||||
IRQ_FLAGS_1_PREAMBLE_DETECT = 0x02,
|
||||
IRQ_FLAGS_1_SYNC_ADDRESS_MATCH = 0x01,
|
||||
};
|
||||
|
||||
enum SX127xModemStat : uint8_t {
|
||||
MODEM_STAT_SIGNAL_DETECTED = 0x01,
|
||||
MODEM_STAT_SIGNAL_SYNCHRONIZED = 0x02,
|
||||
MODEM_STAT_HEADER_VALID = 0x08,
|
||||
};
|
||||
|
||||
enum SX127xModemCfg1 : uint8_t {
|
||||
BW_7_8 = 0x00,
|
||||
BW_10_4 = 0x10,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""TCP links. The packet_interface platform carries packets over a TCP connection."""
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DOMAIN = "tcp"
|
||||
@@ -0,0 +1,134 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor, packet_interface, socket
|
||||
from esphome.components.const import (
|
||||
CONF_ALLOWED_IPS,
|
||||
CONF_CONNECTED,
|
||||
CONF_HOST,
|
||||
CONF_RECONNECT_INTERVAL,
|
||||
CONF_ROLE,
|
||||
)
|
||||
from esphome.components.packet_interface import (
|
||||
CONF_MAX_PACKET_SIZE,
|
||||
new_packet_interface,
|
||||
packet_interface_schema,
|
||||
)
|
||||
from esphome.components.packet_interface.layers import (
|
||||
CONF_ADDRESSING,
|
||||
CONF_RELIABILITY,
|
||||
CONF_SESSION,
|
||||
)
|
||||
from esphome.components.packet_interface.layers.basic_session import CONF_KEEP_ALIVE
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_PORT,
|
||||
CONF_TIMEOUT,
|
||||
DEVICE_CLASS_CONNECTIVITY,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DEPENDENCIES = ["network"]
|
||||
|
||||
|
||||
def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
|
||||
"""The binary_sensor component only for the connected sensor; without a config, as tooling calls it, both."""
|
||||
if not config or CONF_CONNECTED in config:
|
||||
return ["socket", "binary_sensor"]
|
||||
return ["socket"]
|
||||
|
||||
|
||||
# Each packet and its 2 byte length must fit the socket link's 1024 byte send buffer
|
||||
MAX_PACKET_SIZE = 1022
|
||||
|
||||
tcp_ns = cg.esphome_ns.namespace("tcp")
|
||||
TcpPacketInterface = tcp_ns.class_(
|
||||
"TcpPacketInterface", packet_interface.PacketInterface
|
||||
)
|
||||
|
||||
|
||||
def _timeout(config: ConfigType) -> ConfigType:
|
||||
"""Default the timeout to three keep-alive intervals, and require it to be longer than one."""
|
||||
keep_alive = config[CONF_KEEP_ALIVE].total_milliseconds
|
||||
if (timeout := config.get(CONF_TIMEOUT)) is None:
|
||||
config[CONF_TIMEOUT] = cv.positive_time_period_milliseconds(
|
||||
f"{keep_alive * 3}ms"
|
||||
)
|
||||
elif timeout.total_milliseconds <= keep_alive:
|
||||
raise cv.Invalid("Must be longer than keep_alive", path=[CONF_TIMEOUT])
|
||||
return config
|
||||
|
||||
|
||||
BASE_SCHEMA = packet_interface_schema(
|
||||
TcpPacketInterface,
|
||||
MAX_PACKET_SIZE,
|
||||
MAX_PACKET_SIZE,
|
||||
# TCP resends what is lost, joins exactly two devices, and its connection state is the session
|
||||
provides=(CONF_ADDRESSING, CONF_RELIABILITY, CONF_SESSION),
|
||||
).extend(
|
||||
{
|
||||
cv.Required(CONF_PORT): cv.port,
|
||||
cv.Optional(
|
||||
CONF_RECONNECT_INTERVAL, default="5s"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_KEEP_ALIVE, default="10s"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(hours=1)),
|
||||
),
|
||||
# Nothing received for this long closes the connection; it must be longer than the peer's keep_alive
|
||||
cv.Optional(CONF_TIMEOUT): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(hours=3)),
|
||||
),
|
||||
cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_CONNECTIVITY,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.typed_schema(
|
||||
{
|
||||
"client": cv.All(
|
||||
BASE_SCHEMA.extend({cv.Required(CONF_HOST): socket.ipv4_host}),
|
||||
_timeout,
|
||||
),
|
||||
"server": cv.All(
|
||||
BASE_SCHEMA.extend(
|
||||
{cv.Optional(CONF_ALLOWED_IPS): socket.IPV4_ALLOW_SCHEMA}
|
||||
),
|
||||
_timeout,
|
||||
),
|
||||
},
|
||||
key=CONF_ROLE,
|
||||
default_type="client",
|
||||
),
|
||||
# A client uses one socket; a server one listener and one accepted socket
|
||||
socket.consume_role_sockets("tcp.packet_interface"),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# The receive buffer's size is known here, so it is a static array rather than allocated at run time
|
||||
rx_buffer = f"{config[CONF_ID].id}__rx_buffer"
|
||||
cg.add_global(
|
||||
cg.RawStatement(f"static uint8_t {rx_buffer}[{config[CONF_MAX_PACKET_SIZE]}];")
|
||||
)
|
||||
var = await new_packet_interface(config, cg.RawExpression(rx_buffer))
|
||||
if config[CONF_ROLE] == "server":
|
||||
socket.require_tcp_listener()
|
||||
cg.add(var.set_server(True))
|
||||
socket.add_ipv4_allow(
|
||||
var.set_allow, config.get(CONF_ALLOWED_IPS), config[CONF_ID]
|
||||
)
|
||||
else:
|
||||
socket.require_tcp_client_link()
|
||||
cg.add(var.set_host(config[CONF_HOST]))
|
||||
cg.add(var.set_port(config[CONF_PORT]))
|
||||
cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL]))
|
||||
cg.add(var.set_keep_alive(config[CONF_KEEP_ALIVE]))
|
||||
cg.add(var.set_receive_timeout(config[CONF_TIMEOUT]))
|
||||
binary_sensors = binary_sensor.sub_binary_sensors(config)
|
||||
await binary_sensors(CONF_CONNECTED, var.set_connected_sensor)
|
||||
@@ -0,0 +1,169 @@
|
||||
#include "tcp_packet_interface.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::tcp {
|
||||
|
||||
using packet_interface::PacketBuffer;
|
||||
using packet_interface::PacketMetaData;
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "tcp.packet_interface");
|
||||
|
||||
// The packet length before each packet
|
||||
static constexpr size_t LENGTH_SIZE = 2;
|
||||
|
||||
TcpPacketInterface::TcpPacketInterface(std::span<uint8_t> rx_buffer)
|
||||
: packet_interface::PacketInterface(rx_buffer.size()), rx_buffer_(rx_buffer) {
|
||||
this->capabilities_ = packet_interface::CAPABILITY_RELIABILITY | packet_interface::CAPABILITY_SESSION |
|
||||
packet_interface::CAPABILITY_ADDRESSING;
|
||||
}
|
||||
|
||||
void TcpPacketInterface::setup() {
|
||||
this->link_.begin(TAG);
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
this->listener_.begin(TAG);
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_sensor_ != nullptr)
|
||||
this->connected_sensor_->publish_initial_state(false);
|
||||
#endif
|
||||
}
|
||||
|
||||
void TcpPacketInterface::dump_config() {
|
||||
bool server = false;
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
server = this->server_;
|
||||
#endif
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"TCP Packet Interface '%s':\n"
|
||||
" Role: %s\n"
|
||||
" Address: %s:%u\n"
|
||||
" Reconnect Interval: %" PRIu32 "ms\n"
|
||||
" Keep Alive: %" PRIu32 "ms\n"
|
||||
" Timeout: %" PRIu32 "ms\n"
|
||||
" Max Packet Size: %zu",
|
||||
this->name_, server ? LOG_STR_LITERAL("server") : LOG_STR_LITERAL("client"),
|
||||
server ? LOG_STR_LITERAL("*") : this->link_.host(), this->link_.port(),
|
||||
this->link_.reconnect_interval(), this->keep_alive_ms_, this->timeout_ms_, this->max_packet_size_);
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
this->listener_.dump_config();
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
|
||||
#endif
|
||||
}
|
||||
|
||||
void TcpPacketInterface::on_shutdown() {
|
||||
this->link_.close();
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
this->listener_.close();
|
||||
#endif
|
||||
}
|
||||
|
||||
void TcpPacketInterface::loop() {
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
if (this->server_) {
|
||||
// A new client is accepted only once the drop of the last has been reported
|
||||
this->listener_.poll(this->link_, !this->connected_);
|
||||
} else {
|
||||
this->link_.poll();
|
||||
}
|
||||
#else
|
||||
this->link_.poll();
|
||||
#endif
|
||||
if (this->link_.connected() != this->connected_)
|
||||
this->on_connection_(this->link_.connected());
|
||||
if (!this->connected_)
|
||||
return;
|
||||
this->read_();
|
||||
if (!this->connected_)
|
||||
return;
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->last_rx_ms_ > this->timeout_ms_) {
|
||||
ESP_LOGW(TAG, "%s: Nothing received for %" PRIu32 "ms, closing the connection", this->name_, this->timeout_ms_);
|
||||
this->link_.close();
|
||||
this->link_.note_attempt();
|
||||
this->on_connection_(false);
|
||||
return;
|
||||
}
|
||||
if (now - this->last_tx_ms_ >= this->keep_alive_ms_)
|
||||
this->queue_frame_(nullptr, 0);
|
||||
this->link_.flush_tx();
|
||||
}
|
||||
|
||||
void TcpPacketInterface::on_connection_(bool connected) {
|
||||
ESP_LOGD(TAG, "%s: Connection %s", this->name_, connected ? LOG_STR_LITERAL("up") : LOG_STR_LITERAL("down"));
|
||||
this->connected_ = connected;
|
||||
this->rx_have_ = 0;
|
||||
this->last_rx_ms_ = this->last_tx_ms_ = App.get_loop_component_start_time();
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_sensor_ != nullptr)
|
||||
this->connected_sensor_->publish_state(connected);
|
||||
#endif
|
||||
this->session_callback_.call(connected);
|
||||
}
|
||||
|
||||
void TcpPacketInterface::read_() {
|
||||
for (;;) {
|
||||
ssize_t count;
|
||||
if (this->rx_have_ < LENGTH_SIZE) {
|
||||
count = this->link_.read(this->rx_header_ + this->rx_have_, LENGTH_SIZE - this->rx_have_);
|
||||
} else {
|
||||
count = this->link_.read(this->rx_buffer_.data() + this->rx_have_ - LENGTH_SIZE,
|
||||
this->rx_length_ + LENGTH_SIZE - this->rx_have_);
|
||||
}
|
||||
// Nothing more for now; a dropped link is seen on the next loop
|
||||
if (count <= 0)
|
||||
return;
|
||||
this->last_rx_ms_ = App.get_loop_component_start_time();
|
||||
this->rx_have_ += count;
|
||||
if (this->rx_have_ < LENGTH_SIZE)
|
||||
continue;
|
||||
if (this->rx_have_ == LENGTH_SIZE) {
|
||||
this->rx_length_ = encode_uint16(this->rx_header_[1], this->rx_header_[0]);
|
||||
if (this->rx_length_ > this->max_packet_size_) {
|
||||
// The stream cannot be resynchronised, so start a new connection
|
||||
ESP_LOGW(TAG, "%s: Packet of %u bytes is larger than %zu, closing the connection", this->name_,
|
||||
this->rx_length_, this->max_packet_size_);
|
||||
this->link_.close();
|
||||
this->link_.note_attempt();
|
||||
this->on_connection_(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this->rx_have_ == this->rx_length_ + LENGTH_SIZE) {
|
||||
this->rx_have_ = 0;
|
||||
// A zero length packet is a keep-alive
|
||||
if (this->rx_length_ != 0)
|
||||
this->on_receive_from_interface_(PacketBuffer(this->rx_buffer_.data(), this->rx_length_), {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool TcpPacketInterface::queue_frame_(const PacketBuffer *data, size_t size) {
|
||||
if (this->link_.tx_free() < size + LENGTH_SIZE)
|
||||
return false;
|
||||
uint8_t *tail = this->link_.tx_tail();
|
||||
tail[0] = static_cast<uint8_t>(size);
|
||||
tail[1] = static_cast<uint8_t>(size >> 8);
|
||||
if (data != nullptr)
|
||||
data->copy_to(tail + LENGTH_SIZE, size);
|
||||
this->link_.tx_commit(size + LENGTH_SIZE);
|
||||
this->last_tx_ms_ = App.get_loop_component_start_time();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TcpPacketInterface::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
size_t size = data.size();
|
||||
if (size > this->max_packet_size_) {
|
||||
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
|
||||
return false;
|
||||
}
|
||||
if (!this->connected_ || !this->queue_frame_(&data, size))
|
||||
return false;
|
||||
this->link_.flush_tx();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::tcp
|
||||
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include "esphome/components/packet_interface/packet_interface.h"
|
||||
#include "esphome/components/socket/tcp_client_link.h"
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
#include "esphome/components/socket/tcp_listener.h"
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::tcp {
|
||||
|
||||
/**
|
||||
* A packet interface link over one TCP connection, as a client that connects to a host, or a server that accepts
|
||||
* one client at a time.
|
||||
*
|
||||
* Each packet is sent as a 2 byte length, little endian, then the packet. TCP delivers the stream in order and
|
||||
* resends what is lost, and the connection joins exactly two devices, so the link provides the reliability,
|
||||
* addressing and session capabilities itself. Its connection state is the session state.
|
||||
*
|
||||
* Each packet is written to the socket as it is sent, so the link takes packets as fast as the socket does and keeps
|
||||
* the default, unlimited, send capacity; a packet is refused only while the connection is down or the socket is full.
|
||||
*
|
||||
* A zero length packet is a keep-alive, sent when nothing else has been sent for keep_alive. TCP can take minutes to
|
||||
* notice a peer that has gone without closing the connection, so the link closes it, and reconnects or accepts again,
|
||||
* when nothing has been received for the timeout, which must be longer than the peer's keep-alive interval.
|
||||
*/
|
||||
class TcpPacketInterface : public packet_interface::PacketInterface {
|
||||
public:
|
||||
/// @param rx_buffer Holds the packet being received; its size is the largest packet, and it must outlive the link
|
||||
explicit TcpPacketInterface(std::span<uint8_t> rx_buffer);
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
void on_shutdown() override;
|
||||
bool send_to_interface(const packet_interface::PacketBuffer &data,
|
||||
const packet_interface::PacketMetaData &meta_data) override;
|
||||
|
||||
void set_host(const char *host) { this->link_.set_host(host); }
|
||||
void set_port(uint16_t port) { this->link_.set_port(port); }
|
||||
void set_reconnect_interval(uint32_t interval_ms) { this->link_.set_reconnect_interval(interval_ms); }
|
||||
void set_keep_alive(uint32_t keep_alive_ms) { this->keep_alive_ms_ = keep_alive_ms; }
|
||||
/// The connection is closed when nothing has been received for this long; the peer's keep_alive sets the pace.
|
||||
void set_receive_timeout(uint32_t timeout_ms) { this->timeout_ms_ = timeout_ms; }
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
void set_server(bool server) { this->server_ = server; }
|
||||
#ifdef USE_SOCKET_IPV4_ALLOW
|
||||
void set_allow(const socket::Ipv4AllowEntry *entries, size_t count) { this->listener_.set_allow(entries, count); }
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; }
|
||||
#endif
|
||||
|
||||
bool is_connected() const { return this->connected_; }
|
||||
|
||||
protected:
|
||||
void on_connection_(bool connected);
|
||||
void read_();
|
||||
bool queue_frame_(const packet_interface::PacketBuffer *data, size_t size);
|
||||
|
||||
socket::TcpClientLink link_;
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
socket::TcpListener listener_;
|
||||
bool server_{false};
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
binary_sensor::BinarySensor *connected_sensor_{nullptr};
|
||||
#endif
|
||||
const std::span<uint8_t> rx_buffer_;
|
||||
uint32_t keep_alive_ms_{10000};
|
||||
uint32_t timeout_ms_{30000};
|
||||
uint32_t last_rx_ms_{0};
|
||||
uint32_t last_tx_ms_{0};
|
||||
uint16_t rx_length_{0};
|
||||
// Bytes of the current frame received so far, counting the 2 byte length
|
||||
uint16_t rx_have_{0};
|
||||
uint8_t rx_header_[2]{};
|
||||
bool connected_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::tcp
|
||||
@@ -0,0 +1,36 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import packet_interface, uart
|
||||
from esphome.components.packet_interface import (
|
||||
CONF_MAX_PACKET_SIZE,
|
||||
packet_interface_schema,
|
||||
)
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import uart_ns
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
UartPacketInterface = uart_ns.class_(
|
||||
"UartPacketInterface",
|
||||
packet_interface.PacketInterface,
|
||||
uart.UARTDevice,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = packet_interface_schema(UartPacketInterface, 508).extend(
|
||||
uart.UART_DEVICE_SCHEMA
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# The receive buffer's size is known here, so it is a static array rather than allocated at run time
|
||||
rx_buffer = f"{config[CONF_ID].id}__rx_buffer"
|
||||
cg.add_global(
|
||||
cg.RawStatement(f"static uint8_t {rx_buffer}[{config[CONF_MAX_PACKET_SIZE]}];")
|
||||
)
|
||||
var = await packet_interface.new_packet_interface(
|
||||
config, cg.RawExpression(rx_buffer)
|
||||
)
|
||||
await uart.register_uart_device(var, config)
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include "uart_packet_interface.h"
|
||||
|
||||
namespace esphome::uart {
|
||||
|
||||
using packet_interface::PacketBuffer;
|
||||
using packet_interface::PacketMetaData;
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "uart.packet_interface");
|
||||
|
||||
static constexpr uint8_t FLAG_BYTE = 0x7E;
|
||||
static constexpr uint8_t CONTROL_BYTE = 0x7D;
|
||||
static constexpr uint8_t ESCAPE_XOR = 0x20;
|
||||
// Raw bytes escaped and written per UART write
|
||||
static constexpr size_t TX_CHUNK_SIZE = 32;
|
||||
|
||||
void UartPacketInterface::loop() {
|
||||
while (this->available()) {
|
||||
uint8_t byte;
|
||||
if (!this->read_byte(&byte)) {
|
||||
ESP_LOGW(TAG, "Failed to read byte from UART");
|
||||
return;
|
||||
}
|
||||
if (byte == FLAG_BYTE) {
|
||||
// A flag that ends a frame also starts the next, so an unfinished frame cannot hide the one after it
|
||||
if (this->rx_started_ && this->rx_length_ != 0)
|
||||
this->on_receive_from_interface_(PacketBuffer(this->rx_buffer_.data(), this->rx_length_), {});
|
||||
this->rx_started_ = true;
|
||||
this->rx_length_ = 0;
|
||||
this->rx_control_ = false;
|
||||
continue;
|
||||
}
|
||||
if (!this->rx_started_)
|
||||
continue;
|
||||
if (byte == CONTROL_BYTE) {
|
||||
this->rx_control_ = true;
|
||||
continue;
|
||||
}
|
||||
if (this->rx_control_) {
|
||||
byte ^= ESCAPE_XOR;
|
||||
this->rx_control_ = false;
|
||||
}
|
||||
if (this->rx_length_ >= this->rx_buffer_.size()) {
|
||||
ESP_LOGD(TAG, "Packet too large, discarding");
|
||||
this->rx_started_ = false;
|
||||
this->rx_length_ = 0;
|
||||
continue;
|
||||
}
|
||||
this->rx_buffer_[this->rx_length_++] = byte;
|
||||
}
|
||||
}
|
||||
|
||||
bool UartPacketInterface::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
size_t size = data.size();
|
||||
if (size == 0)
|
||||
return true;
|
||||
if (size > this->max_packet_size_) {
|
||||
ESP_LOGW(TAG, "Packet of %zu bytes is larger than %zu", size, this->max_packet_size_);
|
||||
return false;
|
||||
}
|
||||
uint8_t raw[TX_CHUNK_SIZE]{};
|
||||
// Every byte may be escaped, plus a flag byte at each end
|
||||
uint8_t out[TX_CHUNK_SIZE * 2 + 2];
|
||||
size_t out_len = 0;
|
||||
out[out_len++] = FLAG_BYTE;
|
||||
for (size_t offset = 0; offset < size;) {
|
||||
size_t count = data.copy_to(raw, TX_CHUNK_SIZE, offset);
|
||||
offset += count;
|
||||
for (size_t i = 0; i != count; i++) {
|
||||
uint8_t byte = raw[i];
|
||||
if (byte == FLAG_BYTE || byte == CONTROL_BYTE) {
|
||||
out[out_len++] = CONTROL_BYTE;
|
||||
byte ^= ESCAPE_XOR;
|
||||
}
|
||||
out[out_len++] = byte;
|
||||
}
|
||||
if (offset == size)
|
||||
out[out_len++] = FLAG_BYTE;
|
||||
this->write_array(out, out_len);
|
||||
out_len = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::uart
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <span>
|
||||
|
||||
#include "esphome/components/packet_interface/packet_interface.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
|
||||
namespace esphome::uart {
|
||||
|
||||
/**
|
||||
* A packet interface for sending and receiving framed packets over a UART connection.
|
||||
* Uses simple byte-stuffing framing with FLAG_BYTE and CONTROL_BYTE.
|
||||
* The protocol wraps data between FLAG_BYTEs.
|
||||
* Any occurrence of FLAG_BYTE or CONTROL_BYTE in the data is escaped by emitting CONTROL_BYTE followed by the byte
|
||||
* XORed with 0x20.
|
||||
*/
|
||||
class UartPacketInterface : public packet_interface::PacketInterface, public UARTDevice {
|
||||
public:
|
||||
/// @param rx_buffer Holds the unescaped data being received; its size is the largest packet, and it must outlive
|
||||
/// the link
|
||||
explicit UartPacketInterface(std::span<uint8_t> rx_buffer)
|
||||
: packet_interface::PacketInterface(rx_buffer.size()), rx_buffer_(rx_buffer) {}
|
||||
|
||||
void loop() override;
|
||||
bool send_to_interface(const packet_interface::PacketBuffer &data,
|
||||
const packet_interface::PacketMetaData &meta_data) override;
|
||||
|
||||
protected:
|
||||
const std::span<uint8_t> rx_buffer_;
|
||||
size_t rx_length_{0};
|
||||
bool rx_started_{};
|
||||
bool rx_control_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::uart
|
||||
@@ -6,6 +6,10 @@ namespace esphome::uart {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "uart_transport");
|
||||
|
||||
static constexpr uint16_t MAX_PACKET_SIZE = 508;
|
||||
static constexpr uint8_t FLAG_BYTE = 0x7E;
|
||||
static constexpr uint8_t CONTROL_BYTE = 0x7D;
|
||||
|
||||
void UARTTransport::loop() {
|
||||
PacketTransport::loop();
|
||||
|
||||
@@ -78,4 +82,5 @@ void UARTTransport::send_packet(const std::vector<uint8_t> &buf) const {
|
||||
this->parent_->write_byte(FLAG_BYTE);
|
||||
}
|
||||
|
||||
size_t UARTTransport::get_max_packet_size() { return MAX_PACKET_SIZE; }
|
||||
} // namespace esphome::uart
|
||||
|
||||
@@ -16,9 +16,6 @@ namespace esphome::uart {
|
||||
* Any occurrence of FLAG_BYTE or CONTROL_BYTE in the data is escaped by emitting CONTROL_BYTE followed by the byte
|
||||
* XORed with 0x20.
|
||||
*/
|
||||
static const uint16_t MAX_PACKET_SIZE = 508;
|
||||
static const uint8_t FLAG_BYTE = 0x7E;
|
||||
static const uint8_t CONTROL_BYTE = 0x7D;
|
||||
|
||||
class UARTTransport final : public packet_transport::PacketTransport, public UARTDevice {
|
||||
public:
|
||||
@@ -29,7 +26,7 @@ class UARTTransport final : public packet_transport::PacketTransport, public UAR
|
||||
void write_byte_(uint8_t byte) const;
|
||||
void send_packet(const std::vector<uint8_t> &buf) const override;
|
||||
bool should_send() override { return true; };
|
||||
size_t get_max_packet_size() override { return MAX_PACKET_SIZE; }
|
||||
size_t get_max_packet_size() override;
|
||||
std::vector<uint8_t> receive_buffer_{};
|
||||
bool rx_started_{};
|
||||
bool rx_control_{};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import packet_interface
|
||||
from esphome.components.packet_interface import (
|
||||
CONF_MAX_PACKET_SIZE,
|
||||
new_packet_interface,
|
||||
packet_interface_schema,
|
||||
)
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_UDP_ID, UDP_SCHEMA, udp_ns
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
|
||||
DEPENDENCIES = ["udp"]
|
||||
|
||||
# Size of the receive buffer of the udp component
|
||||
MAX_PACKET_SIZE = 508
|
||||
|
||||
UdpPacketInterface = udp_ns.class_(
|
||||
"UdpPacketInterface",
|
||||
packet_interface.PacketInterface,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = packet_interface_schema(
|
||||
UdpPacketInterface, MAX_PACKET_SIZE, MAX_PACKET_SIZE
|
||||
).extend(UDP_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
udp_var = await cg.get_variable(config[CONF_UDP_ID])
|
||||
cg.add(udp_var.set_should_listen())
|
||||
# The udp component sends to its addresses through its broadcast socket
|
||||
cg.add(udp_var.set_should_broadcast())
|
||||
# The copy buffer's size is known here, so it is a static array rather than allocated at run time
|
||||
tx_buffer = f"{config[CONF_ID].id}__tx_buffer"
|
||||
cg.add_global(
|
||||
cg.RawStatement(f"static uint8_t {tx_buffer}[{config[CONF_MAX_PACKET_SIZE]}];")
|
||||
)
|
||||
await new_packet_interface(config, udp_var, cg.RawExpression(tx_buffer))
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "udp_packet_interface.h"
|
||||
#ifdef USE_NETWORK
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::udp {
|
||||
|
||||
using packet_interface::PacketBuffer;
|
||||
using packet_interface::PacketMetaData;
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "udp.packet_interface");
|
||||
|
||||
void UdpPacketInterface::setup() {
|
||||
this->parent_->add_listener([this](std::span<const uint8_t> data) { this->receive_(data); });
|
||||
}
|
||||
|
||||
void UdpPacketInterface::receive_(std::span<const uint8_t> data) {
|
||||
if (data.size() > this->max_packet_size_) {
|
||||
ESP_LOGV(TAG, "%s: Packet of %zu bytes is larger than %zu, dropped", this->name_, data.size(),
|
||||
this->max_packet_size_);
|
||||
return;
|
||||
}
|
||||
this->on_receive_from_interface_(PacketBuffer(data.data(), data.size()), {});
|
||||
}
|
||||
|
||||
bool UdpPacketInterface::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
size_t size = data.size();
|
||||
if (size > this->max_packet_size_) {
|
||||
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
|
||||
return false;
|
||||
}
|
||||
if (!network::is_connected())
|
||||
return false;
|
||||
if (data.is_contiguous()) {
|
||||
auto span = data.as_span();
|
||||
return this->parent_->send_packet(span.data(), span.size());
|
||||
}
|
||||
data.copy_to(this->tx_buffer_.data(), size);
|
||||
return this->parent_->send_packet(this->tx_buffer_.data(), size);
|
||||
}
|
||||
|
||||
} // namespace esphome::udp
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
// udp_component.h declares nothing without USE_NETWORK
|
||||
#ifdef USE_NETWORK
|
||||
#include <span>
|
||||
|
||||
#include "esphome/components/packet_interface/packet_interface.h"
|
||||
#include "../udp_component.h"
|
||||
|
||||
namespace esphome::udp {
|
||||
|
||||
/**
|
||||
* A packet interface link over UDP: each packet is one datagram, sent to every address of the UDP component.
|
||||
*
|
||||
* Every datagram received on the listen port is passed up, from any sender, so on a shared network an addressing
|
||||
* or encryption layer is needed to tell the peer's packets from others.
|
||||
*/
|
||||
class UdpPacketInterface : public packet_interface::PacketInterface {
|
||||
public:
|
||||
/// @param tx_buffer Joins packets split across buffers; its size is the largest packet, and it must outlive the link
|
||||
UdpPacketInterface(UDPComponent *udp_component, std::span<uint8_t> tx_buffer)
|
||||
: packet_interface::PacketInterface(tx_buffer.size()), parent_(udp_component), tx_buffer_(tx_buffer) {}
|
||||
|
||||
void setup() override;
|
||||
bool send_to_interface(const packet_interface::PacketBuffer &data,
|
||||
const packet_interface::PacketMetaData &meta_data) override;
|
||||
|
||||
protected:
|
||||
void receive_(std::span<const uint8_t> data);
|
||||
|
||||
UDPComponent *parent_;
|
||||
const std::span<uint8_t> tx_buffer_;
|
||||
};
|
||||
} // namespace esphome::udp
|
||||
#endif
|
||||
@@ -1,12 +1,9 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "udp_transport.h"
|
||||
|
||||
namespace esphome::udp {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "udp_transport");
|
||||
|
||||
bool UDPTransport::should_send() { return network::is_connected(); }
|
||||
void UDPTransport::setup() {
|
||||
PacketTransport::setup();
|
||||
|
||||
@@ -205,18 +205,25 @@ void UDPComponent::set_poll_warning_(PollStep step, const LogString *what) {
|
||||
}
|
||||
|
||||
void UDPComponent::loop() {
|
||||
if (this->should_listen_) {
|
||||
std::array<uint8_t, MAX_PACKET_SIZE> buf;
|
||||
for (;;) {
|
||||
if (!this->listen_socket_->ready())
|
||||
break;
|
||||
auto len = this->listen_socket_->read(buf.data(), buf.size());
|
||||
if (len <= 0)
|
||||
break;
|
||||
size_t packet_len = static_cast<size_t>(len);
|
||||
ESP_LOGV(TAG, "Received packet of length %zu", packet_len);
|
||||
this->packet_listeners_.call(std::span<const uint8_t>(buf.data(), packet_len));
|
||||
if (!this->should_listen_) {
|
||||
this->disable_loop();
|
||||
return;
|
||||
}
|
||||
// One byte more than the largest packet, as a read cuts a longer datagram to fit and so cannot report it
|
||||
std::array<uint8_t, MAX_PACKET_SIZE + 1> buf;
|
||||
for (;;) {
|
||||
if (!this->listen_socket_->ready())
|
||||
break;
|
||||
auto len = this->listen_socket_->read(buf.data(), buf.size());
|
||||
if (len <= 0)
|
||||
break;
|
||||
size_t packet_len = static_cast<size_t>(len);
|
||||
if (packet_len > MAX_PACKET_SIZE) {
|
||||
ESP_LOGV(TAG, "Packet longer than %zu bytes dropped", MAX_PACKET_SIZE);
|
||||
continue;
|
||||
}
|
||||
ESP_LOGV(TAG, "Received packet of length %zu", packet_len);
|
||||
this->packet_listeners_.call(std::span<const uint8_t>(buf.data(), packet_len));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,9 +246,10 @@ void UDPComponent::dump_config() {
|
||||
YESNO(this->should_broadcast_), YESNO(this->should_listen_));
|
||||
}
|
||||
|
||||
void UDPComponent::send_packet(const uint8_t *data, size_t size) {
|
||||
bool UDPComponent::send_packet(const uint8_t *data, size_t size) {
|
||||
if (this->is_failed())
|
||||
return; // a send socket may be missing
|
||||
return false; // a send socket may be missing
|
||||
bool sent = true;
|
||||
for (const auto &entry : this->sockaddrs_) {
|
||||
#ifdef USE_UDP_IPV6_SEND
|
||||
const bool v6 = entry.sa.sa_family == AF_INET6;
|
||||
@@ -256,11 +264,23 @@ void UDPComponent::send_packet(const uint8_t *data, size_t size) {
|
||||
socket::UDPSendSocket *sock = this->broadcast_socket_.get();
|
||||
socklen_t sockaddr_size = sizeof(sockaddr_in);
|
||||
#endif
|
||||
if (sock == nullptr) {
|
||||
ESP_LOGW(TAG, "Broadcast socket not initialized");
|
||||
return false;
|
||||
}
|
||||
auto result = sock->sendto(data, size, 0, &entry.sa, sockaddr_size);
|
||||
if (result < 0) {
|
||||
ESP_LOGW(TAG, "sendto() error %d", errno);
|
||||
// lwIP refuses a packet with ENOMEM while the destination's ARP lookup is pending and its queue is full;
|
||||
// the packet can be sent again later
|
||||
if (errno == ENOMEM) {
|
||||
ESP_LOGV(TAG, "sendto() out of memory");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "sendto() error %d", errno);
|
||||
}
|
||||
sent = false;
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
} // namespace esphome::udp
|
||||
|
||||
|
||||
@@ -35,8 +35,9 @@ class UDPComponent final : public Component {
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
void send_packet(const uint8_t *data, size_t size);
|
||||
void send_packet(const std::vector<uint8_t> &buf) { this->send_packet(buf.data(), buf.size()); }
|
||||
/// Returns false if the packet could not be sent to every destination.
|
||||
bool send_packet(const uint8_t *data, size_t size);
|
||||
bool send_packet(const std::vector<uint8_t> &buf) { return this->send_packet(buf.data(), buf.size()); }
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; };
|
||||
|
||||
protected:
|
||||
@@ -53,7 +54,7 @@ class UDPComponent final : public Component {
|
||||
bool network_poll_();
|
||||
void set_poll_warning_(PollStep step, const LogString *what);
|
||||
|
||||
CallbackManager<void(std::span<const uint8_t>)> packet_listeners_{};
|
||||
LazyCallbackManager<void(std::span<const uint8_t>)> packet_listeners_{};
|
||||
std::unique_ptr<socket::UDPSendSocket> broadcast_socket_ = nullptr;
|
||||
std::unique_ptr<socket::UDPSocket> listen_socket_ = nullptr;
|
||||
#ifdef USE_UDP_IPV6_SEND
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
#define DFROBOT_SEN0465_COMPENSATION_RANGES 3
|
||||
#define USE_DISPLAY
|
||||
#define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1
|
||||
#define ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT 1
|
||||
#define ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT 1
|
||||
#define USE_EMONTX_VERSION
|
||||
#define USE_EMONTX_VERSION_TEXT_SENSOR
|
||||
#define USE_ENTITY_DEVICE_CLASS
|
||||
@@ -257,6 +259,12 @@
|
||||
#define USE_OTA_STATE_LISTENER
|
||||
#define USE_OUTPUT
|
||||
#define USE_OUTPUT_FLOAT_POWER_SCALING
|
||||
#define PACKET_INTERFACE_ADDRESSING_PORT_COUNT 2
|
||||
#define USE_PACKET_INTERFACE_BASIC_ADDRESSING
|
||||
#define USE_PACKET_INTERFACE_BASIC_FRAGMENTATION
|
||||
#define USE_PACKET_INTERFACE_BASIC_SESSION
|
||||
#define USE_PACKET_INTERFACE_HDLC
|
||||
#define USE_PACKET_INTERFACE_XXTEA
|
||||
#define USE_POWER_SUPPLY
|
||||
// Only defined by key-lookup preference backends; the slot-based platforms
|
||||
// (esp8266, rp2040) never set it in generated builds, and their preferences
|
||||
|
||||
@@ -188,6 +188,8 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict:
|
||||
|
||||
The ``esphome:`` key is special: its sub-keys are merged into the
|
||||
existing esphome config (e.g. to add ``areas:`` or ``devices:``).
|
||||
A list, such as the entries of a platform component, is extended, so
|
||||
several directories can each add their own entries to one key.
|
||||
Keys already present in the base config (e.g. ``host:``, ``logger:``)
|
||||
are protected by ``setdefault`` in the caller.
|
||||
|
||||
@@ -213,6 +215,9 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict:
|
||||
for sub_key, sub_value in value.items():
|
||||
esphome_extra.setdefault(sub_key, sub_value)
|
||||
continue
|
||||
if isinstance(value, list) and isinstance(merged.get(key), list):
|
||||
merged[key] = merged[key] + value
|
||||
continue
|
||||
merged.setdefault(key, value)
|
||||
return merged
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
cg.add_define("ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT", 2)
|
||||
cg.add_define("ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT", 2)
|
||||
cg.add_define("USE_PACKET_INTERFACE_BASIC_SESSION")
|
||||
# No entity platforms are configured, so count the test's own entities to emit USE_SENSOR and friends
|
||||
CORE.platform_counts["sensor"] += 2
|
||||
CORE.platform_counts["binary_sensor"] += 2
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
# A multi_conf component gets an empty list in the test build, so to_code would not run
|
||||
manifest.multi_conf = False
|
||||
manifest.dependencies = manifest.dependencies + ["sensor", "binary_sensor"]
|
||||
@@ -0,0 +1,33 @@
|
||||
packet_interface:
|
||||
- platform: uart
|
||||
id: entity_transport_link
|
||||
reliability:
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: entity_transport_sensor
|
||||
lambda: return 1.0;
|
||||
- platform: entity_transport
|
||||
provider: remote-device
|
||||
remote_id: entity_transport_sensor
|
||||
name: Remote sensor
|
||||
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
id: entity_transport_binary_sensor
|
||||
lambda: return true;
|
||||
- platform: entity_transport
|
||||
id: remote_binary_sensor
|
||||
provider: remote-device
|
||||
remote_id: door
|
||||
name: Remote binary sensor
|
||||
|
||||
entity_transport:
|
||||
- packet_interface: entity_transport_link
|
||||
update_interval: 60s
|
||||
timeout: 3min
|
||||
sensors:
|
||||
- entity_transport_sensor
|
||||
binary_sensors:
|
||||
- id: entity_transport_binary_sensor
|
||||
broadcast_id: door
|
||||
@@ -0,0 +1,538 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include "esphome/components/entity_transport/entity_transport.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::entity_transport::testing {
|
||||
|
||||
// Copies a packet into a vector, for comparing in tests
|
||||
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
|
||||
std::vector<uint8_t> result(data.size());
|
||||
data.copy_to(result.data(), result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
using packet_interface::PacketBuffer;
|
||||
using packet_interface::PacketMetaData;
|
||||
using Packet = std::vector<uint8_t>;
|
||||
|
||||
static constexpr const char *DEVICE_NAME = "this-device";
|
||||
static constexpr uint8_t PACKET_DICTIONARY = 0x11;
|
||||
static constexpr uint8_t PACKET_DATA = 0x12;
|
||||
static constexpr uint8_t PACKET_REQUEST = 0x13;
|
||||
|
||||
// A packet interface that records what is sent, can refuse to send, and can act as a session layer
|
||||
class MockInterface : public packet_interface::PacketInterface {
|
||||
public:
|
||||
explicit MockInterface(size_t max_packet_size = 64) : packet_interface::PacketInterface(max_packet_size) {}
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
|
||||
if (!this->accept || this->budget == 0)
|
||||
return false;
|
||||
this->budget--;
|
||||
this->sent.push_back(to_vector(data));
|
||||
return true;
|
||||
}
|
||||
void inject(const Packet &packet) { this->on_receive_from_interface_(PacketBuffer(packet), {}); }
|
||||
void set_capabilities(uint8_t capabilities) { this->capabilities_ = capabilities; }
|
||||
void report_session(bool connected) { this->session_callback_.call(connected); }
|
||||
// Deliver everything sent so far to another interface
|
||||
void deliver_to(MockInterface &other) {
|
||||
auto packets = std::move(this->sent);
|
||||
this->sent.clear();
|
||||
for (auto &packet : packets)
|
||||
other.inject(packet);
|
||||
}
|
||||
|
||||
std::vector<Packet> sent;
|
||||
bool accept{true};
|
||||
size_t budget{SIZE_MAX}; // packets accepted before refusing, like a full queue
|
||||
};
|
||||
|
||||
class TestTransport : public EntityTransport {
|
||||
public:
|
||||
using EntityTransport::EntityTransport;
|
||||
uint32_t get_dictionary_id() const { return this->dictionary_id_; }
|
||||
uint32_t get_update_interval() const { return this->update_interval_ms_; }
|
||||
using EntityTransport::clear_remote_states_;
|
||||
};
|
||||
|
||||
static Packet header(uint8_t packet_type, uint32_t id) {
|
||||
return {packet_type, static_cast<uint8_t>(id), static_cast<uint8_t>(id >> 8), static_cast<uint8_t>(id >> 16),
|
||||
static_cast<uint8_t>(id >> 24)};
|
||||
}
|
||||
|
||||
// The start of a dictionary packet from this device's name: the header, the part byte, then the name
|
||||
static Packet dictionary_start(uint32_t id, uint8_t part = 0x80) {
|
||||
Packet packet = header(PACKET_DICTIONARY, id);
|
||||
packet.push_back(part);
|
||||
packet.push_back(strlen(DEVICE_NAME));
|
||||
packet.insert(packet.end(), DEVICE_NAME, DEVICE_NAME + strlen(DEVICE_NAME));
|
||||
return packet;
|
||||
}
|
||||
|
||||
class EntityTransportTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
App.~Application();
|
||||
new (&App) Application();
|
||||
static char name[] = "this-device";
|
||||
static char friendly_name[] = "";
|
||||
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
|
||||
}
|
||||
void TearDown() override {
|
||||
App.~Application();
|
||||
new (&App) Application(); // later tests and the test listener use App
|
||||
}
|
||||
|
||||
// The sender, with a sensor and a binary sensor
|
||||
std::unique_ptr<TestTransport> make_sender_(MockInterface *link = nullptr) {
|
||||
auto transport = std::make_unique<TestTransport>(link != nullptr ? link : &this->link_a_);
|
||||
transport->set_entity_count(2);
|
||||
transport->add_sensor("temp", &this->temp_);
|
||||
transport->add_binary_sensor("door", &this->door_);
|
||||
transport->setup();
|
||||
return transport;
|
||||
}
|
||||
// The receiver, showing the sender's entities; both run on this device, so they share its name
|
||||
std::unique_ptr<TestTransport> make_receiver_(const char *provider = DEVICE_NAME, uint32_t timeout_ms = 0) {
|
||||
auto transport = std::make_unique<TestTransport>(&this->link_b_);
|
||||
transport->set_remote_timeout(timeout_ms);
|
||||
transport->add_remote_sensor(provider, "temp", &this->remote_temp_);
|
||||
transport->add_remote_binary_sensor(provider, "door", &this->remote_door_);
|
||||
transport->setup();
|
||||
return transport;
|
||||
}
|
||||
|
||||
MockInterface link_a_, link_b_;
|
||||
sensor::Sensor temp_, remote_temp_;
|
||||
binary_sensor::BinarySensor door_, remote_door_;
|
||||
};
|
||||
|
||||
TEST_F(EntityTransportTest, SendsDictionaryThenStates) {
|
||||
this->temp_.publish_state(21.5f);
|
||||
this->door_.publish_state(true);
|
||||
auto sender = this->make_sender_();
|
||||
sender->loop();
|
||||
uint32_t id = sender->get_dictionary_id();
|
||||
Packet dictionary = dictionary_start(id);
|
||||
dictionary.insert(dictionary.end(), {1, 0, 1, 4, 't', 'e', 'm', 'p', 2, 0, 1, 4, 'd', 'o', 'o', 'r'});
|
||||
Packet data = header(PACKET_DATA, id);
|
||||
// Tags: sensor 0 with a 4 byte state, then binary sensor 0 with its state in the top bit
|
||||
data.insert(data.end(), {0x00, 0x21, 0x00, 0x00, 0xAC, 0x41, 0x00, 0x82});
|
||||
ASSERT_EQ(this->link_a_.sent.size(), 2);
|
||||
EXPECT_EQ(this->link_a_.sent[0], dictionary);
|
||||
EXPECT_EQ(this->link_a_.sent[1], data);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, SendsOnlyChangedStates) {
|
||||
auto sender = this->make_sender_();
|
||||
sender->loop();
|
||||
this->link_a_.sent.clear();
|
||||
this->temp_.publish_state(3.0f);
|
||||
sender->loop();
|
||||
Packet data = header(PACKET_DATA, sender->get_dictionary_id());
|
||||
data.insert(data.end(), {0x00, 0x21, 0x00, 0x00, 0x40, 0x40});
|
||||
ASSERT_EQ(this->link_a_.sent.size(), 1);
|
||||
EXPECT_EQ(this->link_a_.sent[0], data);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, StatesReachRemoteEntities) {
|
||||
this->temp_.publish_state(12.25f);
|
||||
this->door_.publish_state(true);
|
||||
auto sender = this->make_sender_();
|
||||
auto receiver = this->make_receiver_();
|
||||
sender->loop();
|
||||
this->link_a_.deliver_to(this->link_b_);
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 12.25f);
|
||||
EXPECT_TRUE(this->remote_door_.state);
|
||||
this->door_.invalidate_state();
|
||||
this->temp_.publish_state(NAN);
|
||||
sender->loop();
|
||||
ASSERT_EQ(this->link_a_.sent.size(), 1);
|
||||
// Both records have no state, and so no payload
|
||||
EXPECT_EQ(Packet(this->link_a_.sent[0].begin() + 5, this->link_a_.sent[0].end()), (Packet{0x00, 0x61, 0x00, 0x62}));
|
||||
this->link_a_.deliver_to(this->link_b_);
|
||||
EXPECT_FALSE(this->remote_door_.has_state());
|
||||
EXPECT_TRUE(std::isnan(this->remote_temp_.get_state()));
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, SplitsAcrossPackets) {
|
||||
MockInterface link(5 + 2 + strlen(DEVICE_NAME) + 3 + 5);
|
||||
this->temp_.publish_state(1.0f);
|
||||
this->door_.publish_state(true);
|
||||
auto sender = this->make_sender_(&link);
|
||||
auto receiver = this->make_receiver_();
|
||||
sender->loop();
|
||||
EXPECT_EQ(link.sent.size(), 3); // one dictionary packet for each entity, then one data packet
|
||||
link.deliver_to(this->link_b_);
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 1.0f);
|
||||
EXPECT_TRUE(this->remote_door_.state);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, RefusedPacketSentAgain) {
|
||||
this->link_a_.accept = false;
|
||||
auto sender = this->make_sender_();
|
||||
sender->loop();
|
||||
EXPECT_TRUE(this->link_a_.sent.empty());
|
||||
this->link_a_.accept = true;
|
||||
sender->loop();
|
||||
EXPECT_EQ(this->link_a_.sent.size(), 2);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, LongRefusalWarns) {
|
||||
this->link_a_.accept = false;
|
||||
auto sender = this->make_sender_();
|
||||
{ LoopBlockingGuard guard{nullptr, nullptr, 1000}; }
|
||||
sender->loop();
|
||||
{ LoopBlockingGuard guard{nullptr, nullptr, 60999}; }
|
||||
sender->loop();
|
||||
EXPECT_FALSE(sender->status_has_warning());
|
||||
{ LoopBlockingGuard guard{nullptr, nullptr, 61000}; }
|
||||
sender->loop();
|
||||
EXPECT_TRUE(sender->status_has_warning());
|
||||
this->link_a_.accept = true;
|
||||
sender->loop();
|
||||
EXPECT_FALSE(sender->status_has_warning());
|
||||
EXPECT_EQ(this->link_a_.sent.size(), 2);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, UnknownDictionaryRequested) {
|
||||
this->temp_.publish_state(5.0f);
|
||||
auto sender = this->make_sender_();
|
||||
auto receiver = this->make_receiver_();
|
||||
sender->loop();
|
||||
this->link_a_.sent.erase(this->link_a_.sent.begin()); // the dictionary is lost
|
||||
this->link_a_.deliver_to(this->link_b_);
|
||||
EXPECT_FALSE(this->remote_temp_.has_state());
|
||||
ASSERT_EQ(this->link_b_.sent.size(), 1);
|
||||
EXPECT_EQ(this->link_b_.sent[0], header(PACKET_REQUEST, sender->get_dictionary_id()));
|
||||
this->link_b_.deliver_to(this->link_a_);
|
||||
sender->loop();
|
||||
this->link_a_.deliver_to(this->link_b_);
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 5.0f);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, ChangedDictionaryRequested) {
|
||||
this->temp_.publish_state(5.0f);
|
||||
auto sender = this->make_sender_();
|
||||
auto receiver = this->make_receiver_();
|
||||
sender->loop();
|
||||
this->link_a_.deliver_to(this->link_b_);
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 5.0f);
|
||||
// The provider restarts with other entities, and its new dictionary is lost
|
||||
sensor::Sensor temp;
|
||||
MockInterface link;
|
||||
TestTransport changed(&link);
|
||||
changed.set_entity_count(1);
|
||||
changed.add_sensor("temp", &temp);
|
||||
changed.setup();
|
||||
temp.publish_state(6.0f);
|
||||
changed.loop();
|
||||
link.sent.erase(link.sent.begin());
|
||||
link.deliver_to(this->link_b_);
|
||||
ASSERT_EQ(this->link_b_.sent.size(), 1);
|
||||
EXPECT_EQ(this->link_b_.sent[0], header(PACKET_REQUEST, changed.get_dictionary_id()));
|
||||
this->link_b_.deliver_to(link);
|
||||
changed.loop();
|
||||
link.deliver_to(this->link_b_);
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 6.0f);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, MalformedDataNotRequested) {
|
||||
auto receiver = this->make_receiver_();
|
||||
// A data packet under an unknown dictionary, cut off inside its first record
|
||||
Packet data = header(PACKET_DATA, 0x12345678);
|
||||
data.insert(data.end(), {0x00, 0x21, 0x00});
|
||||
this->link_b_.inject(data);
|
||||
EXPECT_TRUE(this->link_b_.sent.empty());
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, EntityLeftOutOfNewDictionaryCleared) {
|
||||
this->temp_.publish_state(5.0f);
|
||||
this->door_.publish_state(true);
|
||||
auto sender = this->make_sender_();
|
||||
auto receiver = this->make_receiver_();
|
||||
sender->loop();
|
||||
this->link_a_.deliver_to(this->link_b_);
|
||||
EXPECT_TRUE(this->remote_door_.state);
|
||||
// The provider restarts without the door
|
||||
sensor::Sensor temp;
|
||||
MockInterface link;
|
||||
TestTransport changed(&link);
|
||||
changed.set_entity_count(1);
|
||||
changed.add_sensor("temp", &temp);
|
||||
changed.setup();
|
||||
temp.publish_state(6.0f);
|
||||
changed.loop();
|
||||
link.deliver_to(this->link_b_);
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 6.0f);
|
||||
EXPECT_FALSE(this->remote_door_.has_state());
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, EntityLeftOutOfSeenDictionaryCleared) {
|
||||
// The provider names only the sensor, then both entities, then goes back to naming only the sensor
|
||||
sensor::Sensor temp;
|
||||
MockInterface link;
|
||||
auto make_temp_only = [&] {
|
||||
auto transport = std::make_unique<TestTransport>(&link);
|
||||
transport->set_entity_count(1);
|
||||
transport->add_sensor("temp", &temp);
|
||||
transport->setup();
|
||||
return transport;
|
||||
};
|
||||
temp.publish_state(4.0f);
|
||||
auto receiver = this->make_receiver_();
|
||||
make_temp_only()->loop();
|
||||
link.deliver_to(this->link_b_);
|
||||
this->door_.publish_state(true);
|
||||
this->make_sender_()->loop();
|
||||
this->link_a_.deliver_to(this->link_b_);
|
||||
EXPECT_TRUE(this->remote_door_.state);
|
||||
make_temp_only()->loop();
|
||||
link.deliver_to(this->link_b_);
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 4.0f);
|
||||
EXPECT_FALSE(this->remote_door_.has_state());
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, LostDictionaryPartRequested) {
|
||||
// One entity in each dictionary packet
|
||||
MockInterface link(5 + 2 + strlen(DEVICE_NAME) + 3 + 5);
|
||||
this->temp_.publish_state(2.0f);
|
||||
this->door_.publish_state(true);
|
||||
auto sender = this->make_sender_(&link);
|
||||
auto receiver = this->make_receiver_();
|
||||
sender->loop();
|
||||
ASSERT_EQ(link.sent.size(), 3);
|
||||
EXPECT_EQ(link.sent[0][5], 0x00);
|
||||
EXPECT_EQ(link.sent[1][5], 0x81);
|
||||
link.sent.erase(link.sent.begin() + 1); // the part naming the binary sensor is lost
|
||||
link.deliver_to(this->link_b_);
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 2.0f);
|
||||
EXPECT_FALSE(this->remote_door_.has_state());
|
||||
// The sensor is known, but the dictionary is not complete, so it is asked for
|
||||
ASSERT_EQ(this->link_b_.sent.size(), 1);
|
||||
EXPECT_EQ(this->link_b_.sent[0], header(PACKET_REQUEST, sender->get_dictionary_id()));
|
||||
this->link_b_.deliver_to(link);
|
||||
sender->loop();
|
||||
link.deliver_to(this->link_b_);
|
||||
EXPECT_TRUE(this->remote_door_.state);
|
||||
// Now complete, it is not asked for again
|
||||
{ LoopBlockingGuard guard{nullptr, nullptr, 10000}; }
|
||||
this->door_.publish_state(false);
|
||||
sender->loop();
|
||||
link.deliver_to(this->link_b_);
|
||||
EXPECT_FALSE(this->remote_door_.state);
|
||||
EXPECT_TRUE(this->link_b_.sent.empty());
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, RefusedRequestSentAgain) {
|
||||
auto receiver = this->make_receiver_();
|
||||
Packet data = header(PACKET_DATA, 0x12345678);
|
||||
this->link_b_.accept = false;
|
||||
this->link_b_.inject(data);
|
||||
this->link_b_.accept = true;
|
||||
this->link_b_.inject(data);
|
||||
ASSERT_EQ(this->link_b_.sent.size(), 1);
|
||||
EXPECT_EQ(this->link_b_.sent[0], header(PACKET_REQUEST, 0x12345678));
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, RequestsLimited) {
|
||||
auto receiver = this->make_receiver_();
|
||||
Packet data = header(PACKET_DATA, 0x12345678);
|
||||
data.insert(data.end(), {0x00, 0x61}); // sensor 0 with no state
|
||||
this->link_b_.inject(data);
|
||||
this->link_b_.inject(data);
|
||||
EXPECT_EQ(this->link_b_.sent.size(), 1);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, OtherProviderAndOwnPacketsIgnored) {
|
||||
this->temp_.publish_state(5.0f);
|
||||
auto sender = this->make_sender_();
|
||||
auto receiver = this->make_receiver_("other-device");
|
||||
sender->loop();
|
||||
auto packets = this->link_a_.sent;
|
||||
this->link_a_.deliver_to(this->link_b_);
|
||||
EXPECT_FALSE(this->remote_temp_.has_state());
|
||||
// The dictionary named none of the receiver's entities, so it is not asked for again
|
||||
EXPECT_TRUE(this->link_b_.sent.empty());
|
||||
for (auto &packet : packets)
|
||||
this->link_a_.inject(packet);
|
||||
this->link_a_.inject(header(PACKET_REQUEST, 0x12345678));
|
||||
sender->loop();
|
||||
EXPECT_TRUE(this->link_a_.sent.empty());
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, MalformedPacketsIgnored) {
|
||||
auto sender = this->make_sender_();
|
||||
auto receiver = this->make_receiver_();
|
||||
sender->loop();
|
||||
Packet dictionary = this->link_a_.sent[0];
|
||||
Packet data = this->link_a_.sent[1];
|
||||
this->link_b_.inject(Packet(dictionary.begin(), dictionary.end() - 1));
|
||||
this->link_b_.inject(dictionary);
|
||||
this->link_b_.inject(Packet(data.begin(), data.end() - 1));
|
||||
this->link_b_.inject({PACKET_DATA, 1, 2});
|
||||
this->link_b_.inject({0x21, 0, 0, 0, 0});
|
||||
EXPECT_FALSE(this->remote_temp_.has_state());
|
||||
}
|
||||
|
||||
// Data packets from the sender's dictionary, holding the given records
|
||||
class EntityTransportRecordTest : public EntityTransportTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
EntityTransportTest::SetUp();
|
||||
this->sender_ = this->make_sender_();
|
||||
this->receiver_ = this->make_receiver_();
|
||||
this->sender_->loop();
|
||||
this->link_a_.sent.resize(1); // the dictionary only
|
||||
this->link_a_.deliver_to(this->link_b_);
|
||||
}
|
||||
void inject_data_(std::initializer_list<uint8_t> records) {
|
||||
Packet data = header(PACKET_DATA, this->sender_->get_dictionary_id());
|
||||
data.insert(data.end(), records);
|
||||
this->link_b_.inject(data);
|
||||
}
|
||||
|
||||
std::unique_ptr<TestTransport> sender_, receiver_;
|
||||
};
|
||||
|
||||
TEST_F(EntityTransportRecordTest, WholePacketCheckedBeforeUse) {
|
||||
// A good record for each entity, then a record cut short
|
||||
this->inject_data_({0x00, 0x21, 0x00, 0x00, 0x10, 0x41, 0x00, 0x82, 0x00, 0x21, 0x00});
|
||||
// A good record, then a sensor record without its state, then a binary sensor record with a payload
|
||||
this->inject_data_({0x00, 0x21, 0x00, 0x00, 0x10, 0x41, 0x00, 0x01});
|
||||
this->inject_data_({0x00, 0x82, 0x00, 0x22, 0x00, 0x00, 0x00, 0x00});
|
||||
// The value bit is only for records without a payload, and type 0 is not used
|
||||
this->inject_data_({0x00, 0xA1, 0x00, 0x00, 0x10, 0x41});
|
||||
this->inject_data_({0x00, 0x82, 0x00, 0x00});
|
||||
// A length byte past the end
|
||||
this->inject_data_({0x00, 0x82, 0x00, 0x45, 0x03, 0x01});
|
||||
EXPECT_FALSE(this->remote_temp_.has_state());
|
||||
EXPECT_FALSE(this->remote_door_.has_state());
|
||||
this->inject_data_({0x00, 0x21, 0x00, 0x00, 0x10, 0x41, 0x00, 0x82});
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 9.0f);
|
||||
EXPECT_TRUE(this->remote_door_.state);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportRecordTest, UnknownTypesSkipped) {
|
||||
// Type 5 with 3 bytes after a length byte, type 6 with a 4 byte payload, type 7 without a state, then the sensor
|
||||
this->inject_data_({0x00, 0x45, 0x03, 0x01, 0x02, 0x03, 0x01, 0x26, 0x00, 0x00,
|
||||
0x00, 0x00, 0x02, 0x67, 0x00, 0x21, 0x00, 0x00, 0x10, 0x41});
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 9.0f);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, PartlyValidDictionaryNotLearnt) {
|
||||
auto receiver = this->make_receiver_();
|
||||
static constexpr uint32_t OTHER_ID = 0x0BADF00D;
|
||||
Packet dictionary = dictionary_start(OTHER_ID);
|
||||
// A good section naming the sensor, then sections cut short, of type 0, and of more than 255 numbers
|
||||
dictionary.insert(dictionary.end(), {1, 0, 1, 4, 't', 'e', 'm', 'p'});
|
||||
for (Packet bad : {Packet{2, 0, 2, 4, 'd', 'o', 'o', 'r'}, Packet{0, 0, 1, 1, 'x'}, Packet{2, 255, 2, 1, 'a', 1, 'b'},
|
||||
Packet{2, 0, 1}}) {
|
||||
Packet packet = dictionary;
|
||||
packet.insert(packet.end(), bad.begin(), bad.end());
|
||||
this->link_b_.inject(packet);
|
||||
}
|
||||
Packet data = header(PACKET_DATA, OTHER_ID);
|
||||
data.insert(data.end(), {0x00, 0x21, 0x00, 0x00, 0x80, 0x3F});
|
||||
this->link_b_.inject(data);
|
||||
EXPECT_FALSE(this->remote_temp_.has_state());
|
||||
this->link_b_.inject(dictionary);
|
||||
this->link_b_.inject(data);
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 1.0f);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, UpdateIntervalFollowsCapabilities) {
|
||||
EXPECT_EQ(this->make_sender_()->get_update_interval(), 15000);
|
||||
this->link_a_.set_capabilities(packet_interface::CAPABILITY_SESSION);
|
||||
EXPECT_EQ(this->make_sender_()->get_update_interval(), 15000);
|
||||
this->link_a_.set_capabilities(packet_interface::CAPABILITY_SESSION | packet_interface::CAPABILITY_RELIABILITY);
|
||||
EXPECT_EQ(this->make_sender_()->get_update_interval(), SCHEDULER_DONT_RUN);
|
||||
auto transport = std::make_unique<TestTransport>(&this->link_a_);
|
||||
transport->set_update_interval(5000);
|
||||
transport->setup();
|
||||
EXPECT_EQ(transport->get_update_interval(), 5000);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, SessionConnectSendsAllAndLossClearsRemote) {
|
||||
this->link_a_.set_capabilities(packet_interface::CAPABILITY_SESSION);
|
||||
this->link_b_.set_capabilities(packet_interface::CAPABILITY_SESSION);
|
||||
this->temp_.publish_state(7.0f);
|
||||
auto sender = this->make_sender_();
|
||||
auto receiver = this->make_receiver_();
|
||||
sender->loop();
|
||||
this->link_a_.deliver_to(this->link_b_);
|
||||
this->link_a_.report_session(true);
|
||||
sender->loop();
|
||||
EXPECT_EQ(this->link_a_.sent.size(), 2);
|
||||
this->link_b_.report_session(false);
|
||||
EXPECT_TRUE(std::isnan(this->remote_temp_.get_state()));
|
||||
EXPECT_FALSE(this->remote_door_.has_state());
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, RefusedDictionaryPartResumes) {
|
||||
// One entity in each dictionary packet
|
||||
MockInterface link(5 + 2 + strlen(DEVICE_NAME) + 3 + 5);
|
||||
this->temp_.publish_state(4.0f);
|
||||
this->door_.publish_state(true);
|
||||
auto sender = this->make_sender_(&link);
|
||||
auto receiver = this->make_receiver_();
|
||||
link.budget = 1;
|
||||
sender->loop();
|
||||
ASSERT_EQ(link.sent.size(), 1);
|
||||
link.budget = SIZE_MAX;
|
||||
sender->loop();
|
||||
int dictionaries = 0;
|
||||
for (auto &packet : link.sent)
|
||||
dictionaries += packet[0] == PACKET_DICTIONARY;
|
||||
EXPECT_EQ(dictionaries, 2); // the first part was not sent again
|
||||
link.deliver_to(this->link_b_);
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 4.0f);
|
||||
EXPECT_TRUE(this->remote_door_.state);
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, RemoteStatesTimeOut) {
|
||||
this->temp_.publish_state(6.0f);
|
||||
this->door_.publish_state(true);
|
||||
auto sender = this->make_sender_();
|
||||
auto receiver = this->make_receiver_(DEVICE_NAME, 5000);
|
||||
{ LoopBlockingGuard guard{nullptr, nullptr, 1000}; }
|
||||
sender->loop();
|
||||
this->link_a_.deliver_to(this->link_b_);
|
||||
{ LoopBlockingGuard guard{nullptr, nullptr, 6000}; }
|
||||
receiver->clear_remote_states_(true);
|
||||
EXPECT_EQ(this->remote_temp_.get_state(), 6.0f);
|
||||
EXPECT_TRUE(this->remote_door_.has_state());
|
||||
{ LoopBlockingGuard guard{nullptr, nullptr, 6001}; }
|
||||
receiver->clear_remote_states_(true);
|
||||
EXPECT_TRUE(std::isnan(this->remote_temp_.get_state()));
|
||||
EXPECT_FALSE(this->remote_door_.has_state());
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, IdMatchingOnlyUpToNulNotLearnt) {
|
||||
auto receiver = this->make_receiver_();
|
||||
static constexpr uint32_t OTHER_ID = 0x0BADF00D;
|
||||
Packet dictionary = dictionary_start(OTHER_ID);
|
||||
// The id "temp" followed by a NUL and more bytes, nine in all
|
||||
dictionary.insert(dictionary.end(), {1, 0, 1, 9, 't', 'e', 'm', 'p', 0, 'a', 'b', 'c', 'd'});
|
||||
this->link_b_.inject(dictionary);
|
||||
Packet data = header(PACKET_DATA, OTHER_ID);
|
||||
data.insert(data.end(), {0x00, 0x21, 0x00, 0x00, 0x80, 0x3F});
|
||||
this->link_b_.inject(data);
|
||||
EXPECT_FALSE(this->remote_temp_.has_state());
|
||||
}
|
||||
|
||||
TEST_F(EntityTransportTest, PacketTooSmallForIdsFailsSetup) {
|
||||
// One byte short of a dictionary packet holding the device name and a four character id
|
||||
MockInterface small_link(5 + 2 + strlen(DEVICE_NAME) + 3 + 1 + 4 - 1);
|
||||
auto sender = this->make_sender_(&small_link);
|
||||
EXPECT_TRUE(sender->is_failed());
|
||||
EXPECT_FALSE(this->make_sender_()->is_failed());
|
||||
}
|
||||
|
||||
} // namespace esphome::entity_transport::testing
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
entity_transport: !include common.yaml
|
||||
@@ -0,0 +1,14 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config: object) -> None:
|
||||
cg.add_define("PACKET_INTERFACE_ADDRESSING_PORT_COUNT", 4)
|
||||
cg.add_define("USE_PACKET_INTERFACE_BASIC_ADDRESSING")
|
||||
cg.add_define("USE_PACKET_INTERFACE_BASIC_FRAGMENTATION")
|
||||
cg.add_define("USE_PACKET_INTERFACE_BASIC_SESSION")
|
||||
cg.add_define("USE_PACKET_INTERFACE_HDLC")
|
||||
cg.add_define("USE_PACKET_INTERFACE_XXTEA")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -0,0 +1,226 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdint>
|
||||
#include <initializer_list>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include "esphome/components/packet_interface/basic_addressing.h"
|
||||
#include "esphome/components/packet_interface/hdlc_reliability.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::packet_interface::addressing_testing {
|
||||
|
||||
// Copies a packet into a vector, for comparing in tests
|
||||
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
|
||||
std::vector<uint8_t> result(data.size());
|
||||
data.copy_to(result.data(), result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
using Packet = std::vector<uint8_t>;
|
||||
|
||||
static constexpr uint16_t BROADCAST = BasicAddressing::BROADCAST_ADDRESS;
|
||||
|
||||
// A link layer that records what is sent, so a test can deliver it to other devices
|
||||
class MockLink : public PacketInterface {
|
||||
public:
|
||||
explicit MockLink(size_t max_packet_size = 64) : PacketInterface(max_packet_size) {}
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
|
||||
this->sent.push_back(to_vector(data));
|
||||
return true;
|
||||
}
|
||||
void inject(const Packet &packet) { this->on_receive_from_interface_(PacketBuffer(packet), {}); }
|
||||
|
||||
std::vector<Packet> sent;
|
||||
};
|
||||
|
||||
// What one port receives
|
||||
struct Port {
|
||||
Port(BasicAddressing *addressing, uint16_t destination, bool only_port) {
|
||||
this->port = std::make_unique<AddressingPort>(addressing, destination, only_port);
|
||||
this->port->add_packet_interface_listener([this](const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
this->received.push_back(to_vector(data));
|
||||
this->sources.push_back(meta_data.source_address);
|
||||
});
|
||||
}
|
||||
void send(const Packet &data, uint16_t destination = 0) {
|
||||
PacketMetaData meta_data;
|
||||
meta_data.destination_address = destination;
|
||||
this->port->send_to_interface(PacketBuffer(data), meta_data);
|
||||
}
|
||||
|
||||
std::unique_ptr<AddressingPort> port;
|
||||
std::vector<Packet> received;
|
||||
std::vector<uint16_t> sources;
|
||||
};
|
||||
|
||||
// A device on the bus with one port per destination given
|
||||
struct Device {
|
||||
Device(uint16_t address, std::initializer_list<uint16_t> destinations) : addressing(&this->link, address) {
|
||||
this->link.set_name("bus");
|
||||
for (uint16_t destination : destinations)
|
||||
this->ports.push_back(std::make_unique<Port>(&this->addressing, destination, destinations.size() == 1));
|
||||
this->addressing.setup();
|
||||
}
|
||||
explicit Device(uint16_t address, uint16_t destination = BROADCAST) : Device(address, {destination}) {}
|
||||
Port &port(size_t index = 0) { return *this->ports[index]; }
|
||||
// Every device on the bus hears every packet, including its own
|
||||
void deliver(std::initializer_list<Device *> bus) {
|
||||
for (auto &packet : this->link.sent) {
|
||||
for (auto *device : bus)
|
||||
device->link.inject(packet);
|
||||
}
|
||||
this->link.sent.clear();
|
||||
}
|
||||
|
||||
MockLink link;
|
||||
BasicAddressing addressing;
|
||||
std::vector<std::unique_ptr<Port>> ports;
|
||||
};
|
||||
|
||||
TEST(BasicAddressingTest, HeaderAndCapability) {
|
||||
Device a(0x1234, 0x5678);
|
||||
EXPECT_EQ(a.port().port->get_max_packet_size(), 64 - BasicAddressing::HEADER_SIZE);
|
||||
EXPECT_TRUE(a.port().port->has_capability(CAPABILITY_ADDRESSING));
|
||||
EXPECT_EQ(a.link.top(), a.port().port.get());
|
||||
a.port().send({0xAA});
|
||||
a.port().send({0xBB}, 0x0042);
|
||||
EXPECT_EQ(a.link.sent, (std::vector<Packet>{{0x78, 0x56, 0x34, 0x12, 0xAA}, {0x42, 0x00, 0x34, 0x12, 0xBB}}));
|
||||
}
|
||||
|
||||
TEST(BasicAddressingTest, LinkTooSmallLeavesNoRoom) {
|
||||
MockLink link(BasicAddressing::HEADER_SIZE);
|
||||
BasicAddressing addressing(&link, 0x0001);
|
||||
AddressingPort port(&addressing, 0x0002, true);
|
||||
EXPECT_EQ(port.get_max_packet_size(), 0);
|
||||
}
|
||||
|
||||
TEST(BasicAddressingTest, BroadcastReachesEveryoneButSender) {
|
||||
Device a(1), b(2), c(3);
|
||||
a.port().send({0x10});
|
||||
a.deliver({&a, &b, &c});
|
||||
EXPECT_TRUE(a.port().received.empty());
|
||||
EXPECT_EQ(b.port().received, std::vector<Packet>{{0x10}});
|
||||
EXPECT_EQ(c.port().received, std::vector<Packet>{{0x10}});
|
||||
EXPECT_EQ(b.port().sources, std::vector<uint16_t>{1});
|
||||
}
|
||||
|
||||
TEST(BasicAddressingTest, UnicastReachesOnlyDestination) {
|
||||
Device a(1, 2), b(2), c(3);
|
||||
a.port().send({0x20});
|
||||
a.deliver({&b, &c});
|
||||
EXPECT_EQ(b.port().received, std::vector<Packet>{{0x20}});
|
||||
EXPECT_TRUE(c.port().received.empty());
|
||||
}
|
||||
|
||||
TEST(BasicAddressingTest, PointToPointAcceptsOnlyPeer) {
|
||||
Device a(1, 2), b(2, 1), c(3);
|
||||
c.port().send({0x30}); // broadcast from a third device
|
||||
c.port().send({0x31}, 2); // addressed to b, from the wrong device
|
||||
a.port().send({0x32}); // from the peer
|
||||
c.deliver({&b});
|
||||
a.deliver({&b});
|
||||
EXPECT_EQ(b.port().received, std::vector<Packet>{{0x32}});
|
||||
EXPECT_EQ(b.port().sources, std::vector<uint16_t>{1});
|
||||
}
|
||||
|
||||
TEST(BasicAddressingTest, PeersHaveSeparateStacks) {
|
||||
Device hub(1, {2, 3, BROADCAST}), b(2, 1), c(3, 1), d(4);
|
||||
EXPECT_EQ(hub.link.top(), &hub.link); // with several ports, applications use the ports
|
||||
EXPECT_EQ(hub.port(0).port->top(), hub.port(0).port.get());
|
||||
b.port().send({0x02});
|
||||
c.port().send({0x03});
|
||||
d.port().send({0x04});
|
||||
b.deliver({&hub});
|
||||
c.deliver({&hub});
|
||||
d.deliver({&hub});
|
||||
EXPECT_EQ(hub.port(0).received, std::vector<Packet>{{0x02}});
|
||||
EXPECT_EQ(hub.port(1).received, std::vector<Packet>{{0x03}});
|
||||
EXPECT_EQ(hub.port(2).received, std::vector<Packet>{{0x04}}); // only devices without a port of their own
|
||||
hub.port(1).send({0x13});
|
||||
hub.deliver({&b, &c, &d});
|
||||
EXPECT_EQ(c.port().received, std::vector<Packet>{{0x13}});
|
||||
EXPECT_TRUE(b.port().received.empty());
|
||||
EXPECT_TRUE(d.port().received.empty());
|
||||
}
|
||||
|
||||
TEST(BasicAddressingTest, BroadcastFromPeerReachesBroadcastPort) {
|
||||
Device hub(1, {2, BROADCAST}), b(2, {1, BROADCAST});
|
||||
hub.port(1).send({0x10}); // a state every device needs
|
||||
hub.port(0).send({0x11}); // point to point
|
||||
hub.deliver({&b});
|
||||
EXPECT_EQ(b.port(0).received, std::vector<Packet>{{0x11}});
|
||||
EXPECT_EQ(b.port(1).received, std::vector<Packet>{{0x10}});
|
||||
EXPECT_EQ(b.port(1).sources, std::vector<uint16_t>{1});
|
||||
}
|
||||
|
||||
TEST(BasicAddressingTest, WithoutBroadcastPortPeerBroadcastReachesItsPort) {
|
||||
Device hub(1, {2, 3}), b(2, BROADCAST);
|
||||
b.port().send({0x20});
|
||||
b.deliver({&hub});
|
||||
EXPECT_EQ(hub.port(0).received, std::vector<Packet>{{0x20}});
|
||||
EXPECT_TRUE(hub.port(1).received.empty());
|
||||
}
|
||||
|
||||
TEST(BasicAddressingTest, WithoutBroadcastPortUnknownDevicesDropped) {
|
||||
Device hub(1, {2, 3}), d(4);
|
||||
d.port().send({0x04});
|
||||
d.deliver({&hub});
|
||||
EXPECT_TRUE(hub.port(0).received.empty());
|
||||
EXPECT_TRUE(hub.port(1).received.empty());
|
||||
}
|
||||
|
||||
TEST(BasicAddressingTest, ShortPacketDropped) {
|
||||
Device a(1);
|
||||
a.link.inject({0xFF, 0xFF, 0x02});
|
||||
EXPECT_TRUE(a.port().received.empty());
|
||||
}
|
||||
|
||||
// Each test replaces App with a fresh one; the reliability layer's timers need its scheduler
|
||||
class AddressingReliabilityTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
App.~Application();
|
||||
new (&App) Application();
|
||||
static char name[] = "addressing-tests";
|
||||
static char friendly_name[] = "";
|
||||
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
|
||||
}
|
||||
void TearDown() override {
|
||||
App.~Application();
|
||||
new (&App) Application(); // later tests and the test listener use App
|
||||
}
|
||||
};
|
||||
|
||||
// A reliability layer on each port, recording what it delivers
|
||||
struct ReliablePort {
|
||||
explicit ReliablePort(AddressingPort *port) : hdlc(port) {
|
||||
this->hdlc.add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); });
|
||||
this->hdlc.setup();
|
||||
}
|
||||
void send(const Packet &data) { this->hdlc.send_to_interface(PacketBuffer(data), {}); }
|
||||
HdlcReliability hdlc;
|
||||
std::vector<Packet> received;
|
||||
};
|
||||
|
||||
TEST_F(AddressingReliabilityTest, SeveralReliablePeersOnOneBus) {
|
||||
Device hub(1, {2, 3}), b(2, 1), c(3, 1);
|
||||
ReliablePort hub_b(hub.port(0).port.get()), hub_c(hub.port(1).port.get());
|
||||
ReliablePort b_hub(b.port().port.get()), c_hub(c.port().port.get());
|
||||
hub_b.send({0x12});
|
||||
hub_c.send({0x13});
|
||||
b_hub.send({0x21});
|
||||
c_hub.send({0x31});
|
||||
for (int i = 0; i != 20; i++) {
|
||||
for (auto *port : {&hub_b, &hub_c, &b_hub, &c_hub})
|
||||
port->hdlc.loop();
|
||||
for (auto *device : {&hub, &b, &c})
|
||||
device->deliver({&hub, &b, &c});
|
||||
}
|
||||
EXPECT_EQ(b_hub.received, std::vector<Packet>{{0x12}});
|
||||
EXPECT_EQ(c_hub.received, std::vector<Packet>{{0x13}});
|
||||
EXPECT_EQ(hub_b.received, std::vector<Packet>{{0x21}});
|
||||
EXPECT_EQ(hub_c.received, std::vector<Packet>{{0x31}});
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface::addressing_testing
|
||||
@@ -0,0 +1,229 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include "esphome/components/packet_interface/basic_fragmentation.h"
|
||||
#include "esphome/components/packet_interface/hdlc_reliability.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::packet_interface::fragmentation_testing {
|
||||
|
||||
// Copies a packet into a vector, for comparing in tests
|
||||
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
|
||||
std::vector<uint8_t> result(data.size());
|
||||
data.copy_to(result.data(), result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
using Packet = std::vector<uint8_t>;
|
||||
|
||||
// A link layer that records what is sent, with a limited send capacity
|
||||
class MockLink : public PacketInterface {
|
||||
public:
|
||||
explicit MockLink(size_t max_packet_size = 12) : PacketInterface(max_packet_size) {}
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
|
||||
this->sent.push_back(to_vector(data));
|
||||
return true;
|
||||
}
|
||||
size_t get_send_capacity() const override { return this->capacity; }
|
||||
void inject(const Packet &packet, uint16_t source = 0) {
|
||||
PacketMetaData meta_data;
|
||||
meta_data.source_address = source;
|
||||
this->on_receive_from_interface_(PacketBuffer(packet), meta_data);
|
||||
}
|
||||
|
||||
std::vector<Packet> sent;
|
||||
size_t capacity{SIZE_MAX};
|
||||
};
|
||||
|
||||
struct Peer {
|
||||
explicit Peer(PacketInterface *lower) : fragmentation(lower, 100) {
|
||||
this->fragmentation.add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); });
|
||||
this->fragmentation.setup();
|
||||
}
|
||||
bool send(const Packet &data) { return this->fragmentation.send_to_interface(PacketBuffer(data), {}); }
|
||||
|
||||
BasicFragmentation fragmentation;
|
||||
std::vector<Packet> received;
|
||||
};
|
||||
|
||||
static Packet make_packet(size_t size) {
|
||||
Packet packet(size);
|
||||
for (size_t i = 0; i != size; i++)
|
||||
packet[i] = i * 7 + 1;
|
||||
return packet;
|
||||
}
|
||||
|
||||
class TestHdlc : public HdlcReliability {
|
||||
public:
|
||||
using HdlcReliability::HdlcReliability;
|
||||
using HdlcReliability::on_timeout_;
|
||||
};
|
||||
|
||||
// Each test replaces App with a fresh one; the reliability layer's timers need its scheduler
|
||||
class BasicFragmentationTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
App.~Application();
|
||||
new (&App) Application();
|
||||
static char name[] = "fragmentation-tests";
|
||||
static char friendly_name[] = "";
|
||||
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
|
||||
}
|
||||
void TearDown() override {
|
||||
App.~Application();
|
||||
new (&App) Application(); // later tests and the test listener use App
|
||||
}
|
||||
|
||||
// Deliver what a has sent to b
|
||||
static void deliver(MockLink &from, MockLink &to) {
|
||||
for (auto &packet : from.sent)
|
||||
to.inject(packet);
|
||||
from.sent.clear();
|
||||
}
|
||||
|
||||
MockLink link_a_, link_b_;
|
||||
};
|
||||
|
||||
TEST_F(BasicFragmentationTest, SizesAndCapability) {
|
||||
Peer a(&this->link_a_);
|
||||
EXPECT_EQ(a.fragmentation.get_max_packet_size(), 100);
|
||||
EXPECT_TRUE(a.fragmentation.has_capability(CAPABILITY_FRAGMENTATION));
|
||||
EXPECT_EQ(this->link_a_.top(), &a.fragmentation);
|
||||
}
|
||||
|
||||
TEST_F(BasicFragmentationTest, SmallPacketIsOneFragment) {
|
||||
Peer a(&this->link_a_), b(&this->link_b_);
|
||||
a.send({1, 2, 3});
|
||||
ASSERT_EQ(this->link_a_.sent.size(), 1);
|
||||
EXPECT_EQ(this->link_a_.sent[0].size(), 3 + BasicFragmentation::HEADER_SIZE);
|
||||
EXPECT_EQ(this->link_a_.sent[0][1], 0x80); // index 0, last
|
||||
deliver(this->link_a_, this->link_b_);
|
||||
EXPECT_EQ(b.received, (std::vector<Packet>{{1, 2, 3}}));
|
||||
}
|
||||
|
||||
TEST_F(BasicFragmentationTest, LargePacketSplitAndJoined) {
|
||||
Peer a(&this->link_a_), b(&this->link_b_);
|
||||
for (size_t size : {10, 11, 50, 100}) {
|
||||
Packet packet = make_packet(size);
|
||||
ASSERT_TRUE(a.send(packet));
|
||||
EXPECT_EQ(this->link_a_.sent.size(), (size + 9) / 10); // 10 bytes of payload in each 12 byte fragment
|
||||
EXPECT_EQ(this->link_a_.sent.back()[1] & 0x80, 0x80);
|
||||
deliver(this->link_a_, this->link_b_);
|
||||
EXPECT_EQ(b.received.back(), packet);
|
||||
}
|
||||
EXPECT_EQ(b.received.size(), 4);
|
||||
}
|
||||
|
||||
TEST_F(BasicFragmentationTest, CompositePacketSplitAcrossFragments) {
|
||||
Peer a(&this->link_a_), b(&this->link_b_);
|
||||
Packet pre = make_packet(7), mid(13, 0xAA), post(9, 0x55);
|
||||
PacketBuffer body(mid.data(), mid.size());
|
||||
ASSERT_TRUE(
|
||||
a.fragmentation.send_to_interface(PacketBuffer(pre.data(), pre.size(), &body, post.data(), post.size()), {}));
|
||||
deliver(this->link_a_, this->link_b_);
|
||||
Packet expected = pre;
|
||||
expected.insert(expected.end(), mid.begin(), mid.end());
|
||||
expected.insert(expected.end(), post.begin(), post.end());
|
||||
EXPECT_EQ(b.received, std::vector<Packet>{expected});
|
||||
}
|
||||
|
||||
TEST_F(BasicFragmentationTest, MissingFragmentDropsOnlyThatPacket) {
|
||||
Peer a(&this->link_a_), b(&this->link_b_);
|
||||
a.send(make_packet(30));
|
||||
this->link_a_.sent.erase(this->link_a_.sent.begin() + 1);
|
||||
deliver(this->link_a_, this->link_b_);
|
||||
EXPECT_TRUE(b.received.empty());
|
||||
a.send(make_packet(25));
|
||||
deliver(this->link_a_, this->link_b_);
|
||||
EXPECT_EQ(b.received, std::vector<Packet>{make_packet(25)});
|
||||
}
|
||||
|
||||
TEST_F(BasicFragmentationTest, NotStartedWithoutRoomForAllFragments) {
|
||||
Peer a(&this->link_a_);
|
||||
this->link_a_.capacity = 2;
|
||||
EXPECT_FALSE(a.send(make_packet(30))); // needs 3
|
||||
EXPECT_TRUE(this->link_a_.sent.empty());
|
||||
EXPECT_TRUE(a.send(make_packet(20)));
|
||||
EXPECT_FALSE(a.send(make_packet(101)));
|
||||
}
|
||||
|
||||
TEST_F(BasicFragmentationTest, InterleavedSourcesNotMixed) {
|
||||
Peer a(&this->link_a_), c(&this->link_a_), b(&this->link_b_);
|
||||
a.send(make_packet(20));
|
||||
auto from_a = std::move(this->link_a_.sent);
|
||||
this->link_a_.sent.clear();
|
||||
c.send(make_packet(20));
|
||||
auto from_c = std::move(this->link_a_.sent);
|
||||
// Fragments from devices 1 and 2 arrive interleaved, perhaps with the same packet number
|
||||
this->link_b_.inject(from_a[0], 1);
|
||||
this->link_b_.inject(from_c[0], 2);
|
||||
this->link_b_.inject(from_a[1], 1);
|
||||
this->link_b_.inject(from_c[1], 2);
|
||||
// Nothing is mixed from the two, and the packet started last gets through
|
||||
EXPECT_EQ(b.received, std::vector<Packet>{make_packet(20)});
|
||||
}
|
||||
|
||||
TEST_F(BasicFragmentationTest, OneFragmentPacketFromAnotherSourceKeepsJoin) {
|
||||
Peer a(&this->link_a_), c(&this->link_a_), b(&this->link_b_);
|
||||
a.send(make_packet(20));
|
||||
auto from_a = std::move(this->link_a_.sent);
|
||||
this->link_a_.sent.clear();
|
||||
c.send(make_packet(5));
|
||||
auto from_c = std::move(this->link_a_.sent);
|
||||
this->link_b_.inject(from_a[0], 1);
|
||||
this->link_b_.inject(from_c[0], 2);
|
||||
this->link_b_.inject(from_a[1], 1);
|
||||
EXPECT_EQ(b.received, (std::vector<Packet>{make_packet(5), make_packet(20)}));
|
||||
}
|
||||
|
||||
TEST_F(BasicFragmentationTest, OverReliabilityWithLosses) {
|
||||
// Fragmentation above HDLC on each side, with every fourth frame from a dropped on the way; not every third, as
|
||||
// each resend is a burst of three, so the same fragment would be lost every time
|
||||
TestHdlc hdlc_a(&this->link_a_), hdlc_b(&this->link_b_);
|
||||
hdlc_a.setup();
|
||||
hdlc_b.setup();
|
||||
// HDLC adds 3 bytes to the 12 byte link, leaving 7 bytes of payload in each fragment
|
||||
Peer a(&hdlc_a), b(&hdlc_b);
|
||||
Packet packet = make_packet(20); // 3 fragments, within the queue of 4
|
||||
ASSERT_TRUE(a.send(packet));
|
||||
int count = 0;
|
||||
for (int i = 0; i != 30; i++) {
|
||||
hdlc_a.loop();
|
||||
hdlc_b.loop();
|
||||
if (i % 5 == 4)
|
||||
hdlc_a.on_timeout_();
|
||||
for (auto &frame : this->link_a_.sent) {
|
||||
if (++count % 4 != 0)
|
||||
this->link_b_.inject(frame);
|
||||
}
|
||||
this->link_a_.sent.clear();
|
||||
deliver(this->link_b_, this->link_a_);
|
||||
}
|
||||
EXPECT_EQ(b.received, std::vector<Packet>{packet});
|
||||
}
|
||||
|
||||
TEST_F(BasicFragmentationTest, OversizedSingleFragmentDropped) {
|
||||
// A link packet holds more than this layer's max_packet_size
|
||||
BasicFragmentation fragmentation(&this->link_a_, 4);
|
||||
std::vector<Packet> received;
|
||||
fragmentation.add_packet_interface_listener(
|
||||
[&received](const PacketBuffer &data, const PacketMetaData &) { received.push_back(to_vector(data)); });
|
||||
fragmentation.setup();
|
||||
this->link_a_.inject({0x01, 0x80, 1, 2, 3, 4, 5});
|
||||
EXPECT_TRUE(received.empty());
|
||||
this->link_a_.inject({0x02, 0x80, 1, 2, 3, 4});
|
||||
EXPECT_EQ(received, std::vector<Packet>{Packet({1, 2, 3, 4})});
|
||||
}
|
||||
|
||||
TEST_F(BasicFragmentationTest, LinkTooSmallFailsSetup) {
|
||||
MockLink link(BasicFragmentation::HEADER_SIZE);
|
||||
BasicFragmentation fragmentation(&link, 100);
|
||||
fragmentation.setup();
|
||||
EXPECT_TRUE(fragmentation.is_failed());
|
||||
Packet packet{1};
|
||||
EXPECT_FALSE(fragmentation.send_to_interface(PacketBuffer(packet), {}));
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface::fragmentation_testing
|
||||
@@ -0,0 +1,252 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include "esphome/components/packet_interface/basic_session.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::packet_interface::session_testing {
|
||||
|
||||
// Copies a packet into a vector, for comparing in tests
|
||||
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
|
||||
std::vector<uint8_t> result(data.size());
|
||||
data.copy_to(result.data(), result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
using Packet = std::vector<uint8_t>;
|
||||
|
||||
// A link layer that records what is sent, so a test can deliver it to the peer
|
||||
class MockLink : public PacketInterface {
|
||||
public:
|
||||
explicit MockLink(size_t max_packet_size = 64) : PacketInterface(max_packet_size) {}
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
|
||||
if (!this->accept)
|
||||
return false;
|
||||
this->sent.push_back(to_vector(data));
|
||||
return true;
|
||||
}
|
||||
void inject(const Packet &packet) { this->on_receive_from_interface_(PacketBuffer(packet), {}); }
|
||||
// Act as a reliability layer that resets and discards data
|
||||
void set_reliable() { this->capabilities_ = CAPABILITY_RELIABILITY; }
|
||||
void report_data_lost() { this->session_callback_.call(false); }
|
||||
|
||||
std::vector<Packet> sent;
|
||||
bool accept{true};
|
||||
};
|
||||
|
||||
class TestSession : public BasicSession {
|
||||
public:
|
||||
using BasicSession::BasicSession;
|
||||
using BasicSession::on_interval_;
|
||||
unsigned get_not_understood_count() const { return this->not_understood_count_; }
|
||||
};
|
||||
|
||||
struct Peer {
|
||||
Peer() {
|
||||
this->session = std::make_unique<TestSession>(&this->link);
|
||||
this->session->set_keep_alive(1000);
|
||||
this->session->set_session_timeout(3000);
|
||||
this->session->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); });
|
||||
this->session->add_on_session_state_callback([this](bool connected) { this->states.push_back(connected); });
|
||||
this->session->setup();
|
||||
}
|
||||
bool send(const Packet &data) { return this->session->send_to_interface(PacketBuffer(data), {}); }
|
||||
|
||||
MockLink link;
|
||||
std::unique_ptr<TestSession> session;
|
||||
std::vector<Packet> received;
|
||||
std::vector<bool> states;
|
||||
};
|
||||
|
||||
static bool is_keep_alive(const Packet &packet) { return !packet.empty() && packet[0] == 0x01; }
|
||||
|
||||
// Move packets between the peers until nothing more is sent
|
||||
static void pump(Peer &a, Peer &b) {
|
||||
for (int i = 0; i != 20; i++) {
|
||||
if (a.link.sent.empty() && b.link.sent.empty())
|
||||
return;
|
||||
auto from_a = std::move(a.link.sent);
|
||||
auto from_b = std::move(b.link.sent);
|
||||
a.link.sent.clear();
|
||||
b.link.sent.clear();
|
||||
for (auto &packet : from_a)
|
||||
b.link.inject(packet);
|
||||
for (auto &packet : from_b)
|
||||
a.link.inject(packet);
|
||||
}
|
||||
FAIL() << "Peers did not go quiet";
|
||||
}
|
||||
|
||||
// Each test replaces App with a fresh one; the keep-alive interval needs its scheduler
|
||||
class BasicSessionTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
App.~Application();
|
||||
new (&App) Application();
|
||||
static char name[] = "session-tests";
|
||||
static char friendly_name[] = "";
|
||||
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
|
||||
}
|
||||
void TearDown() override {
|
||||
App.~Application();
|
||||
new (&App) Application(); // later tests and the test listener use App
|
||||
}
|
||||
|
||||
static void set_time(uint32_t now) { LoopBlockingGuard guard{nullptr, nullptr, now}; }
|
||||
};
|
||||
|
||||
TEST_F(BasicSessionTest, MaxPacketSizeAllowsForHeader) {
|
||||
Peer a;
|
||||
EXPECT_EQ(a.session->get_max_packet_size(), 64 - BasicSession::HEADER_SIZE);
|
||||
EXPECT_EQ(a.link.top(), a.session.get());
|
||||
EXPECT_EQ(a.session->get_capabilities(), CAPABILITY_SESSION);
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, KeepAliveSentAtSetup) {
|
||||
Peer a;
|
||||
ASSERT_EQ(a.link.sent.size(), 1);
|
||||
EXPECT_TRUE(is_keep_alive(a.link.sent[0]));
|
||||
EXPECT_EQ(a.link.sent[0].size(), 5);
|
||||
EXPECT_FALSE(a.session->is_connected());
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, BothConnectWithoutRepeatedAnswers) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
EXPECT_TRUE(a.session->is_connected());
|
||||
EXPECT_TRUE(b.session->is_connected());
|
||||
EXPECT_EQ(a.states, std::vector<bool>{true});
|
||||
EXPECT_EQ(b.states, std::vector<bool>{true});
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, OneSideStartsLater) {
|
||||
Peer a;
|
||||
a.link.sent.clear(); // a's first keep-alive is lost
|
||||
Peer b;
|
||||
pump(a, b);
|
||||
EXPECT_TRUE(a.session->is_connected());
|
||||
EXPECT_TRUE(b.session->is_connected());
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, DataPassesThroughWithHeader) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
ASSERT_TRUE(a.send({0x10, 0x11}));
|
||||
ASSERT_EQ(a.link.sent.size(), 1);
|
||||
EXPECT_EQ(a.link.sent[0], (Packet{0x00, 0x10, 0x11}));
|
||||
pump(a, b);
|
||||
EXPECT_EQ(b.received, (std::vector<Packet>{{0x10, 0x11}}));
|
||||
EXPECT_TRUE(a.received.empty());
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, NotUnderstoodPacketsCountedUntilOneIsUnderstood) {
|
||||
Peer a;
|
||||
for (int i = 0; i != 20; i++)
|
||||
a.link.inject({0x7F, 0x00});
|
||||
// The count stops where the mismatch is logged, so it is logged once
|
||||
EXPECT_EQ(a.session->get_not_understood_count(), 10);
|
||||
a.link.inject({0x00, 0x55});
|
||||
EXPECT_EQ(a.session->get_not_understood_count(), 0);
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, DataMarksPeerConnected) {
|
||||
Peer a;
|
||||
a.link.inject({0x00, 0x42});
|
||||
EXPECT_TRUE(a.session->is_connected());
|
||||
EXPECT_EQ(a.received, (std::vector<Packet>{{0x42}}));
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, DataBeforeFirstKeepAliveIsNotARestart) {
|
||||
Peer a, b;
|
||||
a.link.sent.clear();
|
||||
b.link.sent.clear(); // b's first keep-alive is lost
|
||||
b.send({0x42});
|
||||
pump(a, b);
|
||||
b.session->on_interval_();
|
||||
pump(a, b);
|
||||
EXPECT_EQ(a.states, std::vector<bool>{true});
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, SendFailureIsReturned) {
|
||||
Peer a;
|
||||
a.link.accept = false;
|
||||
EXPECT_FALSE(a.send({0x01}));
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, IntervalSendsKeepAlive) {
|
||||
Peer a;
|
||||
a.link.sent.clear();
|
||||
a.session->on_interval_();
|
||||
ASSERT_EQ(a.link.sent.size(), 1);
|
||||
EXPECT_TRUE(is_keep_alive(a.link.sent[0]));
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, PeerLostAfterTimeout) {
|
||||
Peer a, b;
|
||||
set_time(1000);
|
||||
pump(a, b);
|
||||
set_time(4000);
|
||||
a.session->on_interval_();
|
||||
EXPECT_TRUE(a.session->is_connected());
|
||||
set_time(4001);
|
||||
a.session->on_interval_();
|
||||
EXPECT_FALSE(a.session->is_connected());
|
||||
EXPECT_EQ(a.states, (std::vector<bool>{true, false}));
|
||||
// Heard again
|
||||
b.session->on_interval_();
|
||||
pump(a, b);
|
||||
EXPECT_TRUE(a.session->is_connected());
|
||||
EXPECT_EQ(a.states, (std::vector<bool>{true, false, true}));
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, PeerRestartIsReported) {
|
||||
Peer a;
|
||||
auto b = std::make_unique<Peer>();
|
||||
pump(a, *b);
|
||||
b = std::make_unique<Peer>();
|
||||
pump(a, *b);
|
||||
EXPECT_TRUE(a.session->is_connected());
|
||||
EXPECT_EQ(a.states, (std::vector<bool>{true, false, true}));
|
||||
EXPECT_EQ(b->states, std::vector<bool>{true});
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, MalformedPacketsDropped) {
|
||||
Peer a;
|
||||
a.link.inject({});
|
||||
a.link.inject({0x02, 0x00});
|
||||
a.link.inject({0x01, 0x00, 0x00});
|
||||
EXPECT_FALSE(a.session->is_connected());
|
||||
EXPECT_TRUE(a.received.empty());
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, DataLostBelowReportedAsSessionLost) {
|
||||
MockLink link;
|
||||
link.set_reliable();
|
||||
TestSession session(&link);
|
||||
std::vector<bool> states;
|
||||
session.add_on_session_state_callback([&states](bool connected) { states.push_back(connected); });
|
||||
session.setup();
|
||||
// Not yet connected, so nothing to report
|
||||
link.report_data_lost();
|
||||
EXPECT_TRUE(states.empty());
|
||||
link.inject({0x00, 0x55});
|
||||
link.report_data_lost();
|
||||
EXPECT_FALSE(session.is_connected());
|
||||
// Connected again when the peer is next heard
|
||||
link.inject({0x00, 0x56});
|
||||
EXPECT_EQ(states, (std::vector<bool>{true, false, true}));
|
||||
}
|
||||
|
||||
TEST_F(BasicSessionTest, LinkTooSmallForKeepAliveFailsSetup) {
|
||||
MockLink link(4);
|
||||
TestSession session(&link);
|
||||
session.setup();
|
||||
EXPECT_TRUE(session.is_failed());
|
||||
EXPECT_TRUE(link.sent.empty());
|
||||
Packet packet{1};
|
||||
EXPECT_FALSE(session.send_to_interface(PacketBuffer(packet), {}));
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface::session_testing
|
||||
@@ -0,0 +1,11 @@
|
||||
uart:
|
||||
port: /dev/ttyUSB0
|
||||
baud_rate: 9600
|
||||
|
||||
packet_interface:
|
||||
- platform: uart
|
||||
id: bridge_test_link
|
||||
|
||||
packet_transport:
|
||||
- platform: packet_interface
|
||||
packet_interface: bridge_test_link
|
||||
@@ -0,0 +1,62 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
udp:
|
||||
- id: packet_interface_udp
|
||||
|
||||
packet_interface:
|
||||
- platform: uart
|
||||
id: packet_interface_uart
|
||||
max_packet_size: 256
|
||||
addressing:
|
||||
address: 0x0001
|
||||
peers:
|
||||
- id: packet_interface_peer_a
|
||||
destination: 0x0002
|
||||
encryption:
|
||||
key: Johnny Cash
|
||||
rolling_code_enable: true
|
||||
reliability:
|
||||
timeout: 300ms
|
||||
max_queue_length: 3
|
||||
max_retries: 4
|
||||
session:
|
||||
keep_alive: 5s
|
||||
timeout: 12s
|
||||
binary_sensor:
|
||||
name: Peer A session
|
||||
- id: packet_interface_peer_b
|
||||
destination: 0x0003
|
||||
reliability:
|
||||
max_queue_length: 5
|
||||
fragmentation:
|
||||
max_packet_size: 1024
|
||||
session:
|
||||
- id: packet_interface_bus
|
||||
destination: broadcast
|
||||
- platform: udp
|
||||
id: packet_interface_network
|
||||
udp_id: packet_interface_udp
|
||||
addressing:
|
||||
address: 0x0001
|
||||
destination: 0x0002
|
||||
encryption: Johnny Cash
|
||||
reliability:
|
||||
fragmentation:
|
||||
session:
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: packet_interface_sensor
|
||||
lambda: return 1.0;
|
||||
|
||||
packet_transport:
|
||||
- platform: packet_interface
|
||||
packet_interface: packet_interface_peer_a
|
||||
sensors:
|
||||
- packet_interface_sensor
|
||||
- platform: packet_interface
|
||||
packet_interface: packet_interface_network
|
||||
sensors:
|
||||
- packet_interface_sensor
|
||||
@@ -0,0 +1,464 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include "esphome/components/packet_interface/hdlc_reliability.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::packet_interface::testing {
|
||||
|
||||
// Copies a packet into a vector, for comparing in tests
|
||||
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
|
||||
std::vector<uint8_t> result(data.size());
|
||||
data.copy_to(result.data(), result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
using Frame = std::vector<uint8_t>;
|
||||
|
||||
// A link layer that records what is sent, so a test can deliver it to the peer
|
||||
class MockLink : public PacketInterface {
|
||||
public:
|
||||
explicit MockLink(size_t max_packet_size = 64) : PacketInterface(max_packet_size) {}
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
|
||||
if (this->budget == 0) {
|
||||
this->refused++;
|
||||
return false;
|
||||
}
|
||||
this->budget--;
|
||||
this->sent.push_back(to_vector(data));
|
||||
return true;
|
||||
}
|
||||
void inject(const Frame &frame) { this->on_receive_from_interface_(PacketBuffer(frame), {}); }
|
||||
|
||||
std::vector<Frame> sent;
|
||||
size_t budget{SIZE_MAX}; // frames accepted before the link refuses, like a full network stack
|
||||
size_t refused{0};
|
||||
};
|
||||
|
||||
class TestHdlc : public HdlcReliability {
|
||||
public:
|
||||
using HdlcReliability::HdlcReliability;
|
||||
using HdlcReliability::on_timeout_;
|
||||
using HdlcReliability::outstanding_;
|
||||
};
|
||||
|
||||
struct Peer {
|
||||
explicit Peer(uint8_t queue_length = 4) {
|
||||
this->hdlc = std::make_unique<TestHdlc>(&this->link);
|
||||
this->hdlc->set_max_queue_length(queue_length);
|
||||
this->hdlc->set_max_retries(3);
|
||||
this->hdlc->setup();
|
||||
this->hdlc->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); });
|
||||
this->hdlc->add_on_session_state_callback([this](bool connected) { this->states.push_back(connected); });
|
||||
}
|
||||
bool send(const Frame &data) { return this->hdlc->send_to_interface(PacketBuffer(data), {}); }
|
||||
|
||||
MockLink link;
|
||||
std::unique_ptr<TestHdlc> hdlc;
|
||||
std::vector<Frame> received;
|
||||
std::vector<bool> states;
|
||||
};
|
||||
|
||||
// Returns true to drop a frame on its way from one peer to the other
|
||||
using DropFilter = std::function<bool(const Frame &)>;
|
||||
|
||||
static bool is_info(const Frame &frame) { return (frame[0] & 1) == 0; }
|
||||
static bool is_ack(const Frame &frame) { return (frame[0] & 0x0F) == 0x01; }
|
||||
|
||||
// Runs a layer's loop only while it has asked for it, as the main loop does
|
||||
static void run_loop(TestHdlc &hdlc) {
|
||||
if (!hdlc.is_idle())
|
||||
hdlc.loop();
|
||||
}
|
||||
|
||||
// Move frames between the peers and run their loops until nothing more is sent
|
||||
static void pump(Peer &a, Peer &b, const DropFilter &drop_a_to_b = nullptr, const DropFilter &drop_b_to_a = nullptr) {
|
||||
for (int i = 0; i != 50; i++) {
|
||||
run_loop(*a.hdlc);
|
||||
run_loop(*b.hdlc);
|
||||
if (a.link.sent.empty() && b.link.sent.empty())
|
||||
return;
|
||||
auto from_a = std::move(a.link.sent);
|
||||
auto from_b = std::move(b.link.sent);
|
||||
a.link.sent.clear();
|
||||
b.link.sent.clear();
|
||||
for (auto &frame : from_a) {
|
||||
if (!drop_a_to_b || !drop_a_to_b(frame))
|
||||
b.link.inject(frame);
|
||||
}
|
||||
for (auto &frame : from_b) {
|
||||
if (!drop_b_to_a || !drop_b_to_a(frame))
|
||||
a.link.inject(frame);
|
||||
}
|
||||
}
|
||||
FAIL() << "Peers did not go quiet";
|
||||
}
|
||||
|
||||
static Frame make_frame(uint8_t control, const Frame &payload) {
|
||||
Frame frame{control};
|
||||
frame.insert(frame.end(), payload.begin(), payload.end());
|
||||
uint16_t crc = crc16(frame.data(), frame.size());
|
||||
frame.push_back(crc & 0xFF);
|
||||
frame.push_back(crc >> 8);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// Each test replaces App with a fresh one; the retransmit timer needs its scheduler
|
||||
class HdlcReliabilityTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
App.~Application();
|
||||
new (&App) Application();
|
||||
static char name[] = "hdlc-tests";
|
||||
static char friendly_name[] = "";
|
||||
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
|
||||
}
|
||||
void TearDown() override {
|
||||
App.~Application();
|
||||
new (&App) Application(); // later tests and the test listener use App
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(HdlcReliabilityTest, MaxPacketSizeAllowsForOverhead) {
|
||||
Peer a;
|
||||
EXPECT_EQ(a.hdlc->get_send_capacity(), 4);
|
||||
EXPECT_EQ(a.hdlc->get_max_packet_size(), 64 - HdlcReliability::FRAME_OVERHEAD);
|
||||
EXPECT_EQ(a.link.top(), a.hdlc.get());
|
||||
EXPECT_EQ(a.hdlc->get_capabilities(), CAPABILITY_RELIABILITY);
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, ResetHandshake) {
|
||||
Peer a, b;
|
||||
a.hdlc->loop();
|
||||
ASSERT_EQ(a.link.sent.size(), 1);
|
||||
EXPECT_EQ(a.link.sent[0], make_frame(0x2F, {})); // SABM
|
||||
pump(a, b);
|
||||
EXPECT_TRUE(a.hdlc->is_connected());
|
||||
EXPECT_TRUE(b.hdlc->is_connected());
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, RepeatedSabmBeforeNumberedFramesKeepsQueue) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
// A frame is queued and sent, but has not reached A, so A has sent no numbered frame
|
||||
ASSERT_TRUE(b.send({0x42}));
|
||||
auto held = std::move(b.link.sent);
|
||||
b.link.sent.clear();
|
||||
EXPECT_EQ(b.hdlc->get_send_capacity(), 3);
|
||||
|
||||
// A repeats its SABM because it did not hear the UA; that is not a restart
|
||||
b.link.inject(make_frame(0x2F, {})); // SABM
|
||||
EXPECT_TRUE(b.hdlc->is_connected());
|
||||
EXPECT_TRUE(b.states.empty()); // no session lost reported
|
||||
EXPECT_EQ(b.hdlc->get_send_capacity(), 3); // the queued frame was kept
|
||||
ASSERT_FALSE(b.link.sent.empty());
|
||||
EXPECT_EQ(b.link.sent[0], make_frame(0x63, {})); // UA, then the frame is sent again
|
||||
|
||||
// The frame is still delivered when it reaches A
|
||||
for (auto &frame : held)
|
||||
a.link.inject(frame);
|
||||
EXPECT_EQ(a.received, std::vector<Frame>{{0x42}});
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, SabmAfterNumberedFramesIsARestart) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
// B has heard a numbered frame, so a SABM now means A has started again
|
||||
ASSERT_TRUE(a.send({0x10}));
|
||||
pump(a, b);
|
||||
b.link.inject(make_frame(0x2F, {})); // SABM
|
||||
EXPECT_TRUE(b.hdlc->is_connected());
|
||||
EXPECT_EQ(b.states, std::vector<bool>{false});
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, QueuedBeforeConnectIsSentAfter) {
|
||||
Peer a, b;
|
||||
ASSERT_TRUE(a.send({0x01}));
|
||||
ASSERT_TRUE(a.send({0x02}));
|
||||
pump(a, b);
|
||||
EXPECT_EQ(b.received, (std::vector<Frame>{{0x01}, {0x02}}));
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, DeliversInOrderBothWays) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
a.send({0x10});
|
||||
b.send({0x20});
|
||||
a.send({0x11});
|
||||
pump(a, b);
|
||||
EXPECT_EQ(b.received, (std::vector<Frame>{{0x10}, {0x11}}));
|
||||
EXPECT_EQ(a.received, (std::vector<Frame>{{0x20}}));
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, SequenceNumbersWrap) {
|
||||
Peer a(3), b(3);
|
||||
pump(a, b);
|
||||
std::vector<Frame> expected;
|
||||
for (uint8_t i = 0; i != 20; i++) {
|
||||
expected.push_back({i, static_cast<uint8_t>(i * 3)});
|
||||
ASSERT_TRUE(a.send(expected.back()));
|
||||
if (i % 2 == 1)
|
||||
pump(a, b);
|
||||
}
|
||||
pump(a, b);
|
||||
EXPECT_EQ(b.received, expected);
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, GapRecoveredByReject) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
bool dropped = false;
|
||||
a.send({0x01});
|
||||
a.send({0x02});
|
||||
a.send({0x03});
|
||||
pump(a, b, [&dropped](const Frame &frame) {
|
||||
if (dropped || !is_info(frame))
|
||||
return false;
|
||||
dropped = true;
|
||||
return true;
|
||||
});
|
||||
EXPECT_EQ(b.received, (std::vector<Frame>{{0x01}, {0x02}, {0x03}}));
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, LossRecoveredByTimeout) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
a.send({0x42});
|
||||
pump(a, b, [](const Frame &frame) { return is_info(frame); });
|
||||
EXPECT_TRUE(b.received.empty());
|
||||
a.hdlc->on_timeout_();
|
||||
pump(a, b);
|
||||
EXPECT_EQ(b.received, (std::vector<Frame>{{0x42}}));
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, LostAckDoesNotDuplicate) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
a.send({0x42});
|
||||
pump(a, b, nullptr, [](const Frame &frame) { return is_ack(frame); });
|
||||
ASSERT_EQ(b.received.size(), 1);
|
||||
a.hdlc->on_timeout_();
|
||||
pump(a, b);
|
||||
EXPECT_EQ(b.received.size(), 1);
|
||||
// The resend was acknowledged, so the full queue is available again
|
||||
for (int i = 0; i != 4; i++)
|
||||
EXPECT_TRUE(a.send({0x01}));
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, QueueFull) {
|
||||
Peer a(2), b(2);
|
||||
pump(a, b);
|
||||
EXPECT_TRUE(a.send({0x01}));
|
||||
EXPECT_TRUE(a.send({0x02}));
|
||||
EXPECT_FALSE(a.send({0x03}));
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, OversizedPacketRejected) {
|
||||
Peer a;
|
||||
EXPECT_FALSE(a.send(Frame(a.hdlc->get_max_packet_size() + 1)));
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, CrcErrorDropped) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
Frame frame = make_frame(0x00, {0x55}); // I-frame N(S)=0 N(R)=0
|
||||
frame[1] ^= 0xFF;
|
||||
b.link.inject(frame);
|
||||
EXPECT_TRUE(b.received.empty());
|
||||
b.link.inject(make_frame(0x00, {0x55}));
|
||||
EXPECT_EQ(b.received, (std::vector<Frame>{{0x55}}));
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, RetriesExhaustedResetsLink) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
// One frame is delivered first, so b can tell a reset from a repeated SABM
|
||||
ASSERT_TRUE(a.send({0x00}));
|
||||
pump(a, b);
|
||||
ASSERT_EQ(b.received, std::vector<Frame>{{0x00}});
|
||||
a.send({0x01});
|
||||
pump(a, b, [](const Frame &frame) { return is_info(frame); });
|
||||
for (int i = 0; i != 4; i++) {
|
||||
a.hdlc->on_timeout_();
|
||||
pump(a, b, [](const Frame &frame) { return is_info(frame); });
|
||||
}
|
||||
// The unacknowledged frame was discarded and the link was reset, which both report as data lost
|
||||
EXPECT_EQ(b.received, std::vector<Frame>{{0x00}});
|
||||
EXPECT_TRUE(a.hdlc->is_connected());
|
||||
EXPECT_EQ(a.states, std::vector<bool>{false});
|
||||
EXPECT_EQ(b.states, std::vector<bool>{false});
|
||||
a.send({0x02});
|
||||
pump(a, b);
|
||||
EXPECT_EQ(b.received, (std::vector<Frame>{{0x00}, {0x02}}));
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, PeerRestart) {
|
||||
Peer a;
|
||||
auto b = std::make_unique<Peer>();
|
||||
pump(a, *b);
|
||||
a.send({0x01});
|
||||
pump(a, *b);
|
||||
// The peer restarts with fresh sequence numbers
|
||||
b = std::make_unique<Peer>();
|
||||
pump(a, *b);
|
||||
a.send({0x02});
|
||||
b->send({0x03});
|
||||
pump(a, *b);
|
||||
EXPECT_EQ(b->received, (std::vector<Frame>{{0x02}}));
|
||||
EXPECT_EQ(a.received, (std::vector<Frame>{{0x03}}));
|
||||
// The restarted peer's reset discarded what a had not yet had acknowledged
|
||||
EXPECT_EQ(a.states, std::vector<bool>{false});
|
||||
EXPECT_TRUE(b->states.empty());
|
||||
}
|
||||
|
||||
// Waits past the retry after a refused send, servicing timers as the main loop does
|
||||
static void run_send_retry() {
|
||||
const uint32_t start = millis();
|
||||
while (millis() - start < 60) {
|
||||
App.scheduler.call(millis());
|
||||
delay(1);
|
||||
}
|
||||
App.scheduler.call(millis());
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, RefusedFramesSentAgainWithoutTimeout) {
|
||||
Peer a, b;
|
||||
// Only the retry of a refused send can fire while the test runs, not a retransmit
|
||||
a.hdlc->set_ack_timeout(10000);
|
||||
b.hdlc->set_ack_timeout(10000);
|
||||
for (uint8_t i = 0; i != 3; i++)
|
||||
ASSERT_TRUE(a.send({i}));
|
||||
a.link.budget = 3; // SABM, UA to the peer's SABM, and the first queued frame
|
||||
pump(a, b);
|
||||
EXPECT_GE(a.link.refused, 1);
|
||||
EXPECT_EQ(b.received, (std::vector<Frame>{{0}}));
|
||||
a.link.budget = SIZE_MAX; // the lower layer accepts again, as when a radio stops receiving
|
||||
// The layer waits for its retry rather than running every loop
|
||||
pump(a, b);
|
||||
EXPECT_EQ(b.received, (std::vector<Frame>{{0}}));
|
||||
run_send_retry();
|
||||
pump(a, b);
|
||||
EXPECT_EQ(b.received, (std::vector<Frame>{{0}, {1}, {2}}));
|
||||
EXPECT_EQ(a.hdlc->outstanding_(), 0);
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, RefusedAckSentAgain) {
|
||||
Peer a, b;
|
||||
a.hdlc->set_ack_timeout(10000);
|
||||
b.hdlc->set_ack_timeout(10000);
|
||||
pump(a, b);
|
||||
a.send({1});
|
||||
for (auto &frame : a.link.sent)
|
||||
b.link.inject(frame);
|
||||
a.link.sent.clear();
|
||||
b.link.budget = 0;
|
||||
b.hdlc->loop();
|
||||
EXPECT_TRUE(b.link.sent.empty());
|
||||
b.link.budget = SIZE_MAX;
|
||||
pump(a, b);
|
||||
EXPECT_EQ(a.hdlc->outstanding_(), 1); // the RR waits for the retry
|
||||
run_send_retry();
|
||||
pump(a, b);
|
||||
EXPECT_EQ(a.hdlc->outstanding_(), 0);
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, AckDelayLetsReplyCarryAck) {
|
||||
Peer a, b;
|
||||
b.hdlc->set_ack_delay(100);
|
||||
pump(a, b);
|
||||
a.send({1});
|
||||
for (auto &frame : a.link.sent)
|
||||
b.link.inject(frame);
|
||||
a.link.sent.clear();
|
||||
b.hdlc->loop();
|
||||
EXPECT_TRUE(b.link.sent.empty()); // no RR yet
|
||||
b.send({2});
|
||||
ASSERT_EQ(b.link.sent.size(), 1);
|
||||
EXPECT_TRUE(is_info(b.link.sent[0]));
|
||||
EXPECT_EQ(b.link.sent[0][0] >> 5, 1); // N(R) acknowledges the frame from a
|
||||
b.link.sent.clear();
|
||||
b.hdlc->loop();
|
||||
EXPECT_TRUE(b.link.sent.empty()); // the RR is no longer owed
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, MaxRetriesOf255ResetsLink) {
|
||||
Peer a, b;
|
||||
a.hdlc->set_max_retries(255);
|
||||
pump(a, b);
|
||||
a.send({0x01});
|
||||
a.link.sent.clear();
|
||||
for (int i = 0; i != 255; i++) {
|
||||
a.hdlc->on_timeout_();
|
||||
a.link.sent.clear();
|
||||
}
|
||||
EXPECT_TRUE(a.states.empty());
|
||||
a.hdlc->on_timeout_();
|
||||
EXPECT_EQ(a.states, std::vector<bool>{false});
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, OversizedFrameDropped) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
// An I-frame, N(S) 0 and N(R) 0, one byte larger than this layer's packets
|
||||
b.link.inject(make_frame(0x00, Frame(b.hdlc->get_max_packet_size() + 1, 0x55)));
|
||||
EXPECT_TRUE(b.received.empty());
|
||||
b.link.inject(make_frame(0x00, Frame(b.hdlc->get_max_packet_size(), 0x55)));
|
||||
EXPECT_EQ(b.received.size(), 1);
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, LinkTooSmallFailsSetup) {
|
||||
MockLink link(HdlcReliability::FRAME_OVERHEAD);
|
||||
TestHdlc hdlc(&link);
|
||||
EXPECT_EQ(hdlc.get_max_packet_size(), 0);
|
||||
hdlc.setup();
|
||||
EXPECT_TRUE(hdlc.is_failed());
|
||||
Frame packet{1};
|
||||
EXPECT_FALSE(hdlc.send_to_interface(PacketBuffer(packet), {}));
|
||||
EXPECT_EQ(hdlc.get_send_capacity(), 0);
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, AckDelayTimerSendsAck) {
|
||||
Peer a, b;
|
||||
b.hdlc->set_ack_delay(5);
|
||||
pump(a, b);
|
||||
a.send({1});
|
||||
for (auto &frame : a.link.sent)
|
||||
b.link.inject(frame);
|
||||
a.link.sent.clear();
|
||||
b.hdlc->loop();
|
||||
EXPECT_TRUE(b.link.sent.empty());
|
||||
delay(10);
|
||||
App.scheduler.call(millis());
|
||||
b.hdlc->loop();
|
||||
ASSERT_EQ(b.link.sent.size(), 1);
|
||||
EXPECT_TRUE(is_ack(b.link.sent[0]));
|
||||
EXPECT_EQ(b.link.sent[0][0] >> 5, 1); // N(R) acknowledges the frame from a
|
||||
}
|
||||
|
||||
TEST_F(HdlcReliabilityTest, RefusedRejectFallsBackToAck) {
|
||||
Peer a, b;
|
||||
pump(a, b);
|
||||
a.send({1});
|
||||
a.send({2});
|
||||
ASSERT_EQ(a.link.sent.size(), 2);
|
||||
// The first frame is lost, and the REJ for the gap is refused
|
||||
b.link.budget = 0;
|
||||
b.link.inject(a.link.sent[1]);
|
||||
a.link.sent.clear();
|
||||
EXPECT_EQ(b.link.refused, 1);
|
||||
b.link.budget = SIZE_MAX;
|
||||
b.hdlc->loop();
|
||||
ASSERT_EQ(b.link.sent.size(), 1);
|
||||
EXPECT_TRUE(is_ack(b.link.sent[0]));
|
||||
EXPECT_EQ(b.link.sent[0][0] >> 5, 0);
|
||||
a.hdlc->on_timeout_();
|
||||
pump(a, b);
|
||||
EXPECT_EQ(b.received, (std::vector<Frame>{{1}, {2}}));
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface::testing
|
||||
@@ -0,0 +1,61 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include "esphome/components/packet_interface/listen_before_talk.h"
|
||||
|
||||
namespace esphome::packet_interface::lbt_testing {
|
||||
|
||||
static constexpr uint32_t START = 10000;
|
||||
static constexpr uint32_t MAX_BUSY = ListenBeforeTalk::DEFAULT_MAX_BUSY_MS;
|
||||
|
||||
TEST(ListenBeforeTalkTest, SendsWhenNotBusy) {
|
||||
ListenBeforeTalk lbt;
|
||||
EXPECT_TRUE(lbt.may_send(false, START));
|
||||
}
|
||||
|
||||
TEST(ListenBeforeTalkTest, RefusesWhileBusyUpToLimit) {
|
||||
ListenBeforeTalk lbt;
|
||||
EXPECT_FALSE(lbt.may_send(true, START));
|
||||
// Refused every 50 ms, as the reliability layer retries
|
||||
uint32_t now = START;
|
||||
while (now - START < MAX_BUSY - 50) {
|
||||
now += 50;
|
||||
EXPECT_FALSE(lbt.may_send(true, now));
|
||||
}
|
||||
now += 50;
|
||||
EXPECT_TRUE(lbt.may_send(true, now));
|
||||
EXPECT_EQ(lbt.busy_for(now), MAX_BUSY);
|
||||
}
|
||||
|
||||
TEST(ListenBeforeTalkTest, BusyFlagThatNeverClearsReleasedForSlowSends) {
|
||||
ListenBeforeTalk lbt;
|
||||
// A send every 10 s, as session keep-alives, always finding the radio busy
|
||||
EXPECT_FALSE(lbt.may_send(true, START));
|
||||
EXPECT_TRUE(lbt.may_send(true, START + 10000));
|
||||
}
|
||||
|
||||
TEST(ListenBeforeTalkTest, FreeRadioEndsBusyPeriod) {
|
||||
ListenBeforeTalk lbt;
|
||||
EXPECT_FALSE(lbt.may_send(true, START));
|
||||
EXPECT_TRUE(lbt.may_send(false, START + 1500));
|
||||
// A new busy period starts, so the earlier one does not count
|
||||
EXPECT_FALSE(lbt.may_send(true, START + MAX_BUSY + 100));
|
||||
EXPECT_EQ(lbt.busy_for(START + MAX_BUSY + 100), 0);
|
||||
}
|
||||
|
||||
TEST(ListenBeforeTalkTest, EndBusyStartsNewPeriod) {
|
||||
ListenBeforeTalk lbt;
|
||||
EXPECT_FALSE(lbt.may_send(true, START));
|
||||
// The link saw the radio free between sends, so a later reception is a new busy period
|
||||
lbt.end_busy();
|
||||
EXPECT_FALSE(lbt.may_send(true, START + MAX_BUSY + 100));
|
||||
EXPECT_EQ(lbt.busy_for(START + MAX_BUSY + 100), 0);
|
||||
}
|
||||
|
||||
TEST(ListenBeforeTalkTest, LimitCanBeLonger) {
|
||||
ListenBeforeTalk lbt;
|
||||
lbt.set_max_busy(8000);
|
||||
EXPECT_FALSE(lbt.may_send(true, START));
|
||||
EXPECT_FALSE(lbt.may_send(true, START + 7999));
|
||||
EXPECT_TRUE(lbt.may_send(true, START + 8000));
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface::lbt_testing
|
||||
@@ -0,0 +1,238 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include "esphome/components/packet_interface/packet_buffer.h"
|
||||
|
||||
namespace esphome::packet_interface::testing {
|
||||
|
||||
// Copies a packet into a vector, for comparing in tests
|
||||
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
|
||||
std::vector<uint8_t> result(data.size());
|
||||
data.copy_to(result.data(), result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
// Test default constructor
|
||||
TEST(PacketBufferTest, DefaultConstructor) {
|
||||
PacketBuffer buffer;
|
||||
EXPECT_EQ(buffer.size(), 0);
|
||||
EXPECT_TRUE(buffer.empty());
|
||||
}
|
||||
|
||||
// Test single buffer constructor
|
||||
TEST(PacketBufferTest, SingleBufferConstructor) {
|
||||
uint8_t data[] = {0x01, 0x02, 0x03, 0x04};
|
||||
PacketBuffer buffer(data, 4);
|
||||
EXPECT_EQ(buffer.size(), 4);
|
||||
EXPECT_FALSE(buffer.empty());
|
||||
EXPECT_EQ(buffer[0], 0x01);
|
||||
EXPECT_EQ(buffer[3], 0x04);
|
||||
}
|
||||
|
||||
// Test vector constructor
|
||||
TEST(PacketBufferTest, VectorConstructor) {
|
||||
std::vector<uint8_t> vec = {0x10, 0x20, 0x30};
|
||||
PacketBuffer buffer(vec);
|
||||
EXPECT_EQ(buffer.size(), 3);
|
||||
EXPECT_EQ(buffer[0], 0x10);
|
||||
EXPECT_EQ(buffer[1], 0x20);
|
||||
EXPECT_EQ(buffer[2], 0x30);
|
||||
}
|
||||
|
||||
// Test pre and post buffer constructor
|
||||
TEST(PacketBufferTest, PrePostConstructor) {
|
||||
uint8_t pre[] = {0x01, 0x02};
|
||||
uint8_t post[] = {0x03, 0x04, 0x05};
|
||||
PacketBuffer buffer(pre, 2, post, 3);
|
||||
EXPECT_EQ(buffer.size(), 5);
|
||||
EXPECT_EQ(buffer[0], 0x01);
|
||||
EXPECT_EQ(buffer[1], 0x02);
|
||||
EXPECT_EQ(buffer[2], 0x03);
|
||||
EXPECT_EQ(buffer[3], 0x04);
|
||||
EXPECT_EQ(buffer[4], 0x05);
|
||||
}
|
||||
|
||||
// Test pre, mid, and post buffer constructor
|
||||
TEST(PacketBufferTest, PreMidPostConstructor) {
|
||||
uint8_t pre[] = {0x01};
|
||||
std::vector<uint8_t> mid_vec = {0x02, 0x03, 0x04};
|
||||
PacketBuffer mid_buffer(mid_vec);
|
||||
uint8_t post[] = {0x05, 0x06};
|
||||
|
||||
PacketBuffer buffer(pre, 1, &mid_buffer, post, 2);
|
||||
EXPECT_EQ(buffer.size(), 6);
|
||||
EXPECT_EQ(buffer[0], 0x01);
|
||||
EXPECT_EQ(buffer[1], 0x02);
|
||||
EXPECT_EQ(buffer[2], 0x03);
|
||||
EXPECT_EQ(buffer[3], 0x04);
|
||||
EXPECT_EQ(buffer[4], 0x05);
|
||||
EXPECT_EQ(buffer[5], 0x06);
|
||||
}
|
||||
|
||||
// Test nested mid buffers
|
||||
TEST(PacketBufferTest, NestedMidBuffers) {
|
||||
uint8_t data1[] = {0x01};
|
||||
uint8_t data2[] = {0x02, 0x03};
|
||||
uint8_t data3[] = {0x04};
|
||||
|
||||
PacketBuffer inner(data2, 2);
|
||||
PacketBuffer middle(data1, 1, &inner, data3, 1);
|
||||
|
||||
uint8_t pre[] = {0x00};
|
||||
uint8_t post[] = {0x05};
|
||||
PacketBuffer outer(pre, 1, &middle, post, 1);
|
||||
|
||||
EXPECT_EQ(outer.size(), 6);
|
||||
EXPECT_EQ(outer[0], 0x00);
|
||||
EXPECT_EQ(outer[1], 0x01);
|
||||
EXPECT_EQ(outer[2], 0x02);
|
||||
EXPECT_EQ(outer[3], 0x03);
|
||||
EXPECT_EQ(outer[4], 0x04);
|
||||
EXPECT_EQ(outer[5], 0x05);
|
||||
}
|
||||
|
||||
// Test indexing operator
|
||||
TEST(PacketBufferTest, IndexingOperator) {
|
||||
uint8_t data[] = {0xAA, 0xBB, 0xCC, 0xDD};
|
||||
PacketBuffer buffer(data, 4);
|
||||
EXPECT_EQ(buffer[0], 0xAA);
|
||||
EXPECT_EQ(buffer[1], 0xBB);
|
||||
EXPECT_EQ(buffer[2], 0xCC);
|
||||
EXPECT_EQ(buffer[3], 0xDD);
|
||||
}
|
||||
|
||||
// Test copy_to method
|
||||
TEST(PacketBufferTest, CopyTo) {
|
||||
uint8_t data[] = {0x01, 0x02, 0x03, 0x04, 0x05};
|
||||
PacketBuffer buffer(data, 5);
|
||||
|
||||
uint8_t dest[5] = {0};
|
||||
size_t copied = buffer.copy_to(dest, 5);
|
||||
|
||||
EXPECT_EQ(copied, 5);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
EXPECT_EQ(dest[i], data[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Test copy_to with offset
|
||||
TEST(PacketBufferTest, CopyToWithOffset) {
|
||||
uint8_t data[] = {0x01, 0x02, 0x03, 0x04, 0x05};
|
||||
PacketBuffer buffer(data, 5);
|
||||
|
||||
uint8_t dest[3] = {0};
|
||||
size_t copied = buffer.copy_to(dest, 3, 2); // Copy 3 bytes starting from offset 2
|
||||
|
||||
EXPECT_EQ(copied, 3);
|
||||
EXPECT_EQ(dest[0], 0x03);
|
||||
EXPECT_EQ(dest[1], 0x04);
|
||||
EXPECT_EQ(dest[2], 0x05);
|
||||
}
|
||||
|
||||
// Test copy_to with partial copy
|
||||
TEST(PacketBufferTest, CopyToPartial) {
|
||||
uint8_t data[] = {0x01, 0x02, 0x03};
|
||||
PacketBuffer buffer(data, 3);
|
||||
|
||||
uint8_t dest[5] = {0};
|
||||
size_t copied = buffer.copy_to(dest, 5); // Request more than available
|
||||
|
||||
EXPECT_EQ(copied, 3); // Only 3 bytes copied
|
||||
}
|
||||
|
||||
// Test copy_to from composite buffer
|
||||
TEST(PacketBufferTest, CopyToComposite) {
|
||||
uint8_t pre[] = {0x01, 0x02};
|
||||
uint8_t mid[] = {0x03, 0x04, 0x05};
|
||||
uint8_t post[] = {0x06};
|
||||
|
||||
PacketBuffer mid_buffer(mid, 3);
|
||||
PacketBuffer buffer(pre, 2, &mid_buffer, post, 1);
|
||||
|
||||
uint8_t dest[6] = {0};
|
||||
size_t copied = buffer.copy_to(dest, 6);
|
||||
|
||||
EXPECT_EQ(copied, 6);
|
||||
EXPECT_EQ(dest[0], 0x01);
|
||||
EXPECT_EQ(dest[1], 0x02);
|
||||
EXPECT_EQ(dest[2], 0x03);
|
||||
EXPECT_EQ(dest[3], 0x04);
|
||||
EXPECT_EQ(dest[4], 0x05);
|
||||
EXPECT_EQ(dest[5], 0x06);
|
||||
}
|
||||
|
||||
// Test copying from an offset past the mid buffer
|
||||
TEST(PacketBufferTest, CopyToOffsetPastMid) {
|
||||
uint8_t pre[] = {0x01, 0x02};
|
||||
uint8_t mid_data[] = {0x03};
|
||||
uint8_t post[] = {0x04, 0x05, 0x06};
|
||||
PacketBuffer mid(mid_data, 1);
|
||||
PacketBuffer buffer(pre, 2, &mid, post, 3);
|
||||
|
||||
uint8_t dest[4]{};
|
||||
ASSERT_EQ(buffer.copy_to(dest, 4, 4), 2);
|
||||
EXPECT_EQ(dest[0], 0x05);
|
||||
EXPECT_EQ(dest[1], 0x06);
|
||||
}
|
||||
|
||||
// Test single span access
|
||||
TEST(PacketBufferTest, ContiguousSpan) {
|
||||
uint8_t data[] = {0x10, 0x20, 0x30, 0x40};
|
||||
PacketBuffer buffer(data, 4);
|
||||
|
||||
ASSERT_TRUE(buffer.is_contiguous());
|
||||
auto span = buffer.as_span();
|
||||
EXPECT_EQ(span.data(), data);
|
||||
EXPECT_EQ(span.size(), 4);
|
||||
|
||||
uint8_t post[] = {0x50};
|
||||
PacketBuffer split(data, 4, post, 1);
|
||||
EXPECT_FALSE(split.is_contiguous());
|
||||
|
||||
PacketBuffer nested(nullptr, 0, &buffer, nullptr, 0);
|
||||
EXPECT_FALSE(nested.is_contiguous());
|
||||
}
|
||||
|
||||
// Test indexing past the end
|
||||
TEST(PacketBufferTest, IndexPastEnd) {
|
||||
uint8_t pre[] = {0x01};
|
||||
uint8_t post[] = {0x02};
|
||||
PacketBuffer buffer(pre, 1, post, 1);
|
||||
EXPECT_EQ(buffer[1], 0x02);
|
||||
EXPECT_EQ(buffer[2], 0);
|
||||
EXPECT_EQ(PacketBuffer()[0], 0);
|
||||
}
|
||||
|
||||
// Test empty buffer operations
|
||||
TEST(PacketBufferTest, EmptyBuffer) {
|
||||
PacketBuffer buffer;
|
||||
|
||||
EXPECT_TRUE(buffer.empty());
|
||||
EXPECT_EQ(buffer.size(), 0);
|
||||
|
||||
uint8_t dest[10] = {0xFF};
|
||||
size_t copied = buffer.copy_to(dest, 10);
|
||||
EXPECT_EQ(copied, 0);
|
||||
EXPECT_EQ(dest[0], 0xFF); // Unchanged
|
||||
|
||||
std::vector<uint8_t> vec = to_vector(buffer);
|
||||
EXPECT_EQ(vec.size(), 0);
|
||||
}
|
||||
|
||||
// Test large buffer
|
||||
TEST(PacketBufferTest, LargeBuffer) {
|
||||
std::vector<uint8_t> large_vec(1000);
|
||||
for (size_t i = 0; i < large_vec.size(); i++) {
|
||||
large_vec[i] = static_cast<uint8_t>(i % 256);
|
||||
}
|
||||
|
||||
PacketBuffer buffer(large_vec);
|
||||
|
||||
EXPECT_EQ(buffer.size(), 1000);
|
||||
EXPECT_EQ(buffer[0], 0);
|
||||
EXPECT_EQ(buffer[255], 255);
|
||||
EXPECT_EQ(buffer[256], 0);
|
||||
EXPECT_EQ(buffer[999], 231); // 999 % 256 = 231
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface::testing
|
||||
@@ -0,0 +1,92 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include "esphome/components/packet_interface/packet_transport/packet_interface_transport.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::packet_interface::transport_testing {
|
||||
|
||||
using Packet = std::vector<uint8_t>;
|
||||
|
||||
// A link layer that records what is sent, and passes injected packets to its listeners
|
||||
class FakeInterface : public PacketInterface {
|
||||
public:
|
||||
explicit FakeInterface(size_t max_packet_size = 512) : PacketInterface(max_packet_size) {}
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
|
||||
Packet packet(data.size());
|
||||
data.copy_to(packet.data(), packet.size());
|
||||
this->sent.push_back(packet);
|
||||
return true;
|
||||
}
|
||||
void deliver(const PacketBuffer &data) { this->on_receive_from_interface_(data, {}); }
|
||||
|
||||
std::vector<Packet> sent;
|
||||
};
|
||||
|
||||
// A layer stacked on a link, which the bridge must use in place of the link
|
||||
class FakeLayer : public FakeInterface {
|
||||
public:
|
||||
explicit FakeLayer(PacketInterface *lower) : FakeInterface(lower->get_max_packet_size()) {
|
||||
this->stack_on_(lower, CAPABILITY_ENCRYPTION);
|
||||
}
|
||||
};
|
||||
|
||||
class TestBridge : public PacketInterfaceTransport {
|
||||
public:
|
||||
using PacketInterfaceTransport::PacketInterfaceTransport;
|
||||
using PacketInterfaceTransport::send_packet;
|
||||
using packet_transport::PacketTransport::ping_keys_;
|
||||
};
|
||||
|
||||
// A packet_transport ping packet; with an encryption key set, process_() stores the sender's key
|
||||
static Packet ping_packet(const char *hostname, uint32_t key) {
|
||||
Packet packet{0x48, 0x50, static_cast<uint8_t>(strlen(hostname))};
|
||||
packet.insert(packet.end(), hostname, hostname + strlen(hostname));
|
||||
for (int shift = 0; shift != 32; shift += 8)
|
||||
packet.push_back(static_cast<uint8_t>(key >> shift));
|
||||
return packet;
|
||||
}
|
||||
|
||||
// Each test replaces App with a fresh one, which the transport's setup() reads the device name from
|
||||
class PacketInterfaceTransportTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
App.~Application();
|
||||
new (&App) Application();
|
||||
static char name[] = "bridge-tests";
|
||||
static char friendly_name[] = "";
|
||||
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
|
||||
this->bridge_.set_encryption_key(std::vector<uint8_t>(32, 0xAA));
|
||||
this->bridge_.setup();
|
||||
}
|
||||
void TearDown() override {
|
||||
App.~Application();
|
||||
new (&App) Application(); // later tests and the test listener use App
|
||||
}
|
||||
|
||||
FakeInterface link_;
|
||||
FakeLayer layer_{&this->link_};
|
||||
TestBridge bridge_{&this->link_};
|
||||
};
|
||||
|
||||
TEST_F(PacketInterfaceTransportTest, SendsThroughTopOfStack) {
|
||||
this->bridge_.send_packet({1, 2, 3});
|
||||
EXPECT_EQ(this->layer_.sent, (std::vector<Packet>{{1, 2, 3}}));
|
||||
EXPECT_TRUE(this->link_.sent.empty());
|
||||
}
|
||||
|
||||
TEST_F(PacketInterfaceTransportTest, PacketInOneBufferReachesTransport) {
|
||||
Packet ping = ping_packet("requester", 0xDEADBEEF);
|
||||
this->layer_.deliver(PacketBuffer(ping));
|
||||
ASSERT_EQ(this->bridge_.ping_keys_.size(), 1u);
|
||||
EXPECT_EQ(this->bridge_.ping_keys_["requester"], 0xDEADBEEFu);
|
||||
}
|
||||
|
||||
TEST_F(PacketInterfaceTransportTest, PacketSplitAcrossBuffersDropped) {
|
||||
Packet ping = ping_packet("requester", 0xDEADBEEF);
|
||||
this->layer_.deliver(PacketBuffer(ping.data(), 4, ping.data() + 4, ping.size() - 4));
|
||||
EXPECT_TRUE(this->bridge_.ping_keys_.empty());
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface::transport_testing
|
||||
@@ -0,0 +1,132 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
#include "esphome/components/packet_interface/radio_link.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::packet_interface::radio_link_testing {
|
||||
|
||||
using Packet = std::vector<uint8_t>;
|
||||
|
||||
static constexpr uint32_t START = 10000;
|
||||
static constexpr uint32_t MAX_BUSY = ListenBeforeTalk::DEFAULT_MAX_BUSY_MS;
|
||||
|
||||
class FakeListener {
|
||||
public:
|
||||
virtual void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) = 0;
|
||||
};
|
||||
|
||||
enum class FakeError { NONE, BUSY };
|
||||
|
||||
class FakeRadio : public Component {
|
||||
public:
|
||||
bool is_receiving() { return this->receiving; }
|
||||
void register_listener(FakeListener *listener) { this->listener = listener; }
|
||||
void fail() { this->mark_failed(); }
|
||||
FakeError transmit_packet(const uint8_t *data, size_t length) {
|
||||
this->sent.emplace_back(data, data + length);
|
||||
return FakeError::NONE;
|
||||
}
|
||||
|
||||
FakeListener *listener{nullptr};
|
||||
std::vector<Packet> sent;
|
||||
bool receiving{false};
|
||||
};
|
||||
|
||||
using TestLink = RadioLink<FakeRadio, FakeListener, FakeError, 16>;
|
||||
|
||||
static void set_time(uint32_t now) { LoopBlockingGuard guard{nullptr, nullptr, now}; }
|
||||
|
||||
class RadioLinkTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->radio_.call();
|
||||
this->link_.call();
|
||||
}
|
||||
bool send_() {
|
||||
static const Packet PACKET{1, 2, 3};
|
||||
return this->link_.send_to_interface(PacketBuffer(PACKET), {});
|
||||
}
|
||||
|
||||
FakeRadio radio_;
|
||||
TestLink link_{&radio_, 16};
|
||||
};
|
||||
|
||||
TEST_F(RadioLinkTest, LoopRunsOnlyWhileBusy) {
|
||||
EXPECT_EQ(this->radio_.listener, &this->link_);
|
||||
EXPECT_TRUE(this->link_.is_idle());
|
||||
set_time(START);
|
||||
this->radio_.receiving = true;
|
||||
EXPECT_FALSE(this->send_());
|
||||
EXPECT_TRUE(this->link_.is_in_loop_state());
|
||||
this->link_.loop();
|
||||
EXPECT_TRUE(this->link_.is_in_loop_state());
|
||||
this->radio_.receiving = false;
|
||||
this->link_.loop();
|
||||
EXPECT_TRUE(this->link_.is_idle());
|
||||
EXPECT_TRUE(this->radio_.sent.empty());
|
||||
}
|
||||
|
||||
TEST_F(RadioLinkTest, ReceptionAfterStalePeriodIsNotSentOver) {
|
||||
set_time(START);
|
||||
this->radio_.receiving = true;
|
||||
EXPECT_FALSE(this->send_());
|
||||
// The reception ends with no send to see it
|
||||
this->radio_.receiving = false;
|
||||
this->link_.loop();
|
||||
// A new reception, longer after the first than the limit, still holds the send back
|
||||
set_time(START + MAX_BUSY + 100);
|
||||
this->radio_.receiving = true;
|
||||
EXPECT_FALSE(this->send_());
|
||||
EXPECT_TRUE(this->radio_.sent.empty());
|
||||
}
|
||||
|
||||
TEST_F(RadioLinkTest, BusyFlagThatNeverClearsIsSentOver) {
|
||||
set_time(START);
|
||||
this->radio_.receiving = true;
|
||||
EXPECT_FALSE(this->send_());
|
||||
this->link_.loop();
|
||||
set_time(START + MAX_BUSY);
|
||||
EXPECT_TRUE(this->send_());
|
||||
ASSERT_EQ(this->radio_.sent.size(), 1u);
|
||||
EXPECT_EQ(this->radio_.sent[0], (Packet{1, 2, 3}));
|
||||
}
|
||||
|
||||
TEST_F(RadioLinkTest, EmptyPacketCountsAsSentWithoutTransmitting) {
|
||||
static const Packet EMPTY;
|
||||
EXPECT_TRUE(this->link_.send_to_interface(PacketBuffer(EMPTY), {}));
|
||||
EXPECT_TRUE(this->radio_.sent.empty());
|
||||
}
|
||||
|
||||
TEST_F(RadioLinkTest, FailedRadioRefusesSends) {
|
||||
this->radio_.fail();
|
||||
EXPECT_FALSE(this->send_());
|
||||
EXPECT_FALSE(this->send_());
|
||||
EXPECT_TRUE(this->radio_.sent.empty());
|
||||
}
|
||||
|
||||
TEST_F(RadioLinkTest, RadioFailingWhileBusyEndsBusyPeriod) {
|
||||
set_time(START);
|
||||
this->radio_.receiving = true;
|
||||
EXPECT_FALSE(this->send_());
|
||||
EXPECT_TRUE(this->link_.is_in_loop_state());
|
||||
this->radio_.fail();
|
||||
this->link_.loop();
|
||||
EXPECT_TRUE(this->link_.is_idle());
|
||||
}
|
||||
|
||||
TEST_F(RadioLinkTest, ReceivedPacketCarriesSignal) {
|
||||
Packet received;
|
||||
PacketMetaData meta;
|
||||
this->link_.add_packet_interface_listener([&](const PacketBuffer &data, const PacketMetaData &meta_data) {
|
||||
received.resize(data.size());
|
||||
data.copy_to(received.data(), received.size());
|
||||
meta = meta_data;
|
||||
});
|
||||
this->link_.on_packet({4, 5}, -80.0f, 7.5f);
|
||||
EXPECT_EQ(received, (Packet{4, 5}));
|
||||
EXPECT_FLOAT_EQ(meta.rssi, -80.0f);
|
||||
EXPECT_FLOAT_EQ(meta.snr, 7.5f);
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface::radio_link_testing
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
packet_interface: !include common.yaml
|
||||
@@ -0,0 +1,282 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include "esphome/components/packet_interface/xxtea_encryption.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::packet_interface::encryption_testing {
|
||||
|
||||
// Copies a packet into a vector, for comparing in tests
|
||||
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
|
||||
std::vector<uint8_t> result(data.size());
|
||||
data.copy_to(result.data(), result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
using Packet = std::vector<uint8_t>;
|
||||
|
||||
// A link layer that records what is sent, so a test can deliver it to the peer
|
||||
class MockLink : public PacketInterface {
|
||||
public:
|
||||
explicit MockLink(size_t max_packet_size = 64) : PacketInterface(max_packet_size) {}
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
|
||||
this->sent.push_back(to_vector(data));
|
||||
return true;
|
||||
}
|
||||
void inject(const Packet &packet) { this->on_receive_from_interface_(PacketBuffer(packet), {}); }
|
||||
|
||||
std::vector<Packet> sent;
|
||||
};
|
||||
|
||||
static constexpr size_t ROLLING_TRAILER_SIZE = 16;
|
||||
static constexpr size_t TRAILER_SIZE = 4;
|
||||
|
||||
class TestEncryption : public XxteaEncryption {
|
||||
public:
|
||||
using XxteaEncryption::XxteaEncryption;
|
||||
uint32_t get_boot() const { return this->boot_; }
|
||||
void set_counter(uint32_t counter) { this->counter_ = counter; }
|
||||
};
|
||||
|
||||
struct Peer {
|
||||
explicit Peer(uint32_t key_word = 0x12345678, size_t max_packet_size = 64, bool rolling_code = true)
|
||||
: link(max_packet_size) {
|
||||
this->link.set_name("test_link");
|
||||
this->encryption = std::make_unique<TestEncryption>(&this->link, rolling_code);
|
||||
this->encryption->set_key({key_word, 2, 3, 4, 5, 6, 7, 8});
|
||||
this->encryption->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); });
|
||||
this->encryption->setup();
|
||||
}
|
||||
bool send(const Packet &data) { return this->encryption->send_to_interface(PacketBuffer(data), {}); }
|
||||
// Deliver everything sent so far to the peer
|
||||
void deliver_to(Peer &peer) {
|
||||
for (auto &packet : this->link.sent)
|
||||
peer.link.inject(packet);
|
||||
this->link.sent.clear();
|
||||
}
|
||||
// Exchange a packet each way so that each peer echoes the other's boot number
|
||||
void introduce(Peer &peer) {
|
||||
this->send({});
|
||||
this->deliver_to(peer);
|
||||
peer.send({});
|
||||
peer.deliver_to(*this);
|
||||
this->send({});
|
||||
this->deliver_to(peer);
|
||||
this->received.clear();
|
||||
peer.received.clear();
|
||||
}
|
||||
|
||||
MockLink link;
|
||||
std::unique_ptr<TestEncryption> encryption;
|
||||
std::vector<Packet> received;
|
||||
};
|
||||
|
||||
// Each test replaces App with a fresh one
|
||||
class XxteaEncryptionTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
App.~Application();
|
||||
new (&App) Application();
|
||||
static char name[] = "xxtea-tests";
|
||||
static char friendly_name[] = "";
|
||||
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
|
||||
}
|
||||
void TearDown() override {
|
||||
App.~Application();
|
||||
new (&App) Application(); // later tests and the test listener use App
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(XxteaEncryptionTest, MaxPacketSizeAllowsForTrailerAndWords) {
|
||||
Peer a;
|
||||
EXPECT_EQ(a.encryption->get_max_packet_size(), 64 - ROLLING_TRAILER_SIZE);
|
||||
EXPECT_EQ(a.link.top(), a.encryption.get());
|
||||
EXPECT_TRUE(a.encryption->has_capability(CAPABILITY_ENCRYPTION));
|
||||
EXPECT_FALSE(a.encryption->has_capability(CAPABILITY_SESSION));
|
||||
Peer b(0x12345678, 63);
|
||||
EXPECT_EQ(b.encryption->get_max_packet_size(), 60 - ROLLING_TRAILER_SIZE);
|
||||
Peer c(0x12345678, 64, false);
|
||||
EXPECT_EQ(c.encryption->get_max_packet_size(), 64 - TRAILER_SIZE);
|
||||
}
|
||||
|
||||
static void round_trip_all_sizes(bool rolling_code, size_t trailer_size) {
|
||||
Peer a(0x12345678, 64, rolling_code), b(0x12345678, 64, rolling_code);
|
||||
a.introduce(b);
|
||||
std::vector<Packet> expected;
|
||||
for (size_t size = 0; size <= a.encryption->get_max_packet_size(); size++) {
|
||||
Packet packet(size);
|
||||
for (size_t i = 0; i != size; i++)
|
||||
packet[i] = static_cast<uint8_t>(size + i);
|
||||
expected.push_back(packet);
|
||||
ASSERT_TRUE(a.send(packet));
|
||||
ASSERT_EQ(a.link.sent.back().size() % 4, 0);
|
||||
ASSERT_GE(a.link.sent.back().size(), std::max<size_t>(8, size + trailer_size));
|
||||
}
|
||||
a.deliver_to(b);
|
||||
EXPECT_EQ(b.received, expected);
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, RoundTripAllSizes) { round_trip_all_sizes(true, ROLLING_TRAILER_SIZE); }
|
||||
|
||||
TEST_F(XxteaEncryptionTest, RoundTripAllSizesWithoutRollingCode) { round_trip_all_sizes(false, TRAILER_SIZE); }
|
||||
|
||||
TEST_F(XxteaEncryptionTest, WithoutRollingCodeReplayAccepted) {
|
||||
Peer a(0x12345678, 64, false), b(0x12345678, 64, false);
|
||||
a.send({1});
|
||||
b.link.inject(a.link.sent[0]);
|
||||
b.link.inject(a.link.sent[0]);
|
||||
EXPECT_EQ(b.received, (std::vector<Packet>{{1}, {1}}));
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, RollingCodeSettingMustMatch) {
|
||||
Peer a(0x12345678, 64, true), b(0x12345678, 64, false);
|
||||
a.send({1, 2, 3, 4, 5, 6, 7, 8});
|
||||
b.send({1, 2, 3, 4, 5, 6, 7, 8});
|
||||
a.deliver_to(b);
|
||||
b.deliver_to(a);
|
||||
EXPECT_TRUE(a.received.empty());
|
||||
EXPECT_TRUE(b.received.empty());
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, SamePacketEncryptsDifferently) {
|
||||
Peer a;
|
||||
a.send({1, 2, 3, 4});
|
||||
a.send({1, 2, 3, 4});
|
||||
ASSERT_EQ(a.link.sent.size(), 2);
|
||||
EXPECT_NE(a.link.sent[0], a.link.sent[1]);
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, OversizedPacketRefused) {
|
||||
Peer a;
|
||||
EXPECT_FALSE(a.send(Packet(a.encryption->get_max_packet_size() + 1)));
|
||||
EXPECT_TRUE(a.link.sent.empty());
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, WrongKeyDropped) {
|
||||
Peer a, b(0x87654321);
|
||||
a.send({1, 2, 3});
|
||||
a.deliver_to(b);
|
||||
EXPECT_TRUE(b.received.empty());
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, ChangedPacketDropped) {
|
||||
Peer a, b;
|
||||
a.send({1, 2, 3});
|
||||
a.link.sent[0][2] ^= 0x01;
|
||||
a.deliver_to(b);
|
||||
EXPECT_TRUE(b.received.empty());
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, BadLengthDropped) {
|
||||
Peer a, b;
|
||||
a.send({1, 2, 3});
|
||||
Packet packet = a.link.sent[0];
|
||||
b.link.inject(Packet(packet.begin(), packet.end() - 1));
|
||||
b.link.inject(Packet(8));
|
||||
EXPECT_TRUE(b.received.empty());
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, ReplayDropped) {
|
||||
Peer a, b;
|
||||
a.introduce(b);
|
||||
a.send({1});
|
||||
Packet first = a.link.sent[0];
|
||||
a.send({2});
|
||||
a.deliver_to(b);
|
||||
b.link.inject(first);
|
||||
EXPECT_EQ(b.received, (std::vector<Packet>{{1}, {2}}));
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, OlderPacketDropped) {
|
||||
Peer a, b;
|
||||
a.introduce(b);
|
||||
a.send({1});
|
||||
a.send({2});
|
||||
b.link.inject(a.link.sent[1]);
|
||||
b.link.inject(a.link.sent[0]);
|
||||
EXPECT_EQ(b.received, (std::vector<Packet>{{2}}));
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, BootNumberIncreasesAndPeerRestartAccepted) {
|
||||
Peer b;
|
||||
auto a = std::make_unique<Peer>();
|
||||
uint32_t boot = a->encryption->get_boot();
|
||||
a->introduce(b);
|
||||
a->send({1});
|
||||
a->send({2});
|
||||
a->deliver_to(b);
|
||||
EXPECT_EQ(b.received, (std::vector<Packet>{{1}, {2}}));
|
||||
a = std::make_unique<Peer>(); // the peer restarts, and its counter starts again from 0
|
||||
EXPECT_EQ(a->encryption->get_boot(), boot + 1);
|
||||
a->introduce(b);
|
||||
a->send({3});
|
||||
a->deliver_to(b);
|
||||
EXPECT_EQ(b.received, std::vector<Packet>{{3}});
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, FirstPacketsDroppedUntilEchoed) {
|
||||
Peer a, b;
|
||||
a.send({1});
|
||||
a.deliver_to(b);
|
||||
EXPECT_TRUE(b.received.empty()); // a does not know b's boot number yet
|
||||
b.send({2});
|
||||
b.deliver_to(a);
|
||||
EXPECT_EQ(a.received, std::vector<Packet>{{2}}); // b learnt a's from the dropped packet
|
||||
a.send({3});
|
||||
a.deliver_to(b);
|
||||
EXPECT_EQ(b.received, std::vector<Packet>{{3}});
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, PacketsFromBeforeRestartDropped) {
|
||||
Peer a;
|
||||
auto b = std::make_unique<Peer>();
|
||||
a.introduce(*b);
|
||||
a.send({1});
|
||||
a.send({2});
|
||||
std::vector<Packet> recorded = a.link.sent;
|
||||
a.link.sent.clear();
|
||||
b = std::make_unique<Peer>(); // b restarts and forgets a's counter
|
||||
for (auto &packet : recorded)
|
||||
b->link.inject(packet);
|
||||
EXPECT_TRUE(b->received.empty());
|
||||
a.introduce(*b);
|
||||
a.send({3});
|
||||
a.deliver_to(*b);
|
||||
EXPECT_EQ(b->received, std::vector<Packet>{{3}});
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, LinkTooSmallFailsSetup) {
|
||||
MockLink link(ROLLING_TRAILER_SIZE + 3);
|
||||
TestEncryption encryption(&link, true);
|
||||
EXPECT_EQ(encryption.get_max_packet_size(), 0);
|
||||
encryption.setup();
|
||||
EXPECT_TRUE(encryption.is_failed());
|
||||
Packet packet{1};
|
||||
EXPECT_FALSE(encryption.send_to_interface(PacketBuffer(packet), {}));
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, SendWithoutBuffersRefused) {
|
||||
MockLink link(64);
|
||||
TestEncryption encryption(&link, false);
|
||||
Packet packet{1};
|
||||
EXPECT_FALSE(encryption.send_to_interface(PacketBuffer(packet), {}));
|
||||
EXPECT_TRUE(link.sent.empty());
|
||||
}
|
||||
|
||||
TEST_F(XxteaEncryptionTest, CounterWrapStartsNewBoot) {
|
||||
Peer a, b(0x12345678);
|
||||
a.introduce(b);
|
||||
uint32_t boot = a.encryption->get_boot();
|
||||
a.encryption->set_counter(UINT32_MAX);
|
||||
ASSERT_TRUE(a.send({1}));
|
||||
EXPECT_EQ(a.encryption->get_boot(), boot + 1);
|
||||
ASSERT_TRUE(a.send({2}));
|
||||
a.deliver_to(b);
|
||||
// Both were accepted: the last packet of the old boot, then the first of the new one
|
||||
EXPECT_EQ(b.received, (std::vector<Packet>{{1}, {2}}));
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface::encryption_testing
|
||||
@@ -3,12 +3,14 @@ wifi:
|
||||
password: password1
|
||||
|
||||
udp:
|
||||
listen_address: 239.0.60.53
|
||||
addresses: ["239.0.60.53"]
|
||||
- id: transport_udp_component
|
||||
listen_address: 239.0.60.53
|
||||
addresses: ["239.0.60.53"]
|
||||
|
||||
packet_transport:
|
||||
- platform: udp
|
||||
id: transport_udp
|
||||
udp_id: transport_udp_component
|
||||
update_interval: 5s
|
||||
encryption: "our key goes here"
|
||||
rolling_code_enable: true
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
sx126x:
|
||||
cs_pin: ${cs_pin}
|
||||
rst_pin: ${rst_pin}
|
||||
busy_pin: ${busy_pin}
|
||||
dio1_pin: ${dio1_pin}
|
||||
hw_version: sx1262
|
||||
rf_switch: true
|
||||
frequency: 917MHz
|
||||
modulation: LORA
|
||||
crc_enable: true
|
||||
|
||||
packet_interface:
|
||||
- platform: sx126x
|
||||
id: sx126x_packet_interface
|
||||
addressing:
|
||||
address: 0x0001
|
||||
destination: 0x0002
|
||||
reliability:
|
||||
timeout: 500ms
|
||||
@@ -0,0 +1,9 @@
|
||||
substitutions:
|
||||
cs_pin: GPIO12
|
||||
rst_pin: GPIO13
|
||||
busy_pin: GPIO25
|
||||
dio1_pin: GPIO26
|
||||
|
||||
packages:
|
||||
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
|
||||
sx126x: !include common-packet-interface.yaml
|
||||
@@ -0,0 +1,17 @@
|
||||
sx127x:
|
||||
cs_pin: ${cs_pin}
|
||||
rst_pin: ${rst_pin}
|
||||
dio0_pin: ${dio0_pin}
|
||||
frequency: 917MHz
|
||||
modulation: LORA
|
||||
preamble_size: 8
|
||||
crc_enable: true
|
||||
|
||||
packet_interface:
|
||||
- platform: sx127x
|
||||
id: sx127x_packet_interface
|
||||
addressing:
|
||||
address: 0x0001
|
||||
destination: 0x0002
|
||||
reliability:
|
||||
timeout: 500ms
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
cs_pin: GPIO12
|
||||
rst_pin: GPIO13
|
||||
dio0_pin: GPIO26
|
||||
|
||||
packages:
|
||||
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
|
||||
sx127x: !include common-packet-interface.yaml
|
||||
@@ -0,0 +1,18 @@
|
||||
from esphome.components import socket as socket_component
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
socket_component.require_tcp_listener()
|
||||
# No entity platforms are configured, so count the connected sensor to emit USE_BINARY_SENSOR
|
||||
CORE.platform_counts["binary_sensor"] += 1
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
manifest.dependencies = manifest.dependencies + [
|
||||
"binary_sensor",
|
||||
"packet_interface",
|
||||
"socket",
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
packet_interface:
|
||||
- platform: tcp
|
||||
role: server
|
||||
port: 6000
|
||||
@@ -0,0 +1,36 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
packet_interface:
|
||||
- platform: tcp
|
||||
id: tcp_client_link
|
||||
host: 192.168.1.50
|
||||
port: 6060
|
||||
keep_alive: 5s
|
||||
encryption: Johnny Cash
|
||||
fragmentation:
|
||||
max_packet_size: 2048
|
||||
connected:
|
||||
name: TCP link connected
|
||||
- platform: tcp
|
||||
id: tcp_server_link
|
||||
role: server
|
||||
port: 6061
|
||||
allowed_ips:
|
||||
- 192.168.1.0/24
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: tcp_sensor
|
||||
lambda: return 1.0;
|
||||
|
||||
packet_transport:
|
||||
- platform: packet_interface
|
||||
packet_interface: tcp_client_link
|
||||
sensors:
|
||||
- tcp_sensor
|
||||
- platform: packet_interface
|
||||
packet_interface: tcp_server_link
|
||||
sensors:
|
||||
- tcp_sensor
|
||||
@@ -0,0 +1,307 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/tcp/packet_interface/tcp_packet_interface.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/wake.h"
|
||||
|
||||
#ifdef USE_HOST
|
||||
|
||||
namespace esphome::tcp::testing {
|
||||
|
||||
// Copies a packet into a vector, for comparing in tests
|
||||
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
|
||||
std::vector<uint8_t> result(data.size());
|
||||
data.copy_to(result.data(), result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
using packet_interface::PacketBuffer;
|
||||
using packet_interface::PacketMetaData;
|
||||
using Packet = std::vector<uint8_t>;
|
||||
|
||||
// The receive buffer is a base, so it is set up before the link's constructor sees it
|
||||
struct TcpBufferStorage {
|
||||
uint8_t rx_buffer_storage[64]{};
|
||||
};
|
||||
|
||||
class TestTcp : public TcpBufferStorage, public TcpPacketInterface {
|
||||
public:
|
||||
TestTcp() : TcpPacketInterface(this->rx_buffer_storage) {
|
||||
this->set_host("peer");
|
||||
this->set_port(1);
|
||||
}
|
||||
socket::TcpClientLink &link() { return this->link_; }
|
||||
};
|
||||
|
||||
// A layer stacked on the link, to check that the session state reaches it
|
||||
class UpperLayer : public packet_interface::PacketInterface {
|
||||
public:
|
||||
explicit UpperLayer(PacketInterface *lower) : packet_interface::PacketInterface(lower->get_max_packet_size()) {
|
||||
this->stack_on_(lower, packet_interface::CAPABILITY_ENCRYPTION);
|
||||
}
|
||||
bool send_to_interface(const PacketBuffer &, const PacketMetaData &) override { return true; }
|
||||
};
|
||||
|
||||
struct End {
|
||||
End() {
|
||||
this->link.add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); });
|
||||
this->link.add_on_session_state_callback([this](bool connected) { this->states.push_back(connected); });
|
||||
this->link.setup();
|
||||
}
|
||||
bool send(const Packet &data) { return this->link.send_to_interface(PacketBuffer(data), {}); }
|
||||
|
||||
TestTcp link;
|
||||
std::vector<Packet> received;
|
||||
std::vector<bool> states;
|
||||
};
|
||||
|
||||
class TcpPacketInterfaceTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
App.~Application();
|
||||
new (&App) Application();
|
||||
static char name[] = "tcp-tests";
|
||||
static char friendly_name[] = "";
|
||||
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
|
||||
set_time(1000);
|
||||
int fds[2];
|
||||
ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
|
||||
this->a.link.link().adopt(std::make_unique<socket::Socket>(fds[0]));
|
||||
this->b.link.link().adopt(std::make_unique<socket::Socket>(fds[1]));
|
||||
this->loops(1);
|
||||
}
|
||||
void TearDown() override {
|
||||
this->a.link.link().close();
|
||||
this->b.link.link().close();
|
||||
App.~Application();
|
||||
new (&App) Application(); // later tests and the test listener use App
|
||||
}
|
||||
static void set_time(uint32_t now) { LoopBlockingGuard guard{nullptr, nullptr, now}; }
|
||||
void loops(int count) {
|
||||
for (int i = 0; i != count; i++) {
|
||||
this->a.link.loop();
|
||||
this->b.link.loop();
|
||||
}
|
||||
}
|
||||
|
||||
End a, b;
|
||||
};
|
||||
|
||||
TEST_F(TcpPacketInterfaceTest, ProvidesReliabilitySessionAndAddressing) {
|
||||
for (auto capability : {packet_interface::CAPABILITY_RELIABILITY, packet_interface::CAPABILITY_SESSION,
|
||||
packet_interface::CAPABILITY_ADDRESSING})
|
||||
EXPECT_TRUE(this->a.link.has_capability(capability));
|
||||
EXPECT_EQ(this->a.states, std::vector<bool>{true});
|
||||
EXPECT_TRUE(this->a.link.is_connected());
|
||||
}
|
||||
|
||||
TEST_F(TcpPacketInterfaceTest, PacketsBothWays) {
|
||||
Packet big(64);
|
||||
for (size_t i = 0; i != big.size(); i++)
|
||||
big[i] = i;
|
||||
ASSERT_TRUE(this->a.send({1, 2, 3}));
|
||||
ASSERT_TRUE(this->a.send(big));
|
||||
ASSERT_TRUE(this->b.send({9}));
|
||||
this->loops(3);
|
||||
EXPECT_EQ(this->b.received, (std::vector<Packet>{{1, 2, 3}, big}));
|
||||
EXPECT_EQ(this->a.received, std::vector<Packet>{{9}});
|
||||
EXPECT_FALSE(this->a.send(Packet(65)));
|
||||
}
|
||||
|
||||
TEST_F(TcpPacketInterfaceTest, SessionReachesLayersAbove) {
|
||||
UpperLayer upper(&this->a.link);
|
||||
std::vector<bool> states;
|
||||
upper.add_on_session_state_callback([&states](bool connected) { states.push_back(connected); });
|
||||
EXPECT_TRUE(upper.has_capability(packet_interface::CAPABILITY_SESSION));
|
||||
this->b.link.link().close(); // the peer goes away
|
||||
this->loops(3);
|
||||
EXPECT_EQ(states, std::vector<bool>{false});
|
||||
}
|
||||
|
||||
TEST_F(TcpPacketInterfaceTest, KeepAliveKeepsIdleLinkUp) {
|
||||
for (uint32_t now = 1000; now <= 61000; now += 5000) {
|
||||
set_time(now);
|
||||
this->loops(2);
|
||||
}
|
||||
EXPECT_TRUE(this->a.link.is_connected());
|
||||
EXPECT_TRUE(this->b.link.is_connected());
|
||||
EXPECT_TRUE(this->a.received.empty()); // keep-alives are not passed up
|
||||
}
|
||||
|
||||
TEST_F(TcpPacketInterfaceTest, SilentPeerClosesLink) {
|
||||
set_time(1000 + 30001);
|
||||
this->a.link.loop(); // b has sent nothing for the default timeout of 30 s
|
||||
EXPECT_FALSE(this->a.link.is_connected());
|
||||
EXPECT_EQ(this->a.states, (std::vector<bool>{true, false}));
|
||||
}
|
||||
|
||||
TEST_F(TcpPacketInterfaceTest, SlowerPeerMatchedByTimeout) {
|
||||
// b sends keep-alives every 20 s; three of a's own 5 s intervals would close the link
|
||||
this->a.link.set_keep_alive(5000);
|
||||
this->a.link.set_receive_timeout(60000);
|
||||
this->b.link.set_keep_alive(20000);
|
||||
this->b.link.set_receive_timeout(60000);
|
||||
for (uint32_t now = 1000; now <= 121000; now += 5000) {
|
||||
set_time(now);
|
||||
this->loops(2);
|
||||
}
|
||||
EXPECT_TRUE(this->a.link.is_connected());
|
||||
EXPECT_TRUE(this->b.link.is_connected());
|
||||
EXPECT_EQ(this->a.states, std::vector<bool>{true});
|
||||
}
|
||||
|
||||
TEST_F(TcpPacketInterfaceTest, ConnectedSensorStartsFalse) {
|
||||
TestTcp link;
|
||||
binary_sensor::BinarySensor sensor;
|
||||
link.set_connected_sensor(&sensor);
|
||||
link.setup();
|
||||
EXPECT_TRUE(sensor.has_state());
|
||||
EXPECT_FALSE(sensor.state);
|
||||
}
|
||||
|
||||
TEST_F(TcpPacketInterfaceTest, OversizedLengthClosesLink) {
|
||||
End c;
|
||||
int fds[2];
|
||||
ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
|
||||
c.link.link().adopt(std::make_unique<socket::Socket>(fds[0]));
|
||||
c.link.loop();
|
||||
uint8_t header[2] = {0xFF, 0x00}; // 255 bytes, more than 64
|
||||
ASSERT_EQ(::write(fds[1], header, sizeof(header)), 2);
|
||||
c.link.loop();
|
||||
EXPECT_FALSE(c.link.is_connected());
|
||||
EXPECT_EQ(c.states, (std::vector<bool>{true, false}));
|
||||
::close(fds[1]);
|
||||
}
|
||||
|
||||
TEST_F(TcpPacketInterfaceTest, NotSentWhileDown) {
|
||||
this->a.link.link().close();
|
||||
this->a.link.loop();
|
||||
EXPECT_FALSE(this->a.send({1}));
|
||||
}
|
||||
|
||||
TEST_F(TcpPacketInterfaceTest, FrameAssembledAcrossReads) {
|
||||
End c;
|
||||
int fds[2];
|
||||
ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
|
||||
c.link.link().adopt(std::make_unique<socket::Socket>(fds[0]));
|
||||
c.link.loop();
|
||||
// A keep-alive, then a 3 byte packet in three writes: half the length, the rest of it, then the payload
|
||||
const uint8_t keep_alive[2] = {0x00, 0x00};
|
||||
ASSERT_EQ(::write(fds[1], keep_alive, 2), 2);
|
||||
c.link.loop();
|
||||
const uint8_t length_low = 0x03;
|
||||
ASSERT_EQ(::write(fds[1], &length_low, 1), 1);
|
||||
c.link.loop();
|
||||
const uint8_t length_high_and_start[2] = {0x00, 0xAA};
|
||||
ASSERT_EQ(::write(fds[1], length_high_and_start, 2), 2);
|
||||
c.link.loop();
|
||||
EXPECT_TRUE(c.received.empty());
|
||||
const uint8_t rest[2] = {0xBB, 0xCC};
|
||||
ASSERT_EQ(::write(fds[1], rest, 2), 2);
|
||||
c.link.loop();
|
||||
EXPECT_EQ(c.received, (std::vector<Packet>{{0xAA, 0xBB, 0xCC}}));
|
||||
EXPECT_TRUE(c.link.is_connected());
|
||||
::close(fds[1]);
|
||||
}
|
||||
|
||||
// Waits in the main loop's select(), which marks the sockets that are ready to read
|
||||
static void wait_for_sockets() { esphome::internal::wakeable_delay(5); }
|
||||
|
||||
// The server role, accepting connections on a loopback port
|
||||
class TcpServerTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
App.~Application();
|
||||
new (&App) Application();
|
||||
static char name[] = "tcp-tests";
|
||||
static char friendly_name[] = "";
|
||||
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
|
||||
set_time(1000);
|
||||
this->port = free_port();
|
||||
this->server.link.set_server(true);
|
||||
this->server.link.set_port(this->port);
|
||||
}
|
||||
void TearDown() override {
|
||||
this->server.link.on_shutdown();
|
||||
for (int fd : this->clients)
|
||||
::close(fd);
|
||||
App.~Application();
|
||||
new (&App) Application(); // later tests and the test listener use App
|
||||
}
|
||||
static void set_time(uint32_t now) { LoopBlockingGuard guard{nullptr, nullptr, now}; }
|
||||
static uint16_t free_port() {
|
||||
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
struct sockaddr_in addr {};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
socklen_t len = sizeof(addr);
|
||||
EXPECT_EQ(::bind(fd, reinterpret_cast<struct sockaddr *>(&addr), len), 0);
|
||||
EXPECT_EQ(::getsockname(fd, reinterpret_cast<struct sockaddr *>(&addr), &len), 0);
|
||||
::close(fd);
|
||||
return ntohs(addr.sin_port);
|
||||
}
|
||||
// Connects a client and runs the server's loop until it has accepted it
|
||||
int connect_client() {
|
||||
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
struct sockaddr_in addr {};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
addr.sin_port = htons(this->port);
|
||||
EXPECT_EQ(::connect(fd, reinterpret_cast<struct sockaddr *>(&addr), sizeof(addr)), 0);
|
||||
this->clients.push_back(fd);
|
||||
for (int i = 0; i != 100 && !this->server.link.is_connected(); i++) {
|
||||
this->server.link.loop();
|
||||
wait_for_sockets();
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
End server;
|
||||
uint16_t port{0};
|
||||
std::vector<int> clients;
|
||||
};
|
||||
|
||||
TEST_F(TcpServerTest, AcceptsClientAndReceives) {
|
||||
this->server.link.loop(); // starts listening
|
||||
int client = this->connect_client();
|
||||
ASSERT_TRUE(this->server.link.is_connected());
|
||||
EXPECT_EQ(this->server.states, std::vector<bool>{true});
|
||||
const uint8_t frame[3] = {0x01, 0x00, 0x42};
|
||||
ASSERT_EQ(::write(client, frame, sizeof(frame)), 3);
|
||||
for (int i = 0; i != 100 && this->server.received.empty(); i++) {
|
||||
this->server.link.loop();
|
||||
wait_for_sockets();
|
||||
}
|
||||
EXPECT_EQ(this->server.received, std::vector<Packet>{{0x42}});
|
||||
}
|
||||
|
||||
TEST_F(TcpServerTest, AcceptsNextClientAfterDisconnect) {
|
||||
this->server.link.loop();
|
||||
int first = this->connect_client();
|
||||
ASSERT_TRUE(this->server.link.is_connected());
|
||||
::close(first);
|
||||
this->clients.clear();
|
||||
for (int i = 0; i != 100 && this->server.link.is_connected(); i++) {
|
||||
this->server.link.loop();
|
||||
wait_for_sockets();
|
||||
}
|
||||
EXPECT_EQ(this->server.states, (std::vector<bool>{true, false}));
|
||||
this->connect_client();
|
||||
EXPECT_TRUE(this->server.link.is_connected());
|
||||
EXPECT_EQ(this->server.states, (std::vector<bool>{true, false, true}));
|
||||
}
|
||||
|
||||
} // namespace esphome::tcp::testing
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
tcp: !include common.yaml
|
||||
@@ -0,0 +1,6 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# benchmark.yaml configures a uart packet_interface, whose to_code waits on the uart bus variable.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,6 @@
|
||||
packet_interface:
|
||||
- platform: uart
|
||||
|
||||
uart:
|
||||
port: /dev/ttyUSB0
|
||||
baud_rate: 9600
|
||||
@@ -25,7 +25,10 @@ class MockUARTComponent : public UARTComponent {
|
||||
// consider using StaticVector or FixedVector from esphome/core/helpers.h instead.
|
||||
std::vector<uint8_t> written_data;
|
||||
|
||||
void write_array(const uint8_t *data, size_t len) override { written_data.assign(data, data + len); }
|
||||
void write_array(const uint8_t *data, size_t len) override {
|
||||
written_data.insert(written_data.end(), data, data + len);
|
||||
}
|
||||
void clear() { written_data.clear(); }
|
||||
|
||||
MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override));
|
||||
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <gmock/gmock.h>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include "esphome/components/uart/packet_interface/uart_packet_interface.h"
|
||||
#include "esphome/components/packet_interface/packet_buffer.h"
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
#include "common.h"
|
||||
|
||||
namespace esphome::uart::testing {
|
||||
|
||||
// Copies a packet into a vector, for comparing in tests
|
||||
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
|
||||
std::vector<uint8_t> result(data.size());
|
||||
data.copy_to(result.data(), result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
using packet_interface::PacketBuffer;
|
||||
using packet_interface::PacketMetaData;
|
||||
|
||||
// Test fixture
|
||||
class UartPacketInterfaceTest : public ::testing::Test {
|
||||
protected:
|
||||
MockUARTComponent uart_{};
|
||||
// As large as the default max_packet_size; tests only make it smaller
|
||||
uint8_t rx_buffer_[512]{};
|
||||
UartPacketInterface interface_{this->rx_buffer_};
|
||||
std::vector<std::vector<uint8_t>> received_packets_;
|
||||
|
||||
void SetUp() override {
|
||||
received_packets_.clear();
|
||||
interface_.set_uart_parent(&uart_);
|
||||
interface_.setup();
|
||||
|
||||
interface_.add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &meta) { received_packets_.push_back(to_vector(data)); });
|
||||
}
|
||||
};
|
||||
|
||||
// Feed bytes to the interface through the mocked UART and run its loop
|
||||
static void feed(MockUARTComponent &uart, UartPacketInterface &interface, const std::vector<uint8_t> &bytes) {
|
||||
size_t read_pos = 0;
|
||||
EXPECT_CALL(uart, available()).WillRepeatedly(Invoke([&bytes, &read_pos]() { return bytes.size() - read_pos; }));
|
||||
EXPECT_CALL(uart, read_array(_, 1)).WillRepeatedly(Invoke([&bytes, &read_pos](uint8_t *data, size_t) {
|
||||
if (read_pos >= bytes.size())
|
||||
return false;
|
||||
*data = bytes[read_pos++];
|
||||
return true;
|
||||
}));
|
||||
interface.loop();
|
||||
}
|
||||
|
||||
// Test basic frame sending
|
||||
TEST_F(UartPacketInterfaceTest, SendSimpleFrame) {
|
||||
std::vector<uint8_t> data = {0x01, 0x02, 0x03};
|
||||
PacketBuffer buffer(data);
|
||||
|
||||
// Set up the mock to accumulate bytes
|
||||
ASSERT_EQ(uart_.written_data.size(), 0);
|
||||
interface_.send_to_interface(buffer, {});
|
||||
// Verify the accumulated bytes
|
||||
ASSERT_EQ(uart_.written_data.size(), 5);
|
||||
|
||||
std::vector<uint8_t> expected = {0x7E, 0x01, 0x02, 0x03, 0x7E};
|
||||
EXPECT_EQ(uart_.written_data, expected);
|
||||
}
|
||||
// Test byte stuffing for FLAG_BYTE
|
||||
TEST_F(UartPacketInterfaceTest, SendWithFlagByteStuffing) {
|
||||
std::vector<uint8_t> data = {0x01, 0x7E, 0x03};
|
||||
PacketBuffer buffer(data);
|
||||
|
||||
uart_.clear();
|
||||
interface_.send_to_interface(buffer, {});
|
||||
|
||||
// Should escape 0x7E as 0x7D 0x5E
|
||||
std::vector<uint8_t> expected = {0x7E, 0x01, 0x7D, 0x5E, 0x03, 0x7E};
|
||||
EXPECT_EQ(uart_.written_data, expected);
|
||||
}
|
||||
|
||||
// Test byte stuffing for CONTROL_BYTE
|
||||
TEST_F(UartPacketInterfaceTest, SendWithControlByteStuffing) {
|
||||
std::vector<uint8_t> data = {0x01, 0x7D, 0x03};
|
||||
PacketBuffer buffer(data);
|
||||
|
||||
uart_.clear();
|
||||
interface_.send_to_interface(buffer, {});
|
||||
|
||||
// Should escape 0x7D as 0x7D 0x5D
|
||||
std::vector<uint8_t> expected = {0x7E, 0x01, 0x7D, 0x5D, 0x03, 0x7E};
|
||||
EXPECT_EQ(uart_.written_data, expected);
|
||||
}
|
||||
|
||||
// Test sending empty frame
|
||||
TEST_F(UartPacketInterfaceTest, SendEmptyFrame) {
|
||||
std::vector<uint8_t> data = {};
|
||||
PacketBuffer buffer(data);
|
||||
|
||||
interface_.send_to_interface(buffer, {});
|
||||
|
||||
// Empty data should return without sending
|
||||
EXPECT_EQ(uart_.written_data.size(), 0);
|
||||
}
|
||||
|
||||
// Test receiving simple frame
|
||||
TEST_F(UartPacketInterfaceTest, ReceiveSimpleFrame) {
|
||||
std::vector<uint8_t> frame_data = {0x7E, 0x01, 0x02, 0x03, 0x7E};
|
||||
size_t read_pos = 0;
|
||||
|
||||
EXPECT_CALL(uart_, available()).WillRepeatedly(Invoke([&frame_data, &read_pos]() {
|
||||
return frame_data.size() - read_pos;
|
||||
}));
|
||||
|
||||
EXPECT_CALL(uart_, read_array(_, 1)).WillRepeatedly(Invoke([&frame_data, &read_pos](uint8_t *data, size_t) {
|
||||
if (read_pos >= frame_data.size())
|
||||
return false;
|
||||
*data = frame_data[read_pos++];
|
||||
return true;
|
||||
}));
|
||||
|
||||
interface_.loop();
|
||||
|
||||
ASSERT_EQ(received_packets_.size(), 1);
|
||||
std::vector<uint8_t> expected = {0x01, 0x02, 0x03};
|
||||
EXPECT_EQ(received_packets_[0], expected);
|
||||
}
|
||||
|
||||
// Test receiving frame with FLAG_BYTE escape
|
||||
TEST_F(UartPacketInterfaceTest, ReceiveFrameWithFlagEscape) {
|
||||
std::vector<uint8_t> frame_data = {0x7E, 0x01, 0x7D, 0x5E, 0x03, 0x7E};
|
||||
size_t read_pos = 0;
|
||||
|
||||
EXPECT_CALL(uart_, available()).WillRepeatedly(Invoke([&frame_data, &read_pos]() {
|
||||
return frame_data.size() - read_pos;
|
||||
}));
|
||||
|
||||
EXPECT_CALL(uart_, read_array(_, 1)).WillRepeatedly(Invoke([&frame_data, &read_pos](uint8_t *data, size_t) {
|
||||
if (read_pos >= frame_data.size())
|
||||
return false;
|
||||
*data = frame_data[read_pos++];
|
||||
return true;
|
||||
}));
|
||||
|
||||
interface_.loop();
|
||||
|
||||
ASSERT_EQ(received_packets_.size(), 1);
|
||||
std::vector<uint8_t> expected = {0x01, 0x7E, 0x03}; // 0x7E unescaped
|
||||
EXPECT_EQ(received_packets_[0], expected);
|
||||
}
|
||||
|
||||
// Test receiving frame with CONTROL_BYTE escape
|
||||
TEST_F(UartPacketInterfaceTest, ReceiveFrameWithControlEscape) {
|
||||
std::vector<uint8_t> frame_data = {0x7E, 0x01, 0x7D, 0x5D, 0x03, 0x7E};
|
||||
size_t read_pos = 0;
|
||||
|
||||
EXPECT_CALL(uart_, available()).WillRepeatedly(Invoke([&frame_data, &read_pos]() {
|
||||
return frame_data.size() - read_pos;
|
||||
}));
|
||||
|
||||
EXPECT_CALL(uart_, read_array(_, 1)).WillRepeatedly(Invoke([&frame_data, &read_pos](uint8_t *data, size_t) {
|
||||
if (read_pos >= frame_data.size())
|
||||
return false;
|
||||
*data = frame_data[read_pos++];
|
||||
return true;
|
||||
}));
|
||||
|
||||
interface_.loop();
|
||||
|
||||
ASSERT_EQ(received_packets_.size(), 1);
|
||||
std::vector<uint8_t> expected = {0x01, 0x7D, 0x03}; // 0x7D unescaped
|
||||
EXPECT_EQ(received_packets_[0], expected);
|
||||
}
|
||||
|
||||
// Test receiving multiple frames
|
||||
TEST_F(UartPacketInterfaceTest, ReceiveMultipleFrames) {
|
||||
std::vector<uint8_t> frame_data = {0x7E, 0x01, 0x02, 0x7E, 0x7E, 0x03, 0x04, 0x7E};
|
||||
size_t read_pos = 0;
|
||||
|
||||
EXPECT_CALL(uart_, available()).WillRepeatedly(Invoke([&frame_data, &read_pos]() {
|
||||
return frame_data.size() - read_pos;
|
||||
}));
|
||||
|
||||
EXPECT_CALL(uart_, read_array(_, 1)).WillRepeatedly(Invoke([&frame_data, &read_pos](uint8_t *data, size_t) {
|
||||
if (read_pos >= frame_data.size())
|
||||
return false;
|
||||
*data = frame_data[read_pos++];
|
||||
return true;
|
||||
}));
|
||||
|
||||
interface_.loop();
|
||||
|
||||
ASSERT_EQ(received_packets_.size(), 2) << "Expected 2 packets, got " << received_packets_.size();
|
||||
std::vector<uint8_t> expected1 = {0x01, 0x02};
|
||||
std::vector<uint8_t> expected2 = {0x03, 0x04};
|
||||
auto pkt0 = received_packets_[0];
|
||||
auto pkt1 = received_packets_[1];
|
||||
EXPECT_EQ(pkt0, expected1) << "Packet 0 size: " << pkt0.size();
|
||||
EXPECT_EQ(pkt1, expected2) << "Packet 1 size: " << pkt1.size();
|
||||
}
|
||||
|
||||
// Test receiving empty frame (back-to-back flags)
|
||||
TEST_F(UartPacketInterfaceTest, ReceiveEmptyFrame) {
|
||||
std::vector<uint8_t> frame_data = {0x7E, 0x7E};
|
||||
size_t read_pos = 0;
|
||||
|
||||
EXPECT_CALL(uart_, available()).WillRepeatedly(Invoke([&frame_data, &read_pos]() {
|
||||
return frame_data.size() - read_pos;
|
||||
}));
|
||||
|
||||
EXPECT_CALL(uart_, read_array(_, 1)).WillRepeatedly(Invoke([&frame_data, &read_pos](uint8_t *data, size_t) {
|
||||
if (read_pos >= frame_data.size())
|
||||
return false;
|
||||
*data = frame_data[read_pos++];
|
||||
return true;
|
||||
}));
|
||||
|
||||
interface_.loop();
|
||||
|
||||
// Empty frames should not be delivered
|
||||
EXPECT_EQ(received_packets_.size(), 0);
|
||||
}
|
||||
|
||||
// Test data before first flag is ignored
|
||||
TEST_F(UartPacketInterfaceTest, IgnoreDataBeforeFirstFlag) {
|
||||
std::vector<uint8_t> frame_data = {0xFF, 0xFF, 0x7E, 0x01, 0x02, 0x7E};
|
||||
size_t read_pos = 0;
|
||||
|
||||
EXPECT_CALL(uart_, available()).WillRepeatedly(Invoke([&frame_data, &read_pos]() {
|
||||
return frame_data.size() - read_pos;
|
||||
}));
|
||||
|
||||
EXPECT_CALL(uart_, read_array(_, 1)).WillRepeatedly(Invoke([&frame_data, &read_pos](uint8_t *data, size_t) {
|
||||
if (read_pos >= frame_data.size())
|
||||
return false;
|
||||
*data = frame_data[read_pos++];
|
||||
return true;
|
||||
}));
|
||||
|
||||
interface_.loop();
|
||||
|
||||
ASSERT_EQ(received_packets_.size(), 1);
|
||||
std::vector<uint8_t> expected = {0x01, 0x02};
|
||||
EXPECT_EQ(received_packets_[0], expected);
|
||||
}
|
||||
|
||||
// Test oversized packet is discarded
|
||||
TEST_F(UartPacketInterfaceTest, OversizedPacketDiscarded) {
|
||||
uint8_t small_buffer[10]{};
|
||||
UartPacketInterface interface(small_buffer);
|
||||
interface.set_uart_parent(&this->uart_);
|
||||
std::vector<std::vector<uint8_t>> received;
|
||||
interface.add_packet_interface_listener(
|
||||
[&received](const PacketBuffer &data, const PacketMetaData &) { received.push_back(to_vector(data)); });
|
||||
|
||||
// Create a packet larger than buffer
|
||||
std::vector<uint8_t> frame_data = {0x7E};
|
||||
for (int i = 0; i < 15; i++) {
|
||||
frame_data.push_back(0xAA);
|
||||
}
|
||||
frame_data.push_back(0x7E);
|
||||
|
||||
feed(this->uart_, interface, frame_data);
|
||||
|
||||
// Oversized packet should be discarded
|
||||
EXPECT_TRUE(received.empty());
|
||||
}
|
||||
|
||||
// Test round-trip (send and receive)
|
||||
TEST_F(UartPacketInterfaceTest, RoundTrip) {
|
||||
std::vector<uint8_t> original = {0x01, 0x7E, 0x7D, 0x03};
|
||||
PacketBuffer send_buffer(original);
|
||||
|
||||
// Send
|
||||
interface_.send_to_interface(send_buffer, {});
|
||||
|
||||
// Prepare to receive - use the written data as frame data
|
||||
std::vector<uint8_t> frame_data = uart_.written_data;
|
||||
size_t read_pos = 0;
|
||||
|
||||
EXPECT_CALL(uart_, available()).WillRepeatedly(Invoke([&frame_data, &read_pos]() {
|
||||
return frame_data.size() - read_pos;
|
||||
}));
|
||||
|
||||
EXPECT_CALL(uart_, read_array(_, 1)).WillRepeatedly(Invoke([&frame_data, &read_pos](uint8_t *data, size_t) {
|
||||
if (read_pos >= frame_data.size())
|
||||
return false;
|
||||
*data = frame_data[read_pos++];
|
||||
return true;
|
||||
}));
|
||||
|
||||
// Receive
|
||||
interface_.loop();
|
||||
|
||||
ASSERT_EQ(received_packets_.size(), 1);
|
||||
EXPECT_EQ(received_packets_[0], original);
|
||||
}
|
||||
|
||||
// Test all bytes can be transmitted (0x00 to 0xFF)
|
||||
TEST_F(UartPacketInterfaceTest, AllByteValues) {
|
||||
std::vector<uint8_t> data;
|
||||
data.reserve(256);
|
||||
for (int i = 0; i != 256; i++) {
|
||||
data.push_back(static_cast<uint8_t>(i));
|
||||
}
|
||||
PacketBuffer send_buffer(data);
|
||||
|
||||
// Send
|
||||
interface_.send_to_interface(send_buffer, {});
|
||||
|
||||
// Prepare to receive - use the written data as frame data
|
||||
std::vector<uint8_t> frame_data = uart_.written_data;
|
||||
size_t read_pos = 0;
|
||||
|
||||
EXPECT_CALL(uart_, available()).WillRepeatedly(Invoke([&frame_data, &read_pos]() {
|
||||
return frame_data.size() - read_pos;
|
||||
}));
|
||||
|
||||
EXPECT_CALL(uart_, read_array(_, 1)).WillRepeatedly(Invoke([&frame_data, &read_pos](uint8_t *data, size_t) {
|
||||
if (read_pos >= frame_data.size())
|
||||
return false;
|
||||
*data = frame_data[read_pos++];
|
||||
return true;
|
||||
}));
|
||||
|
||||
// Receive
|
||||
interface_.loop();
|
||||
|
||||
ASSERT_EQ(received_packets_.size(), 1);
|
||||
EXPECT_EQ(received_packets_[0], data);
|
||||
}
|
||||
|
||||
// The buffer passed to the constructor is the largest packet, for receiving and sending alike
|
||||
TEST_F(UartPacketInterfaceTest, ReceiveLimitedToBuffer) {
|
||||
uint8_t small_buffer[4]{};
|
||||
UartPacketInterface interface(small_buffer);
|
||||
interface.set_uart_parent(&this->uart_);
|
||||
std::vector<std::vector<uint8_t>> received;
|
||||
interface.add_packet_interface_listener(
|
||||
[&received](const PacketBuffer &data, const PacketMetaData &) { received.push_back(to_vector(data)); });
|
||||
// The first packet is longer than the buffer and is dropped; the next one fits
|
||||
feed(this->uart_, interface, {0x7E, 1, 2, 3, 4, 5, 6, 0x7E, 7, 8, 0x7E});
|
||||
EXPECT_EQ(received, (std::vector<std::vector<uint8_t>>{{7, 8}}));
|
||||
}
|
||||
|
||||
// Test a packet larger than max_packet_size is not sent
|
||||
TEST_F(UartPacketInterfaceTest, OversizedPacketNotSent) {
|
||||
uint8_t small_buffer[4]{};
|
||||
UartPacketInterface interface(small_buffer);
|
||||
interface.set_uart_parent(&this->uart_);
|
||||
std::vector<uint8_t> data = {0x01, 0x02, 0x03, 0x04, 0x05};
|
||||
PacketBuffer buffer(data);
|
||||
|
||||
EXPECT_FALSE(interface.send_to_interface(buffer, {}));
|
||||
EXPECT_EQ(uart_.written_data.size(), 0);
|
||||
}
|
||||
|
||||
// Test a composite buffer longer than one write chunk is framed correctly
|
||||
TEST_F(UartPacketInterfaceTest, SendCompositeAcrossChunks) {
|
||||
std::vector<uint8_t> pre(40, 0x7E);
|
||||
std::vector<uint8_t> mid_data = {0x11, 0x22};
|
||||
std::vector<uint8_t> post(30, 0x33);
|
||||
PacketBuffer mid(mid_data);
|
||||
PacketBuffer buffer(pre.data(), pre.size(), &mid, post.data(), post.size());
|
||||
|
||||
ASSERT_TRUE(interface_.send_to_interface(buffer, {}));
|
||||
|
||||
std::vector<uint8_t> expected = {0x7E};
|
||||
for (size_t i = 0; i != pre.size(); i++) {
|
||||
expected.push_back(0x7D);
|
||||
expected.push_back(0x5E);
|
||||
}
|
||||
expected.insert(expected.end(), mid_data.begin(), mid_data.end());
|
||||
expected.insert(expected.end(), post.begin(), post.end());
|
||||
expected.push_back(0x7E);
|
||||
EXPECT_EQ(uart_.written_data, expected);
|
||||
}
|
||||
|
||||
// Test one flag between frames both ends the first and starts the second
|
||||
TEST_F(UartPacketInterfaceTest, SharedFlagBetweenFrames) {
|
||||
feed(uart_, interface_, {0x7E, 0x01, 0x7E, 0x02, 0x03, 0x7E});
|
||||
|
||||
EXPECT_EQ(received_packets_, (std::vector<std::vector<uint8_t>>{{0x01}, {0x02, 0x03}}));
|
||||
}
|
||||
|
||||
// Test an unfinished frame does not hide the frame after it
|
||||
TEST_F(UartPacketInterfaceTest, UnfinishedFrameDoesNotHideNext) {
|
||||
feed(uart_, interface_, {0x7E, 0xAA, 0x7E, 0x01, 0x02, 0x7E});
|
||||
|
||||
EXPECT_EQ(received_packets_, (std::vector<std::vector<uint8_t>>{{0xAA}, {0x01, 0x02}}));
|
||||
}
|
||||
|
||||
} // namespace esphome::uart::testing
|
||||
@@ -0,0 +1,6 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# benchmark.yaml configures a udp packet_interface, whose to_code waits on the udp component variable.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,5 @@
|
||||
udp:
|
||||
addresses: ["192.168.1.255"]
|
||||
|
||||
packet_interface:
|
||||
- platform: udp
|
||||
@@ -0,0 +1,78 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/udp/packet_interface/udp_packet_interface.h"
|
||||
|
||||
#ifdef USE_NETWORK
|
||||
|
||||
namespace esphome::udp::testing {
|
||||
|
||||
// Copies a packet into a vector, for comparing in tests
|
||||
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
|
||||
std::vector<uint8_t> result(data.size());
|
||||
data.copy_to(result.data(), result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
using packet_interface::PacketBuffer;
|
||||
using packet_interface::PacketMetaData;
|
||||
using Packet = std::vector<uint8_t>;
|
||||
|
||||
// The copy buffer is a base, so it is set up before the link's constructor sees it
|
||||
struct UdpBufferStorage {
|
||||
uint8_t tx_buffer_storage[8]{};
|
||||
};
|
||||
|
||||
class TestUdp : public UdpBufferStorage, public UdpPacketInterface {
|
||||
public:
|
||||
explicit TestUdp(UDPComponent *udp) : UdpPacketInterface(udp, this->tx_buffer_storage) {
|
||||
this->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); });
|
||||
}
|
||||
using UdpPacketInterface::receive_;
|
||||
|
||||
std::vector<Packet> received;
|
||||
};
|
||||
|
||||
// The udp component is not set up, so it has no destinations and a send does nothing
|
||||
class UdpPacketInterfaceTest : public ::testing::Test {
|
||||
protected:
|
||||
UDPComponent udp_;
|
||||
TestUdp link_{&this->udp_};
|
||||
};
|
||||
|
||||
TEST_F(UdpPacketInterfaceTest, ReceivedPacketsUpToMaxPassedUp) {
|
||||
Packet packet{1, 2, 3, 4, 5, 6, 7, 8};
|
||||
this->link_.receive_(packet);
|
||||
Packet oversized{1, 2, 3, 4, 5, 6, 7, 8, 9};
|
||||
this->link_.receive_(oversized);
|
||||
EXPECT_EQ(this->link_.received, std::vector<Packet>{packet});
|
||||
}
|
||||
|
||||
TEST_F(UdpPacketInterfaceTest, SendsContiguousAndSplitPackets) {
|
||||
this->link_.setup();
|
||||
Packet packet{1, 2, 3, 4};
|
||||
EXPECT_TRUE(this->link_.send_to_interface(PacketBuffer(packet), {}));
|
||||
uint8_t header = 0;
|
||||
PacketBuffer body(packet);
|
||||
EXPECT_TRUE(this->link_.send_to_interface(PacketBuffer(&header, 1, &body, nullptr, 0), {}));
|
||||
// A packet split across buffers is joined in the copy buffer before it is sent
|
||||
const auto *storage = this->link_.tx_buffer_storage;
|
||||
EXPECT_EQ(Packet(storage, storage + 5), (Packet{0, 1, 2, 3, 4}));
|
||||
}
|
||||
|
||||
TEST_F(UdpPacketInterfaceTest, OversizedPacketNotSent) {
|
||||
this->link_.setup();
|
||||
Packet packet{1, 2, 3, 4, 5, 6, 7, 8, 9};
|
||||
EXPECT_FALSE(this->link_.send_to_interface(PacketBuffer(packet), {}));
|
||||
uint8_t header = 0;
|
||||
Packet body_data{1, 2, 3, 4, 5, 6, 7, 8};
|
||||
PacketBuffer body(body_data);
|
||||
EXPECT_FALSE(this->link_.send_to_interface(PacketBuffer(&header, 1, &body, nullptr, 0), {}));
|
||||
}
|
||||
|
||||
} // namespace esphome::udp::testing
|
||||
|
||||
#endif
|
||||
@@ -74,3 +74,19 @@ def test_populate_dependency_config_populates_defaults() -> None:
|
||||
register_platform_fn=lambda domain: None,
|
||||
)
|
||||
assert config["ok"] == {"default": 1}
|
||||
|
||||
|
||||
def test_load_component_yaml_configs_extends_lists(tmp_path: Path) -> None:
|
||||
"""Several directories can each add entries to one platform component."""
|
||||
(tmp_path / "first").mkdir()
|
||||
(tmp_path / "first" / "benchmark.yaml").write_text(
|
||||
"uart:\n port: /dev/a\npacket_interface:\n - platform: uart\n"
|
||||
)
|
||||
(tmp_path / "second").mkdir()
|
||||
(tmp_path / "second" / "benchmark.yaml").write_text(
|
||||
"uart:\n port: /dev/b\npacket_interface:\n - platform: udp\n"
|
||||
)
|
||||
merged = build_helpers.load_component_yaml_configs(["first", "second"], tmp_path)
|
||||
assert merged["packet_interface"] == [{"platform": "uart"}, {"platform": "udp"}]
|
||||
# Other values are taken from the first directory that sets them
|
||||
assert merged["uart"] == {"port": "/dev/a"}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Unit tests for the entity_transport component."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID
|
||||
from esphome.components.entity_transport import (
|
||||
CONFIG_SCHEMA,
|
||||
FINAL_VALIDATE_SCHEMA,
|
||||
remote_entity_schema,
|
||||
)
|
||||
from esphome.components.sensor import sensor_schema
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
SCHEDULER_DONT_RUN,
|
||||
)
|
||||
from esphome.core import ID
|
||||
import esphome.final_validate as fv
|
||||
|
||||
|
||||
def test_remote_id_required_without_manual_id(setup_core) -> None:
|
||||
"""A remote entity needs remote_id unless its own id is given."""
|
||||
schema = remote_entity_schema(sensor_schema())
|
||||
with pytest.raises(cv.Invalid, match="remote_id"):
|
||||
schema({CONF_NAME: "Temp", CONF_PROVIDER: "remote-device"})
|
||||
assert schema(
|
||||
{CONF_NAME: "Temp 2", CONF_PROVIDER: "remote-device", CONF_REMOTE_ID: "temp"}
|
||||
)
|
||||
config = schema(
|
||||
{CONF_PROVIDER: "remote-device", CONF_ID: ID("temp", is_manual=True)}
|
||||
)
|
||||
assert config[CONF_ID].id == "temp"
|
||||
|
||||
|
||||
def test_update_interval_never(setup_core) -> None:
|
||||
"""update_interval accepts never, and is left out when not given."""
|
||||
base = {"packet_interface": "link"}
|
||||
assert CONF_UPDATE_INTERVAL not in CONFIG_SCHEMA(base)
|
||||
config = CONFIG_SCHEMA({**base, CONF_UPDATE_INTERVAL: "never"})
|
||||
assert config[CONF_UPDATE_INTERVAL].total_milliseconds == SCHEDULER_DONT_RUN
|
||||
with pytest.raises(cv.Invalid):
|
||||
CONFIG_SCHEMA({**base, CONF_UPDATE_INTERVAL: "500ms"})
|
||||
|
||||
|
||||
def test_timeout(setup_core) -> None:
|
||||
"""The timeout is optional, and at least a second."""
|
||||
base = {"packet_interface": "link"}
|
||||
assert CONF_TIMEOUT not in CONFIG_SCHEMA(base)
|
||||
assert (
|
||||
CONFIG_SCHEMA({**base, CONF_TIMEOUT: "1min"})[CONF_TIMEOUT].total_seconds == 60
|
||||
)
|
||||
with pytest.raises(cv.Invalid):
|
||||
CONFIG_SCHEMA({**base, CONF_TIMEOUT: "500ms"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("layers", "allowed"),
|
||||
[({}, True), ({"reliability": None}, True), ({"session": {}}, False)],
|
||||
)
|
||||
def test_timeout_not_with_session(setup_core, layers: dict, allowed: bool) -> None:
|
||||
"""With a session layer, unchanged states are never sent again, so a timeout would clear them on a working link."""
|
||||
config = CONFIG_SCHEMA({"packet_interface": "link", CONF_TIMEOUT: "1min"})
|
||||
token = fv.full_config.set(
|
||||
{"packet_interface": [{CONF_ID: ID("link", is_manual=True), **layers}]}
|
||||
)
|
||||
try:
|
||||
if allowed:
|
||||
FINAL_VALIDATE_SCHEMA(config)
|
||||
else:
|
||||
with pytest.raises(cv.Invalid, match="session") as error:
|
||||
FINAL_VALIDATE_SCHEMA(config)
|
||||
assert error.value.path == [CONF_TIMEOUT]
|
||||
# Without a timeout, any layers are fine
|
||||
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA({"packet_interface": "link"}))
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_timeout_not_with_session_on_peer(setup_core) -> None:
|
||||
"""The layers of an addressing peer are found by the peer's id."""
|
||||
peers = [
|
||||
{CONF_ID: ID("plain", is_manual=True)},
|
||||
{CONF_ID: ID("with_session", is_manual=True), "session": {}},
|
||||
]
|
||||
token = fv.full_config.set(
|
||||
{
|
||||
"packet_interface": [
|
||||
{CONF_ID: ID("bus", is_manual=True), "addressing": {"peers": peers}}
|
||||
]
|
||||
}
|
||||
)
|
||||
try:
|
||||
FINAL_VALIDATE_SCHEMA(
|
||||
CONFIG_SCHEMA({"packet_interface": "plain", CONF_TIMEOUT: "1min"})
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="session"):
|
||||
FINAL_VALIDATE_SCHEMA(
|
||||
CONFIG_SCHEMA(
|
||||
{"packet_interface": "with_session", CONF_TIMEOUT: "1min"}
|
||||
)
|
||||
)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_advertised_ids_unique(setup_core) -> None:
|
||||
"""Two entities of one type cannot be sent with the same id."""
|
||||
base = {"packet_interface": "link"}
|
||||
assert CONFIG_SCHEMA({**base, "sensors": ["a", {"id": "b", "broadcast_id": "c"}]})
|
||||
with pytest.raises(cv.Invalid, match="'a' is sent for two entities"):
|
||||
CONFIG_SCHEMA({**base, "sensors": ["a", {"id": "b", "broadcast_id": "a"}]})
|
||||
# Sensors and binary sensors are numbered separately, so may share an id
|
||||
assert CONFIG_SCHEMA({**base, "sensors": ["a"], "binary_sensors": ["a"]})
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Unit tests for the sx126x and sx127x packet interface platforms."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.packet_interface import CONF_MAX_PACKET_SIZE, radio_link
|
||||
from esphome.components.sx126x import packet_interface as sx126x_packet_interface
|
||||
from esphome.components.sx127x import packet_interface as sx127x_packet_interface
|
||||
import esphome.final_validate as fv
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("schema", "radio_key"),
|
||||
[
|
||||
(sx126x_packet_interface.CONFIG_SCHEMA, "sx126x_id"),
|
||||
(sx127x_packet_interface.CONFIG_SCHEMA, "sx127x_id"),
|
||||
],
|
||||
)
|
||||
def test_max_packet_size_is_radio_limit(
|
||||
setup_core, schema: cv.Schema, radio_key: str
|
||||
) -> None:
|
||||
"""A radio packet holds at most 255 bytes, which is also the default."""
|
||||
assert schema({radio_key: "radio"})[CONF_MAX_PACKET_SIZE] == 255
|
||||
with pytest.raises(cv.Invalid):
|
||||
schema({radio_key: "radio", CONF_MAX_PACKET_SIZE: 256})
|
||||
|
||||
|
||||
def _lora(**options: object) -> dict:
|
||||
return {
|
||||
"modulation": "LORA",
|
||||
"spreading_factor": 7,
|
||||
"bandwidth": "125_0kHz",
|
||||
"coding_rate": "CR_4_5",
|
||||
"preamble_size": 8,
|
||||
"crc_enable": False,
|
||||
**options,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("radio", "airtime_ms"),
|
||||
[
|
||||
# Worked by hand from the Semtech formula, for a 255 byte packet with an explicit header
|
||||
(_lora(spreading_factor=7), 394.5),
|
||||
(_lora(spreading_factor=7, crc_enable=True), 399.6),
|
||||
(_lora(spreading_factor=10), 2254.8),
|
||||
# A symbol takes over 16 ms, so the low data rate optimization lengthens the packet
|
||||
(_lora(spreading_factor=12), 9019.4),
|
||||
],
|
||||
)
|
||||
def test_lora_airtime(radio: dict, airtime_ms: float) -> None:
|
||||
"""The airtime of the largest LoRa packet follows the Semtech formula."""
|
||||
assert radio_link.packet_airtime_ms(radio, 255) == pytest.approx(
|
||||
airtime_ms, abs=0.5
|
||||
)
|
||||
|
||||
|
||||
def test_max_busy_follows_airtime() -> None:
|
||||
"""Listen before talk waits at least 2 s, and longer than the largest packet takes."""
|
||||
assert radio_link.max_busy_ms(_lora(), 255) == 2000
|
||||
assert radio_link.max_busy_ms(_lora(spreading_factor=12), 255) > 9019
|
||||
fsk = {
|
||||
"modulation": "FSK",
|
||||
"preamble_size": 4,
|
||||
"bitrate": 1200,
|
||||
"sync_value": [1, 2],
|
||||
}
|
||||
assert radio_link.packet_airtime_ms(fsk, 64) == pytest.approx(
|
||||
(4 + 2 + 1 + 64 + 2) * 8 / 1200 * 1000
|
||||
)
|
||||
|
||||
|
||||
class _FullConfig(dict):
|
||||
"""Enough of the full configuration for the radio links' final validation."""
|
||||
|
||||
def __init__(self, domain: str, radio: dict) -> None:
|
||||
super().__init__({domain: [{"rx_start": True, **radio}]})
|
||||
self.domain = domain
|
||||
|
||||
def get_path_for_id(self, id_: object) -> list:
|
||||
return [self.domain, 0, "id"]
|
||||
|
||||
def get_config_for_path(self, path: list) -> dict:
|
||||
return self[path[0]][path[1]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module", "domain", "radio", "link", "match", "fix"),
|
||||
[
|
||||
(
|
||||
sx126x_packet_interface,
|
||||
"sx126x",
|
||||
_lora(payload_length=10),
|
||||
{},
|
||||
"payload_length",
|
||||
{"payload_length": 0},
|
||||
),
|
||||
(
|
||||
sx127x_packet_interface,
|
||||
"sx127x",
|
||||
_lora(payload_length=10),
|
||||
{},
|
||||
"payload_length",
|
||||
{"payload_length": 0},
|
||||
),
|
||||
(
|
||||
sx127x_packet_interface,
|
||||
"sx127x",
|
||||
{
|
||||
"modulation": "FSK",
|
||||
"packet_mode": False,
|
||||
"payload_length": 0,
|
||||
"sync_value": [0x12],
|
||||
},
|
||||
{},
|
||||
"packet_mode",
|
||||
{"packet_mode": True},
|
||||
),
|
||||
(
|
||||
sx127x_packet_interface,
|
||||
"sx127x",
|
||||
{
|
||||
"modulation": "OOK",
|
||||
"packet_mode": True,
|
||||
"payload_length": 0,
|
||||
"sync_value": [0x12],
|
||||
},
|
||||
{CONF_MAX_PACKET_SIZE: 65},
|
||||
"at most 64",
|
||||
None,
|
||||
),
|
||||
(
|
||||
sx127x_packet_interface,
|
||||
"sx127x",
|
||||
{
|
||||
"modulation": "FSK",
|
||||
"packet_mode": True,
|
||||
"payload_length": 0,
|
||||
"sync_value": [],
|
||||
},
|
||||
{},
|
||||
"sync_value",
|
||||
{"sync_value": [0x12]},
|
||||
),
|
||||
(
|
||||
sx126x_packet_interface,
|
||||
"sx126x",
|
||||
{"modulation": "FSK", "payload_length": 0, "sync_value": []},
|
||||
{},
|
||||
"sync_value",
|
||||
{"sync_value": [0x12, 0x34]},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_radio_settings_checked(
|
||||
module: object,
|
||||
domain: str,
|
||||
radio: dict,
|
||||
link: dict,
|
||||
match: str,
|
||||
fix: dict | None,
|
||||
) -> None:
|
||||
"""Packets vary in length; FSK or OOK on an SX127x needs packet mode and a sync word, and holds at most 64 bytes;
|
||||
FSK on an SX126x needs a sync word.
|
||||
"""
|
||||
config = {f"{domain}_id": "radio", CONF_MAX_PACKET_SIZE: 64, **link}
|
||||
token = fv.full_config.set(_FullConfig(domain, radio))
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match=match):
|
||||
module.FINAL_VALIDATE_SCHEMA(config)
|
||||
if fix is not None:
|
||||
fv.full_config.set(_FullConfig(domain, {**radio, **fix}))
|
||||
assert module.FINAL_VALIDATE_SCHEMA(config)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("link", "match"),
|
||||
[
|
||||
({}, None),
|
||||
({"encryption": "secret"}, None),
|
||||
({"reliability": {}}, "reliability"),
|
||||
(
|
||||
{
|
||||
"addressing": {
|
||||
"address": 1,
|
||||
"peers": [
|
||||
{"id": "a", "destination": 2, "session": {}},
|
||||
{"id": "b", "destination": 3},
|
||||
],
|
||||
}
|
||||
},
|
||||
"session",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rx_start_needed_for_replies(setup_core, link: dict, match: str | None) -> None:
|
||||
"""A radio that sleeps after sending suits only a link whose layers need no packets back."""
|
||||
sleeping = _lora(payload_length=0, rx_start=False)
|
||||
config = sx126x_packet_interface.CONFIG_SCHEMA({"sx126x_id": "radio", **link})
|
||||
token = fv.full_config.set(_FullConfig("sx126x", sleeping))
|
||||
try:
|
||||
if match is None:
|
||||
assert sx126x_packet_interface.FINAL_VALIDATE_SCHEMA(config)
|
||||
return
|
||||
with pytest.raises(cv.Invalid, match=f"'{match}'.*rx_start"):
|
||||
sx126x_packet_interface.FINAL_VALIDATE_SCHEMA(config)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
@@ -0,0 +1,390 @@
|
||||
"""Unit tests for the packet_interface component."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import packet_interface
|
||||
from esphome.components.packet_interface import CONF_MAX_PACKET_SIZE
|
||||
from esphome.components.packet_interface.layers import (
|
||||
CONF_ADDRESSING,
|
||||
CONF_FRAGMENTATION,
|
||||
CONF_RELIABILITY,
|
||||
CONF_SESSION,
|
||||
RELIABILITY,
|
||||
STACK_ORDER,
|
||||
LayerKind,
|
||||
)
|
||||
from esphome.components.packet_interface.layers.basic_session import CONF_KEEP_ALIVE
|
||||
from esphome.components.packet_interface.layers.hdlc_reliability import (
|
||||
CONF_MAX_QUEUE_LENGTH,
|
||||
)
|
||||
from esphome.components.uart import packet_interface as uart_packet_interface
|
||||
from esphome.components.udp import packet_interface as udp_packet_interface
|
||||
from esphome.const import CONF_ENCRYPTION
|
||||
from esphome.core import ID
|
||||
import esphome.final_validate as fv
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("schema", "default", "limit"),
|
||||
[
|
||||
(uart_packet_interface.CONFIG_SCHEMA, 508, 4096),
|
||||
(udp_packet_interface.CONFIG_SCHEMA, 508, 508),
|
||||
],
|
||||
)
|
||||
def test_platform_max_packet_size(
|
||||
setup_core, schema: cv.Schema, default: int, limit: int
|
||||
) -> None:
|
||||
"""Each platform has its own default and upper limit for max_packet_size."""
|
||||
assert schema({})[CONF_MAX_PACKET_SIZE] == default
|
||||
assert schema({CONF_MAX_PACKET_SIZE: limit})[CONF_MAX_PACKET_SIZE] == limit
|
||||
with pytest.raises(cv.Invalid):
|
||||
schema({CONF_MAX_PACKET_SIZE: limit + 1})
|
||||
with pytest.raises(cv.Invalid):
|
||||
schema({CONF_MAX_PACKET_SIZE: 15})
|
||||
|
||||
|
||||
def test_schema_default(setup_core) -> None:
|
||||
"""packet_interface_schema applies the default it is given."""
|
||||
schema = packet_interface.packet_interface_schema(
|
||||
packet_interface.PacketInterface, 100
|
||||
)
|
||||
assert schema({})[CONF_MAX_PACKET_SIZE] == 100
|
||||
|
||||
|
||||
def test_reliability_defaults(setup_core) -> None:
|
||||
"""An empty reliability key selects the HDLC layer with its defaults."""
|
||||
config = uart_packet_interface.CONFIG_SCHEMA({CONF_RELIABILITY: None})
|
||||
reliability = config[CONF_RELIABILITY]
|
||||
assert reliability["type"] == "hdlc"
|
||||
assert reliability["timeout"].total_milliseconds == 200
|
||||
assert reliability[CONF_MAX_QUEUE_LENGTH] == 4
|
||||
assert reliability["max_retries"] == 5
|
||||
|
||||
|
||||
def test_reliability_queue_length_limit(setup_core) -> None:
|
||||
"""The queue cannot hold more frames than 3 bit sequence numbers allow."""
|
||||
with pytest.raises(cv.Invalid):
|
||||
uart_packet_interface.CONFIG_SCHEMA(
|
||||
{CONF_RELIABILITY: {CONF_MAX_QUEUE_LENGTH: 8}}
|
||||
)
|
||||
|
||||
|
||||
def test_reliability_timeout_minimum(setup_core) -> None:
|
||||
"""A timeout shorter than a few loop passes cannot work."""
|
||||
uart_packet_interface.CONFIG_SCHEMA({CONF_RELIABILITY: {"timeout": "50ms"}})
|
||||
with pytest.raises(cv.Invalid):
|
||||
uart_packet_interface.CONFIG_SCHEMA({CONF_RELIABILITY: {"timeout": "40ms"}})
|
||||
|
||||
|
||||
def test_ack_delay_shorter_than_timeout(setup_core) -> None:
|
||||
"""The acknowledgement must not wait longer than the peer's timeout."""
|
||||
reliability = uart_packet_interface.CONFIG_SCHEMA(
|
||||
{CONF_RELIABILITY: {"timeout": "500ms", "ack_delay": "100ms"}}
|
||||
)[CONF_RELIABILITY]
|
||||
assert reliability["ack_delay"].total_milliseconds == 100
|
||||
with pytest.raises(cv.Invalid, match="shorter"):
|
||||
uart_packet_interface.CONFIG_SCHEMA(
|
||||
{CONF_RELIABILITY: {"timeout": "500ms", "ack_delay": "500ms"}}
|
||||
)
|
||||
|
||||
|
||||
def test_layer_types_registered() -> None:
|
||||
"""Importing packet_interface registers the built-in layer types."""
|
||||
assert {kind.key: set(kind.types) for kind in STACK_ORDER} == {
|
||||
CONF_ENCRYPTION: {"xxtea"},
|
||||
CONF_RELIABILITY: {"hdlc"},
|
||||
CONF_FRAGMENTATION: {"basic"},
|
||||
CONF_SESSION: {"basic"},
|
||||
}
|
||||
|
||||
|
||||
async def _build(config: dict, lower: Any) -> Any:
|
||||
return lower
|
||||
|
||||
|
||||
def test_registered_type_selectable(setup_core) -> None:
|
||||
"""A newly registered layer type can be selected with type."""
|
||||
kind = LayerKind("test_kind", "test")
|
||||
kind.register("test", schema=cv.Schema({}))(_build)
|
||||
config = kind.schema()({"type": "test"})
|
||||
assert config["type"] == "test"
|
||||
assert kind.get_type(config).build is _build
|
||||
|
||||
|
||||
def test_duplicate_layer_type_rejected(setup_core) -> None:
|
||||
"""Registering a type under a name already in use is an error, not a silent replacement."""
|
||||
kind = LayerKind("test_kind", "test")
|
||||
kind.register("test", schema=cv.Schema({}))(_build)
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
kind.register("test", schema=cv.Schema({}))(_build)
|
||||
|
||||
|
||||
def test_late_registration_rejected(setup_core) -> None:
|
||||
"""A type registered after the schemas were built would be missing from them, so it is an error."""
|
||||
with pytest.raises(ValueError, match="after the schemas were built"):
|
||||
RELIABILITY.register("late", schema=cv.Schema({}))(_build)
|
||||
|
||||
|
||||
def test_session_defaults(setup_core) -> None:
|
||||
"""An empty session key selects the basic session, timing out after three keep-alives."""
|
||||
session = uart_packet_interface.CONFIG_SCHEMA({CONF_SESSION: None})[CONF_SESSION]
|
||||
assert session["type"] == "basic"
|
||||
assert session[CONF_KEEP_ALIVE].total_milliseconds == 10000
|
||||
assert session["timeout"].total_milliseconds == 30000
|
||||
|
||||
|
||||
def test_session_timeout(setup_core) -> None:
|
||||
"""The timeout follows keep_alive unless given, and must be longer than it."""
|
||||
session = uart_packet_interface.CONFIG_SCHEMA(
|
||||
{CONF_SESSION: {CONF_KEEP_ALIVE: "2s"}}
|
||||
)[CONF_SESSION]
|
||||
assert session["timeout"].total_milliseconds == 6000
|
||||
with pytest.raises(cv.Invalid):
|
||||
uart_packet_interface.CONFIG_SCHEMA(
|
||||
{CONF_SESSION: {CONF_KEEP_ALIVE: "5s", "timeout": "5s"}}
|
||||
)
|
||||
with pytest.raises(cv.Invalid):
|
||||
uart_packet_interface.CONFIG_SCHEMA({CONF_SESSION: {CONF_KEEP_ALIVE: "500ms"}})
|
||||
with pytest.raises(cv.Invalid):
|
||||
uart_packet_interface.CONFIG_SCHEMA({CONF_SESSION: {CONF_KEEP_ALIVE: "2h"}})
|
||||
with pytest.raises(cv.Invalid):
|
||||
uart_packet_interface.CONFIG_SCHEMA({CONF_SESSION: {"timeout": "4h"}})
|
||||
|
||||
|
||||
def test_encryption_key_only(setup_core) -> None:
|
||||
"""The key can be given on its own, and selects XXTEA."""
|
||||
encryption = uart_packet_interface.CONFIG_SCHEMA({CONF_ENCRYPTION: "secret"})[
|
||||
CONF_ENCRYPTION
|
||||
]
|
||||
assert encryption["type"] == "xxtea"
|
||||
assert encryption["key"] == "secret"
|
||||
assert encryption["rolling_code_enable"] is False
|
||||
|
||||
|
||||
def test_encryption_requires_key(setup_core) -> None:
|
||||
"""An encryption layer without a key is rejected."""
|
||||
with pytest.raises(cv.Invalid):
|
||||
uart_packet_interface.CONFIG_SCHEMA({CONF_ENCRYPTION: {}})
|
||||
with pytest.raises(cv.Invalid):
|
||||
uart_packet_interface.CONFIG_SCHEMA({CONF_ENCRYPTION: ""})
|
||||
|
||||
|
||||
def test_rolling_code_requires_session(setup_core) -> None:
|
||||
"""The rolling code needs the session's keep-alives, so the peer sends this device's boot number back."""
|
||||
rolling = {CONF_ENCRYPTION: {"key": "secret", "rolling_code_enable": True}}
|
||||
with pytest.raises(cv.Invalid, match="session"):
|
||||
uart_packet_interface.CONFIG_SCHEMA(rolling)
|
||||
uart_packet_interface.CONFIG_SCHEMA({**rolling, CONF_SESSION: None})
|
||||
uart_packet_interface.CONFIG_SCHEMA({CONF_ENCRYPTION: "secret"})
|
||||
|
||||
|
||||
def test_addressing_defaults_to_broadcast(setup_core) -> None:
|
||||
"""Without a destination, packets are broadcast; broadcast can also be named."""
|
||||
for addressing in ({"address": 1}, {"address": 1, "destination": "broadcast"}):
|
||||
config = uart_packet_interface.CONFIG_SCHEMA({CONF_ADDRESSING: addressing})
|
||||
assert config[CONF_ADDRESSING]["destination"] == 0xFFFF
|
||||
with pytest.raises(cv.Invalid):
|
||||
uart_packet_interface.CONFIG_SCHEMA({CONF_ADDRESSING: {"address": 0xFFFF}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layers",
|
||||
[
|
||||
{CONF_RELIABILITY: None},
|
||||
{CONF_SESSION: None},
|
||||
{
|
||||
CONF_ENCRYPTION: {"key": "secret", "rolling_code_enable": True},
|
||||
CONF_SESSION: None,
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_two_device_layers_need_destination(setup_core, layers: dict) -> None:
|
||||
"""Layers that work between two devices only cannot be used with broadcast addressing."""
|
||||
with pytest.raises(cv.Invalid, match="destination"):
|
||||
uart_packet_interface.CONFIG_SCHEMA({CONF_ADDRESSING: {"address": 1}, **layers})
|
||||
uart_packet_interface.CONFIG_SCHEMA(
|
||||
{CONF_ADDRESSING: {"address": 1, "destination": 2}, **layers}
|
||||
)
|
||||
|
||||
|
||||
def _peer(peer_id: str | ID, destination: Any, **layers: Any) -> dict:
|
||||
return {"id": peer_id, "destination": destination, **layers}
|
||||
|
||||
|
||||
def test_addressing_peers(setup_core) -> None:
|
||||
"""Each peer has its own destination and layers."""
|
||||
config = uart_packet_interface.CONFIG_SCHEMA(
|
||||
{
|
||||
CONF_ADDRESSING: {
|
||||
"address": 1,
|
||||
"peers": [
|
||||
_peer("peer_a", 2, reliability=None, session=None),
|
||||
_peer("peer_b", 3, encryption="secret"),
|
||||
_peer("everyone", "broadcast"),
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
peers = config[CONF_ADDRESSING]["peers"]
|
||||
assert [peer["destination"] for peer in peers] == [2, 3, 0xFFFF]
|
||||
assert CONF_RELIABILITY in peers[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("addressing", "layers", "match"),
|
||||
[
|
||||
({"peers": [_peer("a", 2), _peer("b", 2)]}, {}, "different destination"),
|
||||
({"peers": [_peer("a", "broadcast", reliability=None)]}, {}, "broadcast"),
|
||||
({"peers": [_peer("a", 2)]}, {CONF_RELIABILITY: None}, "goes in each peer"),
|
||||
({"destination": 2, "peers": [_peer("a", 3)]}, {}, "destination"),
|
||||
],
|
||||
)
|
||||
def test_addressing_peers_invalid(
|
||||
setup_core, addressing: dict, layers: dict, match: str
|
||||
) -> None:
|
||||
"""Peers need different destinations, two-device layers need a destination, and layers go in the peers."""
|
||||
with pytest.raises(cv.Invalid, match=match):
|
||||
uart_packet_interface.CONFIG_SCHEMA(
|
||||
{CONF_ADDRESSING: {"address": 1, **addressing}, **layers}
|
||||
)
|
||||
|
||||
|
||||
def test_fragmentation_defaults(setup_core) -> None:
|
||||
"""An empty fragmentation key joins packets of up to 1024 bytes."""
|
||||
fragmentation = uart_packet_interface.CONFIG_SCHEMA({CONF_FRAGMENTATION: None})[
|
||||
CONF_FRAGMENTATION
|
||||
]
|
||||
assert fragmentation["type"] == "basic"
|
||||
assert fragmentation["max_packet_size"] == 1024
|
||||
with pytest.raises(cv.Invalid):
|
||||
uart_packet_interface.CONFIG_SCHEMA(
|
||||
{CONF_FRAGMENTATION: {"max_packet_size": 8}}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("layers", "valid"),
|
||||
[
|
||||
# 508 - 3 leaves fragments of 503 bytes: 1024 bytes take 3, 2048 bytes take 5
|
||||
({CONF_RELIABILITY: None, CONF_FRAGMENTATION: None}, True),
|
||||
(
|
||||
{CONF_RELIABILITY: None, CONF_FRAGMENTATION: {"max_packet_size": 2048}},
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
CONF_RELIABILITY: {CONF_MAX_QUEUE_LENGTH: 5},
|
||||
CONF_FRAGMENTATION: {"max_packet_size": 2048},
|
||||
},
|
||||
True,
|
||||
),
|
||||
({CONF_FRAGMENTATION: {"max_packet_size": 16384}}, True),
|
||||
],
|
||||
)
|
||||
def test_fragments_fit_reliability_queue(setup_core, layers: dict, valid: bool) -> None:
|
||||
"""Every fragment of the largest packet must fit in the reliability queue at once."""
|
||||
if valid:
|
||||
uart_packet_interface.CONFIG_SCHEMA(layers)
|
||||
return
|
||||
with pytest.raises(cv.Invalid, match="5 fragments") as err:
|
||||
uart_packet_interface.CONFIG_SCHEMA(layers)
|
||||
assert err.value.path == [CONF_FRAGMENTATION, CONF_MAX_PACKET_SIZE]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("link", "match"),
|
||||
[
|
||||
# 256 - 4 for addressing - 16 for the rolling code - 3 for reliability leaves fragments of 231 bytes
|
||||
({}, "5 fragments of 231 bytes"),
|
||||
# 24 - 4 - 16 - 3 leaves 1 byte, too few for the fragment header
|
||||
({CONF_MAX_PACKET_SIZE: 24}, "no room"),
|
||||
],
|
||||
)
|
||||
def test_fragments_checked_in_peers(setup_core, link: dict, match: str) -> None:
|
||||
"""Fragments are checked against the layers of each peer, below the addressing layer."""
|
||||
peer = _peer(
|
||||
"a",
|
||||
2,
|
||||
encryption={"key": "secret", "rolling_code_enable": True},
|
||||
reliability=None,
|
||||
fragmentation=None,
|
||||
session=None,
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match=match) as err:
|
||||
uart_packet_interface.CONFIG_SCHEMA(
|
||||
{
|
||||
CONF_MAX_PACKET_SIZE: 256,
|
||||
**link,
|
||||
CONF_ADDRESSING: {"address": 1, "peers": [peer]},
|
||||
}
|
||||
)
|
||||
assert err.value.path[:4] == [CONF_ADDRESSING, "peers", 0, CONF_FRAGMENTATION]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "loads"),
|
||||
[
|
||||
({}, []),
|
||||
({CONF_RELIABILITY: None, CONF_SESSION: None}, []),
|
||||
({CONF_SESSION: {"binary_sensor": {"name": "Link"}}}, ["binary_sensor"]),
|
||||
({CONF_ENCRYPTION: "secret"}, ["xxtea"]),
|
||||
(
|
||||
{
|
||||
CONF_ADDRESSING: {
|
||||
"address": 1,
|
||||
"peers": [_peer("a", 2, encryption="secret"), _peer("b", 3)],
|
||||
}
|
||||
},
|
||||
["xxtea"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_auto_load_follows_layers(
|
||||
setup_core, config: dict[str, Any], loads: list[str]
|
||||
) -> None:
|
||||
"""Only the components that the configured layers use are loaded."""
|
||||
validated = uart_packet_interface.CONFIG_SCHEMA(config)
|
||||
assert packet_interface.AUTO_LOAD([validated]) == loads
|
||||
|
||||
|
||||
def test_auto_load_without_config() -> None:
|
||||
"""Tooling that lists dependencies gets everything a layer could load."""
|
||||
assert packet_interface.AUTO_LOAD() == ["binary_sensor", "xxtea"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"addressing",
|
||||
[
|
||||
{"address": 1, "destination": 1},
|
||||
{"address": 1, "peers": [_peer("a", 2), _peer("b", 1)]},
|
||||
],
|
||||
)
|
||||
def test_addressing_own_address_rejected(setup_core, addressing: dict) -> None:
|
||||
"""A device cannot send to its own address, as its own packets are dropped as echoes."""
|
||||
with pytest.raises(cv.Invalid, match="own address"):
|
||||
uart_packet_interface.CONFIG_SCHEMA({CONF_ADDRESSING: addressing})
|
||||
|
||||
|
||||
def test_application_needs_peer_id_with_peers(setup_core) -> None:
|
||||
"""An application must use one peer's id, not the id of a link with peers."""
|
||||
validator = packet_interface.final_validate_interface("packet_interface")
|
||||
token = fv.full_config.set(
|
||||
{
|
||||
"packet_interface": [
|
||||
{"id": ID("bus"), CONF_ADDRESSING: {"peers": [_peer(ID("a"), 2)]}},
|
||||
{"id": ID("single"), CONF_ADDRESSING: {"destination": 2}},
|
||||
]
|
||||
}
|
||||
)
|
||||
try:
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="has peers, so use the id of one of them, such as 'a'"
|
||||
):
|
||||
validator({"packet_interface": ID("bus")})
|
||||
assert validator({"packet_interface": ID("single")})
|
||||
assert validator({"packet_interface": ID("a")})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for the tcp packet interface platform."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import socket
|
||||
from esphome.components.packet_interface import CONF_MAX_PACKET_SIZE
|
||||
from esphome.components.tcp import packet_interface as tcp_packet_interface
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_client_and_server(setup_core) -> None:
|
||||
"""A client needs a host; a server listens on its port."""
|
||||
client = tcp_packet_interface.CONFIG_SCHEMA({"host": "192.168.1.50", "port": 6060})
|
||||
assert client["role"] == "client"
|
||||
assert client[CONF_MAX_PACKET_SIZE] == 1022
|
||||
assert tcp_packet_interface.CONFIG_SCHEMA({"role": "server", "port": 6060})
|
||||
with pytest.raises(cv.Invalid):
|
||||
tcp_packet_interface.CONFIG_SCHEMA({"port": 6060})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layer", ["reliability", "session", "addressing"])
|
||||
def test_provided_layers_rejected(setup_core, layer: str) -> None:
|
||||
"""TCP provides these itself, so they cannot be configured on it."""
|
||||
with pytest.raises(cv.Invalid, match=f"provides '{layer}' itself"):
|
||||
tcp_packet_interface.CONFIG_SCHEMA(
|
||||
{"host": "192.168.1.50", "port": 6060, layer: {"address": 1}}
|
||||
)
|
||||
|
||||
|
||||
def test_rolling_code_rejected(setup_core) -> None:
|
||||
"""The rolling code needs the session's keep-alives, which TCP does not send through the layers."""
|
||||
with pytest.raises(cv.Invalid, match="cannot be used on this link"):
|
||||
tcp_packet_interface.CONFIG_SCHEMA(
|
||||
{
|
||||
"host": "192.168.1.50",
|
||||
"port": 6060,
|
||||
"encryption": {"key": "secret", "rolling_code_enable": True},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_auto_load_binary_sensor_for_connected(setup_core) -> None:
|
||||
"""The binary_sensor component is loaded only for the connected sensor."""
|
||||
assert tcp_packet_interface.AUTO_LOAD() == ["socket", "binary_sensor"]
|
||||
assert tcp_packet_interface.AUTO_LOAD({}) == ["socket", "binary_sensor"]
|
||||
config = {"host": "192.168.1.50", "port": 6060}
|
||||
assert tcp_packet_interface.AUTO_LOAD(config) == ["socket"]
|
||||
config["connected"] = {"name": "Link"}
|
||||
assert tcp_packet_interface.AUTO_LOAD(config) == ["socket", "binary_sensor"]
|
||||
|
||||
|
||||
def test_timeout(setup_core) -> None:
|
||||
"""The timeout follows keep_alive unless given, and must be longer than it."""
|
||||
base = {"host": "192.168.1.50", "port": 6060}
|
||||
config = tcp_packet_interface.CONFIG_SCHEMA({**base, "keep_alive": "2s"})
|
||||
assert config["timeout"].total_milliseconds == 6000
|
||||
# A peer that sends keep-alives less often is matched with a longer timeout
|
||||
config = tcp_packet_interface.CONFIG_SCHEMA({**base, "timeout": "90s"})
|
||||
assert config["timeout"].total_milliseconds == 90000
|
||||
with pytest.raises(cv.Invalid, match="longer than keep_alive"):
|
||||
tcp_packet_interface.CONFIG_SCHEMA(
|
||||
{**base, "keep_alive": "5s", "timeout": "5s"}
|
||||
)
|
||||
with pytest.raises(cv.Invalid):
|
||||
tcp_packet_interface.CONFIG_SCHEMA({**base, "keep_alive": "2h"})
|
||||
with pytest.raises(cv.Invalid):
|
||||
tcp_packet_interface.CONFIG_SCHEMA({**base, "timeout": "4h"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("role", "tcp", "listen"), [("client", 1, 0), ("server", 1, 1)]
|
||||
)
|
||||
def test_sockets_reserved(setup_core, role: str, tcp: int, listen: int) -> None:
|
||||
"""A client reserves one socket; a server also reserves a listener."""
|
||||
config = {"role": role, "port": 6060}
|
||||
if role == "client":
|
||||
config["host"] = "192.168.1.50"
|
||||
tcp_packet_interface.CONFIG_SCHEMA(config)
|
||||
name = "tcp.packet_interface"
|
||||
assert CORE.data[socket.KEY_SOCKET_CONSUMERS_TCP][name] == tcp
|
||||
assert (
|
||||
CORE.data.get(socket.KEY_SOCKET_CONSUMERS_TCP_LISTEN, {}).get(name, 0) == listen
|
||||
)
|
||||
Reference in New Issue
Block a user