diff --git a/esphome/components/packet_interface/listen_before_talk.h b/esphome/components/packet_interface/listen_before_talk.h index b7fa98959cc..c7eb590eee6 100644 --- a/esphome/components/packet_interface/listen_before_talk.h +++ b/esphome/components/packet_interface/listen_before_talk.h @@ -8,9 +8,9 @@ namespace esphome::packet_interface { * Decides whether a half-duplex radio link may send while its radio reports that it is receiving a packet. * * Sending is refused while the radio is busy, so the packet being received is not lost. A busy period starts when a - * send first finds the radio busy, and ends when one finds it free. Once a busy period has lasted max_busy_ms, sends - * go ahead, so a busy flag that never clears cannot stop sending for good, however often sends are tried. Code - * generation sets max_busy_ms from the time the radio takes to receive the largest packet. + * send first finds the radio busy, and ends when a send finds it free or end_busy() is called. Once a busy period has + * lasted max_busy_ms, sends go ahead, so a busy flag that never clears cannot stop sending for good, however often + * sends are tried. Code generation sets max_busy_ms from the time the radio takes to receive the largest packet. */ class ListenBeforeTalk { public: @@ -31,6 +31,8 @@ class ListenBeforeTalk { } return now - this->busy_since_ms_ >= this->max_busy_ms_; } + /// Ends the busy period, when the radio is seen to be free other than by a send. + void end_busy() { this->busy_ = false; } /// How long the current busy period has lasted at \p now. uint32_t busy_for(uint32_t now) const { return now - this->busy_since_ms_; } diff --git a/esphome/components/packet_interface/radio_airtime.py b/esphome/components/packet_interface/radio_airtime.py deleted file mode 100644 index 213c7e795a3..00000000000 --- a/esphome/components/packet_interface/radio_airtime.py +++ /dev/null @@ -1,48 +0,0 @@ -"""How long a radio packet takes to send, for the listen before talk limit of the radio links.""" - -import math - -from esphome.types import ConfigType - -# Listen before talk holds a send back for at most the airtime of the largest packet, and never less than this -MIN_MAX_BUSY_MS = 2000 -# Room for the preamble detection and the radio's own delays -MARGIN = 1.25 - - -def packet_airtime_ms(radio: ConfigType, size: int) -> float: - """The time a radio configured as in radio takes to send a packet of size bytes. - - LoRa follows the Semtech formula for an explicit header; the low data rate optimization is assumed when a symbol - takes more than 16 ms, as the drivers enable it. Other modulations count the preamble, sync word, length byte, - payload and CRC at the bitrate, treating the preamble size as bytes, which gives the longer time. - """ - if str(radio["modulation"]) == "LORA": - spreading_factor = radio["spreading_factor"] - bandwidth_hz = ( - float(str(radio["bandwidth"]).removesuffix("kHz").replace("_", ".")) * 1000 - ) - coding_rate = int(str(radio["coding_rate"])[-1]) - 4 - symbol_s = (1 << spreading_factor) / bandwidth_hz - low_data_rate = 1 if symbol_s > 0.016 else 0 - crc = 16 if radio.get("crc_enable", False) else 0 - payload_symbols = 8 + max( - math.ceil( - (8 * size - 4 * spreading_factor + 28 + crc) - / (4 * (spreading_factor - 2 * low_data_rate)) - ) - * (coding_rate + 4), - 0, - ) - return (radio["preamble_size"] + 4.25 + payload_symbols) * symbol_s * 1000 - bytes_sent = ( - radio["preamble_size"] + len(radio.get("sync_value", [])) + 1 + size + 2 - ) - return bytes_sent * 8 / radio["bitrate"] * 1000 - - -def max_busy_ms(radio: ConfigType, max_packet_size: int) -> int: - """How long listen before talk may hold a send back.""" - return max( - MIN_MAX_BUSY_MS, math.ceil(packet_airtime_ms(radio, max_packet_size) * MARGIN) - ) diff --git a/esphome/components/packet_interface/radio_link.cpp b/esphome/components/packet_interface/radio_link.cpp new file mode 100644 index 00000000000..1aff47248b2 --- /dev/null +++ b/esphome/components/packet_interface/radio_link.cpp @@ -0,0 +1,41 @@ +#include "radio_link.h" +#include +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::packet_interface { + +ESPHOME_LOG_TAG(TAG, "packet_interface.radio"); + +bool RadioLinkBase::check_size_(size_t size) const { + if (size <= this->max_packet_size_) + return true; + ESP_LOGW(TAG, "Packet of %zu bytes is larger than %zu", size, this->max_packet_size_); + return false; +} + +bool RadioLinkBase::may_send_(bool busy) { + uint32_t now = App.get_loop_component_start_time(); + if (busy) + this->enable_loop(); + if (!this->listen_before_talk_.may_send(busy, now)) + return false; + if (busy) { + ESP_LOGV(TAG, "Radio busy for %" PRIu32 "ms, sending anyway", this->listen_before_talk_.busy_for(now)); + } + return true; +} + +void RadioLinkBase::radio_free_() { + this->listen_before_talk_.end_busy(); + this->disable_loop(); +} + +void RadioLinkBase::receive_(const std::vector &packet, float rssi, float snr) { + PacketMetaData meta_data; + meta_data.rssi = rssi; + meta_data.snr = snr; + this->on_receive_from_interface_(PacketBuffer(packet.data(), packet.size()), meta_data); +} + +} // namespace esphome::packet_interface diff --git a/esphome/components/packet_interface/radio_link.h b/esphome/components/packet_interface/radio_link.h new file mode 100644 index 00000000000..ffe5020542a --- /dev/null +++ b/esphome/components/packet_interface/radio_link.h @@ -0,0 +1,79 @@ +#pragma once + +#include + +#include "esphome/components/packet_interface/listen_before_talk.h" +#include "esphome/components/packet_interface/packet_interface.h" + +namespace esphome::packet_interface { + +/// The parts of RadioLink that do not depend on the radio. +class RadioLinkBase : public PacketInterface { + public: + void set_max_busy(uint32_t max_busy_ms) { this->listen_before_talk_.set_max_busy(max_busy_ms); } + + protected: + explicit RadioLinkBase(size_t max_packet_size) : PacketInterface(max_packet_size) {} + /// False if a packet of this size is too large. + bool check_size_(size_t size) const; + /// False if sending must wait, as the radio is busy; starts loop() to watch the radio. + bool may_send_(bool busy); + /// Ends the busy period when loop() finds the radio free. + void radio_free_(); + void receive_(const std::vector &packet, float rssi, float snr); + + ListenBeforeTalk listen_before_talk_; +}; + +/** + * A packet interface link over a packet radio: each packet is one radio packet, with the RSSI and SNR of received + * packets in the metadata. Sending waits for the radio to finish transmitting. A radio cannot hear while it sends, so + * sending is refused while the radio is receiving a packet, and the layers above send again shortly. While a busy + * period lasts, loop() watches the radio and ends the period as soon as the radio is free, so a later reception + * starts a new one. + * + * Radio provides is_receiving(), register_listener(Listener *) and transmit_packet(const uint8_t *, size_t), which + * returns Error::NONE on success. Listener declares on_packet(packet, rssi, snr). MaxPacketSize is the radio's limit. + */ +template +class RadioLink : public RadioLinkBase, public Listener { + public: + RadioLink(Radio *radio, size_t max_packet_size) : RadioLinkBase(max_packet_size), radio_(radio) {} + + void setup() override { + this->radio_->register_listener(this); + this->disable_loop(); + } + // Runs only during a busy period; a busy flag that never clears keeps it running + void loop() override { + if (!this->radio_->is_receiving()) + this->radio_free_(); + } + + bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override { + // The radio is set up after the layers above, which may send from their own setup + if (!this->radio_->is_ready()) + return false; + size_t size = data.size(); + if (size == 0) + return true; + if (!this->check_size_(size) || !this->may_send_(this->radio_->is_receiving())) + return false; + if (data.is_contiguous()) { + auto span = data.as_span(); + return this->radio_->transmit_packet(span.data(), span.size()) == Error::NONE; + } + uint8_t buf[MaxPacketSize]; + data.copy_to(buf, size); + return this->radio_->transmit_packet(buf, size) == Error::NONE; + } + + void on_packet(const std::vector &packet, float rssi, float snr) override { + this->receive_(packet, rssi, snr); + } + + protected: + Radio *radio_; +}; + +} // namespace esphome::packet_interface diff --git a/esphome/components/packet_interface/radio_link.py b/esphome/components/packet_interface/radio_link.py new file mode 100644 index 00000000000..648d8948631 --- /dev/null +++ b/esphome/components/packet_interface/radio_link.py @@ -0,0 +1,112 @@ +"""Shared code generation for the packet interface links over packet radios, such as the SX126x and SX127x.""" + +from collections.abc import Callable +import math + +import esphome.codegen as cg +from esphome.components.const import CONF_MAX_PACKET_SIZE +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE +from esphome.cpp_generator import MockObjClass +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import new_packet_interface, packet_interface_schema + +# The radios' payload length register is 8 bits +MAX_PACKET_SIZE = 255 + +# Listen before talk holds a send back for at most the airtime of the largest packet, and never less than this +MIN_MAX_BUSY_MS = 2000 +# Room for the preamble detection and the radio's own delays +MARGIN = 1.25 + + +def packet_airtime_ms(radio: ConfigType, size: int) -> float: + """The time a radio configured as in radio takes to send a packet of size bytes. + + LoRa follows the Semtech formula for an explicit header; the low data rate optimization is assumed when a symbol + takes more than 16 ms, as the drivers enable it. Other modulations count the preamble, sync word, length byte, + payload and CRC at the bitrate, treating the preamble size as bytes, which gives the longer time. + """ + if str(radio["modulation"]) == "LORA": + spreading_factor = radio["spreading_factor"] + bandwidth_hz = ( + float(str(radio["bandwidth"]).removesuffix("kHz").replace("_", ".")) * 1000 + ) + coding_rate = int(str(radio["coding_rate"])[-1]) - 4 + symbol_s = (1 << spreading_factor) / bandwidth_hz + low_data_rate = 1 if symbol_s > 0.016 else 0 + crc = 16 if radio.get("crc_enable", False) else 0 + payload_symbols = 8 + max( + math.ceil( + (8 * size - 4 * spreading_factor + 28 + crc) + / (4 * (spreading_factor - 2 * low_data_rate)) + ) + * (coding_rate + 4), + 0, + ) + return (radio["preamble_size"] + 4.25 + payload_symbols) * symbol_s * 1000 + bytes_sent = ( + radio["preamble_size"] + len(radio.get("sync_value", [])) + 1 + size + 2 + ) + return bytes_sent * 8 / radio["bitrate"] * 1000 + + +def max_busy_ms(radio: ConfigType, max_packet_size: int) -> int: + """How long listen before talk may hold a send back.""" + return max( + MIN_MAX_BUSY_MS, math.ceil(packet_airtime_ms(radio, max_packet_size) * MARGIN) + ) + + +def radio_link_schema( + class_: MockObjClass, radio_id_key: str, radio_class: MockObjClass +) -> cv.Schema: + """Schema for a radio link, referring to its radio by radio_id_key.""" + return packet_interface_schema(class_, MAX_PACKET_SIZE, MAX_PACKET_SIZE).extend( + {cv.GenerateID(radio_id_key): cv.use_id(radio_class)} + ) + + +def _radio_config(config: ConfigType, radio_id_key: str) -> ConfigType: + full_config = fv.full_config.get() + return full_config.get_config_for_path( + full_config.get_path_for_id(config[radio_id_key])[:-1] + ) + + +def radio_link_final_validate( + radio_id_key: str, + payload_length_key: str, + check: Callable[[ConfigType, ConfigType], None] | None = None, +) -> Callable[[ConfigType], ConfigType]: + """Final validation for a radio link: packets vary in length, and check(link, radio) checks the rest.""" + + def validator(config: ConfigType) -> ConfigType: + radio = _radio_config(config, radio_id_key) + if radio[payload_length_key] != 0: + raise cv.Invalid( + f"Packets vary in length, so the radio's '{payload_length_key}' must be 0", + path=[radio_id_key], + ) + if check is not None: + check(config, radio) + return config + + return validator + + +async def new_radio_link( + config: ConfigType, radio_id_key: str, radio_domain: str +) -> None: + """Create a radio link, with its listen before talk limit worked out from the radio's settings.""" + radio = await cg.get_variable(config[radio_id_key]) + var = await new_packet_interface(config, radio, config[CONF_MAX_PACKET_SIZE]) + radio_config = next( + conf + for conf in CORE.config[radio_domain] + if conf[CONF_ID] == config[radio_id_key] + ) + cg.add(var.set_max_busy(max_busy_ms(radio_config, config[CONF_MAX_PACKET_SIZE]))) diff --git a/esphome/components/sx126x/packet_interface/__init__.py b/esphome/components/sx126x/packet_interface/__init__.py index 9fad6a60df6..1b283ef4a45 100644 --- a/esphome/components/sx126x/packet_interface/__init__.py +++ b/esphome/components/sx126x/packet_interface/__init__.py @@ -1,15 +1,9 @@ -import esphome.codegen as cg from esphome.components import packet_interface -from esphome.components.packet_interface import ( - CONF_MAX_PACKET_SIZE, - new_packet_interface, - packet_interface_schema, +from esphome.components.packet_interface.radio_link import ( + new_radio_link, + radio_link_final_validate, + radio_link_schema, ) -from esphome.components.packet_interface.radio_airtime import max_busy_ms -import esphome.config_validation as cv -from esphome.const import CONF_ID -from esphome.core import CORE -import esphome.final_validate as fv from esphome.types import ConfigType from .. import ( @@ -24,36 +18,14 @@ from .. import ( CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["sx126x"] -# The radio's payload length register is 8 bits -MAX_PACKET_SIZE = 255 - SX126xPacketInterface = sx126x_ns.class_( "SX126xPacketInterface", packet_interface.PacketInterface, SX126xListener ) -CONFIG_SCHEMA = packet_interface_schema( - SX126xPacketInterface, MAX_PACKET_SIZE, MAX_PACKET_SIZE -).extend({cv.GenerateID(CONF_SX126X_ID): cv.use_id(SX126x)}) +CONFIG_SCHEMA = radio_link_schema(SX126xPacketInterface, CONF_SX126X_ID, SX126x) - -def _final_validate(config: ConfigType) -> ConfigType: - full_config = fv.full_config.get() - path = full_config.get_path_for_id(config[CONF_SX126X_ID])[:-1] - if full_config.get_config_for_path(path)[CONF_PAYLOAD_LENGTH] != 0: - raise cv.Invalid( - f"Packets vary in length, so the radio's '{CONF_PAYLOAD_LENGTH}' must be 0", - path=[CONF_SX126X_ID], - ) - return config - - -FINAL_VALIDATE_SCHEMA = _final_validate +FINAL_VALIDATE_SCHEMA = radio_link_final_validate(CONF_SX126X_ID, CONF_PAYLOAD_LENGTH) async def to_code(config: ConfigType) -> None: - radio = await cg.get_variable(config[CONF_SX126X_ID]) - var = await new_packet_interface(config, radio, config[CONF_MAX_PACKET_SIZE]) - radio_config = next( - conf for conf in CORE.config[DOMAIN] if conf[CONF_ID] == config[CONF_SX126X_ID] - ) - cg.add(var.set_max_busy(max_busy_ms(radio_config, config[CONF_MAX_PACKET_SIZE]))) + await new_radio_link(config, CONF_SX126X_ID, DOMAIN) diff --git a/esphome/components/sx126x/packet_interface/sx126x_packet_interface.cpp b/esphome/components/sx126x/packet_interface/sx126x_packet_interface.cpp deleted file mode 100644 index 56a1c6452ef..00000000000 --- a/esphome/components/sx126x/packet_interface/sx126x_packet_interface.cpp +++ /dev/null @@ -1,46 +0,0 @@ -#include "sx126x_packet_interface.h" -#include "esphome/core/application.h" -#include "esphome/core/log.h" - -namespace esphome::sx126x { - -using packet_interface::PacketBuffer; -using packet_interface::PacketMetaData; - -ESPHOME_LOG_TAG(TAG, "sx126x.packet_interface"); - -bool SX126xPacketInterface::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) { - // The radio is set up after the layers above, which may send from their own setup - if (!this->radio_->is_ready()) - return false; - size_t size = data.size(); - if (size == 0) - return true; - if (size > this->max_packet_size_) { - ESP_LOGW(TAG, "Packet of %zu bytes is larger than %zu", size, this->max_packet_size_); - return false; - } - bool busy = this->radio_->is_receiving(); - uint32_t now = App.get_loop_component_start_time(); - if (!this->listen_before_talk_.may_send(busy, now)) - return false; - if (busy) { - ESP_LOGV(TAG, "Radio busy for %" PRIu32 "ms, sending anyway", this->listen_before_talk_.busy_for(now)); - } - if (data.is_contiguous()) { - auto span = data.as_span(); - return this->radio_->transmit_packet(span.data(), span.size()) == SX126xError::NONE; - } - uint8_t buf[SX126X_MAX_PACKET_SIZE]; - data.copy_to(buf, size); - return this->radio_->transmit_packet(buf, size) == SX126xError::NONE; -} - -void SX126xPacketInterface::on_packet(const std::vector &packet, float rssi, float snr) { - PacketMetaData meta_data; - meta_data.rssi = rssi; - meta_data.snr = snr; - this->on_receive_from_interface_(PacketBuffer(packet.data(), packet.size()), meta_data); -} - -} // namespace esphome::sx126x diff --git a/esphome/components/sx126x/packet_interface/sx126x_packet_interface.h b/esphome/components/sx126x/packet_interface/sx126x_packet_interface.h index 571fffb46d8..c1cf744fa9c 100644 --- a/esphome/components/sx126x/packet_interface/sx126x_packet_interface.h +++ b/esphome/components/sx126x/packet_interface/sx126x_packet_interface.h @@ -1,28 +1,10 @@ #pragma once -#include "esphome/components/packet_interface/listen_before_talk.h" -#include "esphome/components/packet_interface/packet_interface.h" +#include "esphome/components/packet_interface/radio_link.h" #include "esphome/components/sx126x/sx126x.h" namespace esphome::sx126x { -/// A packet interface link over an SX126x radio: each packet is one radio packet, with the RSSI and SNR of received -/// packets in the metadata. Sending waits for the radio to finish transmitting. A radio cannot hear while it -/// sends, so sending is refused while the radio is receiving a packet; the layers above send again shortly. -class SX126xPacketInterface : public packet_interface::PacketInterface, public SX126xListener { - public: - SX126xPacketInterface(SX126x *radio, size_t max_packet_size) - : packet_interface::PacketInterface(max_packet_size), radio_(radio) {} - - void setup() override { this->radio_->register_listener(this); } - void set_max_busy(uint32_t max_busy_ms) { this->listen_before_talk_.set_max_busy(max_busy_ms); } - bool send_to_interface(const packet_interface::PacketBuffer &data, - const packet_interface::PacketMetaData &meta_data) override; - void on_packet(const std::vector &packet, float rssi, float snr) override; - - protected: - SX126x *radio_; - packet_interface::ListenBeforeTalk listen_before_talk_; -}; +using SX126xPacketInterface = packet_interface::RadioLink; } // namespace esphome::sx126x diff --git a/esphome/components/sx127x/packet_interface/__init__.py b/esphome/components/sx127x/packet_interface/__init__.py index 43c24bd216c..3a15709ec07 100644 --- a/esphome/components/sx127x/packet_interface/__init__.py +++ b/esphome/components/sx127x/packet_interface/__init__.py @@ -1,19 +1,16 @@ -import esphome.codegen as cg from esphome.components import packet_interface -from esphome.components.packet_interface import ( - CONF_MAX_PACKET_SIZE, - new_packet_interface, - packet_interface_schema, +from esphome.components.packet_interface import CONF_MAX_PACKET_SIZE +from esphome.components.packet_interface.radio_link import ( + new_radio_link, + radio_link_final_validate, + radio_link_schema, ) -from esphome.components.packet_interface.radio_airtime import max_busy_ms import esphome.config_validation as cv -from esphome.const import CONF_ID -from esphome.core import CORE -import esphome.final_validate as fv from esphome.types import ConfigType from .. import ( CONF_MODULATION, + CONF_PACKET_MODE, CONF_PAYLOAD_LENGTH, CONF_SX127X_ID, DOMAIN, @@ -25,9 +22,6 @@ from .. import ( CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["sx127x"] -# The radio's payload length register is 8 bits -MAX_PACKET_SIZE = 255 - # The FIFO limit outside LoRa mode FSK_MAX_PACKET_SIZE = 64 @@ -35,39 +29,29 @@ SX127xPacketInterface = sx127x_ns.class_( "SX127xPacketInterface", packet_interface.PacketInterface, SX127xListener ) -CONFIG_SCHEMA = packet_interface_schema( - SX127xPacketInterface, MAX_PACKET_SIZE, MAX_PACKET_SIZE -).extend({cv.GenerateID(CONF_SX127X_ID): cv.use_id(SX127x)}) +CONFIG_SCHEMA = radio_link_schema(SX127xPacketInterface, CONF_SX127X_ID, SX127x) -def _final_validate(config: ConfigType) -> ConfigType: - full_config = fv.full_config.get() - radio = full_config.get_config_for_path( - full_config.get_path_for_id(config[CONF_SX127X_ID])[:-1] - ) - if radio[CONF_PAYLOAD_LENGTH] != 0: +def _check_fsk(config: ConfigType, radio: ConfigType) -> None: + """Without LoRa, the radio must send packets, not a continuous bit stream, and holds fewer bytes.""" + if radio[CONF_MODULATION] == "LORA": + return + if not radio.get(CONF_PACKET_MODE, False): raise cv.Invalid( - f"Packets vary in length, so the radio's '{CONF_PAYLOAD_LENGTH}' must be 0", + f"Without LoRa modulation the radio's '{CONF_PACKET_MODE}' must be true, as a link sends packets", path=[CONF_SX127X_ID], ) - if ( - radio[CONF_MODULATION] != "LORA" - and config[CONF_MAX_PACKET_SIZE] > FSK_MAX_PACKET_SIZE - ): + if config[CONF_MAX_PACKET_SIZE] > FSK_MAX_PACKET_SIZE: raise cv.Invalid( f"Without LoRa modulation the radio sends at most {FSK_MAX_PACKET_SIZE} bytes", path=[CONF_MAX_PACKET_SIZE], ) - return config -FINAL_VALIDATE_SCHEMA = _final_validate +FINAL_VALIDATE_SCHEMA = radio_link_final_validate( + CONF_SX127X_ID, CONF_PAYLOAD_LENGTH, _check_fsk +) async def to_code(config: ConfigType) -> None: - radio = await cg.get_variable(config[CONF_SX127X_ID]) - var = await new_packet_interface(config, radio, config[CONF_MAX_PACKET_SIZE]) - radio_config = next( - conf for conf in CORE.config[DOMAIN] if conf[CONF_ID] == config[CONF_SX127X_ID] - ) - cg.add(var.set_max_busy(max_busy_ms(radio_config, config[CONF_MAX_PACKET_SIZE]))) + await new_radio_link(config, CONF_SX127X_ID, DOMAIN) diff --git a/esphome/components/sx127x/packet_interface/sx127x_packet_interface.cpp b/esphome/components/sx127x/packet_interface/sx127x_packet_interface.cpp deleted file mode 100644 index a33d2d8cf8c..00000000000 --- a/esphome/components/sx127x/packet_interface/sx127x_packet_interface.cpp +++ /dev/null @@ -1,46 +0,0 @@ -#include "sx127x_packet_interface.h" -#include "esphome/core/application.h" -#include "esphome/core/log.h" - -namespace esphome::sx127x { - -using packet_interface::PacketBuffer; -using packet_interface::PacketMetaData; - -ESPHOME_LOG_TAG(TAG, "sx127x.packet_interface"); - -bool SX127xPacketInterface::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) { - // The radio is set up after the layers above, which may send from their own setup - if (!this->radio_->is_ready()) - return false; - size_t size = data.size(); - if (size == 0) - return true; - if (size > this->max_packet_size_) { - ESP_LOGW(TAG, "Packet of %zu bytes is larger than %zu", size, this->max_packet_size_); - return false; - } - bool busy = this->radio_->is_receiving(); - uint32_t now = App.get_loop_component_start_time(); - if (!this->listen_before_talk_.may_send(busy, now)) - return false; - if (busy) { - ESP_LOGV(TAG, "Radio busy for %" PRIu32 "ms, sending anyway", this->listen_before_talk_.busy_for(now)); - } - if (data.is_contiguous()) { - auto span = data.as_span(); - return this->radio_->transmit_packet(span.data(), span.size()) == SX127xError::NONE; - } - uint8_t buf[SX127X_MAX_PACKET_SIZE]; - data.copy_to(buf, size); - return this->radio_->transmit_packet(buf, size) == SX127xError::NONE; -} - -void SX127xPacketInterface::on_packet(const std::vector &packet, float rssi, float snr) { - PacketMetaData meta_data; - meta_data.rssi = rssi; - meta_data.snr = snr; - this->on_receive_from_interface_(PacketBuffer(packet.data(), packet.size()), meta_data); -} - -} // namespace esphome::sx127x diff --git a/esphome/components/sx127x/packet_interface/sx127x_packet_interface.h b/esphome/components/sx127x/packet_interface/sx127x_packet_interface.h index 827d41bffcb..24f9601bf5d 100644 --- a/esphome/components/sx127x/packet_interface/sx127x_packet_interface.h +++ b/esphome/components/sx127x/packet_interface/sx127x_packet_interface.h @@ -1,28 +1,10 @@ #pragma once -#include "esphome/components/packet_interface/listen_before_talk.h" -#include "esphome/components/packet_interface/packet_interface.h" +#include "esphome/components/packet_interface/radio_link.h" #include "esphome/components/sx127x/sx127x.h" namespace esphome::sx127x { -/// A packet interface link over an SX127x radio: each packet is one radio packet, with the RSSI and SNR of received -/// packets in the metadata. Sending waits for the radio to finish transmitting. A radio cannot hear while it -/// sends, so sending is refused while the radio is receiving a packet; the layers above send again shortly. -class SX127xPacketInterface : public packet_interface::PacketInterface, public SX127xListener { - public: - SX127xPacketInterface(SX127x *radio, size_t max_packet_size) - : packet_interface::PacketInterface(max_packet_size), radio_(radio) {} - - void setup() override { this->radio_->register_listener(this); } - void set_max_busy(uint32_t max_busy_ms) { this->listen_before_talk_.set_max_busy(max_busy_ms); } - bool send_to_interface(const packet_interface::PacketBuffer &data, - const packet_interface::PacketMetaData &meta_data) override; - void on_packet(const std::vector &packet, float rssi, float snr) override; - - protected: - SX127x *radio_; - packet_interface::ListenBeforeTalk listen_before_talk_; -}; +using SX127xPacketInterface = packet_interface::RadioLink; } // namespace esphome::sx127x diff --git a/tests/components/packet_interface/listen_before_talk.cpp b/tests/components/packet_interface/listen_before_talk.cpp index da7ef490773..973fd1c5457 100644 --- a/tests/components/packet_interface/listen_before_talk.cpp +++ b/tests/components/packet_interface/listen_before_talk.cpp @@ -41,6 +41,15 @@ TEST(ListenBeforeTalkTest, FreeRadioEndsBusyPeriod) { EXPECT_EQ(lbt.busy_for(START + MAX_BUSY + 100), 0); } +TEST(ListenBeforeTalkTest, EndBusyStartsNewPeriod) { + ListenBeforeTalk lbt; + EXPECT_FALSE(lbt.may_send(true, START)); + // The link saw the radio free between sends, so a later reception is a new busy period + lbt.end_busy(); + EXPECT_FALSE(lbt.may_send(true, START + MAX_BUSY + 100)); + EXPECT_EQ(lbt.busy_for(START + MAX_BUSY + 100), 0); +} + TEST(ListenBeforeTalkTest, LimitCanBeLonger) { ListenBeforeTalk lbt; lbt.set_max_busy(8000); diff --git a/tests/components/packet_interface/radio_link.cpp b/tests/components/packet_interface/radio_link.cpp new file mode 100644 index 00000000000..46c82b7e12b --- /dev/null +++ b/tests/components/packet_interface/radio_link.cpp @@ -0,0 +1,108 @@ +#include +#include +#include +#include "esphome/components/packet_interface/radio_link.h" +#include "esphome/core/application.h" + +namespace esphome::packet_interface::radio_link_testing { + +using Packet = std::vector; + +static constexpr uint32_t START = 10000; +static constexpr uint32_t MAX_BUSY = ListenBeforeTalk::DEFAULT_MAX_BUSY_MS; + +class FakeListener { + public: + virtual void on_packet(const std::vector &packet, float rssi, float snr) = 0; +}; + +enum class FakeError { NONE, BUSY }; + +class FakeRadio : public Component { + public: + bool is_receiving() { return this->receiving; } + void register_listener(FakeListener *listener) { this->listener = listener; } + FakeError transmit_packet(const uint8_t *data, size_t length) { + this->sent.emplace_back(data, data + length); + return FakeError::NONE; + } + + FakeListener *listener{nullptr}; + std::vector sent; + bool receiving{false}; +}; + +using TestLink = RadioLink; + +static void set_time(uint32_t now) { LoopBlockingGuard guard{nullptr, nullptr, now}; } + +class RadioLinkTest : public ::testing::Test { + protected: + void SetUp() override { + this->radio_.call(); + this->link_.call(); + } + bool send_() { + static const Packet PACKET{1, 2, 3}; + return this->link_.send_to_interface(PacketBuffer(PACKET), {}); + } + + FakeRadio radio_; + TestLink link_{&radio_, 16}; +}; + +TEST_F(RadioLinkTest, LoopRunsOnlyWhileBusy) { + EXPECT_EQ(this->radio_.listener, &this->link_); + EXPECT_TRUE(this->link_.is_idle()); + set_time(START); + this->radio_.receiving = true; + EXPECT_FALSE(this->send_()); + EXPECT_TRUE(this->link_.is_in_loop_state()); + this->link_.loop(); + EXPECT_TRUE(this->link_.is_in_loop_state()); + this->radio_.receiving = false; + this->link_.loop(); + EXPECT_TRUE(this->link_.is_idle()); + EXPECT_TRUE(this->radio_.sent.empty()); +} + +TEST_F(RadioLinkTest, ReceptionAfterStalePeriodIsNotSentOver) { + set_time(START); + this->radio_.receiving = true; + EXPECT_FALSE(this->send_()); + // The reception ends with no send to see it + this->radio_.receiving = false; + this->link_.loop(); + // A new reception, longer after the first than the limit, still holds the send back + set_time(START + MAX_BUSY + 100); + this->radio_.receiving = true; + EXPECT_FALSE(this->send_()); + EXPECT_TRUE(this->radio_.sent.empty()); +} + +TEST_F(RadioLinkTest, BusyFlagThatNeverClearsIsSentOver) { + set_time(START); + this->radio_.receiving = true; + EXPECT_FALSE(this->send_()); + this->link_.loop(); + set_time(START + MAX_BUSY); + EXPECT_TRUE(this->send_()); + ASSERT_EQ(this->radio_.sent.size(), 1u); + EXPECT_EQ(this->radio_.sent[0], (Packet{1, 2, 3})); +} + +TEST_F(RadioLinkTest, ReceivedPacketCarriesSignal) { + Packet received; + PacketMetaData meta; + this->link_.add_packet_interface_listener([&](const PacketBuffer &data, const PacketMetaData &meta_data) { + received.resize(data.size()); + data.copy_to(received.data(), received.size()); + meta = meta_data; + }); + this->link_.on_packet({4, 5}, -80.0f, 7.5f); + EXPECT_EQ(received, (Packet{4, 5})); + EXPECT_FLOAT_EQ(meta.rssi, -80.0f); + EXPECT_FLOAT_EQ(meta.snr, 7.5f); +} + +} // namespace esphome::packet_interface::radio_link_testing diff --git a/tests/unit_tests/test_lora_packet_interface.py b/tests/unit_tests/test_lora_packet_interface.py index 533c3244c95..62c40cf7b73 100644 --- a/tests/unit_tests/test_lora_packet_interface.py +++ b/tests/unit_tests/test_lora_packet_interface.py @@ -3,7 +3,7 @@ import pytest from esphome import config_validation as cv -from esphome.components.packet_interface import CONF_MAX_PACKET_SIZE, radio_airtime +from esphome.components.packet_interface import CONF_MAX_PACKET_SIZE, radio_link from esphome.components.sx126x import packet_interface as sx126x_packet_interface from esphome.components.sx127x import packet_interface as sx127x_packet_interface import esphome.final_validate as fv @@ -50,22 +50,22 @@ def _lora(**options: object) -> dict: ) def test_lora_airtime(radio: dict, airtime_ms: float) -> None: """The airtime of the largest LoRa packet follows the Semtech formula.""" - assert radio_airtime.packet_airtime_ms(radio, 255) == pytest.approx( + assert radio_link.packet_airtime_ms(radio, 255) == pytest.approx( airtime_ms, abs=0.5 ) def test_max_busy_follows_airtime() -> None: """Listen before talk waits at least 2 s, and longer than the largest packet takes.""" - assert radio_airtime.max_busy_ms(_lora(), 255) == 2000 - assert radio_airtime.max_busy_ms(_lora(spreading_factor=12), 255) > 9019 + assert radio_link.max_busy_ms(_lora(), 255) == 2000 + assert radio_link.max_busy_ms(_lora(spreading_factor=12), 255) > 9019 fsk = { "modulation": "FSK", "preamble_size": 4, "bitrate": 1200, "sync_value": [1, 2], } - assert radio_airtime.packet_airtime_ms(fsk, 64) == pytest.approx( + assert radio_link.packet_airtime_ms(fsk, 64) == pytest.approx( (4 + 2 + 1 + 64 + 2) * 8 / 1200 * 1000 ) @@ -85,7 +85,7 @@ class _FullConfig(dict): @pytest.mark.parametrize( - ("module", "domain", "radio", "link", "match"), + ("module", "domain", "radio", "link", "match", "fix"), [ ( sx126x_packet_interface, @@ -93,6 +93,7 @@ class _FullConfig(dict): {"payload_length": 10}, {}, "payload_length", + {"payload_length": 0}, ), ( sx127x_packet_interface, @@ -100,31 +101,42 @@ class _FullConfig(dict): _lora(payload_length=10), {}, "payload_length", + {"payload_length": 0}, ), ( sx127x_packet_interface, "sx127x", - {"modulation": "FSK", "payload_length": 0}, + {"modulation": "FSK", "packet_mode": False, "payload_length": 0}, + {}, + "packet_mode", + {"packet_mode": True}, + ), + ( + sx127x_packet_interface, + "sx127x", + {"modulation": "OOK", "packet_mode": True, "payload_length": 0}, {CONF_MAX_PACKET_SIZE: 65}, "at most 64", + None, ), ], ) def test_radio_settings_checked( - module: object, domain: str, radio: dict, link: dict, match: str + module: object, + domain: str, + radio: dict, + link: dict, + match: str, + fix: dict | None, ) -> None: - """Packets vary in length, and FSK on an SX127x holds at most 64 bytes.""" + """Packets vary in length, and FSK or OOK on an SX127x needs packet mode and holds at most 64 bytes.""" + config = {f"{domain}_id": "radio", CONF_MAX_PACKET_SIZE: 64, **link} token = fv.full_config.set(_FullConfig(domain, radio)) try: with pytest.raises(cv.Invalid, match=match): - module._final_validate( - {f"{domain}_id": "radio", CONF_MAX_PACKET_SIZE: 64, **link} - ) - fixed = {**radio, "payload_length": 0} - fv.full_config.set(_FullConfig(domain, fixed)) - if CONF_MAX_PACKET_SIZE not in link: - assert module._final_validate( - {f"{domain}_id": "radio", CONF_MAX_PACKET_SIZE: 64} - ) + module.FINAL_VALIDATE_SCHEMA(config) + if fix is not None: + fv.full_config.set(_FullConfig(domain, {**radio, **fix})) + assert module.FINAL_VALIDATE_SCHEMA(config) finally: fv.full_config.reset(token)