[sx126x][sx127x] Share the radio link code, and fixes from code review

- Make both links one RadioLink template in packet_interface, with shared
  code generation in radio_link.py, so a fix applies to both radios
- While sending is held back, watch the radio from loop() and end the
  busy period once it is free, so a later reception starts a new period
  instead of being sent over
- Reject an SX127x link without LoRa unless the radio is in packet mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
clydebarrow
2026-10-20 09:35:06 +11:00
co-authored by Claude Opus 5.5
parent e5f6e5f09f
commit b14eb2ee37
14 changed files with 413 additions and 270 deletions
@@ -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_; }
@@ -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)
)
@@ -0,0 +1,41 @@
#include "radio_link.h"
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.radio");
bool RadioLinkBase::check_size_(size_t size) const {
if (size <= this->max_packet_size_)
return true;
ESP_LOGW(TAG, "Packet of %zu bytes is larger than %zu", size, this->max_packet_size_);
return false;
}
bool RadioLinkBase::may_send_(bool busy) {
uint32_t now = App.get_loop_component_start_time();
if (busy)
this->enable_loop();
if (!this->listen_before_talk_.may_send(busy, now))
return false;
if (busy) {
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<uint8_t> &packet, float rssi, float snr) {
PacketMetaData meta_data;
meta_data.rssi = rssi;
meta_data.snr = snr;
this->on_receive_from_interface_(PacketBuffer(packet.data(), packet.size()), meta_data);
}
} // namespace esphome::packet_interface
@@ -0,0 +1,79 @@
#pragma once
#include <vector>
#include "esphome/components/packet_interface/listen_before_talk.h"
#include "esphome/components/packet_interface/packet_interface.h"
namespace esphome::packet_interface {
/// The parts of RadioLink that do not depend on the radio.
class RadioLinkBase : public PacketInterface {
public:
void set_max_busy(uint32_t max_busy_ms) { this->listen_before_talk_.set_max_busy(max_busy_ms); }
protected:
explicit RadioLinkBase(size_t max_packet_size) : PacketInterface(max_packet_size) {}
/// False if a packet of this size is too large.
bool check_size_(size_t size) const;
/// False if sending must wait, as the radio is busy; starts loop() to watch the radio.
bool may_send_(bool busy);
/// Ends the busy period when loop() finds the radio free.
void radio_free_();
void receive_(const std::vector<uint8_t> &packet, float rssi, float snr);
ListenBeforeTalk listen_before_talk_;
};
/**
* 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<typename Radio, typename Listener, typename Error, size_t MaxPacketSize>
class RadioLink : public RadioLinkBase, public Listener {
public:
RadioLink(Radio *radio, size_t max_packet_size) : RadioLinkBase(max_packet_size), radio_(radio) {}
void setup() override {
this->radio_->register_listener(this);
this->disable_loop();
}
// Runs only during a busy period; a busy flag that never clears keeps it running
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<uint8_t> &packet, float rssi, float snr) override {
this->receive_(packet, rssi, snr);
}
protected:
Radio *radio_;
};
} // namespace esphome::packet_interface
@@ -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])))
@@ -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)
@@ -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<uint8_t> &packet, float rssi, float snr) {
PacketMetaData meta_data;
meta_data.rssi = rssi;
meta_data.snr = snr;
this->on_receive_from_interface_(PacketBuffer(packet.data(), packet.size()), meta_data);
}
} // namespace esphome::sx126x
@@ -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<uint8_t> &packet, float rssi, float snr) override;
protected:
SX126x *radio_;
packet_interface::ListenBeforeTalk listen_before_talk_;
};
using SX126xPacketInterface = packet_interface::RadioLink<SX126x, SX126xListener, SX126xError, SX126X_MAX_PACKET_SIZE>;
} // namespace esphome::sx126x
@@ -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)
@@ -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<uint8_t> &packet, float rssi, float snr) {
PacketMetaData meta_data;
meta_data.rssi = rssi;
meta_data.snr = snr;
this->on_receive_from_interface_(PacketBuffer(packet.data(), packet.size()), meta_data);
}
} // namespace esphome::sx127x
@@ -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<uint8_t> &packet, float rssi, float snr) override;
protected:
SX127x *radio_;
packet_interface::ListenBeforeTalk listen_before_talk_;
};
using SX127xPacketInterface = packet_interface::RadioLink<SX127x, SX127xListener, SX127xError, SX127X_MAX_PACKET_SIZE>;
} // namespace esphome::sx127x
@@ -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);
@@ -0,0 +1,108 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/packet_interface/radio_link.h"
#include "esphome/core/application.h"
namespace esphome::packet_interface::radio_link_testing {
using Packet = std::vector<uint8_t>;
static constexpr uint32_t START = 10000;
static constexpr uint32_t MAX_BUSY = ListenBeforeTalk::DEFAULT_MAX_BUSY_MS;
class FakeListener {
public:
virtual void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) = 0;
};
enum class FakeError { NONE, BUSY };
class FakeRadio : public Component {
public:
bool is_receiving() { return this->receiving; }
void register_listener(FakeListener *listener) { this->listener = listener; }
FakeError transmit_packet(const uint8_t *data, size_t length) {
this->sent.emplace_back(data, data + length);
return FakeError::NONE;
}
FakeListener *listener{nullptr};
std::vector<Packet> sent;
bool receiving{false};
};
using TestLink = RadioLink<FakeRadio, FakeListener, FakeError, 16>;
static void set_time(uint32_t now) { LoopBlockingGuard guard{nullptr, nullptr, now}; }
class RadioLinkTest : public ::testing::Test {
protected:
void SetUp() override {
this->radio_.call();
this->link_.call();
}
bool send_() {
static const Packet PACKET{1, 2, 3};
return this->link_.send_to_interface(PacketBuffer(PACKET), {});
}
FakeRadio radio_;
TestLink link_{&radio_, 16};
};
TEST_F(RadioLinkTest, LoopRunsOnlyWhileBusy) {
EXPECT_EQ(this->radio_.listener, &this->link_);
EXPECT_TRUE(this->link_.is_idle());
set_time(START);
this->radio_.receiving = true;
EXPECT_FALSE(this->send_());
EXPECT_TRUE(this->link_.is_in_loop_state());
this->link_.loop();
EXPECT_TRUE(this->link_.is_in_loop_state());
this->radio_.receiving = false;
this->link_.loop();
EXPECT_TRUE(this->link_.is_idle());
EXPECT_TRUE(this->radio_.sent.empty());
}
TEST_F(RadioLinkTest, ReceptionAfterStalePeriodIsNotSentOver) {
set_time(START);
this->radio_.receiving = true;
EXPECT_FALSE(this->send_());
// The reception ends with no send to see it
this->radio_.receiving = false;
this->link_.loop();
// A new reception, longer after the first than the limit, still holds the send back
set_time(START + MAX_BUSY + 100);
this->radio_.receiving = true;
EXPECT_FALSE(this->send_());
EXPECT_TRUE(this->radio_.sent.empty());
}
TEST_F(RadioLinkTest, BusyFlagThatNeverClearsIsSentOver) {
set_time(START);
this->radio_.receiving = true;
EXPECT_FALSE(this->send_());
this->link_.loop();
set_time(START + MAX_BUSY);
EXPECT_TRUE(this->send_());
ASSERT_EQ(this->radio_.sent.size(), 1u);
EXPECT_EQ(this->radio_.sent[0], (Packet{1, 2, 3}));
}
TEST_F(RadioLinkTest, 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
+30 -18
View File
@@ -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)