mirror of
https://github.com/esphome/esphome.git
synced 2026-10-21 21:36:14 +00:00
[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:
co-authored by
Claude Opus 5.5
parent
e5f6e5f09f
commit
b14eb2ee37
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user