diff --git a/esphome/components/packet_interface/__init__.py b/esphome/components/packet_interface/__init__.py index c663d5b84ff..60a7f281080 100644 --- a/esphome/components/packet_interface/__init__.py +++ b/esphome/components/packet_interface/__init__.py @@ -10,7 +10,13 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType from . import layers -from .layers import PacketInterface, build_stack, packet_interface_ns, stack_schema +from .layers import ( + PacketInterface, + build_stack, + packet_interface_ns, + stack_auto_load, + stack_schema, +) CODEOWNERS = ["@clydebarrow"] DOMAIN = "packet_interface" @@ -34,6 +40,19 @@ __all__ = [ ] +def AUTO_LOAD(config: list[ConfigType] | None = None) -> list[str]: + """Load only what the configured layers use. + + 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) + return sorted(loads) + + def packet_interface_schema( class_: MockObjClass, default_max_packet_size: int, diff --git a/esphome/components/packet_interface/basic_session.cpp b/esphome/components/packet_interface/basic_session.cpp new file mode 100644 index 00000000000..bc8b6ea76db --- /dev/null +++ b/esphome/components/packet_interface/basic_session.cpp @@ -0,0 +1,129 @@ +#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) : lower_(lower) { + this->stack_on_(lower, CAPABILITY_SESSION); + this->max_packet_size_ = lower->get_max_packet_size() - HEADER_SIZE; + // 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() { + 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) { + 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(this->boot_id_), static_cast(this->boot_id_ >> 8), + static_cast(this->boot_id_ >> 16), static_cast(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 diff --git a/esphome/components/packet_interface/basic_session.h b/esphome/components/packet_interface/basic_session.h new file mode 100644 index 00000000000..452a0c7415f --- /dev/null +++ b/esphome/components/packet_interface/basic_session.h @@ -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 diff --git a/esphome/components/packet_interface/layers/__init__.py b/esphome/components/packet_interface/layers/__init__.py index c509e59ae1f..fa9ab8792b6 100644 --- a/esphome/components/packet_interface/layers/__init__.py +++ b/esphome/components/packet_interface/layers/__init__.py @@ -16,7 +16,7 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_TYPE +from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_TYPE from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -24,31 +24,46 @@ packet_interface_ns = cg.esphome_ns.namespace("packet_interface") PacketInterface = packet_interface_ns.class_("PacketInterface", cg.Component) CONF_RELIABILITY = "reliability" +CONF_SESSION = "session" # Builds a layer above lower and returns it LayerBuilder = Callable[[ConfigType, MockObj], Awaitable[Any]] +def _nothing(config: ConfigType) -> list[str]: + return [] + + @dataclass(frozen=True) class LayerType: """One implementation of a kind of layer.""" schema: Any build: LayerBuilder + # The components this configuration needs loaded + auto_load: Callable[[ConfigType], list[str]] class LayerKind: - """A kind of layer, such as reliability, with the types that implement it.""" + """A kind of layer, such as encryption, with the types that implement it.""" - def __init__(self, key: str, default_type: str) -> None: + 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 + self, + name: str, + *, + schema: Any, + auto_load: Callable[[ConfigType], list[str]] = _nothing, ) -> Callable[[LayerBuilder], LayerBuilder]: """Register a type with its schema.""" @@ -62,15 +77,18 @@ class LayerKind: raise ValueError( f"{self.key} layer type '{name}' is already registered" ) - self.types[name] = LayerType(schema, build) + self.types[name] = LayerType(schema, build, auto_load) return build return decorator - @staticmethod - def _expand(value: Any) -> Any: - """Allow a layer to be enabled with no options, as in `reliability:`.""" - return {} if value is None else value + 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.""" @@ -87,9 +105,11 @@ class LayerKind: return self.types[config[CONF_TYPE]] +ENCRYPTION = LayerKind(CONF_ENCRYPTION, "xxtea", shorthand=CONF_KEY) RELIABILITY = LayerKind(CONF_RELIABILITY, "hdlc") +SESSION = LayerKind(CONF_SESSION, "basic") # The kinds stacked above the link, from the bottom up -STACK_ORDER = (RELIABILITY,) +STACK_ORDER = (ENCRYPTION, RELIABILITY, SESSION) def stack_schema() -> dict: @@ -97,6 +117,15 @@ def stack_schema() -> dict: return {cv.Optional(kind.key): kind.schema() for kind in STACK_ORDER} +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: diff --git a/esphome/components/packet_interface/layers/basic_session.py b/esphome/components/packet_interface/layers/basic_session.py new file mode 100644 index 00000000000..d311b431a16 --- /dev/null +++ b/esphome/components/packet_interface/layers/basic_session.py @@ -0,0 +1,67 @@ +"""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)), + ), + cv.Optional(CONF_TIMEOUT): cv.positive_time_period_milliseconds, + 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, + 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 diff --git a/esphome/components/packet_interface/layers/xxtea_encryption.py b/esphome/components/packet_interface/layers/xxtea_encryption.py new file mode 100644 index 00000000000..7c69935c0ee --- /dev/null +++ b/esphome/components/packet_interface/layers/xxtea_encryption.py @@ -0,0 +1,35 @@ +"""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 + +CONF_ROLLING_CODE_ENABLE = "rolling_code_enable" + +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) + + +@ENCRYPTION.register("xxtea", schema=XXTEA_SCHEMA, auto_load=lambda config: ["xxtea"]) +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 diff --git a/esphome/components/packet_interface/xxtea_encryption.cpp b/esphome/components/packet_interface/xxtea_encryption.cpp new file mode 100644 index 00000000000..5b90934b3f7 --- /dev/null +++ b/esphome/components/packet_interface/xxtea_encryption.cpp @@ -0,0 +1,156 @@ +#include "xxtea_encryption.h" +#ifdef USE_PACKET_INTERFACE_XXTEA +#include +#include +#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]); } + +XxteaEncryption::XxteaEncryption(PacketInterface *lower, bool rolling_code) + : lower_(lower), trailer_size_(rolling_code ? ROLLING_SIZE + CHECK_SIZE : CHECK_SIZE), rolling_code_(rolling_code) { + this->stack_on_(lower, CAPABILITY_ENCRYPTION); + // XXTEA works on whole 32-bit words + this->max_packet_size_ = (lower->get_max_packet_size() & ~size_t{3}) - this->trailer_size_; +} + +void XxteaEncryption::set_key(std::initializer_list key) { + std::copy_n(key.begin(), std::min(key.size(), this->key_.size()), this->key_.begin()); +} + +void XxteaEncryption::setup() { + size_t words = this->lower_->get_max_packet_size() / 4; + this->tx_buffer_ = std::make_unique(words); + this->rx_buffer_ = std::make_unique(words); + if (this->rolling_code_) { + this->pref_ = global_preferences->make_preference(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; + this->pref_.save(&this->boot_); + global_preferences->sync(); +} + +bool XxteaEncryption::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; + } + 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(this->tx_buffer_.get()); + 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_.get(), 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(this->rx_buffer_.get()); + data.copy_to(buf, length); + xxtea::decrypt(this->rx_buffer_.get(), 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)) { + 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 diff --git a/esphome/components/packet_interface/xxtea_encryption.h b/esphome/components/packet_interface/xxtea_encryption.h new file mode 100644 index 00000000000..ba30952ea9e --- /dev/null +++ b/esphome/components/packet_interface/xxtea_encryption.h @@ -0,0 +1,60 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_PACKET_INTERFACE_XXTEA +#include +#include +#include + +#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 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 key_{}; + // Separate buffers, as a layer above may send a reply while a received packet is being passed up + std::unique_ptr tx_buffer_; + std::unique_ptr rx_buffer_; + 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}; + const bool rolling_code_; +}; + +} // namespace esphome::packet_interface +#endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9e376277716..a96bef32e2b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -257,7 +257,9 @@ #define USE_OTA_STATE_LISTENER #define USE_OUTPUT #define USE_OUTPUT_FLOAT_POWER_SCALING +#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 diff --git a/tests/components/packet_interface/__init__.py b/tests/components/packet_interface/__init__.py index a49be401949..c3a3e781a63 100644 --- a/tests/components/packet_interface/__init__.py +++ b/tests/components/packet_interface/__init__.py @@ -4,6 +4,8 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: object) -> None: + 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 diff --git a/tests/components/packet_interface/basic_session.cpp b/tests/components/packet_interface/basic_session.cpp new file mode 100644 index 00000000000..9c2181f3a49 --- /dev/null +++ b/tests/components/packet_interface/basic_session.cpp @@ -0,0 +1,229 @@ +#include +#include +#include +#include +#include "esphome/components/packet_interface/basic_session.h" +#include "esphome/core/application.h" + +namespace esphome::packet_interface::session_testing { + +using Packet = std::vector; + +// A link layer that records what is sent, so a test can deliver it to the peer +class MockLink : public PacketInterface { + public: + MockLink() { this->set_max_packet_size(64); } + bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override { + if (!this->accept) + return false; + this->sent.push_back(data.to_vector()); + 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 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(&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(data.to_vector()); }); + 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 session; + std::vector received; + std::vector 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"; +} + +// The test main does not construct App; the keep-alive interval needs its scheduler +class BasicSessionTest : public ::testing::Test { + protected: + void SetUp() override { + new (&App) Application; + App.pre_setup("session-tests", 10, "", 0); + } + void TearDown() override { App.~Application(); } + + 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{true}); + EXPECT_EQ(b.states, std::vector{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{{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{{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{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{true, false})); + // Heard again + b.session->on_interval_(); + pump(a, b); + EXPECT_TRUE(a.session->is_connected()); + EXPECT_EQ(a.states, (std::vector{true, false, true})); +} + +TEST_F(BasicSessionTest, PeerRestartIsReported) { + Peer a; + auto b = std::make_unique(); + pump(a, *b); + b = std::make_unique(); + pump(a, *b); + EXPECT_TRUE(a.session->is_connected()); + EXPECT_EQ(a.states, (std::vector{true, false, true})); + EXPECT_EQ(b->states, std::vector{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 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{true, false, true})); +} + +} // namespace esphome::packet_interface::session_testing diff --git a/tests/components/packet_interface/common.yaml b/tests/components/packet_interface/common.yaml index 836d6497028..0c388d95051 100644 --- a/tests/components/packet_interface/common.yaml +++ b/tests/components/packet_interface/common.yaml @@ -14,10 +14,20 @@ packet_interface: 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 - platform: udp id: packet_interface_network udp_id: packet_interface_udp + encryption: Johnny Cash reliability: + session: sensor: - platform: template diff --git a/tests/components/packet_interface/xxtea_encryption.cpp b/tests/components/packet_interface/xxtea_encryption.cpp new file mode 100644 index 00000000000..26318e610a0 --- /dev/null +++ b/tests/components/packet_interface/xxtea_encryption.cpp @@ -0,0 +1,237 @@ +#include +#include +#include +#include +#include +#include "esphome/components/packet_interface/xxtea_encryption.h" +#include "esphome/core/application.h" + +namespace esphome::packet_interface::encryption_testing { + +using Packet = std::vector; + +// 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) { this->set_max_packet_size(max_packet_size); } + 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 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_; } +}; + +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(&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(data.to_vector()); }); + 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 encryption; + std::vector received; +}; + +// The test main does not construct App as generated code does +class XxteaEncryptionTest : public ::testing::Test { + protected: + void SetUp() override { + new (&App) Application; + App.pre_setup("xxtea-tests", 10, "", 0); + } + void TearDown() override { App.~Application(); } +}; + +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 expected; + for (uint8_t size = 0; size <= a.encryption->get_max_packet_size(); size++) { + Packet packet(size); + for (uint8_t i = 0; i != size; i++) + packet[i] = 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(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{{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{{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{{2}})); +} + +TEST_F(XxteaEncryptionTest, BootNumberIncreasesAndPeerRestartAccepted) { + Peer b; + auto a = std::make_unique(); + 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{{1}, {2}})); + a = std::make_unique(); // 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{{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{{2}}); // b learnt a's from the dropped packet + a.send({3}); + a.deliver_to(b); + EXPECT_EQ(b.received, std::vector{{3}}); +} + +TEST_F(XxteaEncryptionTest, PacketsFromBeforeRestartDropped) { + Peer a; + auto b = std::make_unique(); + a.introduce(*b); + a.send({1}); + a.send({2}); + std::vector recorded = a.link.sent; + a.link.sent.clear(); + b = std::make_unique(); // 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{{3}}); +} + +} // namespace esphome::packet_interface::encryption_testing diff --git a/tests/unit_tests/test_packet_interface.py b/tests/unit_tests/test_packet_interface.py index 959f9efe18e..6ea12ce7731 100644 --- a/tests/unit_tests/test_packet_interface.py +++ b/tests/unit_tests/test_packet_interface.py @@ -9,15 +9,18 @@ from esphome.components import packet_interface from esphome.components.packet_interface import CONF_MAX_PACKET_SIZE from esphome.components.packet_interface.layers import ( 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 @pytest.mark.parametrize( @@ -87,7 +90,9 @@ def test_ack_delay_shorter_than_timeout(setup_core) -> None: 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"}, } @@ -116,3 +121,65 @@ 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"}}) + + +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: ""}) + + +@pytest.mark.parametrize( + ("config", "loads"), + [ + ({}, []), + ({CONF_RELIABILITY: None, CONF_SESSION: None}, []), + ({CONF_SESSION: {"binary_sensor": {"name": "Link"}}}, ["binary_sensor"]), + ({CONF_ENCRYPTION: "secret"}, ["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"]