mirror of
https://github.com/esphome/esphome.git
synced 2026-10-21 13:34:12 +00:00
[packet_interface] Add addressing and fragmentation layers
The addressing layer lets several devices share a channel such as an RS485 bus or a radio, with 16-bit addresses. A link can carry several peers, each with its own stack of layers and its own id. Layers that work between two devices only are rejected with broadcast addressing. The fragmentation layer splits packets larger than the layers below allow, and joins them again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1552846578
commit
592d1820ba
@@ -7,7 +7,11 @@ 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, interface_layers
|
||||
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
|
||||
@@ -108,7 +112,9 @@ def _final_validate(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
final_validate_interface(CONF_PACKET_INTERFACE), _final_validate
|
||||
)
|
||||
|
||||
|
||||
def _validate_remote_id(config: ConfigType) -> ConfigType:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Callable
|
||||
import importlib
|
||||
import pkgutil
|
||||
from typing import Any
|
||||
@@ -13,9 +14,16 @@ from esphome.types import ConfigType
|
||||
|
||||
from . import layers
|
||||
from .layers import (
|
||||
ADDRESSING,
|
||||
BROADCAST_ADDRESS,
|
||||
CONF_ADDRESSING,
|
||||
CONF_DESTINATION,
|
||||
CONF_PEERS,
|
||||
STACK_ORDER,
|
||||
PacketInterface,
|
||||
build_stack,
|
||||
check_session,
|
||||
check_single_peer,
|
||||
packet_interface_ns,
|
||||
stack_auto_load,
|
||||
stack_schema,
|
||||
@@ -31,13 +39,17 @@ CONF_PACKET_INTERFACE = "packet_interface"
|
||||
for module_info in pkgutil.iter_modules(layers.__path__):
|
||||
importlib.import_module(f".layers.{module_info.name}", package=__package__)
|
||||
|
||||
LINK_LAYER_SCHEMAS = stack_schema()
|
||||
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",
|
||||
@@ -45,7 +57,7 @@ __all__ = [
|
||||
|
||||
|
||||
def AUTO_LOAD(config: list[ConfigType] | None = None) -> list[str]:
|
||||
"""Load only what the configured layers use.
|
||||
"""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.
|
||||
"""
|
||||
@@ -54,22 +66,61 @@ def AUTO_LOAD(config: list[ConfigType] | None = None) -> list[str]:
|
||||
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)
|
||||
if (addressing := config.get(CONF_ADDRESSING)) is None:
|
||||
return config
|
||||
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],
|
||||
)
|
||||
return config
|
||||
addressing.setdefault(CONF_DESTINATION, BROADCAST_ADDRESS)
|
||||
check_single_peer(config, addressing[CONF_DESTINATION])
|
||||
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."""
|
||||
"""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 several peers must be used through one of the peers."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
interface_id: ID = config[key]
|
||||
for interface in fv.full_config.get().get(DOMAIN, []):
|
||||
if interface[CONF_ID].id == interface_id.id and CONF_PEERS in interface.get(
|
||||
CONF_ADDRESSING, {}
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{interface_id.id}' has several peers, so use the id of one of them",
|
||||
path=[key],
|
||||
)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def packet_interface_schema(
|
||||
class_: MockObjClass,
|
||||
default_max_packet_size: int,
|
||||
@@ -97,10 +148,17 @@ async def new_packet_interface(config: ConfigType, *args: Any) -> MockObj:
|
||||
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().
|
||||
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)
|
||||
await build_stack(config, var)
|
||||
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,87 @@
|
||||
#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)
|
||||
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;
|
||||
// A device with a port of its own is heard only there; the broadcast port hears the rest
|
||||
AddressingPort *target = nullptr;
|
||||
for (auto *port : this->ports_) {
|
||||
if (port->destination_ == source) {
|
||||
target = port;
|
||||
break;
|
||||
}
|
||||
if (port->destination_ == BROADCAST_ADDRESS)
|
||||
target = 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)
|
||||
: addressing_(addressing), destination_(destination) {
|
||||
if (only_port) {
|
||||
this->stack_on_(addressing->get_link(), CAPABILITY_ADDRESSING);
|
||||
} else {
|
||||
this->inherit_from_(addressing->get_link(), CAPABILITY_ADDRESSING);
|
||||
}
|
||||
this->max_packet_size_ = addressing->get_link()->get_max_packet_size() - BasicAddressing::HEADER_SIZE;
|
||||
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,68 @@
|
||||
#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 packets from devices that have no port of their own.
|
||||
*/
|
||||
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,138 @@
|
||||
#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) : lower_(lower) {
|
||||
this->stack_on_(lower, CAPABILITY_FRAGMENTATION);
|
||||
this->max_packet_size_ = max_packet_size;
|
||||
}
|
||||
|
||||
size_t BasicFragmentation::fragments_for_(size_t size) const {
|
||||
size_t fragment_size = this->fragment_size_();
|
||||
return std::max<size_t>(1, (size + fragment_size - 1) / fragment_size);
|
||||
}
|
||||
|
||||
void BasicFragmentation::setup() {
|
||||
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
|
||||
this->queue_too_short_ = this->fragments_for_(this->max_packet_size_) > this->lower_->get_send_capacity();
|
||||
this->tx_buffer_ = std::make_unique<uint8_t[]>(this->fragment_size_());
|
||||
this->rx_buffer_ = std::make_unique<uint8_t[]>(this->max_packet_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() {
|
||||
size_t fragments = 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->is_failed())
|
||||
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_.get(), fragment_size, index * fragment_size);
|
||||
header[1] = index | (index + 1 == fragments ? LAST_FRAGMENT : 0);
|
||||
PacketBuffer body(this->tx_buffer_.get(), 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)
|
||||
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) {
|
||||
if (this->rx_joining_) {
|
||||
ESP_LOGV(TAG, "%s: Incomplete packet %u dropped", this->name_, this->rx_number_);
|
||||
}
|
||||
this->rx_joining_ = false;
|
||||
if (last) {
|
||||
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;
|
||||
}
|
||||
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_ || number != this->rx_number_ || meta_data.source_address != this->rx_source_ ||
|
||||
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_.get() + 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_.get(), this->rx_length_), meta_data);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface
|
||||
#endif
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_PACKET_INTERFACE_BASIC_FRAGMENTATION
|
||||
#include <memory>
|
||||
|
||||
#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 one arrives with another packet number or from another source address. 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 { return this->lower_->get_max_packet_size() - HEADER_SIZE; }
|
||||
size_t fragments_for_(size_t size) const;
|
||||
|
||||
PacketInterface *lower_;
|
||||
// One fragment being sent, and the packet being joined; both allocated in setup()
|
||||
std::unique_ptr<uint8_t[]> tx_buffer_;
|
||||
std::unique_ptr<uint8_t[]> rx_buffer_;
|
||||
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
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Each module in this package implements one layer type and registers it with its kind, for example:
|
||||
|
||||
@RELIABILITY.register("hdlc", schema=HDLC_SCHEMA)
|
||||
@RELIABILITY.register("hdlc", schema=HDLC_SCHEMA, single_peer=lambda config: True)
|
||||
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
|
||||
...
|
||||
|
||||
@@ -16,17 +16,23 @@ from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_TYPE
|
||||
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"
|
||||
|
||||
# Builds a layer above lower and returns it
|
||||
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]]
|
||||
|
||||
|
||||
@@ -42,8 +48,11 @@ def _nothing(config: ConfigType) -> list[str]:
|
||||
class LayerType:
|
||||
"""One implementation of a kind of layer."""
|
||||
|
||||
schema: Any
|
||||
# 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
|
||||
@@ -68,11 +77,16 @@ class LayerKind:
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
schema: Any,
|
||||
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,
|
||||
) -> Callable[[LayerBuilder], LayerBuilder]:
|
||||
"""Register a type with its schema."""
|
||||
"""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:
|
||||
@@ -84,7 +98,13 @@ class LayerKind:
|
||||
raise ValueError(
|
||||
f"{self.key} layer type '{name}' is already registered"
|
||||
)
|
||||
self.types[name] = LayerType(schema, build, needs_session, auto_load)
|
||||
self.types[name] = LayerType(
|
||||
schema_factory or fixed_schema,
|
||||
build,
|
||||
single_peer,
|
||||
needs_session,
|
||||
auto_load,
|
||||
)
|
||||
return build
|
||||
|
||||
return decorator
|
||||
@@ -103,7 +123,7 @@ class LayerKind:
|
||||
return cv.All(
|
||||
self._expand,
|
||||
cv.typed_schema(
|
||||
{name: layer.schema for name, layer in self.types.items()},
|
||||
{name: layer.schema_factory() for name, layer in self.types.items()},
|
||||
default_type=self.default_type,
|
||||
),
|
||||
)
|
||||
@@ -112,11 +132,15 @@ class LayerKind:
|
||||
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, from the bottom up
|
||||
STACK_ORDER = (ENCRYPTION, RELIABILITY, SESSION)
|
||||
# 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:
|
||||
@@ -139,6 +163,21 @@ def check_session(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
|
||||
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 stack_auto_load(config: ConfigType) -> set[str]:
|
||||
"""The components that the layers configured in config need loaded."""
|
||||
loads: set[str] = set()
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""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,
|
||||
stack_schema,
|
||||
)
|
||||
|
||||
CONF_PORT_ID = "port_id"
|
||||
|
||||
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
|
||||
|
||||
|
||||
# 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),
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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,31 @@
|
||||
"""Fragmentation layer that splits packets larger than the layers below allow, and joins them again."""
|
||||
|
||||
import esphome.codegen as cg
|
||||
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
|
||||
|
||||
CONF_MAX_PACKET_SIZE = "max_packet_size"
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Fragments from different devices are told apart by the addressing layer's source address, so several devices can
|
||||
# share a channel; without addressing, two devices sending fragmented packets at once can make the receiver drop both
|
||||
@FRAGMENTATION.register("basic", schema=BASIC_FRAGMENTATION_SCHEMA)
|
||||
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
|
||||
@@ -57,6 +57,7 @@ BASIC_SESSION_SCHEMA = cv.All(
|
||||
@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:
|
||||
|
||||
@@ -47,7 +47,7 @@ HDLC_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
@RELIABILITY.register("hdlc", schema=HDLC_SCHEMA)
|
||||
@RELIABILITY.register("hdlc", schema=HDLC_SCHEMA, single_peer=lambda config: True)
|
||||
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
|
||||
cg.add_define("USE_PACKET_INTERFACE_HDLC")
|
||||
var = cg.new_Pvariable(config[CONF_ID], lower)
|
||||
|
||||
@@ -23,10 +23,12 @@ XXTEA_SCHEMA = cv.Schema(
|
||||
).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,
|
||||
# The rolling code accepts a packet only once the peer has sent back this device's boot number
|
||||
single_peer=lambda config: config[CONF_ROLLING_CODE_ENABLE],
|
||||
needs_session=lambda config: config[CONF_ROLLING_CODE_ENABLE],
|
||||
auto_load=lambda config: ["xxtea"],
|
||||
)
|
||||
|
||||
@@ -7,7 +7,12 @@ from esphome.components.packet_transport import (
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_PACKET_INTERFACE, PacketInterface, packet_interface_ns
|
||||
from .. import (
|
||||
CONF_PACKET_INTERFACE,
|
||||
PacketInterface,
|
||||
final_validate_interface,
|
||||
packet_interface_ns,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DEPENDENCIES = ["packet_interface"]
|
||||
@@ -22,6 +27,8 @@ CONFIG_SCHEMA = transport_schema(PacketInterfaceTransport).extend(
|
||||
}
|
||||
)
|
||||
|
||||
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])
|
||||
|
||||
@@ -259,6 +259,9 @@
|
||||
#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
|
||||
|
||||
@@ -4,6 +4,9 @@ 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")
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#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 {
|
||||
|
||||
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:
|
||||
MockLink() { this->set_max_packet_size(64); }
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
|
||||
this->sent.push_back(data.to_vector());
|
||||
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(data.to_vector());
|
||||
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, 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, 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());
|
||||
}
|
||||
|
||||
// The test main does not construct App; the reliability layer's timers need its scheduler
|
||||
class AddressingReliabilityTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
new (&App) Application;
|
||||
App.pre_setup("addressing-tests", 16, "", 0);
|
||||
}
|
||||
void TearDown() override { App.~Application(); }
|
||||
};
|
||||
|
||||
// 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(data.to_vector()); });
|
||||
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,194 @@
|
||||
#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 {
|
||||
|
||||
using Packet = std::vector<uint8_t>;
|
||||
|
||||
// A link layer that records what is sent, with a limited send capacity
|
||||
class MockLink : public PacketInterface {
|
||||
public:
|
||||
MockLink() { this->set_max_packet_size(12); }
|
||||
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
|
||||
this->sent.push_back(data.to_vector());
|
||||
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(data.to_vector()); });
|
||||
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_;
|
||||
};
|
||||
|
||||
// The test main does not construct App; the reliability layer's timers need its scheduler
|
||||
class BasicFragmentationTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
new (&App) Application;
|
||||
App.pre_setup("fragmentation-tests", 19, "", 0);
|
||||
}
|
||||
void TearDown() override { App.~Application(); }
|
||||
|
||||
// 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);
|
||||
for (auto &packet : b.received)
|
||||
EXPECT_EQ(packet, make_packet(20)); // nothing mixed from the two
|
||||
}
|
||||
|
||||
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(data.to_vector()); });
|
||||
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})});
|
||||
}
|
||||
|
||||
} // namespace esphome::packet_interface::fragmentation_testing
|
||||
@@ -9,24 +9,40 @@ packet_interface:
|
||||
- platform: uart
|
||||
id: packet_interface_uart
|
||||
max_packet_size: 256
|
||||
reliability:
|
||||
timeout: 300ms
|
||||
ack_delay: 50ms
|
||||
max_queue_length: 3
|
||||
max_retries: 4
|
||||
encryption:
|
||||
key: Johnny Cash
|
||||
rolling_code_enable: true
|
||||
session:
|
||||
keep_alive: 5s
|
||||
timeout: 12s
|
||||
binary_sensor:
|
||||
name: UART session
|
||||
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:
|
||||
fragmentation:
|
||||
max_packet_size: 2048
|
||||
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:
|
||||
@@ -36,7 +52,7 @@ sensor:
|
||||
|
||||
packet_transport:
|
||||
- platform: packet_interface
|
||||
packet_interface: packet_interface_uart
|
||||
packet_interface: packet_interface_peer_a
|
||||
sensors:
|
||||
- packet_interface_sensor
|
||||
- platform: packet_interface
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for the packet_interface component."""
|
||||
"""Unit tests for the packet_interface component and its platforms."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -8,6 +8,8 @@ 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,
|
||||
@@ -68,61 +70,6 @@ def test_reliability_queue_length_limit(setup_core) -> None:
|
||||
)
|
||||
|
||||
|
||||
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_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]
|
||||
@@ -176,13 +123,162 @@ def test_rolling_code_requires_session(setup_core) -> 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, 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_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_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_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(
|
||||
("config", "loads"),
|
||||
[
|
||||
({}, []),
|
||||
({CONF_RELIABILITY: None, CONF_SESSION: None}, []),
|
||||
({CONF_SESSION: {"binary_sensor": {"name": "Link"}}}, ["binary_sensor"]),
|
||||
({CONF_RELIABILITY: None, "session": None}, []),
|
||||
({"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(
|
||||
|
||||
Reference in New Issue
Block a user