Compare commits

..
Author SHA1 Message Date
J. Nick Koston 0ac2299482 Merge remote-tracking branch 'origin/web-server-sse-listing-per-session' into HEAD 2026-11-14 08:40:18 -06:00
J. Nick Koston b58038af63 Merge remote-tracking branch 'origin/web-server-idf-stream-response' into HEAD 2026-11-14 08:39:50 -06:00
J. Nick Koston c741de7a47 Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:38:08 -06:00
J. Nick Koston 8a5e07e29f [web_server] Keep the server out of host builds, which only need sse_chunk.cpp for unit tests
tests/components/web_server now pulls web_server into the --all host
build, where another test defines USE_WEBSERVER.
2026-11-14 00:19:08 -06:00
J. Nick Koston f7b52be670 Merge remote-tracking branch 'origin/web-server-sse-listing-per-session' into web-server-event-source-shared 2026-11-13 23:15:51 -06:00
J. Nick Koston d5317669c9 Merge remote-tracking branch 'origin/web-server-idf-stream-response' into web-server-sse-listing-per-session 2026-11-13 23:15:42 -06:00
J. Nick Koston 5d0bd88bc6 Merge remote-tracking branch 'origin/dev' into web-server-idf-stream-response 2026-11-13 23:13:08 -06:00
pre-commit-ci-lite[bot] b714d8f69b apply automatic formatting fixes 2026-11-14 04:33:03 +00:00
J. Nick Koston ecabdbb24b [web_server] Keep the shared /events layer free of IDF-only types
The gather type comes from stream_response.h, the default header count is
defined on every platform, the session takes a non-const request, and the
fd in log lines goes through log_id(). Also drops a dead forward declaration
and an unused alias, and narrows the source filter.
2026-11-13 22:28:19 -06:00
J. Nick Koston ffbc4148fb [web_server] Move the ESP32 /events layer to web_server/event_source
The event source and its sessions move out of web_server_idf into
web_server/event_source.h/.cpp, sse_chunk moves to web_server and
stream_head.h to web_server_base, so the ESPAsyncWebServer platforms can
share them later. web_server_base/stream_response.h picks the stream
transport. No behaviour change; off ESP32 the build is unchanged.
2026-11-13 22:16:53 -06:00
J. Nick Koston 2169167908 [web_server] Name the iterator's session member session_ 2026-11-13 22:00:50 -06:00
J. Nick Koston 900574c03a [web_server_idf] List entities to the new /events session only
The listing iterator held the whole event source, so each new client's
detail_all listing went to every open session. Hold the session instead,
as the Arduino path already does.
2026-11-13 21:55:42 -06:00
J. Nick Koston 7b1f9c6bf1 Merge remote-tracking branch 'origin/dev' into web-server-idf-stream-response
# Conflicts:
#	.agents/skills/hardware-test/reference/recipes/web_server.md
2026-11-07 22:09:36 -06:00
J. Nick Koston 3587e29725 Merge branch 'dev' into web-server-idf-stream-response 2026-11-04 22:21:12 -06:00
J. Nick Koston d4244c5388 [web_server_idf] Correct the session close order comment and point stall docs at the stream 2026-11-04 21:47:51 -06:00
J. Nick Koston bec53baf1d Merge branch 'dev' into web-server-idf-stream-response 2026-11-04 21:29:11 -06:00
J. Nick Koston 98fd3786dd [web_server_idf] Create the stream send mutex at boot and test that a failed head batch stops the rest 2026-11-04 21:15:36 -06:00
J. Nick Koston 1f531ff022 Merge branch 'dev' into web-server-idf-stream-response 2026-11-04 20:58:44 -06:00
J. Nick Koston 65e7eb6b06 [web_server_idf] Note that sess_ctx is reserved for AsyncStreamResponse 2026-11-04 20:41:33 -06:00
J. Nick Koston 7538864e1e [web_server_idf] Keep a stream from sending on a socket httpd has closed 2026-11-04 20:36:27 -06:00
J. Nick Koston c0f97475d3 [web_server_idf] Document the stream head body as a C string 2026-11-04 20:07:37 -06:00
J. Nick Koston df6fcaa6fe Merge remote-tracking branch 'origin/dev' into web-server-idf-stream-response
# Conflicts:
#	esphome/components/web_server_idf/web_server_idf.cpp
#	esphome/components/web_server_idf/web_server_idf.h
2026-11-03 18:47:25 -06:00
J. Nick Koston eb1b793af9 Merge branch 'dev' into web-server-idf-stream-response 2026-10-30 15:46:21 -10:00
J. Nick Koston b8ce904e0c [web_server_idf] Let a stream response header replace a default header of the same name 2026-10-30 14:24:31 -10:00
J. Nick Koston 6befa73b00 [web_server_idf] Shut the socket down when the stream head is not fully sent 2026-10-30 13:09:28 -10:00
J. Nick Koston 02211e15d8 [web_server_idf] Skip the stall clock for empty writes and clear it on send errors 2026-10-30 12:52:36 -10:00
J. Nick Koston dbbe02f423 [web_server_idf] Include stream_head.h without web_server, for HttpHeader 2026-10-30 12:35:17 -10:00
J. Nick Koston 97a1aa781e [web_server_idf] Simplify the stream response stall clock and head batching
Log the no-memory warning once per stall, free the closing session's buffers once,
add is_stalled() and check that the event stream head fits one write.
2026-10-30 12:22:07 -10:00
J. Nick Koston 0b67a754cd [web_server_idf] Build the stream head tests only for host 2026-10-30 12:15:18 -10:00
J. Nick Koston 320b6a8221 [web_server_idf] Factor the stream response head, iovec advance and stall clock out for host tests
Moves the pure parts of AsyncStreamResponse into stream_head.h (no httpd or
lwIP includes) and adds gtests: the event stream head is compared byte for
byte against what httpd_resp_send_chunk() sent, plus raw heads, batching,
short writes and the stall clock including a millis() rollover.
2026-10-30 12:06:59 -10:00
J. Nick Koston 4e5a5ec32d [web_server_idf] Send the stream response head in one write and tidy the stall clock
Gather the status line, headers and optional body into one iovec list
and send it with a single blocking sendmsg, instead of one httpd_send per
piece. Split the stall clock into start_stall_(), stall_expired_() and
clear_stall_(), and use is_connected() and is_closing() in the event
stream.
2026-10-30 11:40:30 -10:00
J. Nick Koston 09ce8d7d6e [web_server_idf] Add a long-lived streaming response that components can use
Move the session handling of AsyncEventSourceResponse into a new
AsyncStreamResponse base: the response head and session context set up
in the request handler, the non-blocking send override, main loop gather
writes with the send stall clock, the identity-checked close through
httpd_queue_work and the LRU refresh. The event stream now derives from
it, so other components can stream raw or chunked bytes from the main
loop without their own tasks or raw socket calls.

The response head is written directly instead of through
httpd_resp_send_chunk(), so a stream does not have to start with a data
chunk. The event stream still sends the same bytes on the wire.
2026-10-30 11:35:09 -10:00
101 changed files with 2352 additions and 6988 deletions

No files matched your search

+1 -3
View File
@@ -149,7 +149,6 @@ esphome/components/cst226/* @clydebarrow
esphome/components/cst328/* @latonita
esphome/components/cst816/* @clydebarrow
esphome/components/cst9220/* @clydebarrow
esphome/components/cstxxx/* @clydebarrow @latonita
esphome/components/ct_clamp/* @jesserockz
esphome/components/current_based/* @djwmarcx
esphome/components/cw2017/* @exciton
@@ -467,7 +466,6 @@ esphome/components/nfc/* @jesserockz @kbx81
esphome/components/noblex/* @AGalfra
esphome/components/noise/* @esphome/core
esphome/components/npi19/* @bakerkj
esphome/components/npm1300/* @tomaszduda23
esphome/components/nrf52/* @tomaszduda23
esphome/components/ns2009/* @tchilov
esphome/components/ns2009/touchscreen/* @tchilov
@@ -743,7 +741,7 @@ esphome/components/web_server_idf/* @dentra
esphome/components/weikai/* @DrCoolZic
esphome/components/weikai_i2c/* @DrCoolZic
esphome/components/weikai_spi/* @DrCoolZic
esphome/components/whirlpool/* @arpena @glmnet @Rodrigoah
esphome/components/whirlpool/* @arpena @glmnet
esphome/components/whynter/* @aeonsablaze
esphome/components/wiegand/* @ssieb
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
@@ -114,10 +114,10 @@ void BME280Component::setup() {
this->calibration_.h1 = read_u8_(BME280_REGISTER_DIG_H1);
this->calibration_.h2 = read_s16_le_(BME280_REGISTER_DIG_H2);
this->calibration_.h3 = read_u8_(BME280_REGISTER_DIG_H3);
this->calibration_.h4 = sign_extend<12>(
encode_uint12(read_u8_(BME280_REGISTER_DIG_H4), low_nibble(read_u8_(BME280_REGISTER_DIG_H4 + 1))));
this->calibration_.h5 = sign_extend<12>(
encode_uint12(read_u8_(BME280_REGISTER_DIG_H5 + 1), high_nibble(read_u8_(BME280_REGISTER_DIG_H5))));
this->calibration_.h4 =
sign_extend<12>(read_u8_(BME280_REGISTER_DIG_H4) << 4 | (read_u8_(BME280_REGISTER_DIG_H4 + 1) & 0x0F));
this->calibration_.h5 =
sign_extend<12>(read_u8_(BME280_REGISTER_DIG_H5 + 1) << 4 | (read_u8_(BME280_REGISTER_DIG_H5) >> 4));
this->calibration_.h6 = read_u8_(BME280_REGISTER_DIG_H6);
uint8_t humid_control_val = 0;
+3 -4
View File
@@ -70,9 +70,8 @@ bool BME680Component::setup_registers_() {
this->calibration_.t2 = encode_value_le<int16_t>(&coeff1[1]);
this->calibration_.t3 = coeff1[3];
// H1 and H2 are 12-bit values sharing byte 1: its low nibble ends H1, its high nibble ends H2
this->calibration_.h1 = encode_uint12(coeff2[2], low_nibble(coeff2[1]));
this->calibration_.h2 = encode_uint12(coeff2[0], high_nibble(coeff2[1]));
this->calibration_.h1 = coeff2[2] << 4 | (coeff2[1] & 0x0F);
this->calibration_.h2 = encode_uint16(coeff2[0], coeff2[1]) >> 4;
this->calibration_.h3 = coeff2[3];
this->calibration_.h4 = coeff2[4];
this->calibration_.h5 = coeff2[5];
@@ -244,7 +243,7 @@ void BME680Component::read_data_() {
uint32_t raw_pressure = encode_uint24(data[2], data[3], data[4]) >> 4;
uint32_t raw_humidity = encode_uint16(data[8], data[9]);
uint16_t raw_gas = encode_uint16(data[13], data[14]) >> 6;
uint8_t gas_range = low_nibble(data[14]);
uint8_t gas_range = data[14] & 0x0F;
float temperature = this->calc_temperature_(raw_temperature);
float pressure = this->calc_pressure_(raw_pressure);
+7 -42
View File
@@ -1,5 +1,3 @@
from typing import Any
from esphome import automation, pins
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
@@ -15,7 +13,6 @@ from esphome.const import (
CONF_VALUE,
CONF_WAIT_TIME,
)
from esphome.types import ConfigType
CODEOWNERS = ["@lygris", "@gabest11"]
DEPENDENCIES = ["spi"]
@@ -25,8 +22,6 @@ MULTI_CONF = True
ns = cg.esphome_ns.namespace("cc1101")
CC1101Component = ns.class_("CC1101Component", cg.Component, spi.SPIDevice)
CC1101Listener = ns.class_("CC1101Listener")
PACKET_TYPE = cg.std_vector.template(cg.uint8)
RX_FIFO_SIZE = 64
# Config keys
CONF_RX_ATTENUATION = "rx_attenuation"
@@ -246,34 +241,20 @@ CONFIG_MAP = {
cv.Optional(CONF_BS_POST_KI): cv.enum(BS_POST_KI, upper=False),
cv.Optional(CONF_BS_POST_KP): cv.enum(BS_POST_KP, upper=False),
cv.Optional(CONF_PACKET_MODE, default=False): cv.boolean,
cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t,
cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean,
cv.Optional(CONF_WHITENING, default=False): cv.boolean,
}
def _is_long_packet(packet_length: Any) -> bool:
"""A packet_length that does not fit the RX FIFO, or is a lambda."""
return cg.is_template(packet_length) or (packet_length or 0) > RX_FIFO_SIZE
def _validate_packet_mode(config: ConfigType) -> ConfigType:
packet_mode = config.get(CONF_PACKET_MODE, False)
packet_length = config.get(CONF_PACKET_LENGTH)
if packet_mode:
def _validate_packet_mode(config):
if config.get(CONF_PACKET_MODE, False):
if CONF_GDO0_PIN not in config:
raise cv.Invalid("gdo0_pin is required when packet_mode is enabled")
if packet_length is None:
if CONF_PACKET_LENGTH not in config:
raise cv.Invalid("packet_length is required when packet_mode is enabled")
elif cg.is_template(packet_length):
raise cv.Invalid("packet_mode is required when packet_length is a lambda")
if (
packet_mode
and _is_long_packet(packet_length)
and config.get(CONF_CRC_ENABLE, False)
):
raise cv.Invalid(
"crc_enable is not supported with packet_length > 64 or a lambda"
)
if config[CONF_PACKET_LENGTH] > 64:
raise cv.Invalid("packet_length must be <= 64 (FIFO size)")
return config
@@ -282,9 +263,6 @@ CONFIG_SCHEMA = cv.All(
{
cv.GenerateID(): cv.declare_id(CC1101Component),
cv.Optional(CONF_GDO0_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_PACKET_LENGTH): cv.templatable(
cv.int_range(min=0, max=1024)
),
cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True),
}
)
@@ -304,24 +282,11 @@ async def to_code(config):
cg.add(getattr(var, f"set_{key}")(config[key]))
await cg.add_pin(var.set_gdo0_pin, config.get(CONF_GDO0_PIN))
if (packet_length := config.get(CONF_PACKET_LENGTH)) is not None:
# packet_mode can also be switched on at run time, so this does not check it
if _is_long_packet(packet_length):
cg.add_define("USE_CC1101_LONG_PACKET")
if cg.is_template(packet_length):
lambda_ = await cg.process_lambda(
packet_length,
[(PACKET_TYPE.operator("ref").operator("const"), "x")],
return_type=cg.int32,
)
cg.add(var.set_packet_length_lambda(lambda_))
else:
cg.add(var.set_packet_length(packet_length))
if CONF_ON_PACKET in config:
await automation.build_automation(
var.get_packet_trigger(),
[
(PACKET_TYPE, "x"),
(cg.std_vector.template(cg.uint8), "x"),
(cg.float_, "freq_offset"),
(cg.float_, "rssi"),
(cg.uint8, "lqi"),
+33 -353
View File
@@ -1,10 +1,8 @@
#include "cc1101.h"
#include "cc1101pa.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <algorithm>
#include <cmath>
#include <cstring>
@@ -16,15 +14,9 @@ namespace esphome::cc1101 {
ESPHOME_LOG_TAG(TAG, "cc1101");
static constexpr size_t TX_FIFO_SIZE = 64;
static constexpr size_t RX_FIFO_SIZE = 64;
// RX FIFO threshold for FIFO_THR = 7 in long packet mode
static constexpr uint8_t RX_FIFO_THRESHOLD = 32;
// The crystal takes about 150 us to start after CSn goes low
static constexpr uint32_t WAKEUP_DELAY_US = 200;
static float freq_offset_hz(uint8_t freqest) { return static_cast<int8_t>(freqest) * (XTAL_FREQUENCY / (1 << 14)); }
static float rssi_dbm(int8_t rssi) { return (rssi * RSSI_STEP) - RSSI_OFFSET; }
// MDMCFG2 MOD_FORMAT values
PROGMEM_STRING_TABLE(ModulationStrings, "2-FSK", "GFSK", "UNUSED", "ASK/OOK", "4-FSK", "UNUSED", "UNUSED", "MSK");
@@ -122,6 +114,8 @@ CC1101Component::CC1101Component() {
memset(this->pa_table_, 0, sizeof(this->pa_table_));
}
void IRAM_ATTR CC1101Component::gpio_intr(CC1101Component *arg) { arg->enable_loop_soon_any_context(); }
void CC1101Component::setup() {
this->spi_setup();
@@ -199,220 +193,7 @@ void CC1101Component::call_listeners_(const std::vector<uint8_t> &packet, float
this->packet_trigger_.trigger(packet, freq_offset, rssi, lqi);
}
float CC1101Component::symbol_rate_() const {
return (((256.0f + this->state_.DRATE_M) * (1 << this->state_.DRATE_E)) / (1 << 28)) * XTAL_FREQUENCY;
}
#ifdef USE_CC1101_LONG_PACKET
bool CC1101Component::is_long_packet_mode_() const {
return this->packet_mode_ && (this->packet_length_ > RX_FIFO_SIZE || this->packet_length_func_ != nullptr);
}
void CC1101Component::arm_length_(uint16_t length) {
// Over 255 bytes or unknown starts in infinite mode (datasheet section 15.2.2). PKTLEN must not be 0, so a
// multiple of 256 stays in infinite mode and ends on the MCU byte count alone.
this->packet_expected_length_ = length;
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(length > 0 && length < 256 ? LengthConfig::LENGTH_CONFIG_FIXED
: LengthConfig::LENGTH_CONFIG_INFINITE);
this->state_.PKTLEN = length % 256 == 0 ? 0xFF : static_cast<uint8_t>(length % 256);
}
void CC1101Component::reset_long_packet_rx_() {
this->packet_.clear();
this->packet_last_byte_ms_ = 0;
this->high_freq_.stop();
this->arm_length_(this->packet_length_func_ != nullptr ? 0 : this->packet_length_);
}
void CC1101Component::restart_long_packet_rx_() {
this->enter_idle_();
this->enter_rx_();
this->disable_loop();
}
void CC1101Component::maybe_switch_long_packet_to_fixed_() {
const uint16_t expected = this->packet_expected_length_;
if (this->state_.LENGTH_CONFIG == static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_FIXED) || expected == 0 ||
expected % 256 == 0) {
return;
}
const uint16_t received = this->packet_.size();
if (expected - received >= 256) {
return;
}
// PKTLEN keeps the (length % 256) set at the start, as in TI Design Note DN500 section 2.3
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_FIXED);
this->write_(Register::PKTCTRL0);
ESP_LOGVV(TAG, "Switched long packet RX to fixed mode: received=%u expected=%u pktlen=%u", received, expected,
this->state_.PKTLEN);
}
void CC1101Component::finish_long_packet_() {
this->packet_.resize(this->packet_expected_length_);
this->read_(Register::FREQEST);
this->read_(Register::LQI);
const float freq_offset = freq_offset_hz(this->state_.FREQEST);
const uint8_t lqi = this->state_.LQI & STATUS_LQI_MASK;
// No CRC check: crc_enable is rejected for long packets, CRC_OK is only set after the last payload byte.
// Restart RX before dispatching so a callback may change settings; swapping back keeps the reserved capacity
// unless a callback reserved a larger buffer.
std::vector<uint8_t> packet;
packet.swap(this->packet_);
this->restart_long_packet_rx_();
this->call_listeners_(packet, freq_offset, rssi_dbm(this->long_packet_rssi_), lqi);
if (packet.capacity() > this->packet_.capacity()) {
packet.clear();
this->packet_.swap(packet);
}
}
void CC1101Component::handle_long_packet_(uint8_t rx_bytes) {
// Leave one byte in the FIFO unless it holds the rest of the packet (datasheet section 20)
uint16_t to_read = rx_bytes > 0 ? rx_bytes - 1 : 0;
if (this->packet_expected_length_ > 0) {
const uint16_t remaining = this->packet_expected_length_ - this->packet_.size();
if (remaining <= rx_bytes) {
to_read = remaining;
}
}
if (to_read == 0) {
if (this->packet_.empty()) {
return;
}
// Discard a packet when no byte arrived for the air time of PACKET_IDLE_TIMEOUT_BYTES
const uint32_t timeout_ms =
std::max(static_cast<uint32_t>(std::ceil((8.0f * PACKET_IDLE_TIMEOUT_BYTES * 1000.0f) / this->symbol_rate_())),
PACKET_IDLE_TIMEOUT_MIN_MS);
if (App.get_loop_component_start_time() - this->packet_last_byte_ms_ > timeout_ms) {
ESP_LOGW(TAG, "Packet receive timeout, discarding packet: received=%u expected=%u fifo=%u",
static_cast<uint16_t>(this->packet_.size()), this->packet_expected_length_, rx_bytes);
this->restart_long_packet_rx_();
}
return;
}
const size_t old_size = this->packet_.size();
if (old_size == 0) {
// The RSSI register follows the live signal; sample it while the packet is still on air
this->read_(Register::RSSI);
this->long_packet_rssi_ = this->state_.RSSI;
// The FIFO threshold signal does not fire for the bytes after the last threshold crossing; poll until done
this->high_freq_.start();
}
// Read through an aligned buffer: for an unaligned target the ESP32 SPI driver allocates a bounce buffer
alignas(4) uint8_t staged[RX_FIFO_SIZE];
this->read_(Register::FIFO, staged, to_read);
this->packet_.resize(old_size + to_read);
std::memcpy(this->packet_.data() + old_size, staged, to_read);
this->packet_last_byte_ms_ = App.get_loop_component_start_time();
ESP_LOGVV(TAG, "Drained RX FIFO: read=%u received=%u expected=%u fifo=%u", to_read,
static_cast<uint16_t>(this->packet_.size()), this->packet_expected_length_, rx_bytes);
if (this->packet_expected_length_ == 0 && this->packet_length_func_ != nullptr) {
const int32_t length = this->packet_length_func_(this->packet_);
if (length < 0) {
ESP_LOGVV(TAG, "Packet length lambda discarded packet: received=%u", static_cast<uint16_t>(this->packet_.size()));
this->restart_long_packet_rx_();
return;
}
if (length > MAX_PACKET_LENGTH) {
ESP_LOGW(TAG, "Packet length exceeds maximum, discarding packet: length=%" PRId32 " max=%u", length,
MAX_PACKET_LENGTH);
this->restart_long_packet_rx_();
return;
}
if (length > 0) {
this->arm_length_(static_cast<uint16_t>(length));
// A packet shorter than the first FIFO drain is already complete; finish_long_packet_() truncates it
if (this->packet_expected_length_ <= this->packet_.size()) {
this->finish_long_packet_();
return;
}
this->write_(Register::PKTLEN);
this->write_(Register::PKTCTRL0);
ESP_LOGVV(TAG, "Resolved packet length: received=%u expected=%u pktlen=%u",
static_cast<uint16_t>(this->packet_.size()), this->packet_expected_length_, this->state_.PKTLEN);
}
}
// Only reachable while the length is still unknown; a known length caps every read
if (this->packet_.size() > MAX_PACKET_LENGTH) {
ESP_LOGW(TAG, "Packet length exceeds maximum, discarding packet: length=%u max=%u",
static_cast<uint16_t>(this->packet_.size()), MAX_PACKET_LENGTH);
this->restart_long_packet_rx_();
return;
}
this->maybe_switch_long_packet_to_fixed_();
if (this->packet_expected_length_ > 0 && this->packet_.size() >= this->packet_expected_length_) {
this->finish_long_packet_();
}
}
void CC1101Component::loop_long_packet_() {
if (this->sleeping_ || this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO) ||
this->gdo0_pin_ == nullptr) {
this->high_freq_.stop();
this->disable_loop();
return;
}
// While GDO0 (RX FIFO threshold) is high, the FIFO holds at least RX_FIFO_THRESHOLD bytes, so drain without
// reading RXBYTES. At high data rates RXBYTES changes between two reads, so waiting for two reads that agree
// and retrying on the next loop pass lets the FIFO overflow. MARCSTATE does not change during RX.
for (uint8_t i = 0; i < RX_FIFO_SIZE / RX_FIFO_THRESHOLD + 2 && this->gdo0_pin_->digital_read(); i++) {
this->read_(Register::MARCSTATE);
if (static_cast<State>(this->state_.MARC_STATE) == State::RXFIFO_OVERFLOW) {
ESP_LOGW(TAG, "RX FIFO overflow, discarding packet: received=%u expected=%u",
static_cast<uint16_t>(this->packet_.size()), this->packet_expected_length_);
this->restart_long_packet_rx_();
return;
}
this->handle_long_packet_(RX_FIFO_THRESHOLD);
if (this->packet_.empty()) {
return; // finished or discarded
}
}
// Below the threshold the next crossing raises GDO0 again, and after the packet the count no longer changes,
// so retrying on the next loop pass is safe here. RXBYTES can be misread while bytes arrive (TI SWRZ020), so
// only trust two consecutive reads that agree.
this->read_(Register::RXBYTES);
uint8_t rx_bytes = this->state_.RXBYTES;
bool stable = false;
for (uint8_t retries = RXBYTES_STABLE_RETRIES; retries > 0 && !stable; retries--) {
const uint8_t previous = rx_bytes;
this->read_(Register::RXBYTES);
rx_bytes = this->state_.RXBYTES;
stable = rx_bytes == previous;
}
if (!stable) {
return;
}
rx_bytes = this->state_.NUM_RXBYTES;
// More than the FIFO holds can only be a misread; the staged drain buffer is FIFO sized
if (this->state_.RXFIFO_OVERFLOW || rx_bytes > RX_FIFO_SIZE) {
ESP_LOGW(TAG, "RX FIFO overflow, discarding packet: received=%u expected=%u fifo=%u",
static_cast<uint16_t>(this->packet_.size()), this->packet_expected_length_, rx_bytes);
this->restart_long_packet_rx_();
return;
}
if (rx_bytes == 0 && this->packet_.empty() && !this->gdo0_pin_->digital_read()) {
this->disable_loop();
return;
}
this->handle_long_packet_(rx_bytes);
}
#endif
void CC1101Component::loop() {
#ifdef USE_CC1101_LONG_PACKET
if (this->is_long_packet_mode_()) {
this->loop_long_packet_();
return;
}
#endif
this->disable_loop();
if (this->sleeping_ || this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO) ||
this->gdo0_pin_ == nullptr || !this->gdo0_pin_->digital_read()) {
@@ -440,7 +221,7 @@ void CC1101Component::loop() {
payload_length = this->state_.PKTLEN;
expected_rx = payload_length;
}
if (payload_length == 0 || payload_length > RX_FIFO_SIZE || rx_bytes != expected_rx) {
if (payload_length == 0 || payload_length > 64 || rx_bytes != expected_rx) {
ESP_LOGW(TAG, "Invalid packet: rx_bytes %u, payload_length %u", rx_bytes, payload_length);
this->enter_idle_();
this->strobe_(Command::FRX);
@@ -454,8 +235,8 @@ void CC1101Component::loop() {
this->read_(Register::FREQEST);
this->read_(Register::RSSI);
this->read_(Register::LQI);
float freq_offset = freq_offset_hz(this->state_.FREQEST);
float rssi = rssi_dbm(this->state_.RSSI);
float freq_offset = static_cast<int8_t>(this->state_.FREQEST) * (XTAL_FREQUENCY / (1 << 14));
float rssi = (this->state_.RSSI * RSSI_STEP) - RSSI_OFFSET;
bool crc_ok = (this->state_.LQI & STATUS_CRC_OK_MASK) != 0;
uint8_t lqi = this->state_.LQI & STATUS_LQI_MASK;
if (this->state_.CRC_EN == 0 || crc_ok) {
@@ -475,7 +256,7 @@ void CC1101Component::loop() {
void CC1101Component::dump_config() {
int32_t freq = static_cast<int32_t>(this->state_.FREQ2 << 16 | this->state_.FREQ1 << 8 | this->state_.FREQ0) *
XTAL_FREQUENCY / (1 << 16);
float symbol_rate = this->symbol_rate_();
float symbol_rate = (((256.0f + this->state_.DRATE_M) * (1 << this->state_.DRATE_E)) / (1 << 28)) * XTAL_FREQUENCY;
float bw = XTAL_FREQUENCY / (8.0f * (4 + this->state_.CHANBW_M) * (1 << this->state_.CHANBW_E));
ESP_LOGCONFIG(TAG,
"CC1101:\n"
@@ -490,20 +271,6 @@ void CC1101Component::dump_config() {
LOG_STR_ARG(ModulationStrings::get_log_str(this->state_.MOD_FORMAT & 0x07, 0)), symbol_rate, bw,
this->output_power_effective_);
LOG_PIN(" CS Pin: ", this->cs_);
LOG_PIN(" GDO0 Pin: ", this->gdo0_pin_);
if (this->packet_mode_) {
#ifdef USE_CC1101_LONG_PACKET
if (this->packet_length_func_ != nullptr) {
ESP_LOGCONFIG(TAG, " Packet Length: lambda");
} else
#endif
if (this->packet_length_ == 0) {
ESP_LOGCONFIG(TAG, " Packet Length: variable");
} else {
ESP_LOGCONFIG(TAG, " Packet Length: %u", this->packet_length_);
}
}
}
void CC1101Component::begin_tx() {
@@ -549,14 +316,6 @@ void CC1101Component::reset() {
void CC1101Component::set_idle() {
ESP_LOGV(TAG, "Setting IDLE state");
this->enter_idle_();
#ifdef USE_CC1101_LONG_PACKET
if (this->is_long_packet_mode_()) {
// Drop a partial packet so a pending loop pass cannot finish it and re-enter RX
this->strobe_(Command::FRX);
this->reset_long_packet_rx_();
this->disable_loop();
}
#endif
}
void CC1101Component::power_down() {
@@ -634,18 +393,7 @@ void CC1101Component::enter_idle_() {
}
}
bool CC1101Component::enter_rx_() {
#ifdef USE_CC1101_LONG_PACKET
if (this->is_long_packet_mode_()) {
// Every RX entry starts a fresh long packet; callers leave the chip in IDLE, where SFRX is allowed
this->strobe_(Command::FRX);
this->reset_long_packet_rx_();
this->write_(Register::PKTLEN);
this->write_(Register::PKTCTRL0);
}
#endif
return this->enter_calibrated_(State::RX, Command::RX);
}
bool CC1101Component::enter_rx_() { return this->enter_calibrated_(State::RX, Command::RX); }
bool CC1101Component::enter_tx_() { return this->enter_calibrated_(State::TX, Command::TX); }
@@ -708,22 +456,12 @@ void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) {
CC1101Error CC1101Component::transmit_packet(const uint8_t *data, size_t len) {
if (this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO)) {
ESP_LOGW(TAG, "Cannot transmit packet: packet mode is not FIFO");
return CC1101Error::PARAMS;
}
#ifdef USE_CC1101_LONG_PACKET
// Long packet TX needs chunked FIFO refills and is not implemented
if (this->is_long_packet_mode_()) {
ESP_LOGW(TAG, "Cannot transmit packet: long packet mode is RX-only");
return CC1101Error::PARAMS;
}
#endif
const bool variable = this->state_.LENGTH_CONFIG == static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE);
// In variable length mode the FIFO also holds the length byte
if (len + (variable ? 1 : 0) > TX_FIFO_SIZE) {
ESP_LOGW(TAG,
"Cannot transmit packet: long packet TX is not implemented, payload length %u exceeds TX FIFO limit %u",
static_cast<unsigned>(len), static_cast<unsigned>(TX_FIFO_SIZE));
ESP_LOGE(TAG, "Packet of %u bytes does not fit the TX FIFO", static_cast<unsigned>(len));
return CC1101Error::PARAMS;
}
@@ -1096,60 +834,19 @@ void CC1101Component::set_bs_post_kp(BsPostKp value) {
}
}
void CC1101Component::configure_packet_mode_() {
#ifdef USE_CC1101_LONG_PACKET
// Ends a long reception in progress, also when switching to a short packet mode
this->high_freq_.stop();
this->packet_.clear();
#endif
if (!this->packet_mode_) {
this->state_.PKT_FORMAT = static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_ASYNC_SERIAL);
this->state_.GDO0_CFG = 0x0D;
} else {
this->state_.PKT_FORMAT = static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO);
this->state_.APPEND_STATUS = 0;
#ifdef USE_CC1101_LONG_PACKET
if (this->is_long_packet_mode_()) {
// Threshold-only signal, reasserts after each partial FIFO drain
this->state_.GDO0_CFG = 0x00;
this->state_.FIFO_THR = 7;
// Reserve once so receiving never allocates; a lambda may read one FIFO drain past MAX_PACKET_LENGTH
this->packet_.reserve(this->packet_length_func_ != nullptr ? MAX_PACKET_LENGTH + RX_FIFO_SIZE
: this->packet_length_);
this->reset_long_packet_rx_();
} else
#endif
{
this->state_.GDO0_CFG = 0x01;
this->state_.FIFO_THR = 15;
if (this->packet_length_ == 0) {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE);
} else {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_FIXED);
this->state_.PKTLEN = static_cast<uint8_t>(this->packet_length_);
}
}
}
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTCTRL1);
this->write_(Register::PKTLEN);
this->write_(Register::IOCFG0);
this->write_(Register::FIFOTHR);
}
}
void CC1101Component::set_packet_mode(bool value) {
this->packet_mode_ = value;
#ifdef USE_CC1101_LONG_PACKET
// Long packet RX must drop the FIFO along with a partial packet, so go through IDLE and restart RX
if (this->initialized_ && this->is_long_packet_mode_()) {
this->reconfigure_packet_length_();
} else
#endif
{
this->configure_packet_mode_();
this->state_.PKT_FORMAT =
static_cast<uint8_t>(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL);
if (value) {
// Configure GDO0 for FIFO status (asserts on RX FIFO threshold or end of packet)
this->state_.GDO0_CFG = 0x01;
// Set max RX FIFO threshold to ensure we only trigger on end-of-packet
this->state_.FIFO_THR = 15;
// Don't append status bytes to FIFO - we read from registers instead
this->state_.APPEND_STATUS = 0;
} else {
// Configure GDO0 for serial data (async serial mode)
this->state_.GDO0_CFG = 0x0D;
}
if (this->initialized_) {
if (this->gdo0_pin_ != nullptr) {
@@ -1159,41 +856,24 @@ void CC1101Component::set_packet_mode(bool value) {
this->gdo0_pin_->detach_interrupt();
}
}
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTCTRL1);
this->write_(Register::IOCFG0);
this->write_(Register::FIFOTHR);
}
}
void CC1101Component::set_packet_length(uint16_t value) {
#ifndef USE_CC1101_LONG_PACKET
if (value > RX_FIFO_SIZE) {
ESP_LOGW(TAG, "Packet length %u needs a configured packet_length above %u", value,
static_cast<unsigned>(RX_FIFO_SIZE));
return;
void CC1101Component::set_packet_length(uint8_t value) {
if (value == 0) {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE);
} else {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_FIXED);
this->state_.PKTLEN = value;
}
#else
this->packet_length_func_ = nullptr;
#endif
this->packet_length_ = value;
this->reconfigure_packet_length_();
}
#ifdef USE_CC1101_LONG_PACKET
void CC1101Component::set_packet_length_lambda(packet_length_func_t func) {
this->packet_length_func_ = func;
this->reconfigure_packet_length_();
}
#endif
void CC1101Component::reconfigure_packet_length_() {
// While asleep the writes wait for wake_up() and the radio stays asleep, like set_frequency()
if (!this->initialized_ || this->sleeping_) {
this->configure_packet_mode_();
return;
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTLEN);
}
// Like the other runtime setters: go through IDLE, and flush bytes of a reception in progress
this->enter_idle_();
this->strobe_(Command::FRX);
this->configure_packet_mode_();
this->enter_rx_();
}
void CC1101Component::set_crc_enable(bool value) {
+2 -33
View File
@@ -2,7 +2,6 @@
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/components/spi/spi.h"
#include "esphome/core/automation.h"
#include "cc1101defs.h"
@@ -11,7 +10,6 @@
namespace esphome::cc1101 {
enum class CC1101Error { NONE = 0, TIMEOUT, PARAMS, CRC_ERROR, FIFO_OVERFLOW, PLL_LOCK };
using packet_length_func_t = int32_t (*)(const std::vector<uint8_t> &);
class CC1101Listener {
public:
@@ -88,10 +86,7 @@ class CC1101Component final : public Component,
// Packet mode settings
void set_packet_mode(bool value);
void set_packet_length(uint16_t value);
#ifdef USE_CC1101_LONG_PACKET
void set_packet_length_lambda(packet_length_func_t func);
#endif
void set_packet_length(uint8_t value);
void set_crc_enable(bool value);
void set_whitening(bool value);
@@ -106,7 +101,6 @@ class CC1101Component final : public Component,
protected:
uint16_t chip_id_{0};
bool initialized_{false};
bool packet_mode_{false};
bool sleeping_{false};
bool registers_dirty_{false};
@@ -118,38 +112,13 @@ class CC1101Component final : public Component,
// GDO pin for packet reception
InternalGPIOPin *gdo0_pin_{nullptr};
// Inline like App.get_loop_component_start_time(): on RP2040 an out-of-line IRAM_ATTR function in the same
// file as that inline one fails with a section type conflict
static void IRAM_ATTR gpio_intr(CC1101Component *arg) { arg->enable_loop_soon_any_context(); }
static void gpio_intr(CC1101Component *arg);
// Packet handling
void call_listeners_(const std::vector<uint8_t> &packet, float freq_offset, float rssi, uint8_t lqi);
void configure_packet_mode_();
void reconfigure_packet_length_();
float symbol_rate_() const;
#ifdef USE_CC1101_LONG_PACKET
bool is_long_packet_mode_() const;
void arm_length_(uint16_t length);
void reset_long_packet_rx_();
void restart_long_packet_rx_();
void loop_long_packet_();
void handle_long_packet_(uint8_t rx_bytes);
void finish_long_packet_();
void maybe_switch_long_packet_to_fixed_();
#endif
Trigger<std::vector<uint8_t>, float, float, uint8_t> packet_trigger_;
std::vector<uint8_t> packet_;
std::vector<CC1101Listener *> listeners_;
#ifdef USE_CC1101_LONG_PACKET
packet_length_func_t packet_length_func_{nullptr};
uint32_t packet_last_byte_ms_{0};
#endif
uint16_t packet_length_{0};
#ifdef USE_CC1101_LONG_PACKET
uint16_t packet_expected_length_{0};
int8_t long_packet_rssi_{0}; // raw RSSI register
HighFrequencyLoopRequester high_freq_;
#endif
// Low-level Helpers
uint8_t strobe_(Command cmd);
-5
View File
@@ -15,11 +15,6 @@ static constexpr uint8_t PLL_LOCK_RETRIES = 3;
static constexpr uint8_t STATUS_CRC_OK_MASK = 0x80;
static constexpr uint8_t STATUS_LQI_MASK = 0x7F;
static constexpr uint16_t MAX_PACKET_LENGTH = 1024;
static constexpr uint16_t PACKET_IDLE_TIMEOUT_BYTES = 64;
static constexpr uint32_t PACKET_IDLE_TIMEOUT_MIN_MS = 5;
static constexpr uint8_t RXBYTES_STABLE_RETRIES = 4;
static constexpr uint8_t BUS_BURST = 0x40;
static constexpr uint8_t BUS_READ = 0x80;
static constexpr uint8_t BUS_WRITE = 0x00;
@@ -1,15 +1,36 @@
from esphome.components import cstxxx
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
from esphome.types import ConfigType
from .. import cst226_ns
AUTO_LOAD = ["cstxxx"]
CST226Touchscreen = cst226_ns.class_("CST226Touchscreen", cstxxx.CSTTouchscreen)
CST226Touchscreen = cst226_ns.class_(
"CST226Touchscreen",
touchscreen.Touchscreen,
i2c.I2CDevice,
)
CST226ButtonListener = cst226_ns.class_("CST226ButtonListener")
CONFIG_SCHEMA = cstxxx.cst_touchscreen_schema(CST226Touchscreen, 0x5A)
CONFIG_SCHEMA = (
touchscreen.touchscreen_schema("100ms")
.extend(
{
cv.GenerateID(): cv.declare_id(CST226Touchscreen),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(i2c.i2c_device_schema(0x5A))
)
async def to_code(config: ConfigType) -> None:
await cstxxx.new_cst_touchscreen(config)
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN))
@@ -14,10 +14,6 @@ void CST226Touchscreen::setup() {
}
void CST226Touchscreen::update_touches() {
if (!this->setup_complete_) {
this->skip_update_ = true;
return;
}
uint8_t data[28];
if (!this->status_comm_result(this->read_bytes(CST226_REG_STATUS, data, sizeof data))) {
this->skip_update_ = true;
@@ -28,39 +24,56 @@ void CST226Touchscreen::update_touches() {
return;
}
this->update_button_state_(false);
if (data[cstxxx::SYNC_IDX] != cstxxx::SYNC_BYTE || data[0] == cstxxx::SYNC_BYTE ||
data[cstxxx::FINGER_COUNT_IDX] == 0x80) {
if (data[6] != 0xAB || data[0] == 0xAB || data[5] == 0x80) {
this->skip_update_ = true;
return;
}
uint8_t num_of_touches = data[cstxxx::FINGER_COUNT_IDX] & 0x7F;
uint8_t num_of_touches = data[5] & 0x7F;
if (num_of_touches == 0 || num_of_touches > 5) {
this->write_byte(CST226_REG_STATUS, cstxxx::SYNC_BYTE);
this->write_byte(0, 0xAB);
return;
}
size_t index = 0;
for (uint8_t i = 0; i != num_of_touches; i++) {
auto touch = cstxxx::decode_touch(data, i);
this->add_raw_touch_position_(touch.id, touch.x, touch.y, touch.z);
ESP_LOGV(TAG, "Read touch %d: %d/%d", touch.id, touch.x, touch.y);
uint8_t id = data[index] >> 4;
int16_t x = (data[index + 1] << 4) | ((data[index + 3] >> 4) & 0x0F);
int16_t y = (data[index + 2] << 4) | (data[index + 3] & 0x0F);
int16_t z = data[index + 4];
this->add_raw_touch_position_(id, x, y, z);
ESP_LOGV(TAG, "Read touch %d: %d/%d", id, x, y);
index += 5;
if (i == 0)
index += 2;
}
}
bool CST226Touchscreen::read16_(uint16_t addr, uint8_t *data, size_t len) {
if (this->read_register16(addr, data, len) != i2c::ERROR_OK) {
ESP_LOGE(TAG, "Read data from 0x%04X failed", addr);
this->mark_failed();
return false;
}
return true;
}
void CST226Touchscreen::continue_setup_() {
this->setup_interrupt_pin_(gpio::INTERRUPT_FALLING_EDGE);
if (!this->enter_command_mode_())
uint8_t buffer[8];
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
if (!this->setup_check(this->write_byte_reg16(0xD1, 0xD1), LOG_STR("Write byte to 0xD1 failed")))
return;
delay(10);
if (!this->check_firmware_())
return;
if (!this->setup_check(this->read_words_(cstxxx::REG_CHIP_INFO, this->project_id_, this->chip_id_),
LOG_STR("Failed to read chip ID")))
return;
if (this->read16_(0xD204, buffer, 4)) {
uint16_t chip_id = buffer[2] + (buffer[3] << 8);
uint16_t project_id = buffer[0] + (buffer[1] << 8);
ESP_LOGCONFIG(TAG, "Chip ID %X, project ID %x", chip_id, project_id);
}
if (this->x_raw_max_ == 0 || this->y_raw_max_ == 0) {
uint16_t x_max, y_max;
if (this->read_words_(cstxxx::REG_RESOLUTION, x_max, y_max)) {
this->x_raw_max_ = x_max;
this->y_raw_max_ = y_max;
if (this->read16_(0xD1F8, buffer, 4)) {
this->x_raw_max_ = buffer[0] + (buffer[1] << 8);
this->y_raw_max_ = buffer[2] + (buffer[3] << 8);
if (this->swap_x_y_)
std::swap(this->x_raw_max_, this->y_raw_max_);
} else {
@@ -68,10 +81,8 @@ void CST226Touchscreen::continue_setup_() {
this->y_raw_max_ = this->display_->get_native_height();
}
}
this->status_comm_check(this->write_register16(cstxxx::REG_NORMAL_MODE, nullptr, 0) == i2c::ERROR_OK);
this->setup_complete_ = true;
}
void CST226Touchscreen::update_button_state_(bool state) {
if (this->button_touched_ == state)
return;
@@ -85,10 +96,6 @@ void CST226Touchscreen::dump_config() {
LOG_I2C_DEVICE(this);
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
ESP_LOGCONFIG(TAG,
" Chip ID: 0x%04X, Project ID: 0x%04X\n"
" X/Y raw max: %d/%d",
this->chip_id_, this->project_id_, this->x_raw_max_, this->y_raw_max_);
}
} // namespace esphome::cst226
@@ -1,12 +1,11 @@
#pragma once
#include "esphome/components/cstxxx/cstxxx.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <vector>
namespace esphome::cst226 {
static const uint8_t CST226_REG_STATUS = 0x00;
@@ -16,23 +15,28 @@ class CST226ButtonListener {
virtual void update_button(bool state) = 0;
};
class CST226Touchscreen final : public cstxxx::CSTTouchscreen {
class CST226Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
void setup() override;
void update_touches() override;
void dump_config() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
bool can_proceed() override { return this->setup_complete_ || this->is_failed(); }
void register_button_listener(CST226ButtonListener *listener) { this->button_listeners_.push_back(listener); }
protected:
bool read16_(uint16_t addr, uint8_t *data, size_t len);
void continue_setup_();
void update_button_state_(bool state);
bool button_touched_{};
uint16_t chip_id_{};
uint16_t project_id_{};
InternalGPIOPin *interrupt_pin_{};
GPIOPin *reset_pin_{};
uint8_t chip_id_{};
bool setup_complete_{};
std::vector<CST226ButtonListener *> button_listeners_;
bool button_touched_{};
};
} // namespace esphome::cst226
@@ -1,15 +1,37 @@
from esphome.components import cstxxx
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
from esphome.types import ConfigType
from .. import cst328_ns
AUTO_LOAD = ["cstxxx"]
CST328Touchscreen = cst328_ns.class_("CST328Touchscreen", cstxxx.CSTTouchscreen)
CST328Touchscreen = cst328_ns.class_(
"CST328Touchscreen",
touchscreen.Touchscreen,
i2c.I2CDevice,
)
CST328ButtonListener = cst328_ns.class_("CST328ButtonListener")
CONFIG_SCHEMA = cstxxx.cst_touchscreen_schema(CST328Touchscreen, 0x1A)
CONFIG_SCHEMA = (
touchscreen.touchscreen_schema("100ms")
.extend(
{
cv.GenerateID(): cv.declare_id(CST328Touchscreen),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(i2c.i2c_device_schema(0x1A))
)
async def to_code(config: ConfigType) -> None:
await cstxxx.new_cst_touchscreen(config)
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN))
@@ -1,13 +1,35 @@
#include "cst328_touchscreen.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::cst328 {
ESPHOME_LOG_TAG(TAG, "cst328.touchscreen");
static constexpr uint32_t CST328_BEFORE_RESET_TIMEOUT = 50; // 50 ms from datasheet
static constexpr uint32_t CST328_TRANSITION_TIMEOUT = 300; // 200 ms from datasheet, but typically much less
static const uint32_t CST328_BEFORE_RESET_TIMEOUT = 50; // 50 ms from datasheet
static const uint32_t CST328_TRANSITION_TIMEOUT = 300; // 200 ms from datasheet, but typically much less
static const uint16_t CST328_FW_CRC = 0xCACA; // Expected firmware CRC value
static const uint8_t CST328_SYNC_BYTE = 0xAB; // Sync byte used in communication
static const uint8_t ZERO_BYTE = 0;
#define I2C_WARN_ON_ERROR(x, log_tag, format, ...) \
do { \
i2c::ErrorCode err_rc_ = (x); \
if (err_rc_ != i2c::ERROR_OK) { \
ESP_LOGW(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \
this->status_set_warning(format); \
} \
} while (0)
#define I2C_FAIL_ON_ERROR(x, log_tag, format, ...) \
do { \
i2c::ErrorCode err_rc_ = (x); \
if (err_rc_ != i2c::ERROR_OK) { \
ESP_LOGE(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \
this->mark_failed(); \
return; \
} \
} while (0)
void CST328Touchscreen::setup() {
ESP_LOGCONFIG(TAG, "Setting up CST328 Touchscreen...");
@@ -28,36 +50,50 @@ void CST328Touchscreen::reset_device_() {
void CST328Touchscreen::continue_setup_() {
ESP_LOGV(TAG, "Continuing CST328 setup...");
if (!this->enter_command_mode_() || !this->check_firmware_())
return;
if (!this->setup_check(this->read_words_(cstxxx::REG_CHIP_INFO, this->project_id_, this->chip_id_),
LOG_STR("Failed to read chip and project ID")))
uint8_t data_byte{0};
uint8_t buf[24]{};
I2C_FAIL_ON_ERROR(this->write_register16(CST_WM_DEBUG_INFO, buf, 0), TAG, "Failed to enter debug/info mode");
I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_CRC_AND_BOOT_TIME, buf, 4), TAG,
"Failed to read FW CRC and boot time");
uint16_t fw_crc = buf[2] + (buf[3] << 8);
if (fw_crc != CST328_FW_CRC) {
ESP_LOGE(TAG, "Error: Firmware CRC mismatch, expected 0x%04X but got 0x%04X", CST328_FW_CRC, fw_crc);
this->mark_failed();
return;
}
I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_CHIP_TYPE_AND_PROJECT_ID, buf, 4), TAG,
"Failed to read chip and project ID");
this->chip_id_ = buf[2] + (buf[3] << 8);
this->project_id_ = buf[0] + (buf[1] << 8);
ESP_LOGD(TAG, "Chip ID %X, project ID %X", this->chip_id_, this->project_id_);
uint16_t fw_version;
if (!this->setup_check(this->read_words_(cstxxx::REG_FW_VERSION, this->fw_build_, fw_version),
LOG_STR("Failed to read FW version")))
return;
this->fw_ver_major_ = fw_version >> 8;
this->fw_ver_minor_ = fw_version & 0xFF;
I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_REVISION, buf, 4), TAG, "Failed to read FW version");
this->fw_ver_major_ = buf[3];
this->fw_ver_minor_ = buf[2];
this->fw_build_ = buf[0] + (buf[1] << 8);
ESP_LOGV(TAG, "FW version %d.%d.%d", this->fw_ver_major_, this->fw_ver_minor_, this->fw_build_);
uint16_t x_max, y_max;
if (this->read_words_(cstxxx::REG_RESOLUTION, x_max, y_max)) {
this->x_raw_max_ = x_max;
this->y_raw_max_ = y_max;
if (i2c::ERROR_OK == this->read_register16(CST_REG_X_Y_RESOLUTION, buf, 4)) {
this->x_raw_max_ = buf[0] + (buf[1] << 8);
this->y_raw_max_ = buf[2] + (buf[3] << 8);
} else {
this->x_raw_max_ = this->display_->get_native_width();
this->y_raw_max_ = this->display_->get_native_height();
}
uint8_t sync;
bool ok = this->write_register16(cstxxx::REG_NORMAL_MODE, nullptr, 0) == i2c::ERROR_OK;
ok &= this->read_byte_reg16(cstxxx::REG_TOUCH_DATA, &sync);
ok &= this->write_byte_reg16(cstxxx::REG_TOUCH_DATA, cstxxx::SYNC_BYTE);
this->status_comm_check(ok);
I2C_WARN_ON_ERROR(this->write_register16(CST_WM_NORMAL, buf, 0), TAG, "Failed to enter normal mode");
I2C_WARN_ON_ERROR(this->read_register16(CST_REG_TOUCH_INFORMATION, &data_byte, 1), TAG, "Failed to read sync");
I2C_WARN_ON_ERROR(this->write_register16(CST_REG_TOUCH_INFORMATION, &CST328_SYNC_BYTE, 1), TAG,
"Failed to write sync");
this->setup_interrupt_pin_(gpio::INTERRUPT_FALLING_EDGE);
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
this->setup_complete_ = true;
ESP_LOGV(TAG, "CST328 setup complete");
@@ -100,21 +136,27 @@ void CST328Touchscreen::update_touches() {
return;
}
uint8_t touch_cnt = low_nibble(touch_data[CST_REG_FINGER_COUNT_IDX]);
uint8_t touch_cnt = touch_data[CST_REG_FINGER_COUNT_IDX] & 0x0F;
if (touch_cnt == 0 || touch_cnt > CST328_TOUCH_MAX_POINTS) {
this->update_button_state_(false);
} else {
this->update_button_state_(true);
uint8_t data_idx = 0;
for (uint8_t i = 0; i < touch_cnt; i++) {
auto touch = cstxxx::decode_touch(touch_data, i);
this->add_raw_touch_position_(touch.id, touch.x, touch.y, touch.z);
uint8_t id = touch_data[data_idx] >> 4;
int16_t x = (touch_data[data_idx + 1] << 4) | ((touch_data[data_idx + 3] >> 4) & 0x0F);
int16_t y = (touch_data[data_idx + 2] << 4) | (touch_data[data_idx + 3] & 0x0F);
int16_t z = touch_data[data_idx + 4];
this->add_raw_touch_position_(id, x, y, z);
data_idx += (i == 0) ? 7 : 5;
}
}
bool cleanup_error = false;
cleanup_error |= !this->write_byte_reg16(cstxxx::REG_FINGER_COUNT, 0);
cleanup_error |= !this->write_byte_reg16(cstxxx::REG_TOUCH_DATA, cstxxx::SYNC_BYTE);
cleanup_error |= !this->write_byte_reg16(CST_REG_TOUCH_FINGER_NUMBER, ZERO_BYTE);
cleanup_error |= !this->write_byte_reg16(CST_REG_TOUCH_INFORMATION, CST328_SYNC_BYTE);
if (cleanup_error) {
ESP_LOGW(TAG, "Failed to clean up touch registers");
@@ -1,45 +1,61 @@
#pragma once
#include "esphome/components/cstxxx/cstxxx.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include <vector>
namespace esphome::cst328 {
static constexpr uint8_t CST328_TOUCH_MAX_POINTS = 5;
static constexpr uint8_t CST328_TOUCH_DATA_SIZE = cstxxx::report_size(CST328_TOUCH_MAX_POINTS);
static const uint8_t CST328_TOUCH_MAX_POINTS = 5;
static const uint8_t CST328_TOUCH_DATA_SIZE = CST328_TOUCH_MAX_POINTS * 5 + 2;
static constexpr uint16_t CST_REG_TOUCH_INFORMATION = cstxxx::REG_TOUCH_DATA;
static constexpr uint16_t CST_REG_FINGER_COUNT_IDX = cstxxx::FINGER_COUNT_IDX;
static constexpr uint16_t CST_REG_X_Y_RESOLUTION = cstxxx::REG_RESOLUTION;
static constexpr uint16_t CST_REG_FW_CRC_AND_BOOT_TIME = cstxxx::REG_FW_CHECK_CODE;
static const uint16_t CST_REG_TOUCH_INFORMATION = 0xD000;
static const uint16_t CST_REG_TOUCH_FINGER_NUMBER = 0xD005;
static const uint16_t CST_REG_FINGER_COUNT_IDX = CST_REG_TOUCH_FINGER_NUMBER - CST_REG_TOUCH_INFORMATION;
static const uint16_t CST_REG_X_Y_RESOLUTION = 0xD1F8;
static const uint16_t CST_REG_FW_CRC_AND_BOOT_TIME = 0xD1FC;
static const uint16_t CST_REG_CHIP_TYPE_AND_PROJECT_ID = 0xD204;
static const uint16_t CST_REG_FW_REVISION = 0xD208;
static const uint16_t CST_WM_DEBUG_INFO = 0xD101;
static const uint16_t CST_WM_NORMAL = 0xD109;
class CST328ButtonListener {
public:
virtual void update_button(bool state) = 0;
};
class CST328Touchscreen : public cstxxx::CSTTouchscreen {
class CST328Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
void setup() override;
void register_button_listener(CST328ButtonListener *listener) { this->button_listeners_.push_back(listener); }
void dump_config() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
protected:
void update_touches() override;
void reset_device_();
void continue_setup_();
void update_button_state_(bool state);
bool button_touched_{};
uint8_t fw_ver_major_{};
uint8_t fw_ver_minor_{};
InternalGPIOPin *interrupt_pin_{};
GPIOPin *reset_pin_{};
std::vector<CST328ButtonListener *> button_listeners_;
bool button_touched_{};
uint16_t chip_id_{};
uint16_t project_id_{};
uint8_t fw_ver_major_{};
uint8_t fw_ver_minor_{};
uint16_t fw_build_{};
bool setup_complete_{};
};
} // namespace esphome::cst328
@@ -1,14 +1,35 @@
from esphome.components import cstxxx
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
from esphome.types import ConfigType
from .. import cst9220_ns
AUTO_LOAD = ["cstxxx"]
CST9220Touchscreen = cst9220_ns.class_(
"CST9220Touchscreen",
touchscreen.Touchscreen,
i2c.I2CDevice,
)
CST9220Touchscreen = cst9220_ns.class_("CST9220Touchscreen", cstxxx.CSTTouchscreen)
CONFIG_SCHEMA = cstxxx.cst_touchscreen_schema(CST9220Touchscreen, 0x5A)
CONFIG_SCHEMA = (
touchscreen.touchscreen_schema("100ms")
.extend(
{
cv.GenerateID(): cv.declare_id(CST9220Touchscreen),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(i2c.i2c_device_schema(0x5A))
)
async def to_code(config: ConfigType) -> None:
await cstxxx.new_cst_touchscreen(config)
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN))
@@ -1,4 +1,7 @@
#include "cst9220_touchscreen.h"
#include "esphome/core/helpers.h"
#include <cinttypes>
namespace esphome::cst9220 {
@@ -13,29 +16,55 @@ void CST9220Touchscreen::setup() {
}
void CST9220Touchscreen::continue_setup_() {
this->setup_interrupt_pin_(gpio::INTERRUPT_FALLING_EDGE);
uint8_t buffer[4];
if (!this->enter_command_mode_())
return;
delay(10);
if (!this->check_firmware_())
return;
// Read the panel resolution unless the user supplied calibration values.
uint16_t x_max, y_max;
if (this->read_words_(cstxxx::REG_RESOLUTION, x_max, y_max)) {
if (this->x_raw_max_ == this->x_raw_min_)
this->x_raw_max_ = x_max;
if (this->y_raw_max_ == this->y_raw_min_)
this->y_raw_max_ = y_max;
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
if (!this->setup_check(this->read_words_(cstxxx::REG_CHIP_INFO, this->project_id_, this->chip_id_),
LOG_STR("Failed to read chip ID")))
// Enter command mode so the configuration registers can be read.
if (this->write_register16(REG_CMD_MODE, buffer, 0) != i2c::ERROR_OK) {
this->status_set_error(LOG_STR("Failed to enter command mode"));
this->mark_failed();
return;
}
delay(10);
// The firmware check code confirms that valid firmware is loaded.
if (this->read_register16(REG_CHECKCODE, buffer, 4) != i2c::ERROR_OK) {
this->status_set_error(LOG_STR("Failed to read check code"));
this->mark_failed();
return;
}
uint32_t checkcode = encode_uint32(buffer[3], buffer[2], buffer[1], buffer[0]);
if ((checkcode & 0xFFFF0000) != 0xCACA0000) {
ESP_LOGE(TAG, "Invalid firmware check code: 0x%08" PRIX32, checkcode);
this->status_set_error(LOG_STR("Invalid firmware check code"));
this->mark_failed();
return;
}
// Read the panel resolution unless the user supplied calibration values.
if (this->read_register16(REG_RESOLUTION, buffer, 4) == i2c::ERROR_OK) {
if (this->x_raw_max_ == this->x_raw_min_)
this->x_raw_max_ = encode_value_le<uint16_t>(buffer);
if (this->y_raw_max_ == this->y_raw_min_)
this->y_raw_max_ = encode_value_le<uint16_t>(&buffer[2]);
}
// Read the chip type and project id and validate the controller.
if (this->read_register16(REG_CHIP_INFO, buffer, 4) != i2c::ERROR_OK) {
this->status_set_error(LOG_STR("Failed to read chip ID"));
this->mark_failed();
return;
}
this->chip_id_ = encode_value_le<uint16_t>(&buffer[2]);
this->project_id_ = encode_value_le<uint16_t>(buffer);
if (this->chip_id_ != CST9220_CHIP_ID && this->chip_id_ != CST9217_CHIP_ID) {
ESP_LOGE(TAG, "Unknown chip ID: 0x%04X", this->chip_id_);
this->mark_failed(LOG_STR("Unknown chip ID"));
this->status_set_error(LOG_STR("Unknown chip ID"));
this->mark_failed();
return;
}
@@ -55,7 +84,7 @@ void CST9220Touchscreen::update_touches() {
// Only an actual I2C failure should skip the update; a successful read with no
// touches is a real "all fingers lifted" state that must flow through so the
// base class can generate the release event.
if (!this->status_comm_result(this->read_register16(cstxxx::REG_TOUCH_DATA, data, sizeof(data)) == i2c::ERROR_OK)) {
if (!this->status_comm_result(this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) == i2c::ERROR_OK)) {
this->skip_update_ = true;
return;
}
@@ -63,23 +92,30 @@ void CST9220Touchscreen::update_touches() {
// A valid report carries the marker at offset 6. Writing an acknowledge back makes the controller return
// invalid frames during a long press, so none is written. An invalid frame keeps the previous points; while
// a finger is down, read again shortly so a lost release frame does not leave the touch held.
if (data[cstxxx::SYNC_IDX] != cstxxx::SYNC_BYTE) {
if (data[6] != TOUCH_ACK) {
this->skip_update_ = true;
if (!this->touches_.empty())
this->set_timeout(REREAD_TIMEOUT_ID, REREAD_DELAY_MS, [this] { this->store_.touched = true; });
return;
}
uint8_t num_touches = data[cstxxx::FINGER_COUNT_IDX] & 0x7F;
uint8_t num_touches = data[5] & 0x7F;
if (num_touches > CST9220_MAX_TOUCHES)
num_touches = CST9220_MAX_TOUCHES;
for (uint8_t i = 0; i < num_touches; i++) {
auto touch = cstxxx::decode_touch(data, i);
if (touch.event != TOUCH_EVENT_DOWN)
// The first point starts at offset 0; subsequent points are offset by the
// two status bytes that follow it.
const uint8_t *p = data + i * 5 + (i == 0 ? 0 : 2);
uint8_t id = p[0] >> 4;
uint8_t event = p[0] & 0x0F;
if (event != TOUCH_EVENT_DOWN)
continue;
ESP_LOGV(TAG, "Read touch %d: %d/%d", touch.id, touch.x, touch.y);
this->add_raw_touch_position_(touch.id, touch.x, touch.y);
// p[3] is shared: high nibble holds the X LSBs, low nibble the Y LSBs.
uint16_t x = (p[1] << 4) | (p[3] >> 4);
uint16_t y = (p[2] << 4) | (p[3] & 0x0F);
ESP_LOGV(TAG, "Read touch %d: %d/%d", id, x, y);
this->add_raw_touch_position_(id, x, y);
}
}
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/components/cstxxx/cstxxx.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
@@ -9,26 +10,41 @@ namespace esphome::cst9220 {
ESPHOME_LOG_TAG(TAG, "cst9220.touchscreen");
static constexpr uint8_t TOUCH_EVENT_DOWN = 0x06;
// The CST92xx family uses 16-bit (big-endian) register addresses.
static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report
static const uint16_t REG_CMD_MODE = 0xD101; // enter command mode
static const uint16_t REG_CHECKCODE = 0xD1FC; // firmware check code
static const uint16_t REG_RESOLUTION = 0xD1F8; // panel resolution
static const uint16_t REG_CHIP_INFO = 0xD204; // chip type + project id
static constexpr uint16_t CST9220_CHIP_ID = 0x9220;
static constexpr uint16_t CST9217_CHIP_ID = 0x9217;
static const uint8_t TOUCH_ACK = 0xAB;
static const uint8_t TOUCH_EVENT_DOWN = 0x06;
static constexpr uint8_t CST9220_MAX_TOUCHES = 5;
static const uint16_t CST9220_CHIP_ID = 0x9220;
static const uint16_t CST9217_CHIP_ID = 0x9217;
// Maximum simultaneous touch points reported by the family.
static const uint8_t CST9220_MAX_TOUCHES = 5;
// Report layout: 5 bytes per touch point plus 5 bytes of status/ack overhead.
static constexpr size_t CST9220_DATA_LENGTH = CST9220_MAX_TOUCHES * 5 + 5;
static const size_t CST9220_DATA_LENGTH = CST9220_MAX_TOUCHES * 5 + 5;
class CST9220Touchscreen : public cstxxx::CSTTouchscreen {
class CST9220Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
protected:
void update_touches() override;
void continue_setup_();
InternalGPIOPin *interrupt_pin_{};
GPIOPin *reset_pin_{};
uint16_t chip_id_{};
uint16_t project_id_{};
bool setup_complete_{};
};
} // namespace esphome::cst9220
-42
View File
@@ -1,42 +0,0 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
CODEOWNERS = ["@clydebarrow", "@latonita"]
DEPENDENCIES = ["i2c", "touchscreen"]
DOMAIN = "cstxxx"
cstxxx_ns = cg.esphome_ns.namespace("cstxxx")
CSTTouchscreen = cstxxx_ns.class_(
"CSTTouchscreen", touchscreen.Touchscreen, i2c.I2CDevice
)
def cst_touchscreen_schema(touchscreen_class: MockObjClass, address: int) -> cv.Schema:
"""Return the touchscreen schema of a Hynitron driver."""
return (
touchscreen.touchscreen_schema("100ms")
.extend(
{
cv.GenerateID(): cv.declare_id(touchscreen_class),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(i2c.i2c_device_schema(address))
)
async def new_cst_touchscreen(config: ConfigType) -> MockObj:
"""Create a Hynitron touchscreen and set its pins."""
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN))
return var
-13
View File
@@ -1,13 +0,0 @@
#include "cstxxx.h"
#include "esphome/core/log.h"
namespace esphome::cstxxx {
ESPHOME_LOG_TAG(TAG, "cstxxx");
void CSTTouchscreen::fail_check_code_(uint16_t check_code) {
ESP_LOGE(TAG, "Firmware check code 0x%04X", check_code);
this->mark_failed(LOG_STR("Invalid firmware check code"));
}
} // namespace esphome::cstxxx
-99
View File
@@ -1,99 +0,0 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/core/gpio.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::cstxxx {
static constexpr uint16_t REG_TOUCH_DATA = 0xD000;
static constexpr uint16_t REG_FINGER_COUNT = 0xD005;
static constexpr uint16_t REG_COMMAND_MODE = 0xD101;
static constexpr uint16_t REG_NORMAL_MODE = 0xD109;
static constexpr uint16_t REG_RESOLUTION = 0xD1F8;
static constexpr uint16_t REG_FW_CHECK_CODE = 0xD1FC;
static constexpr uint16_t REG_CHIP_INFO = 0xD204;
static constexpr uint16_t REG_FW_VERSION = 0xD208;
static constexpr uint8_t FINGER_COUNT_IDX = REG_FINGER_COUNT - REG_TOUCH_DATA;
static constexpr uint8_t SYNC_IDX = FINGER_COUNT_IDX + 1;
static constexpr uint8_t SYNC_BYTE = 0xAB;
static constexpr uint16_t FW_CHECK_CODE = 0xCACA;
/// Bytes of a touch report holding `points` records; two status bytes follow the first record.
constexpr size_t report_size(uint8_t points) { return points * 5 + 2; }
struct TouchRecord {
uint8_t id;
uint8_t event;
uint16_t x;
uint16_t y;
uint8_t z;
};
/// Decodes record `index` of a touch report. Each record is 5 bytes:
/// [id:4 | event:4] [x 11..4] [y 11..4] [x 3..0 | y 3..0] [z].
/// The first record is followed by 2 extra bytes, so later records start 2 bytes further on.
inline TouchRecord decode_touch(const uint8_t *report, uint8_t index) {
const uint8_t *r = report + index * 5 + (index == 0 ? 0 : 2);
return {
.id = high_nibble(r[0]),
.event = low_nibble(r[0]),
.x = encode_uint12(r[1], high_nibble(r[3])),
.y = encode_uint12(r[2], low_nibble(r[3])),
.z = r[4],
};
}
class CSTTouchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
protected:
/// Sets up the interrupt pin, if configured, to fire on `type`.
void setup_interrupt_pin_(gpio::InterruptType type) {
if (this->interrupt_pin_ == nullptr)
return;
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, type);
}
/// Reads two little-endian words starting at `reg`.
bool ESPHOME_ALWAYS_INLINE read_words_(uint16_t reg, uint16_t &low, uint16_t &high) {
uint16_t words[2];
if (this->read_register16(reg, reinterpret_cast<uint8_t *>(words), sizeof(words)) != i2c::ERROR_OK)
return false;
low = convert_little_endian(words[0]);
high = convert_little_endian(words[1]);
return true;
}
/// Enters command mode, marking the component failed if the write fails.
bool enter_command_mode_() {
return this->setup_check(this->write_register16(REG_COMMAND_MODE, nullptr, 0) == i2c::ERROR_OK,
LOG_STR("Failed to enter command mode"));
}
/// Checks the firmware check code, marking the component failed if it is unreadable or wrong.
bool check_firmware_() {
uint16_t boot_time, check_code;
if (!this->setup_check(this->read_words_(REG_FW_CHECK_CODE, boot_time, check_code)))
return false;
if (check_code == FW_CHECK_CODE)
return true;
this->fail_check_code_(check_code);
return false;
}
/// Logs a wrong firmware check code and marks the component failed.
void fail_check_code_(uint16_t check_code);
InternalGPIOPin *interrupt_pin_{};
GPIOPin *reset_pin_{};
bool setup_complete_{};
};
} // namespace esphome::cstxxx
@@ -35,12 +35,12 @@ void EKTF2232Touchscreen::setup() {
if (err == i2c::ERROR_OK) {
err = this->read(received, 4);
if (err == i2c::ERROR_OK) {
this->x_raw_max_ = encode_uint16(high_nibble(received[3]), received[2]);
this->x_raw_max_ = ((received[2])) | ((received[3] & 0xf0) << 4);
err = this->write(GET_Y_RES, 4);
if (err == i2c::ERROR_OK) {
err = this->read(received, 4);
if (err == i2c::ERROR_OK) {
this->y_raw_max_ = encode_uint16(high_nibble(received[3]), received[2]);
this->y_raw_max_ = ((received[2])) | ((received[3] & 0xf0) << 4);
}
}
}
@@ -74,9 +74,8 @@ void EKTF2232Touchscreen::update_touches() {
for (int i = 0; i < touch_count; i++) {
uint8_t *d = raw + 1 + (i * 3);
// d[0] holds the top 4 bits of X (high nibble) and of Y (low nibble)
x_raw = encode_uint16(high_nibble(d[0]), d[1]);
y_raw = encode_uint16(low_nibble(d[0]), d[2]);
x_raw = (d[0] & 0xF0) << 4 | d[1];
y_raw = (d[0] & 0x0F) << 8 | d[2];
this->add_raw_touch_position_(i, x_raw, y_raw);
}
}
@@ -7,8 +7,6 @@
#include "esphome/core/automation.h"
#include "esphome/components/network/ip_address.h"
#include <atomic>
#ifdef USE_ETHERNET
#ifdef USE_ESP32
@@ -368,9 +366,6 @@ class EthernetComponent final : public Component {
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
StaticVector<EthernetIPStateListener *, ESPHOME_ETHERNET_IP_STATE_LISTENERS> ip_state_listeners_;
#ifdef USE_ESP32
std::atomic<uint8_t> ip_state_pending_{0};
#endif
#endif
#ifdef USE_ETHERNET_CONNECT_TRIGGER
@@ -129,14 +129,6 @@ void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *me
void EthernetComponent::loop() {
const uint32_t now = App.get_loop_component_start_time();
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
// The IP events arrive on the event task; the listeners publish states, so notify them here
if (this->ip_state_pending_.load(std::memory_order_acquire) != 0) {
this->ip_state_pending_.store(0, std::memory_order_relaxed);
this->notify_ip_state_listeners_();
}
#endif
switch (this->state_) {
case EthernetComponentState::STOPPED:
if (this->started_) {
@@ -778,8 +770,7 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base
event_name = "ETH connected";
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP)
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
global_eth_component->enable_loop_soon_any_context();
global_eth_component->notify_ip_state_listeners_();
#endif
#if USE_NETWORK_IPV6
// Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the
@@ -807,9 +798,6 @@ void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_b
const esp_netif_ip_info_t *ip_info = &event->ip_info;
ESP_LOGV(TAG, "[Ethernet event] ETH Got IP " IPSTR, IP2STR(&ip_info->ip));
global_eth_component->got_ipv4_address_ = true;
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
#endif
#if USE_NETWORK_IPV6 && (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
global_eth_component->connected_ = global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
@@ -817,6 +805,9 @@ void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_b
global_eth_component->connected_ = true;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
#endif /* USE_NETWORK_IPV6 */
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->notify_ip_state_listeners_();
#endif
}
#if USE_NETWORK_IPV6
@@ -828,9 +819,6 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
// after a link flap fires another event for the same address.
struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(global_eth_component->eth_netif_, if_ip6s);
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
#endif
#if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
global_eth_component->connected_ =
global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT);
@@ -839,6 +827,9 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
global_eth_component->connected_ = global_eth_component->got_ipv4_address_;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
#endif
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->notify_ip_state_listeners_();
#endif
}
#endif /* USE_NETWORK_IPV6 */
@@ -69,8 +69,8 @@ void FT5x06Touchscreen::update_touches() {
for (uint8_t i = 0; i != touch_cnt; i++) {
uint8_t status = data[i][0] >> 6;
uint8_t id = data[i][2] >> 3;
uint16_t x = encode_uint16(low_nibble(data[i][0]), data[i][1]);
uint16_t y = encode_uint16(low_nibble(data[i][2]), data[i][3]);
uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]);
uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]);
ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
if (status == 0 || status == 2) {
+5 -5
View File
@@ -90,31 +90,31 @@ void FT63X6Touchscreen::update_touches() {
}
}
uint8_t FT63X6Touchscreen::read_touch_number_() { return low_nibble(this->read_byte_(FT63X6_ADDR_TD_STATUS)); }
uint8_t FT63X6Touchscreen::read_touch_number_() { return this->read_byte_(FT63X6_ADDR_TD_STATUS) & 0x0F; }
// Touch 1 functions
uint16_t FT63X6Touchscreen::read_touch_x_(uint8_t touch) {
uint8_t read_buf[2];
read_buf[0] = this->read_byte_(FT63X6_ADDR_TOUCH1_X + (touch * 6));
read_buf[1] = this->read_byte_(FT63X6_ADDR_TOUCH1_X + 1 + (touch * 6));
return encode_uint16(low_nibble(read_buf[0]), read_buf[1]);
return encode_uint16(read_buf[0] & 0x0f, read_buf[1]);
}
uint16_t FT63X6Touchscreen::read_touch_y_(uint8_t touch) {
uint8_t read_buf[2];
read_buf[0] = this->read_byte_(FT63X6_ADDR_TOUCH1_Y + (touch * 6));
read_buf[1] = this->read_byte_(FT63X6_ADDR_TOUCH1_Y + 1 + (touch * 6));
return encode_uint16(low_nibble(read_buf[0]), read_buf[1]);
return encode_uint16(read_buf[0] & 0x0f, read_buf[1]);
}
uint8_t FT63X6Touchscreen::read_touch_event_(uint8_t touch) {
return this->read_byte_(FT63X6_ADDR_TOUCH1_X + (touch * 6)) >> 6;
}
uint8_t FT63X6Touchscreen::read_touch_id_(uint8_t touch) {
return high_nibble(this->read_byte_(FT63X6_ADDR_TOUCH1_ID + (touch * 6)));
return this->read_byte_(FT63X6_ADDR_TOUCH1_ID + (touch * 6)) >> 4;
}
uint8_t FT63X6Touchscreen::read_touch_weight_(uint8_t touch) {
return this->read_byte_(FT63X6_ADDR_TOUCH1_WEIGHT + (touch * 6));
}
uint8_t FT63X6Touchscreen::read_touch_misc_(uint8_t touch) {
return high_nibble(this->read_byte_(FT63X6_ADDR_TOUCH1_MISC + (touch * 6)));
return this->read_byte_(FT63X6_ADDR_TOUCH1_MISC + (touch * 6)) >> 4;
}
uint8_t FT63X6Touchscreen::read_byte_(uint8_t addr) {
@@ -1,7 +1,6 @@
#include "gsl3670_touchscreen.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
namespace esphome::gsl3670 {
@@ -67,11 +66,10 @@ void GSL3670Touchscreen::update_touches() {
// Build gsl_touch_info exactly as the Seeed driver does
for (uint8_t j = 0; j != finger_num; j++) {
// Record: y_lo, y_hi, x_lo, then the id (high nibble) and the top 4 bits of X (low nibble)
const uint8_t *record = &buf[(j + 1) * 4];
auto x = encode_uint16(low_nibble(record[3]), record[2]);
auto y = encode_uint16(record[1], record[0]);
auto id = high_nibble(record[3]);
// buf[(j+1)*4 + 0..3]: byte0=y_lo, byte1=y_hi, byte2=x_lo, byte3=id|x_hi
auto x = (uint16_t) (((buf[(j + 1) * 4 + 3] & 0x0f) << 8) | buf[(j + 1) * 4 + 2]);
auto y = (uint16_t) ((buf[(j + 1) * 4 + 1] << 8) | buf[(j + 1) * 4 + 0]);
auto id = (buf[(j + 1) * 4 + 3] >> 4) & 0x0f;
ESP_LOGV(TAG, "Touch id=%u, x=%u y=%u", id, x, y);
if (x <= 8192 && y <= 8192)
this->add_raw_touch_position_(id, x, y);
@@ -62,7 +62,7 @@ void LilygoT547Touchscreen::update_touches() {
return;
}
point = low_nibble(buffer[5]);
point = buffer[5] & 0xF;
if (point > 2) {
ESP_LOGW(TAG, "Invalid touch point count: %d", point);
point = 2;
@@ -88,11 +88,9 @@ void LilygoT547Touchscreen::update_touches() {
uint16_t id, x_raw, y_raw;
for (uint8_t i = 0; i < point; i++) {
const uint8_t *record = buffer + i * 5;
id = high_nibble(record[0]);
// The low 4 bits of Y and X share byte 3
y_raw = encode_uint12(record[1], high_nibble(record[3]));
x_raw = encode_uint12(record[2], low_nibble(record[3]));
id = (buffer[i * 5] >> 4) & 0x0F;
y_raw = (uint16_t) ((buffer[i * 5 + 1] << 4) | ((buffer[i * 5 + 3] >> 4) & 0x0F));
x_raw = (uint16_t) ((buffer[i * 5 + 2] << 4) | (buffer[i * 5 + 3] & 0x0F));
this->add_raw_touch_position_(id, x_raw, y_raw);
}
-303
View File
@@ -1,303 +0,0 @@
from collections.abc import Callable
from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.const import CONF_CHARGE_CURRENT, CONF_ENABLED
import esphome.config_validation as cv
from esphome.const import CONF_DURATION, CONF_ID, CONF_MODE, CONF_VOLTAGE
from esphome.types import ConfigType
CODEOWNERS = ["@tomaszduda23"]
DEPENDENCIES = ["i2c"]
DOMAIN = "npm1300"
MULTI_CONF = True
CONF_NPM1300_ID = "npm1300_id"
CONF_VBUS_CURRENT_LIMIT = "vbus_current_limit"
CONF_THERMISTOR_RESISTANCE = "thermistor_resistance"
CONF_THERMISTOR_BETA = "thermistor_beta"
CONF_CHARGER = "charger"
CONF_TERMINATION_VOLTAGE = "termination_voltage"
CONF_WARM_TERMINATION_VOLTAGE = "warm_termination_voltage"
CONF_TERMINATION_CURRENT = "termination_current"
CONF_TRICKLE_VOLTAGE = "trickle_voltage"
CONF_DISCHARGE_CURRENT_LIMIT = "discharge_current_limit"
CONF_RECHARGE = "recharge"
CONF_BUCK1 = "buck1"
CONF_BUCK2 = "buck2"
CONF_LDO1 = "ldo1"
CONF_LDO2 = "ldo2"
npm1300_ns = cg.esphome_ns.namespace("npm1300")
NPM1300Component = npm1300_ns.class_(
"NPM1300Component", cg.PollingComponent, i2c.I2CDevice
)
NPM1300Output = npm1300_ns.enum("NPM1300Output", is_class=True)
NPM1300OutputConfig = npm1300_ns.struct("NPM1300OutputConfig")
NPM1300ChargerConfig = npm1300_ns.struct("NPM1300ChargerConfig")
NPM1300Config = npm1300_ns.struct("NPM1300Config")
NPM1300_CHILD_SCHEMA = cv.Schema(
{cv.GenerateID(CONF_NPM1300_ID): cv.use_id(NPM1300Component)}
)
# Status registers read on each update: VBUSINSTATUS, BCHGCHARGESTATUS and BCHGERRREASON
STATUS_PUBLISHER = cg.HubFunction(
"status",
cg.void,
[
(cg.uint8, "vbus_status"),
(cg.uint8, "charge_status"),
(cg.uint8, "error_reason"),
],
define="USE_NPM1300_STATUS_PUBLISHER",
)
VBUS_STATUS = STATUS_PUBLISHER.param("vbus_status")
CHARGE_STATUS = STATUS_PUBLISHER.param("charge_status")
ERROR_REASON = STATUS_PUBLISHER.param("error_reason")
# The ADC results and the amperes per code of the battery current
DATA_PUBLISHER = cg.HubFunction(
"data",
cg.void,
[(cg.uint8.operator("const").operator("ptr"), "adc"), (cg.float_, "ibat_scale")],
define="USE_NPM1300_DATA_PUBLISHER",
)
ADC = DATA_PUBLISHER.param("adc")
IBAT_SCALE = DATA_PUBLISHER.param("ibat_scale")
# The dump lines of the children decoded by the two functions above
DUMP = cg.HubFunction("dump", cg.void, [(cg.const_char_ptr, "tag")])
DUMP_TAG = DUMP.param("tag")
# Config key of each regulator and its NPM1300Output enumerator
OUTPUTS = NPM1300Output.options(
CONF_BUCK1, CONF_BUCK2, CONF_LDO1, CONF_LDO2, prefix="NPM1300_OUTPUT_"
)
# Written for a field the YAML leaves out
NPM1300_UNSET = 0xFF
# ADCNTCRSEL codes, by kiloohms
THERMISTOR_RESISTANCES = {10: 1, 47: 2, 100: 3}
LDO_MODES = {"load_switch": 0, "ldo": 1}
TERMINATION_CURRENTS = {"10%": 0, "20%": 1}
# BCHGVTRICKLESEL, by millivolts
TRICKLE_VOLTAGES = {2900: 0, 2500: 1}
# BCHGISETDISCHARGE codes, by milliamperes
DISCHARGE_CURRENT_LIMITS = {200: 84, 1000: 415}
# BCHGVTERM: 3.50 V to 3.65 V, then 4.00 V to 4.45 V, both in 50 mV steps
TERMINATION_VOLTAGES = [3500 + 50 * i for i in range(4)] + [
4000 + 50 * i for i in range(10)
]
# The wake-up timer counts 16 ms ticks in a 24-bit register
HIBERNATE_TICK_MS = 16
HIBERNATE_MAX_TICKS = 0xFFFFFF
def _millis(value: float) -> int:
return round(value * 1000)
def _one_of_units(
validator: Callable[[object], float],
allowed: list[int],
unit: str,
scale: float = 1000,
) -> Callable[[object], float]:
"""Validate a quantity with `validator` and accept only the listed values, given in units * `scale`."""
def validate(value: object) -> float:
value = validator(value)
if round(value * scale) not in allowed:
options = ", ".join(f"{v / scale:g}{unit}" for v in allowed)
raise cv.Invalid(f"Must be one of {options}")
return value
return validate
def _stepped(
validator: Callable[[object], float],
minimum: int,
maximum: int,
step: int,
unit: str,
) -> Callable[[object], float]:
"""Validate a quantity in a range of milli-units, in steps of `step`."""
allowed = list(range(minimum, maximum + 1, step))
in_range = cv.All(validator, cv.Range(min=minimum / 1000, max=maximum / 1000))
def validate(value: object) -> float:
value = in_range(value)
if _millis(value) not in allowed:
raise cv.Invalid(f"Must be a multiple of {step}m{unit}")
return value
return validate
OUTPUT_VOLTAGE = _stepped(cv.voltage, 1000, 3300, 100, "V")
VBUS_CURRENT_LIMIT = _stepped(cv.current, 100, 1500, 100, "A")
BUCK_SCHEMA = cv.Schema(
{
cv.Optional(CONF_VOLTAGE): OUTPUT_VOLTAGE,
cv.Optional(CONF_ENABLED): cv.boolean,
}
)
LDO_SCHEMA = BUCK_SCHEMA.extend(
{
cv.Optional(CONF_MODE): cv.enum(LDO_MODES, lower=True),
}
)
CHARGER_SCHEMA = cv.Schema(
{
cv.Required(CONF_CHARGE_CURRENT): _stepped(cv.current, 32, 800, 2, "A"),
cv.Optional(CONF_TERMINATION_VOLTAGE, default="4.2V"): _one_of_units(
cv.voltage, TERMINATION_VOLTAGES, "V"
),
cv.Optional(CONF_WARM_TERMINATION_VOLTAGE): _one_of_units(
cv.voltage, TERMINATION_VOLTAGES, "V"
),
cv.Optional(CONF_TERMINATION_CURRENT, default="10%"): cv.enum(
TERMINATION_CURRENTS
),
cv.Optional(CONF_TRICKLE_VOLTAGE, default="2.9V"): _one_of_units(
cv.voltage, list(TRICKLE_VOLTAGES), "V"
),
cv.Optional(CONF_DISCHARGE_CURRENT_LIMIT, default="1000mA"): _one_of_units(
cv.current, list(DISCHARGE_CURRENT_LIMITS), "A"
),
cv.Optional(CONF_RECHARGE, default=True): cv.boolean,
cv.Optional(CONF_ENABLED, default=True): cv.boolean,
}
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(NPM1300Component),
cv.Optional(CONF_VBUS_CURRENT_LIMIT): VBUS_CURRENT_LIMIT,
cv.Optional(CONF_THERMISTOR_RESISTANCE): _one_of_units(
cv.resistance, list(THERMISTOR_RESISTANCES), "kOhm", scale=0.001
),
cv.Optional(CONF_THERMISTOR_BETA, default=3380): cv.int_range(
min=1, max=65535
),
cv.Optional(CONF_CHARGER): CHARGER_SCHEMA,
cv.Optional(CONF_BUCK1): BUCK_SCHEMA,
cv.Optional(CONF_BUCK2): BUCK_SCHEMA,
cv.Optional(CONF_LDO1): LDO_SCHEMA,
cv.Optional(CONF_LDO2): LDO_SCHEMA,
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x6B))
)
def _termination_voltage_code(volts: float) -> int:
return TERMINATION_VOLTAGES.index(_millis(volts))
def _vbus_current_limit_code(amps: float) -> int:
"""VBUSINILIM0: 100 mA per step of the code."""
return _millis(amps) // 100
def _charger_config(config: ConfigType) -> cg.StructInitializer:
termination = _termination_voltage_code(config[CONF_TERMINATION_VOLTAGE])
warm = config.get(CONF_WARM_TERMINATION_VOLTAGE)
return cg.StructInitializer(
NPM1300ChargerConfig,
("current", _millis(config[CONF_CHARGE_CURRENT]) // 2),
(
"discharge_limit",
DISCHARGE_CURRENT_LIMITS[_millis(config[CONF_DISCHARGE_CURRENT_LIMIT])],
),
("termination_voltage", termination),
(
"warm_termination_voltage",
termination if warm is None else _termination_voltage_code(warm),
),
("trickle_voltage", TRICKLE_VOLTAGES[_millis(config[CONF_TRICKLE_VOLTAGE])]),
("termination_current", TERMINATION_CURRENTS[config[CONF_TERMINATION_CURRENT]]),
("recharge", config[CONF_RECHARGE]),
("enabled", config[CONF_ENABLED]),
)
def _output_config(config: ConfigType | None) -> cg.StructInitializer:
config = config or {}
voltage = config.get(CONF_VOLTAGE)
mode = config.get(CONF_MODE)
enabled = config.get(CONF_ENABLED)
return cg.StructInitializer(
NPM1300OutputConfig,
(
"voltage",
NPM1300_UNSET if voltage is None else (_millis(voltage) - 1000) // 100,
),
("ldo_mode", NPM1300_UNSET if mode is None else LDO_MODES[mode]),
("enabled", NPM1300_UNSET if enabled is None else int(enabled)),
)
def _config(config: ConfigType) -> cg.StructInitializer:
charger = config.get(CONF_CHARGER)
resistance = config.get(CONF_THERMISTOR_RESISTANCE)
return cg.StructInitializer(
NPM1300Config,
(
"outputs",
cg.ArrayInitializer(*(_output_config(config.get(key)) for key in OUTPUTS)),
),
("charger", None if charger is None else _charger_config(charger)),
("thermistor_beta", config[CONF_THERMISTOR_BETA]),
(
"thermistor",
NPM1300_UNSET
if resistance is None
else THERMISTOR_RESISTANCES[round(resistance / 1000)],
),
("charger_configured", charger is not None),
)
async def to_code(config: ConfigType) -> None:
# In flash: only setup() and dump_config() read it
table = cg.shared_progmem_array(
"npm1300_config", NPM1300Config, cg.ArrayInitializer(_config(config))
)
var = await i2c.new_i2c_component(config, table)
if (limit := config.get(CONF_VBUS_CURRENT_LIMIT)) is not None:
cg.add(var.set_vbus_current_limit(_vbus_current_limit_code(limit)))
automation.register_apply_action(
"npm1300.enter_ship_mode",
automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(NPM1300Component)}),
automation.ApplyCall("enter_ship_mode()"),
)
automation.register_apply_action(
"npm1300.hibernate",
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(NPM1300Component),
cv.Required(CONF_DURATION): cv.templatable(
cv.All(
cv.positive_time_period_milliseconds,
cv.Range(
min=cv.TimePeriod(milliseconds=HIBERNATE_TICK_MS),
max=cv.TimePeriod(
milliseconds=HIBERNATE_TICK_MS * HIBERNATE_MAX_TICKS
),
),
)
),
}
),
automation.ApplyField(CONF_DURATION, "hibernate", cg.uint32),
)
-106
View File
@@ -1,106 +0,0 @@
from dataclasses import dataclass
from esphome.automation import parent_ref
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_BATTERY_CHARGING,
DEVICE_CLASS_POWER,
DEVICE_CLASS_PROBLEM,
ENTITY_CATEGORY_DIAGNOSTIC,
)
from esphome.cpp_generator import HexIntLiteral
from esphome.types import ConfigType
from . import (
CHARGE_STATUS,
CONF_NPM1300_ID,
DUMP,
DUMP_TAG,
ERROR_REASON,
NPM1300_CHILD_SCHEMA,
STATUS_PUBLISHER,
VBUS_STATUS,
)
DEPENDENCIES = ["npm1300"]
@dataclass(frozen=True, kw_only=True)
class _StatusBits:
"""On when any bit of mask is set in the status register."""
key: str
register: cg.MockObj
mask: int
name: str
device_class: str = cv.UNDEFINED
entity_category: str = cv.UNDEFINED
_FIELDS = (
_StatusBits(
key="vbus_present",
register=VBUS_STATUS,
mask=0x01,
name="VBUS Present",
device_class=DEVICE_CLASS_POWER,
),
_StatusBits(
key="vbus_current_limit_active",
register=VBUS_STATUS,
mask=0x02,
name="VBUS Current Limit Active",
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
# Trickle, constant current or constant voltage charging
_StatusBits(
key="charging",
register=CHARGE_STATUS,
mask=0x1C,
name="Charging",
device_class=DEVICE_CLASS_BATTERY_CHARGING,
),
_StatusBits(
key="charge_complete",
register=CHARGE_STATUS,
mask=0x02,
name="Charge Complete",
),
_StatusBits(
key="charger_error",
register=ERROR_REASON,
mask=0xFF,
name="Charger Error",
device_class=DEVICE_CLASS_PROBLEM,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
)
CONFIG_SCHEMA = NPM1300_CHILD_SCHEMA.extend(
{
cv.Optional(field.key): binary_sensor.binary_sensor_schema(
device_class=field.device_class, entity_category=field.entity_category
)
for field in _FIELDS
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_NPM1300_ID])
sensors = binary_sensor.sub_binary_sensors(config)
for field in _FIELDS:
if (var := await sensors(field.key, None)) is not None:
state = (field.register & HexIntLiteral(field.mask)) != 0
STATUS_PUBLISHER.add(hub, parent_ref(var).publish_state(state))
DUMP.add(
hub,
binary_sensor.binary_sensor_ns.log_binary_sensor(
DUMP_TAG,
" ",
cg.RawExpression(f'LOG_STR_LITERAL("{field.name}")'),
parent_ref(var),
),
)
-483
View File
@@ -1,483 +0,0 @@
#include "npm1300.h"
#include <algorithm>
#include <cinttypes>
#include <cmath>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#ifdef USE_TEXT_SENSOR
#include "esphome/core/progmem.h"
#endif
namespace esphome::npm1300 {
ESPHOME_LOG_TAG(TAG, "npm1300");
// Registers: the base address of the peripheral in the high byte, the offset in the low byte
static constexpr uint16_t REG_VBUSIN_TASK_UPDATE_ILIM = 0x0200;
static constexpr uint16_t REG_VBUSIN_ILIM0 = 0x0201;
static constexpr uint16_t REG_VBUSIN_ILIM_STARTUP = 0x0202;
static constexpr uint16_t REG_VBUSIN_STATUS = 0x0207;
static constexpr uint16_t REG_BCHG_TASK_RELEASE_ERR = 0x0300;
static constexpr uint16_t REG_BCHG_TASK_CLEAR_CHG_ERR = 0x0301;
static constexpr uint16_t REG_BCHG_ENABLE_SET = 0x0304;
static constexpr uint16_t REG_BCHG_ENABLE_CLR = 0x0305;
static constexpr uint16_t REG_BCHG_DISABLE_SET = 0x0306;
static constexpr uint16_t REG_BCHG_DISABLE_CLR = 0x0307;
static constexpr uint16_t REG_BCHG_ISET_MSB = 0x0308;
static constexpr uint16_t REG_BCHG_ISET_DISCHARGE_MSB = 0x030A;
static constexpr uint16_t REG_BCHG_VTERM = 0x030C;
static constexpr uint16_t REG_BCHG_VTERM_R = 0x030D;
static constexpr uint16_t REG_BCHG_VTRICKLE_SEL = 0x030E;
static constexpr uint16_t REG_BCHG_ITERM_SEL = 0x030F;
static constexpr uint16_t REG_BCHG_CHARGE_STATUS = 0x0334;
static constexpr uint16_t REG_BCHG_ERR_REASON = 0x0336;
static constexpr uint16_t REG_BUCK1_ENA_SET = 0x0400;
static constexpr uint16_t REG_BUCK1_NORM_VOUT = 0x0408;
static constexpr uint16_t REG_BUCK_SW_CTRL_SEL = 0x040F;
static constexpr uint16_t REG_BUCK_STATUS = 0x0434;
static constexpr uint16_t REG_ADC_TASK_VBAT = 0x0500;
static constexpr uint16_t REG_ADC_TASK_VBUS = 0x0507;
static constexpr uint16_t REG_ADC_NTC_R_SEL = 0x050A;
static constexpr uint16_t REG_ADC_TASK_AUTO_TIM_UPDATE = 0x050C;
static constexpr uint16_t REG_ADC_RESULTS = 0x0510;
static constexpr uint16_t REG_ADC_IBAT_MEAS_EN = 0x0524;
static constexpr uint16_t REG_TIMER_TARGET_STROBE = 0x0703;
static constexpr uint16_t REG_TIMER_CONFIG = 0x0705;
static constexpr uint16_t REG_TIMER_HI_BYTE = 0x0708;
static constexpr uint16_t REG_LDSW1_TASK_SET = 0x0800;
static constexpr uint16_t REG_LDSW_STATUS = 0x0804;
static constexpr uint16_t REG_LDSW1_LDO_SEL = 0x0808;
static constexpr uint16_t REG_LDSW1_VOUT_SEL = 0x080C;
static constexpr uint16_t REG_SHIP_TASK_HIBERNATE = 0x0B00;
static constexpr uint16_t REG_SHIP_TASK_SHIP_MODE = 0x0B02;
// VBUSINSTATUS
static constexpr uint8_t VBUS_PRESENT = 0x01;
// BCHGCHARGESTATUS; bit 0 (battery detected) is reserved since C00 silicon
static constexpr uint8_t CHARGE_STATUS_COMPLETED = 0x02;
static constexpr uint8_t CHARGE_STATUS_TRICKLE = 0x04;
static constexpr uint8_t CHARGE_STATUS_CONSTANT_CURRENT = 0x08;
static constexpr uint8_t CHARGE_STATUS_CONSTANT_VOLTAGE = 0x10;
static constexpr uint8_t CHARGE_STATUS_DIE_TEMP_HIGH = 0x40;
// BCHGDISABLESET and BCHGDISABLECLR
static constexpr uint8_t CHARGER_DISABLE_RECHARGE = 0x01;
static constexpr uint8_t CHARGER_DISABLE_NTC = 0x02;
// BUCKSTATUS power good, LDSWSTATUS powered up as load switch or as LDO
static constexpr uint8_t BUCK_POWER_GOOD[2] = {0x04, 0x40};
static constexpr uint8_t LDSW_POWERED[2] = {0x03, 0x0C};
// TIMERCONFIG: wake-up timer with the 16 ms prescaler
static constexpr uint8_t TIMER_CONFIG_WAKEUP = 0x04;
static constexpr uint32_t TIMER_TICK_MS = 16;
static constexpr uint32_t TIMER_MAX_TICKS = 0xFFFFFF;
// BCHGISETDISCHARGE codes up to this one select the 200 mA discharge limit, the others 1000 mA
static constexpr uint16_t DISCHARGE_LIMIT_200MA_CODE = 84;
static uint16_t discharge_limit_ma(uint16_t code) { return code <= DISCHARGE_LIMIT_200MA_CODE ? 200 : 1000; }
// BCHGISET and BCHGISETDISCHARGE hold a 9-bit code: the high 8 bits, then bit 0 in the next register
static void split_9bit(uint16_t code, uint8_t *out) {
out[0] = code >> 1;
out[1] = code & 1;
}
#ifdef USE_NPM1300_DATA_PUBLISHER
static uint16_t join_9bit(const uint8_t *in) { return (in[0] << 1) | (in[1] & 1); }
// ADCIBATMEASSTATUS
static constexpr uint8_t IBAT_MEAS_INVALID = 0x10;
static constexpr uint8_t IBAT_MODE_SHIFT = 2;
static constexpr uint8_t IBAT_MODE_DISCHARGE = 0x01;
static constexpr uint8_t IBAT_MODE_CHARGE = 0x03;
// Full scale of the battery current measurement per milliampere of the charge and discharge limits
static constexpr float CHARGE_FULL_SCALE_FACTOR = 1.25f;
static constexpr float DISCHARGE_FULL_SCALE_FACTOR = 1.12f;
static constexpr float ADC_MAX_CODE = 1023.0f;
// Five conversions of 250 us each; the loop runs the timeout later than this anyway
static constexpr uint32_t CONVERSION_TIME_MS = 2;
static constexpr uint32_t READ_TIMEOUT_ID = 0;
float ntc_temperature(uint16_t code, float inv_beta) {
if (code == 0)
return NAN;
// The NTC divider gives code = 1024 * R / (R + R_nominal)
return 1.0f / (1.0f / 298.15f - logf(1024.0f / code - 1.0f) * inv_beta) - 273.15f;
}
#endif
#ifdef USE_TEXT_SENSOR
PROGMEM_STRING_TABLE(ChargerStatusStrings, "Error", "Paused, Die Too Hot", "Charge Complete", "Trickle Charge",
"Constant Current", "Constant Voltage", "Discharging", "Not Charging");
// BCHGCHARGESTATUS bits in order of priority; bit i is reported as string i + 1
static const uint8_t CHARGER_STATUS_BITS[] PROGMEM = {CHARGE_STATUS_DIE_TEMP_HIGH, CHARGE_STATUS_COMPLETED,
CHARGE_STATUS_TRICKLE, CHARGE_STATUS_CONSTANT_CURRENT,
CHARGE_STATUS_CONSTANT_VOLTAGE};
static uint8_t charger_status_index(uint8_t vbus_status, uint8_t charge_status, uint8_t error_reason) {
if (error_reason != 0)
return 0;
for (size_t i = 0; i < sizeof(CHARGER_STATUS_BITS); i++) {
if ((charge_status & progmem_read_byte(&CHARGER_STATUS_BITS[i])) != 0)
return i + 1;
}
return (vbus_status & VBUS_PRESENT) == 0 ? 6 : 7;
}
void publish_charger_status(text_sensor::TextSensor *sensor, uint8_t &last, uint8_t vbus_status, uint8_t charge_status,
uint8_t error_reason) {
const uint8_t index = charger_status_index(vbus_status, charge_status, error_reason);
if (index == last)
return;
last = index;
sensor->publish_state(ChargerStatusStrings::get_progmem_str(index, ChargerStatusStrings::LAST_INDEX));
}
#endif
NPM1300Config NPM1300Component::load_config_() const {
NPM1300Config config;
progmem_memcpy(&config, this->config_, sizeof(config));
return config;
}
void NPM1300Component::setup() {
uint8_t status;
if (!this->setup_check(this->read_byte_reg16(REG_BCHG_CHARGE_STATUS, &status)))
return;
// Raise the VBUS current limit before anything turns on a regulator: at the power-on limit of 100 mA the
// inrush current of a regulator can trip the over-current protection and brown out the board
if (this->vbus_current_limit_ != NPM1300_UNSET &&
!this->setup_check(this->write_byte_reg16(REG_VBUSIN_ILIM_STARTUP, this->vbus_current_limit_) &&
this->apply_vbus_current_limit_()))
return;
const NPM1300Config config = this->load_config_();
if (config.thermistor != NPM1300_UNSET &&
!this->setup_check(this->write_byte_reg16(REG_ADC_NTC_R_SEL, config.thermistor)))
return;
if (config.charger_configured && !this->setup_check(this->configure_charger_(config)))
return;
for (uint8_t i = 0; i < NPM1300_OUTPUT_COUNT; i++) {
if (!this->setup_check(this->configure_output_(i, config.outputs[i])))
return;
}
#ifdef USE_NPM1300_DATA_PUBLISHER
if (this->data_publisher_ != nullptr) {
// The battery current is measured after every battery voltage measurement
if (!this->setup_check(this->write_byte_reg16(REG_ADC_IBAT_MEAS_EN, 1)))
return;
if (config.charger_configured) {
this->charge_code_ = config.charger.current;
this->discharge_limit_high_ = discharge_limit_ma(config.charger.discharge_limit) == 1000;
} else if (!this->setup_check(this->read_charge_currents_())) {
return;
}
}
#endif
this->update();
}
bool NPM1300Component::apply_vbus_current_limit_() {
return this->write_byte_reg16(REG_VBUSIN_ILIM0, this->vbus_current_limit_) &&
this->write_byte_reg16(REG_VBUSIN_TASK_UPDATE_ILIM, 1);
}
bool NPM1300Component::configure_charger_(const NPM1300Config &config) {
const NPM1300ChargerConfig &charger = config.charger;
// The charge settings are only taken while the charger is off
if (!this->write_byte_reg16(REG_BCHG_ENABLE_CLR, 1))
return false;
uint8_t current[2];
uint8_t discharge[2];
split_9bit(charger.current, current);
split_9bit(charger.discharge_limit, discharge);
uint8_t disable = charger.recharge ? 0 : CHARGER_DISABLE_RECHARGE;
// Without a thermistor the charger reports an NTC error and does not charge
if (config.thermistor == NPM1300_UNSET)
disable |= CHARGER_DISABLE_NTC;
const uint8_t enable = (CHARGER_DISABLE_RECHARGE | CHARGER_DISABLE_NTC) & ~disable;
if (this->write_register16(REG_BCHG_ISET_MSB, current, sizeof(current)) != i2c::ERROR_OK ||
this->write_register16(REG_BCHG_ISET_DISCHARGE_MSB, discharge, sizeof(discharge)) != i2c::ERROR_OK ||
!this->write_byte_reg16(REG_BCHG_VTERM, charger.termination_voltage) ||
!this->write_byte_reg16(REG_BCHG_VTERM_R, charger.warm_termination_voltage) ||
!this->write_byte_reg16(REG_BCHG_VTRICKLE_SEL, charger.trickle_voltage) ||
!this->write_byte_reg16(REG_BCHG_ITERM_SEL, charger.termination_current) ||
!this->write_byte_reg16(REG_BCHG_DISABLE_SET, disable) || !this->write_byte_reg16(REG_BCHG_DISABLE_CLR, enable) ||
// Measure the battery temperature automatically while charging
!this->write_byte_reg16(REG_ADC_TASK_AUTO_TIM_UPDATE, 1))
return false;
return !charger.enabled || this->set_charger_enabled(true);
}
bool NPM1300Component::configure_output_(uint8_t index, const NPM1300OutputConfig &config) {
const uint8_t channel = index & 1;
if (index < 2) {
// The software voltage replaces the one set by the VSET pin only once its SWCTRLSEL bit is set
if (config.voltage != NPM1300_UNSET &&
(!this->write_byte_reg16(REG_BUCK1_NORM_VOUT + channel * 2, config.voltage) ||
!this->update_register16(REG_BUCK_SW_CTRL_SEL, 1 << channel, 1 << channel)))
return false;
} else if ((config.voltage != NPM1300_UNSET &&
!this->write_byte_reg16(REG_LDSW1_VOUT_SEL + channel, config.voltage)) ||
(config.ldo_mode != NPM1300_UNSET &&
!this->write_byte_reg16(REG_LDSW1_LDO_SEL + channel, config.ldo_mode))) {
return false;
}
return config.enabled == NPM1300_UNSET ||
this->set_output_enabled(static_cast<NPM1300Output>(index), config.enabled != 0);
}
#ifdef USE_NPM1300_DATA_PUBLISHER
bool NPM1300Component::read_charge_currents_() {
uint8_t charge[2];
uint8_t discharge[2];
if (this->read_register16(REG_BCHG_ISET_MSB, charge, sizeof(charge)) != i2c::ERROR_OK ||
this->read_register16(REG_BCHG_ISET_DISCHARGE_MSB, discharge, sizeof(discharge)) != i2c::ERROR_OK)
return false;
this->charge_code_ = join_9bit(charge);
this->discharge_limit_high_ = discharge_limit_ma(join_9bit(discharge)) == 1000;
return true;
}
#endif
bool NPM1300Component::set_charger_enabled(bool enabled) {
if (this->is_failed())
return false;
if (!enabled)
return this->write_byte_reg16(REG_BCHG_ENABLE_CLR, 1);
// A charger error stops the charger until the error is cleared and the charger released
return this->write_byte_reg16(REG_BCHG_TASK_CLEAR_CHG_ERR, 1) &&
this->write_byte_reg16(REG_BCHG_TASK_RELEASE_ERR, 1) && this->write_byte_reg16(REG_BCHG_ENABLE_SET, 1);
}
bool NPM1300Component::set_output_enabled(NPM1300Output output, bool enabled) {
if (this->is_failed())
return false;
const uint8_t index = static_cast<uint8_t>(output);
// A pair of SET and CLR task registers per output
const uint16_t base = index < 2 ? REG_BUCK1_ENA_SET : REG_LDSW1_TASK_SET;
const uint16_t reg = base + (index & 1) * 2 + (enabled ? 0 : 1);
if (!this->write_byte_reg16(reg, 1)) {
ESP_LOGW(TAG, "Turning output %u %s failed", index, enabled ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("off"));
return false;
}
return true;
}
void NPM1300Component::enter_ship_mode() {
ESP_LOGI(TAG, "Entering ship mode");
this->write_byte_reg16(REG_SHIP_TASK_SHIP_MODE, 1);
}
void NPM1300Component::hibernate(uint32_t duration_ms) {
// A zero period would never wake the chip
const uint32_t ticks = std::clamp<uint32_t>(duration_ms / TIMER_TICK_MS, 1, TIMER_MAX_TICKS);
uint8_t timer[3];
put_be24(timer, ticks);
ESP_LOGI(TAG, "Hibernating for %" PRIu32 " ms", ticks * TIMER_TICK_MS);
if (!this->write_byte_reg16(REG_TIMER_CONFIG, TIMER_CONFIG_WAKEUP) ||
this->write_register16(REG_TIMER_HI_BYTE, timer, sizeof(timer)) != i2c::ERROR_OK ||
!this->write_byte_reg16(REG_TIMER_TARGET_STROBE, 1)) {
ESP_LOGW(TAG, "Setting the wake-up timer failed");
return;
}
// The timer needs a moment to load the new period
delay(1);
this->write_byte_reg16(REG_SHIP_TASK_HIBERNATE, 1);
}
void NPM1300Component::update() {
uint8_t vbus_status;
uint8_t charge_status;
uint8_t error_reason;
if (!this->status_comm_check(this->read_byte_reg16(REG_VBUSIN_STATUS, &vbus_status) &&
this->read_byte_reg16(REG_BCHG_CHARGE_STATUS, &charge_status) &&
this->read_byte_reg16(REG_BCHG_ERR_REASON, &error_reason)))
return;
// The chip goes back to its default VBUS current limit whenever VBUS is removed
const bool vbus_present = (vbus_status & VBUS_PRESENT) != 0;
if (vbus_present && !this->vbus_present_ && this->vbus_current_limit_ != NPM1300_UNSET &&
!this->status_comm_check(this->apply_vbus_current_limit_()))
return;
this->vbus_present_ = vbus_present;
if (error_reason != this->last_error_reason_) {
this->last_error_reason_ = error_reason;
if (error_reason != 0) {
ESP_LOGW(TAG, "Charger error, BCHGERRREASON 0x%02X", error_reason);
}
}
#ifdef USE_NPM1300_STATUS_PUBLISHER
if (this->status_publisher_ != nullptr)
this->status_publisher_(vbus_status, charge_status, error_reason);
#endif
#ifdef USE_SWITCH
if (!this->status_comm_check(this->publish_switches_()))
return;
#endif
#ifdef USE_SENSOR
if (this->vbus_current_limit_sensor_ != nullptr) {
uint8_t code;
if (!this->status_comm_check(this->read_byte_reg16(REG_VBUSIN_ILIM0, &code)))
return;
// 100 mA per step of the code, but code 0 is 500 mA
const uint16_t milliamps = code == 0 ? 500 : code * 100;
this->vbus_current_limit_sensor_->publish_state(milliamps / 1000.0f);
}
#endif
#ifdef USE_NPM1300_DATA_PUBLISHER
if (this->data_publisher_ != nullptr) {
// Start the battery voltage (followed by the battery current), NTC, die temperature and system voltage
// conversions, then the VBUS one
static constexpr uint8_t START[4] = {1, 1, 1, 1};
if (!this->status_comm_check(this->write_register16(REG_ADC_TASK_VBAT, START, sizeof(START)) == i2c::ERROR_OK &&
this->write_byte_reg16(REG_ADC_TASK_VBUS, 1)))
return;
this->set_timeout(READ_TIMEOUT_ID, CONVERSION_TIME_MS, [this]() { this->read_measurements_(); });
return;
}
#endif
this->status_comm_result(true);
}
#ifdef USE_SWITCH
bool NPM1300Component::publish_switches_() {
uint8_t value;
bool ok = true;
if (this->charger_switch_ != nullptr) {
if (this->read_byte_reg16(REG_BCHG_ENABLE_SET, &value)) {
this->charger_switch_->publish_state((value & 1) != 0);
} else {
ok = false;
}
}
switch_::Switch *const bucks[2] = {this->buck1_switch_, this->buck2_switch_};
if (bucks[0] != nullptr || bucks[1] != nullptr) {
if (this->read_byte_reg16(REG_BUCK_STATUS, &value)) {
for (uint8_t i = 0; i < 2; i++)
PUBLISH_STATE(bucks[i], (value & BUCK_POWER_GOOD[i]) != 0);
} else {
ok = false;
}
}
switch_::Switch *const ldos[2] = {this->ldo1_switch_, this->ldo2_switch_};
if (ldos[0] != nullptr || ldos[1] != nullptr) {
if (this->read_byte_reg16(REG_LDSW_STATUS, &value)) {
for (uint8_t i = 0; i < 2; i++)
PUBLISH_STATE(ldos[i], (value & LDSW_POWERED[i]) != 0);
} else {
ok = false;
}
}
return ok;
}
#endif
#ifdef USE_NPM1300_DATA_PUBLISHER
void NPM1300Component::read_measurements_() {
// ADCIBATMEASSTATUS, then the VBAT, NTC, TEMP and VSYS results, their low bits, VBAT0 to VBAT3 (VBAT2 holds the
// battery current and VBAT3 the VBUS voltage) and their low bits
uint8_t adc[11];
if (!this->status_comm_result(this->read_register16(REG_ADC_RESULTS, adc, sizeof(adc)) == i2c::ERROR_OK))
return;
// Amperes per code of the battery current, positive while charging and negative while discharging
float ibat_scale = NAN;
// Any other mode means the system runs from VBUS and the battery current is not measured
const uint8_t mode = (adc[0] >> IBAT_MODE_SHIFT) & 0x03;
if ((adc[0] & IBAT_MEAS_INVALID) == 0) {
if (mode == IBAT_MODE_CHARGE) {
ibat_scale = this->charge_code_ * (2 * CHARGE_FULL_SCALE_FACTOR) / (ADC_MAX_CODE * 1000.0f);
} else if (mode == IBAT_MODE_DISCHARGE) {
ibat_scale = -(this->discharge_limit_high_ ? 1000 : 200) * DISCHARGE_FULL_SCALE_FACTOR / (ADC_MAX_CODE * 1000.0f);
}
}
if (this->data_publisher_ != nullptr)
this->data_publisher_(adc, ibat_scale);
}
#endif
// BCHGVTERM: 3.50 V to 3.65 V, then 4.00 V to 4.45 V, both in 50 mV steps
static uint16_t termination_voltage_mv(uint8_t code) { return code < 4 ? 3500 + 50 * code : 4000 + 50 * (code - 4); }
static const LogString *output_name(uint8_t index) {
switch (static_cast<NPM1300Output>(index)) {
case NPM1300Output::NPM1300_OUTPUT_BUCK1:
return LOG_STR("BUCK1");
case NPM1300Output::NPM1300_OUTPUT_BUCK2:
return LOG_STR("BUCK2");
case NPM1300Output::NPM1300_OUTPUT_LDO1:
return LOG_STR("LDO1");
default:
return LOG_STR("LDO2");
}
}
void NPM1300Component::dump_config() {
ESP_LOGCONFIG(TAG, "nPM1300:");
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
if (this->vbus_current_limit_ != NPM1300_UNSET) {
// 100 mA per step of the code
ESP_LOGCONFIG(TAG, " VBUS current limit: %u mA", this->vbus_current_limit_ * 100u);
}
const NPM1300Config config = this->load_config_();
if (config.charger_configured) {
const NPM1300ChargerConfig &charger = config.charger;
ESP_LOGCONFIG(TAG,
" Charger:\n"
" Current: %u mA\n"
" Discharge limit: %u mA\n"
" Termination voltage: %u mV\n"
" Warm termination voltage: %u mV\n"
" Termination current: %u%%\n"
" Trickle voltage: %u mV\n"
" Enabled: %s\n"
" Recharge: %s",
charger.current * 2u, discharge_limit_ma(charger.discharge_limit),
termination_voltage_mv(charger.termination_voltage),
termination_voltage_mv(charger.warm_termination_voltage), charger.termination_current ? 20u : 10u,
charger.trickle_voltage ? 2500u : 2900u, YESNO(charger.enabled), YESNO(charger.recharge));
}
for (uint8_t i = 0; i < NPM1300_OUTPUT_COUNT; i++) {
const NPM1300OutputConfig &output = config.outputs[i];
if (output.voltage == NPM1300_UNSET && output.ldo_mode == NPM1300_UNSET && output.enabled == NPM1300_UNSET)
continue;
ESP_LOGCONFIG(TAG, " %s:", LOG_STR_ARG(output_name(i)));
if (output.voltage != NPM1300_UNSET) {
ESP_LOGCONFIG(TAG, " Voltage: %u mV", 1000u + output.voltage * 100u);
}
if (output.ldo_mode != NPM1300_UNSET) {
ESP_LOGCONFIG(TAG, " Mode: %s",
output.ldo_mode != 0 ? LOG_STR_LITERAL("LDO") : LOG_STR_LITERAL("load switch"));
}
if (output.enabled != NPM1300_UNSET) {
ESP_LOGCONFIG(TAG, " Enabled at boot: %s", YESNO(output.enabled != 0));
}
}
if (config.thermistor != NPM1300_UNSET) {
ESP_LOGCONFIG(TAG, " Thermistor B constant: %u", config.thermistor_beta);
}
#ifdef USE_SENSOR
LOG_SENSOR(" ", "VBUS Current Limit", this->vbus_current_limit_sensor_);
#endif
#ifdef USE_SWITCH
LOG_SWITCH(" ", "Charger", this->charger_switch_);
LOG_SWITCH(" ", "BUCK1", this->buck1_switch_);
LOG_SWITCH(" ", "BUCK2", this->buck2_switch_);
LOG_SWITCH(" ", "LDO1", this->ldo1_switch_);
LOG_SWITCH(" ", "LDO2", this->ldo2_switch_);
#endif
#if defined(USE_NPM1300_STATUS_PUBLISHER) || defined(USE_NPM1300_DATA_PUBLISHER)
if (this->dump_publisher_ != nullptr)
this->dump_publisher_(TAG);
#endif
}
} // namespace esphome::npm1300
-145
View File
@@ -1,145 +0,0 @@
#pragma once
// Product specification: https://docs.nordicsemi.com/bundle/ps_npm1300/page/keyfeatures_html5.html
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
#ifdef USE_TEXT_SENSOR
#include "esphome/components/text_sensor/text_sensor.h"
#endif
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
namespace esphome::npm1300 {
/// The regulators, in the order of their enable task registers
enum class NPM1300Output : uint8_t {
NPM1300_OUTPUT_BUCK1 = 0,
NPM1300_OUTPUT_BUCK2 = 1,
NPM1300_OUTPUT_LDO1 = 2,
NPM1300_OUTPUT_LDO2 = 3,
};
static constexpr uint8_t NPM1300_OUTPUT_COUNT = 4;
/// A configuration field the YAML leaves out, so the chip keeps its own value
static constexpr uint8_t NPM1300_UNSET = 0xFF;
/// Register codes for one regulator, NPM1300_UNSET where the YAML leaves the field out
struct NPM1300OutputConfig {
uint8_t voltage; // VOUT: (millivolts - 1000) / 100
uint8_t ldo_mode; // LDSWxLDOSEL, LDO1 and LDO2 only: 1 LDO, 0 load switch
uint8_t enabled; // turned on (1) or off (0) at boot
};
/// Register codes for the battery charger
struct NPM1300ChargerConfig {
uint16_t current; // BCHGISET: milliamperes / 2
uint16_t discharge_limit; // BCHGISETDISCHARGE: 84 for 200 mA, 415 for 1000 mA
uint8_t termination_voltage; // BCHGVTERM
uint8_t warm_termination_voltage; // BCHGVTERMR
uint8_t trickle_voltage; // BCHGVTRICKLESEL: 0 for 2.9 V, 1 for 2.5 V
uint8_t termination_current; // BCHGITERMSEL: 0 for 10 %, 1 for 20 %
bool recharge;
bool enabled;
};
/// The settings applied in setup(), built by code generation into a PROGMEM table
struct NPM1300Config {
NPM1300OutputConfig outputs[NPM1300_OUTPUT_COUNT];
NPM1300ChargerConfig charger; // only applied when charger_configured
uint16_t thermistor_beta;
uint8_t thermistor; // ADCNTCRSEL code: 1 for 10 kOhm, 2 for 47 kOhm, 3 for 100 kOhm, or NPM1300_UNSET
bool charger_configured;
};
#ifdef USE_NPM1300_DATA_PUBLISHER
/// The 10-bit ADC result whose high 8 bits are adc[msb] and low 2 bits are at `shift` in adc[lsbs]
inline uint16_t adc_code(const uint8_t *adc, uint8_t msb, uint8_t lsbs, uint8_t shift) {
return (adc[msb] << 2) | ((adc[lsbs] >> shift) & 0x03);
}
/// Battery temperature in degrees Celsius from the NTC code and 1 / B constant of the thermistor
float ntc_temperature(uint16_t code, float inv_beta);
#endif
#ifdef USE_TEXT_SENSOR
/// Publish the charger status text, only when it differs from `last`
void publish_charger_status(text_sensor::TextSensor *sensor, uint8_t &last, uint8_t vbus_status, uint8_t charge_status,
uint8_t error_reason);
#endif
class NPM1300Component : public PollingComponent, public i2c::I2CDevice {
#ifdef USE_SENSOR
SUB_SENSOR(vbus_current_limit)
#endif
#ifdef USE_SWITCH
SUB_SWITCH(charger)
SUB_SWITCH(buck1)
SUB_SWITCH(buck2)
SUB_SWITCH(ldo1)
SUB_SWITCH(ldo2)
#endif
public:
/// `config` points into PROGMEM
explicit NPM1300Component(const NPM1300Config *config) : config_(config) {}
void setup() override;
void update() override;
void dump_config() override;
// Right after the I2C bus: the regulators may power other peripherals
float get_setup_priority() const override { return setup_priority::BUS - 1.0f; }
#ifdef USE_NPM1300_STATUS_PUBLISHER
HUB_PUBLISHER(status, uint8_t, uint8_t, uint8_t)
#endif
#ifdef USE_NPM1300_DATA_PUBLISHER
HUB_PUBLISHER(data, const uint8_t *, float)
#endif
#if defined(USE_NPM1300_STATUS_PUBLISHER) || defined(USE_NPM1300_DATA_PUBLISHER)
HUB_PUBLISHER(dump, const char *)
#endif
/// VBUSINILIM0 code: 100 mA per step (code 0, the reset value, is 500 mA)
void set_vbus_current_limit(uint8_t code) { this->vbus_current_limit_ = code; }
/// Turn the battery charger on or off; turning it on also clears a charger error
bool set_charger_enabled(bool enabled);
/// Turn a regulator on or off
bool set_output_enabled(NPM1300Output output, bool enabled);
/// Enter ship mode: every output is turned off until the SHPHLD button is pressed or VBUS is connected
void enter_ship_mode();
/// Enter hibernate mode: every output is turned off for `duration_ms`, or until the SHPHLD button is
/// pressed or VBUS is connected
void hibernate(uint32_t duration_ms);
protected:
NPM1300Config load_config_() const;
bool apply_vbus_current_limit_();
bool configure_charger_(const NPM1300Config &config);
bool configure_output_(uint8_t index, const NPM1300OutputConfig &config);
#ifdef USE_NPM1300_DATA_PUBLISHER
bool read_charge_currents_();
void read_measurements_();
#endif
#ifdef USE_SWITCH
/// Returns false when a status read failed
bool publish_switches_();
#endif
const NPM1300Config *config_;
#ifdef USE_NPM1300_DATA_PUBLISHER
// BCHGISET code and discharge limit, for the full scale of the battery current measurement
uint16_t charge_code_{0};
bool discharge_limit_high_{false};
#endif
uint8_t vbus_current_limit_{NPM1300_UNSET};
uint8_t last_error_reason_{0};
bool vbus_present_{false};
};
} // namespace esphome::npm1300
-151
View File
@@ -1,151 +0,0 @@
from esphome.automation import parent_ref
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_VOLTAGE,
CONF_ID,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_VOLT,
)
from esphome.core import CORE
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import (
ADC,
CONF_NPM1300_ID,
CONF_THERMISTOR_BETA,
CONF_THERMISTOR_RESISTANCE,
CONF_VBUS_CURRENT_LIMIT,
DATA_PUBLISHER,
DOMAIN,
DUMP,
DUMP_TAG,
IBAT_SCALE,
NPM1300_CHILD_SCHEMA,
npm1300_ns,
)
DEPENDENCIES = ["npm1300"]
CONF_BATTERY_CURRENT = "battery_current"
CONF_BATTERY_TEMPERATURE = "battery_temperature"
CONF_DIE_TEMPERATURE = "die_temperature"
CONF_SYSTEM_VOLTAGE = "system_voltage"
CONF_VBUS_VOLTAGE = "vbus_voltage"
ADC_MAX_CODE = 1023
def _adc(msb: int, lsbs: int, shift: int) -> cg.MockObj:
"""The 10-bit result in adc[msb] and bits shift..shift+1 of adc[lsbs]."""
return npm1300_ns.adc_code(ADC, msb, lsbs, shift)
# The decoded value and the dump name of each ADC sensor but the battery temperature, which needs the hub config.
# Voltages scale by their full scale (product specification, section ADC).
STATES = {
CONF_BATTERY_VOLTAGE: (_adc(1, 5, 0) * (5.0 / ADC_MAX_CODE), "Battery Voltage"),
CONF_BATTERY_CURRENT: (_adc(8, 10, 4) * IBAT_SCALE, "Battery Current"),
# Product specification: T = 394.67 - 0.7926 * code
CONF_DIE_TEMPERATURE: (394.67 - 0.7926 * _adc(3, 5, 4), "Die Temperature"),
CONF_SYSTEM_VOLTAGE: (_adc(4, 5, 6) * (6.375 / ADC_MAX_CODE), "System Voltage"),
CONF_VBUS_VOLTAGE: (_adc(9, 10, 6) * (7.5 / ADC_MAX_CODE), "VBUS Voltage"),
}
def _voltage_schema() -> cv.Schema:
return sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
)
def _temperature_schema(**extra: str) -> cv.Schema:
return sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
**extra,
)
CONFIG_SCHEMA = NPM1300_CHILD_SCHEMA.extend(
{
cv.Optional(CONF_BATTERY_VOLTAGE): _voltage_schema(),
cv.Optional(CONF_BATTERY_CURRENT): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_CURRENT,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_BATTERY_TEMPERATURE): _temperature_schema(),
cv.Optional(CONF_DIE_TEMPERATURE): _temperature_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_SYSTEM_VOLTAGE): _voltage_schema(),
cv.Optional(CONF_VBUS_VOLTAGE): _voltage_schema(),
cv.Optional(CONF_VBUS_CURRENT_LIMIT): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=1,
device_class=DEVICE_CLASS_CURRENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
def _final_validate(config: ConfigType) -> None:
if CONF_BATTERY_TEMPERATURE not in config:
return
hub = fv.get_declaring_config(config[CONF_NPM1300_ID])
if CONF_THERMISTOR_RESISTANCE not in hub:
raise cv.Invalid(
f"'{CONF_BATTERY_TEMPERATURE}' needs '{CONF_THERMISTOR_RESISTANCE}' "
"set on the npm1300 component",
path=[CONF_BATTERY_TEMPERATURE],
)
FINAL_VALIDATE_SCHEMA = _final_validate
def _add_adc_sensor(
hub: cg.MockObj, var: cg.MockObj, state: cg.MockObj, name: str
) -> None:
DATA_PUBLISHER.add(hub, parent_ref(var).publish_state(state))
DUMP.add(
hub,
sensor.sensor_ns.log_sensor(
DUMP_TAG,
" ",
cg.RawExpression(f'LOG_STR_LITERAL("{name}")'),
parent_ref(var),
),
)
async def to_code(config: ConfigType) -> None:
hub_id = config[CONF_NPM1300_ID]
hub = await cg.get_variable(hub_id)
sensors = sensor.sub_sensors(config)
for key, (state, name) in STATES.items():
if (var := await sensors(key, None)) is not None:
_add_adc_sensor(hub, var, state, name)
if (var := await sensors(CONF_BATTERY_TEMPERATURE, None)) is not None:
hub_config = next(c for c in CORE.config[DOMAIN] if c[CONF_ID] == hub_id)
state = npm1300_ns.ntc_temperature(
_adc(2, 5, 2), 1.0 / hub_config[CONF_THERMISTOR_BETA]
)
_add_adc_sensor(hub, var, state, "Battery Temperature")
await sensors(CONF_VBUS_CURRENT_LIMIT, hub.set_vbus_current_limit_sensor)
-95
View File
@@ -1,95 +0,0 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import (
CONF_BUCK1,
CONF_BUCK2,
CONF_CHARGER,
CONF_LDO1,
CONF_LDO2,
CONF_NPM1300_ID,
CONF_THERMISTOR_RESISTANCE,
NPM1300_CHILD_SCHEMA,
OUTPUTS,
)
DEPENDENCIES = ["npm1300"]
def _switch_schema() -> cv.Schema:
# The switches show the state of the chip, so nothing is restored at boot
return switch.switch_schema(
switch.ApplySwitch,
default_restore_mode="DISABLED",
entity_category=ENTITY_CATEGORY_CONFIG,
)
CONFIG_SCHEMA = NPM1300_CHILD_SCHEMA.extend(
{
cv.Optional(CONF_CHARGER): _switch_schema(),
cv.Optional(CONF_BUCK1): _switch_schema(),
cv.Optional(CONF_BUCK2): _switch_schema(),
cv.Optional(CONF_LDO1): _switch_schema(),
cv.Optional(CONF_LDO2): _switch_schema(),
}
)
def _final_validate(config: ConfigType) -> None:
if CONF_CHARGER not in config:
return
hub = fv.get_declaring_config(config[CONF_NPM1300_ID])
# Only the charger block turns off the NTC check, without which the charger never starts
if CONF_CHARGER not in hub and CONF_THERMISTOR_RESISTANCE not in hub:
raise cv.Invalid(
f"The '{CONF_CHARGER}' switch needs a '{CONF_CHARGER}' block or "
f"'{CONF_THERMISTOR_RESISTANCE}' on the npm1300 component",
path=[CONF_CHARGER],
)
FINAL_VALIDATE_SCHEMA = _final_validate
def _output_apply(key: str) -> str:
return f"set_output_enabled({OUTPUTS[key]}, state)"
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_NPM1300_ID])
switches = switch.sub_switches(config, parent=hub)
await switches(
CONF_CHARGER,
hub.set_charger_switch,
apply="set_charger_enabled(state)",
publish="on_success",
)
await switches(
CONF_BUCK1,
hub.set_buck1_switch,
apply=_output_apply(CONF_BUCK1),
publish="on_success",
)
await switches(
CONF_BUCK2,
hub.set_buck2_switch,
apply=_output_apply(CONF_BUCK2),
publish="on_success",
)
await switches(
CONF_LDO1,
hub.set_ldo1_switch,
apply=_output_apply(CONF_LDO1),
publish="on_success",
)
await switches(
CONF_LDO2,
hub.set_ldo2_switch,
apply=_output_apply(CONF_LDO2),
publish="on_success",
)
-63
View File
@@ -1,63 +0,0 @@
from esphome.automation import parent_ref
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.const import ICON_BATTERY
from esphome.cpp_generator import (
AssignmentExpression,
HexIntLiteral,
VariableDeclarationExpression,
)
from esphome.types import ConfigType
from . import (
CHARGE_STATUS,
CONF_NPM1300_ID,
DUMP,
DUMP_TAG,
ERROR_REASON,
NPM1300_CHILD_SCHEMA,
STATUS_PUBLISHER,
VBUS_STATUS,
npm1300_ns,
)
DEPENDENCIES = ["npm1300"]
CONF_CHARGER_STATUS = "charger_status"
CONFIG_SCHEMA = NPM1300_CHILD_SCHEMA.extend(
{
cv.Optional(CONF_CHARGER_STATUS): text_sensor.text_sensor_schema(
icon=ICON_BATTERY,
),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_NPM1300_ID])
text_sensors = text_sensor.sub_text_sensors(config)
if (var := await text_sensors(CONF_CHARGER_STATUS, None)) is None:
return
last = cg.MockObj(f"esphome__{var}__last")
# 0xFF so the first update publishes
declaration = VariableDeclarationExpression(cg.uint8, "", last, static=True)
STATUS_PUBLISHER.add(
hub, AssignmentExpression(None, None, declaration, HexIntLiteral(0xFF))
)
STATUS_PUBLISHER.add(
hub,
npm1300_ns.publish_charger_status(
parent_ref(var), last, VBUS_STATUS, CHARGE_STATUS, ERROR_REASON
),
)
DUMP.add(
hub,
text_sensor.text_sensor_ns.log_text_sensor(
DUMP_TAG,
" ",
cg.RawExpression('LOG_STR_LITERAL("Charger Status")'),
parent_ref(var),
),
)
+2 -4
View File
@@ -48,7 +48,7 @@ CONF_WHITENING_ENABLE = "whitening_enable"
CONF_WHITENING_INITIAL = "whitening_initial"
sx126x_ns = cg.esphome_ns.namespace("sx126x")
SX126x = sx126x_ns.class_("SX126x", cg.Component)
SX126x = sx126x_ns.class_("SX126x", cg.Component, spi.SPIDevice)
SX126xListener = sx126x_ns.class_("SX126xListener")
SX126xBw = sx126x_ns.enum("SX126xBw")
SX126xPacketType = sx126x_ns.enum("SX126xPacketType")
@@ -217,9 +217,7 @@ CONFIG_SCHEMA = (
cv.Required(CONF_RF_SWITCH): cv.boolean,
cv.Optional(CONF_SHAPING, default="NONE"): cv.enum(SHAPING),
cv.Optional(CONF_SPREADING_FACTOR, default=7): cv.int_range(min=6, max=12),
cv.Optional(CONF_SYNC_VALUE, default=[]): cv.All(
cv.ensure_list(cv.hex_uint8_t), cv.Length(max=8)
),
cv.Optional(CONF_SYNC_VALUE, default=[]): cv.ensure_list(cv.hex_uint8_t),
cv.Optional(CONF_TCXO_VOLTAGE, default="NONE"): cv.enum(TCXO_VOLTAGE),
cv.Optional(CONF_TCXO_DELAY, default="5ms"): cv.All(
cv.positive_time_period_microseconds,
-97
View File
@@ -1,97 +0,0 @@
#pragma once
#include "sx126x_reg.h"
#include <array>
#include <span>
#include <algorithm>
namespace esphome::sx126x {
enum SX126xBw : uint8_t {
// FSK
SX126X_BW_4800,
SX126X_BW_5800,
SX126X_BW_7300,
SX126X_BW_9700,
SX126X_BW_11700,
SX126X_BW_14600,
SX126X_BW_19500,
SX126X_BW_23400,
SX126X_BW_29300,
SX126X_BW_39000,
SX126X_BW_46900,
SX126X_BW_58600,
SX126X_BW_78200,
SX126X_BW_93800,
SX126X_BW_117300,
SX126X_BW_156200,
SX126X_BW_187200,
SX126X_BW_234300,
SX126X_BW_312000,
SX126X_BW_373600,
SX126X_BW_467000,
// LORA
SX126X_BW_7810,
SX126X_BW_10420,
SX126X_BW_15630,
SX126X_BW_20830,
SX126X_BW_31250,
SX126X_BW_41670,
SX126X_BW_62500,
SX126X_BW_125000,
SX126X_BW_250000,
SX126X_BW_500000,
};
// Requested settings are separate from the last successfully applied profile.
// Bounded sync storage makes profile snapshots allocation-free.
struct RadioProfile {
uint32_t bitrate{0};
uint32_t deviation{0};
uint32_t frequency{0};
uint32_t tcxo_delay{0};
uint16_t crc_polynomial{0};
uint16_t crc_initial{0};
uint16_t whitening_initial{0};
uint16_t preamble_detect{0};
uint16_t preamble_size{0};
SX126xBw bandwidth{SX126X_BW_125000};
uint8_t crc_size{0};
uint8_t tcxo_voltage{0};
uint8_t coding_rate{0};
uint8_t modulation{PACKET_TYPE_LORA};
uint8_t pa_ramp{0};
uint8_t shaping{0};
uint8_t spreading_factor{0};
int8_t pa_power{0};
uint8_t payload_length{0};
bool sx1261{false};
bool crc_enable{false};
bool crc_inverted{false};
bool whitening_enable{false};
bool rx_start{false};
bool rf_switch{false};
std::array<uint8_t, 8> sync_value{};
uint8_t sync_size{0};
bool sync_valid{true};
void set_sync_value(std::span<const uint8_t> bytes) {
this->sync_valid = bytes.size() <= this->sync_value.size();
if (!this->sync_valid) {
return;
}
std::copy(bytes.begin(), bytes.end(), this->sync_value.begin());
this->sync_size = bytes.size();
}
size_t max_packet_size() const { return this->payload_length > 0 ? this->payload_length : 255; }
};
// The parts of the last successfully applied profile that runtime operations read.
// SetPacketParams is encoded once; sends only change its payload length byte.
struct AppliedProfile {
uint32_t frequency{0};
std::array<uint8_t, 9> packet_params{};
uint8_t packet_params_size{0};
uint8_t payload_length{0};
uint8_t modulation{PACKET_TYPE_LORA};
bool rx_start{false};
size_t max_packet_size() const { return this->payload_length > 0 ? this->payload_length : 255; }
};
} // namespace esphome::sx126x
+487 -172
View File
@@ -2,217 +2,534 @@
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::sx126x {
ESPHOME_LOG_TAG(TAG, "sx126x");
static constexpr uint32_t TRANSMIT_TIMEOUT_MS = 4000;
static constexpr uint32_t MAX_TRANSMIT_TIMEOUT_MS = 262143; // 24-bit SetTx timeout in 1/64 ms steps
static constexpr uint32_t RECONFIGURE_INTERVAL_ID = 0;
static constexpr uint32_t RECONFIGURE_INTERVAL_MS = 1000;
static constexpr uint16_t RAMP[8] PROGMEM = {10, 20, 40, 80, 200, 800, 1700, 3400};
// Aligned 32 bit loads from flash are safe on ESP8266, so this table is read directly
static constexpr uint32_t BW_HZ[31] PROGMEM = {4800, 5800, 7300, 9700, 11700, 14600, 19500, 23400,
29300, 39000, 46900, 58600, 78200, 93800, 117300, 156200,
187200, 234300, 312000, 373600, 467000, 7810, 10420, 15630,
20830, 31250, 41670, 62500, 125000, 250000, 500000};
static constexpr uint8_t BW_LORA[10] PROGMEM = {LORA_BW_7810, LORA_BW_10420, LORA_BW_15630, LORA_BW_20830,
LORA_BW_31250, LORA_BW_41670, LORA_BW_62500, LORA_BW_125000,
LORA_BW_250000, LORA_BW_500000};
static constexpr uint8_t BW_FSK[21] PROGMEM = {
FSK_BW_4800, FSK_BW_5800, FSK_BW_7300, FSK_BW_9700, FSK_BW_11700, FSK_BW_14600, FSK_BW_19500,
FSK_BW_23400, FSK_BW_29300, FSK_BW_39000, FSK_BW_46900, FSK_BW_58600, FSK_BW_78200, FSK_BW_93800,
FSK_BW_117300, FSK_BW_156200, FSK_BW_187200, FSK_BW_234300, FSK_BW_312000, FSK_BW_373600, FSK_BW_467000};
// Indexed by SX126xError.
PROGMEM_STRING_TABLE(SX126xErrorStrings, "none", "timeout", "invalid payload", "radio not ready", "bus timeout",
"radio busy", "cancelled");
static constexpr uint32_t RESET_DELAY_HIGH_US = 5000;
static constexpr uint32_t RESET_DELAY_LOW_US = 2000;
static constexpr uint32_t SWITCHING_DELAY_US = 1;
static constexpr uint32_t TRANSMIT_TIMEOUT_MS = 4000;
static constexpr uint32_t BUSY_TIMEOUT_MS = 20;
// OCP (Over Current Protection) values
static constexpr uint8_t OCP_80MA = 0x18; // 80 mA max current
static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current
// LoRa low data rate optimization threshold
static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms
uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
this->enable();
this->wait_busy_();
this->transfer_byte(RADIO_READ_BUFFER);
this->transfer_byte(offset);
uint8_t status = this->transfer_byte(0x00);
for (uint8_t &byte : packet) {
byte = this->transfer_byte(0x00);
}
this->disable();
return status;
}
void SX126x::write_fifo_(uint8_t offset, const uint8_t *data, size_t len) {
this->enable();
this->wait_busy_();
this->transfer_byte(RADIO_WRITE_BUFFER);
this->transfer_byte(offset);
for (size_t i = 0; i < len; i++) {
this->transfer_byte(data[i]);
}
this->disable();
delayMicroseconds(SWITCHING_DELAY_US);
}
uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->transfer_byte(opcode);
uint8_t status = this->transfer_byte(0x00);
for (int32_t i = 0; i < size; i++) {
data[i] = this->transfer_byte(0x00);
}
this->disable();
return status;
}
void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->transfer_byte(opcode);
for (int32_t i = 0; i < size; i++) {
this->transfer_byte(data[i]);
}
this->disable();
delayMicroseconds(SWITCHING_DELAY_US);
}
void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->write_byte(RADIO_READ_REGISTER);
this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF);
this->write_byte(0x00);
for (int32_t i = 0; i < size; i++) {
data[i] = this->transfer_byte(0x00);
}
this->disable();
}
void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->write_byte(RADIO_WRITE_REGISTER);
this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF);
for (int32_t i = 0; i < size; i++) {
this->transfer_byte(data[i]);
}
this->disable();
delayMicroseconds(SWITCHING_DELAY_US);
}
void IRAM_ATTR SX126x::gpio_intr(SX126x *arg) { arg->enable_loop_soon_any_context(); }
void SX126x::setup() {
// Sized once so later packet reads never reallocate; read_packet() sets the length.
this->packet_.resize(SX126X_MAX_PACKET_SIZE);
// setup pins
GPIOPin *dio1 = this->radio_.get_dio1_pin();
this->radio_.bus().get_busy_pin()->setup();
this->radio_.get_rst_pin()->setup();
dio1->setup();
if (dio1->is_internal()) {
static_cast<InternalGPIOPin *>(dio1)->attach_interrupt(&SX126x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
this->busy_pin_->setup();
this->rst_pin_->setup();
this->dio1_pin_->setup();
if (this->dio1_pin_->is_internal()) {
static_cast<InternalGPIOPin *>(this->dio1_pin_)
->attach_interrupt(&SX126x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
}
// start spi
this->radio_.bus().setup();
this->spi_setup();
// configure rf; a failure is reported through mark_failed() only, with its own reason
const RadioEffects effects = this->radio_.configure(this->requested_profile_, this->version_);
if (effects.warning != nullptr) {
this->mark_failed(effects.warning);
return;
}
this->apply_(effects);
}
void SX126x::apply_(const RadioEffects &effects) {
// User code may only run once the radio has committed. A rejection during another
// operation's command sequence carries no status or callbacks.
ESPHOME_DEBUG_ASSERT(!this->radio_.busy() || (!effects.report && effects.listener == nullptr && !effects.received));
if (effects.sleep_loop) {
this->disable_loop();
} else if (this->radio_.state() != RadioState::RADIO_STATE_NEEDS_CONFIGURATION) {
this->enable_loop();
}
this->watch_configuration_();
// The packet buffer is borrowed from the status report until listeners return;
// loop() will not refill it meanwhile.
if (effects.received) {
this->rx_dispatching_ = true;
}
if (effects.report) {
this->status_comm_result(effects.warning == nullptr, effects.warning);
}
if (effects.listener != nullptr) {
effects.listener->on_transmit_complete(effects.result);
}
if (effects.received) {
for (auto *listener : this->listeners_) {
listener->on_packet(this->packet_, effects.rssi, effects.snr);
}
this->packet_trigger_.trigger(this->packet_, effects.rssi, effects.snr);
this->rx_dispatching_ = false;
}
// configure rf
this->configure();
}
void SX126x::configure() {
// Before setup() the bus is not set up, and after a failed setup the radio stays unused
if (!this->is_ready()) {
uint8_t buf[8];
// toggle chip reset
this->rst_pin_->digital_write(true);
delayMicroseconds(RESET_DELAY_HIGH_US);
this->rst_pin_->digital_write(false);
delayMicroseconds(RESET_DELAY_LOW_US);
this->rst_pin_->digital_write(true);
delayMicroseconds(RESET_DELAY_HIGH_US);
// wakeup
this->read_opcode_(RADIO_GET_STATUS, nullptr, 0);
// config tcxo
if (this->tcxo_voltage_ != TCXO_CTRL_NONE) {
uint32_t delay = this->tcxo_delay_ >> 6;
buf[0] = this->tcxo_voltage_;
buf[1] = (delay >> 16) & 0xFF;
buf[2] = (delay >> 8) & 0xFF;
buf[3] = (delay >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_TCXOMODE, buf, 4);
buf[0] = 0x7F;
this->write_opcode_(RADIO_CALIBRATE, buf, 1);
}
// clear errors
buf[0] = 0x00;
buf[1] = 0x00;
this->write_opcode_(RADIO_CLR_ERROR, buf, 2);
// rf switch
if (this->rf_switch_) {
buf[0] = 0x01;
this->write_opcode_(RADIO_SET_RFSWITCHMODE, buf, 1);
}
// check silicon version to make sure hw is ok
this->read_register_(REG_VERSION_STRING, (uint8_t *) this->version_, sizeof(this->version_));
this->version_[sizeof(this->version_) - 1] = '\0';
if (strncmp(this->version_, "SX126", 5) != 0 && strncmp(this->version_, "LLCC68", 6) != 0) {
this->mark_failed(LOG_STR("Unsupported chip version"));
return;
}
if (!this->radio_.can_configure()) {
ESP_LOGW(TAG, "Radio operation in progress, configuration ignored");
return;
}
this->cold_sleep_ = false;
this->apply_(this->radio_.configure(this->requested_profile_, this->version_));
}
// A failed command leaves the radio needing configuration, which nothing else would do, so a node
// that only uses YAML would stay deaf. Cold sleep is asked for, so it waits for a mode command.
void SX126x::watch_configuration_() {
const bool needed = this->radio_.state() == RadioState::RADIO_STATE_NEEDS_CONFIGURATION && !this->cold_sleep_;
if (needed == this->reconfigure_pending_) {
return;
}
this->reconfigure_pending_ = needed;
if (needed) {
this->set_interval(RECONFIGURE_INTERVAL_ID, RECONFIGURE_INTERVAL_MS, [this]() { this->configure(); });
} else {
this->cancel_interval(RECONFIGURE_INTERVAL_ID);
}
}
// setup packet type
buf[0] = this->modulation_;
this->write_opcode_(RADIO_SET_PACKETTYPE, buf, 1);
void SX126x::set_mode_rx() { this->change_mode_(ModeCommand::MODE_COMMAND_RX); }
void SX126x::set_mode_tx() { this->change_mode_(ModeCommand::MODE_COMMAND_TX); }
void SX126x::set_mode_standby(SX126xStandbyMode mode) { this->change_mode_(ModeCommand::MODE_COMMAND_STANDBY, mode); }
void SX126x::set_mode_sleep(bool cold) {
this->change_mode_(cold ? ModeCommand::MODE_COMMAND_COLD_SLEEP : ModeCommand::MODE_COMMAND_SLEEP);
}
void SX126x::run_image_cal() { this->change_mode_(ModeCommand::MODE_COMMAND_IMAGE_CAL); }
// calibrate image
this->run_image_cal();
void SX126x::change_mode_(ModeCommand command, uint8_t arg) {
const bool sleep = command == ModeCommand::MODE_COMMAND_SLEEP || command == ModeCommand::MODE_COMMAND_COLD_SLEEP;
if (this->cold_sleep_) {
if (sleep) {
return;
// set frequency
uint64_t freq = ((uint64_t) this->frequency_ << 25) / XTAL_FREQ;
buf[0] = (uint8_t) ((freq >> 24) & 0xFF);
buf[1] = (uint8_t) ((freq >> 16) & 0xFF);
buf[2] = (uint8_t) ((freq >> 8) & 0xFF);
buf[3] = (uint8_t) (freq & 0xFF);
this->write_opcode_(RADIO_SET_RFFREQUENCY, buf, 4);
// configure pa
int8_t pa_power = this->pa_power_;
if (this->sx1261_) {
// the following values were taken from section 13.1.14.1 table 13-21
// in rev 2.1 of the datasheet
if (pa_power == 15) {
uint8_t cfg[4] = {0x06, 0x00, 0x01, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
} else {
uint8_t cfg[4] = {0x04, 0x00, 0x01, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
}
// Cold sleep lost the configuration, so waking needs it applied again first
this->configure();
pa_power = std::max(pa_power, (int8_t) -3);
pa_power = std::min(pa_power, (int8_t) 14);
buf[0] = OCP_80MA;
this->write_register_(REG_OCP, buf, 1);
} else {
// the following values were taken from section 13.1.14.1 table 13-21
// in rev 2.1 of the datasheet
uint8_t cfg[4] = {0x04, 0x07, 0x00, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
pa_power = std::max(pa_power, (int8_t) -3);
pa_power = std::min(pa_power, (int8_t) 22);
buf[0] = OCP_140MA;
this->write_register_(REG_OCP, buf, 1);
}
if (!this->radio_.can_change_mode()) {
ESP_LOGW(TAG, "Radio unavailable, mode change ignored");
return;
buf[0] = pa_power;
buf[1] = this->pa_ramp_;
this->write_opcode_(RADIO_SET_TXPARAMS, buf, 2);
// configure modem
if (this->modulation_ == PACKET_TYPE_LORA) {
// set modulation params
float duration = 1000.0f * (1UL << this->spreading_factor_) / BW_HZ[this->bandwidth_];
buf[0] = this->spreading_factor_;
buf[1] = progmem_read_byte(&BW_LORA[this->bandwidth_ - SX126X_BW_7810]);
buf[2] = this->coding_rate_;
buf[3] = (duration > LOW_DATA_RATE_OPTIMIZE_THRESHOLD) ? 0x01 : 0x00;
this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 4);
// set packet params and sync word
this->set_packet_params_(this->get_max_packet_size());
if (this->sync_value_.size() == 2) {
this->write_register_(REG_LORA_SYNCWORD, this->sync_value_.data(), this->sync_value_.size());
}
} else {
// set modulation params
uint32_t bitrate = ((uint64_t) XTAL_FREQ * 32) / this->bitrate_;
uint32_t fdev = ((uint64_t) this->deviation_ << 25) / XTAL_FREQ;
buf[0] = (bitrate >> 16) & 0xFF;
buf[1] = (bitrate >> 8) & 0xFF;
buf[2] = (bitrate >> 0) & 0xFF;
buf[3] = this->shaping_;
buf[4] = progmem_read_byte(&BW_FSK[this->bandwidth_ - SX126X_BW_4800]);
buf[5] = (fdev >> 16) & 0xFF;
buf[6] = (fdev >> 8) & 0xFF;
buf[7] = (fdev >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 8);
// set crc params
if (this->crc_enable_) {
buf[0] = this->crc_initial_ >> 8;
buf[1] = this->crc_initial_ & 0xFF;
this->write_register_(REG_CRC_INITIAL, buf, 2);
buf[0] = this->crc_polynomial_ >> 8;
buf[1] = this->crc_polynomial_ & 0xFF;
this->write_register_(REG_CRC_POLYNOMIAL, buf, 2);
}
// set whitening params
if (this->whitening_enable_) {
// according to the datasheet, section 12 table 12-1 "The user should not
// change the value of the 7 MSB of this register"
this->read_register_(REG_WHITENING_INITIAL, buf, 1);
buf[0] = (buf[0] & 0xFE) | ((this->whitening_initial_ >> 8) & 0x01);
buf[1] = this->whitening_initial_ & 0xFF;
this->write_register_(REG_WHITENING_INITIAL, buf, 2);
}
// set packet params and sync word
this->set_packet_params_(this->get_max_packet_size());
if (!this->sync_value_.empty()) {
this->write_register_(REG_GFSK_SYNCWORD, this->sync_value_.data(), this->sync_value_.size());
}
}
// switch to rx or sleep
if (this->rx_start_) {
this->set_mode_rx();
} else {
this->set_mode_sleep();
}
this->cold_sleep_ = command == ModeCommand::MODE_COMMAND_COLD_SLEEP;
this->apply_(this->radio_.change_mode(command, arg));
}
size_t SX126x::get_max_packet_size() { return this->radio_.max_packet_size(); }
size_t SX126x::get_max_packet_size() {
if (this->payload_length_ > 0) {
return this->payload_length_;
}
return SX126X_MAX_PACKET_SIZE;
}
void SX126x::set_packet_params_(uint8_t payload_length) {
uint8_t buf[9];
if (this->modulation_ == PACKET_TYPE_LORA) {
buf[0] = (this->preamble_size_ >> 8) & 0xFF;
buf[1] = (this->preamble_size_ >> 0) & 0xFF;
buf[2] = (this->payload_length_ > 0) ? 0x01 : 0x00;
buf[3] = payload_length;
buf[4] = (this->crc_enable_) ? 0x01 : 0x00;
buf[5] = 0x00;
this->write_opcode_(RADIO_SET_PACKETPARAMS, buf, 6);
} else {
uint16_t preamble_size = this->preamble_size_ * 8;
buf[0] = (preamble_size >> 8) & 0xFF;
buf[1] = (preamble_size >> 0) & 0xFF;
buf[2] = (this->preamble_detect_ > 0) ? ((this->preamble_detect_ - 1) | 0x04) : 0x00;
buf[3] = this->sync_value_.size() * 8;
buf[4] = 0x00;
buf[5] = (this->payload_length_ > 0) ? 0x00 : 0x01;
buf[6] = payload_length;
if (this->crc_enable_) {
buf[7] = (this->crc_inverted_ ? 0x04 : 0x00) + (this->crc_size_ & 0x02);
} else {
buf[7] = 0x01;
}
buf[8] = (this->whitening_enable_) ? 0x01 : 0x00;
this->write_opcode_(RADIO_SET_PACKETPARAMS, buf, 9);
}
}
SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) {
SX126xError result = this->start_transmit_(nullptr, data, len, TRANSMIT_TIMEOUT_MS);
if (result == SX126xError::SX126X_ERROR_NONE) {
// Wait for this send's own completion. A send without a listener cannot be cancelled,
// and no user code runs between these polls, so the send cannot be replaced.
result = SX126xError::SX126X_ERROR_NOT_READY;
while (this->radio_.is_transmitting()) {
const RadioEffects effects = this->radio_.poll_transmit(millis());
if (effects.finished) {
result = effects.result;
this->apply_(effects);
break;
}
App.feed_wdt();
}
}
if (result != SX126xError::SX126X_ERROR_NONE) {
ESP_LOGW(
TAG, "Packet send failed: %s",
LOG_STR_ARG(SX126xErrorStrings::get_log_str(static_cast<uint8_t>(result), SX126xErrorStrings::LAST_INDEX)));
}
return result;
}
SX126xError SX126x::start_transmit_async(SX126xTransmitListener *listener, const uint8_t *data, size_t len,
uint32_t timeout_ms) {
if (listener == nullptr || timeout_ms == 0 || timeout_ms > MAX_TRANSMIT_TIMEOUT_MS) {
return SX126xError::SX126X_ERROR_INVALID_PARAMS;
}
return this->start_transmit_(listener, data, len, timeout_ms);
}
SX126xError SX126x::start_transmit_(SX126xTransmitListener *listener, const uint8_t *data, size_t len,
uint32_t timeout_ms) {
// Callers such as a template number's on_value can run before setup() or after a failed setup
if (!this->is_ready()) {
ESP_LOGW(TAG, "Radio not ready, packet dropped");
return SX126xError::SX126X_ERROR_NOT_READY;
}
const RadioEffects effects = this->radio_.start_transmit(listener, data, len, timeout_ms);
this->apply_(effects);
return effects.result;
if (this->payload_length_ > 0 && this->payload_length_ != len) {
ESP_LOGE(TAG, "Packet size does not match config");
return SX126xError::INVALID_PARAMS;
}
if (len == 0 || len > this->get_max_packet_size()) {
ESP_LOGE(TAG, "Packet size out of range");
return SX126xError::INVALID_PARAMS;
}
SX126xError ret = SX126xError::NONE;
this->set_mode_standby(STDBY_XOSC);
if (this->payload_length_ == 0) {
this->set_packet_params_(len);
}
this->write_fifo_(0x00, data, len);
this->set_mode_tx();
// wait until transmit completes, typically the delay will be less than 100 ms
uint32_t start = millis();
while (!this->dio1_pin_->digital_read()) {
App.feed_wdt();
if (millis() - start > TRANSMIT_TIMEOUT_MS) {
ESP_LOGE(TAG, "Transmit packet failure");
ret = SX126xError::TIMEOUT;
break;
}
}
uint8_t buf[2];
buf[0] = 0xFF;
buf[1] = 0xFF;
this->write_opcode_(RADIO_CLR_IRQSTATUS, buf, 2);
if (this->payload_length_ == 0) {
this->set_packet_params_(this->get_max_packet_size());
}
if (this->rx_start_) {
this->set_mode_rx();
} else {
this->set_mode_sleep();
}
return ret;
}
bool SX126x::cancel_transmit_async(SX126xTransmitListener *listener) {
const RadioEffects effects = this->radio_.cancel_transmit(listener);
if (!effects.finished) {
return false;
void SX126x::call_listeners_(const std::vector<uint8_t> &packet, float rssi, float snr) {
for (auto &listener : this->listeners_) {
listener->on_packet(packet, rssi, snr);
}
this->apply_(effects);
return true;
this->packet_trigger_.trigger(packet, rssi, snr);
}
void SX126x::loop() {
if (this->rx_dispatching_ || this->is_failed()) {
if (this->dio1_pin_->is_internal()) {
this->disable_loop();
}
if (!this->dio1_pin_->digital_read()) {
return;
}
this->apply_(this->radio_.poll(App.get_loop_component_start_time(), this->packet_));
uint16_t status;
uint8_t buf[3];
uint8_t rssi;
int8_t snr;
this->read_opcode_(RADIO_GET_IRQSTATUS, buf, 2);
this->write_opcode_(RADIO_CLR_IRQSTATUS, buf, 2);
status = (buf[0] << 8) | buf[1];
if ((status & IRQ_RX_DONE) == IRQ_RX_DONE) {
if ((status & IRQ_CRC_ERROR) != IRQ_CRC_ERROR) {
this->read_opcode_(RADIO_GET_PACKETSTATUS, buf, 3);
if (this->modulation_ == PACKET_TYPE_LORA) {
rssi = buf[0];
snr = buf[1];
} else {
rssi = buf[2];
snr = 0;
}
this->read_opcode_(RADIO_GET_RXBUFFERSTATUS, buf, 2);
this->packet_.resize(buf[0]);
this->read_fifo_(buf[1], this->packet_);
this->call_listeners_(this->packet_, (float) rssi / -2.0f, (float) snr / 4.0f);
}
}
}
void SX126x::run_image_cal() {
// the following values were taken from section 9.2.1 table 9-2
// in rev 2.1 of the datasheet
uint8_t buf[2] = {0, 0};
if (this->frequency_ > 900000000) {
buf[0] = 0xE1;
buf[1] = 0xE9;
} else if (this->frequency_ > 850000000) {
buf[0] = 0xD7;
buf[1] = 0xDB;
} else if (this->frequency_ > 770000000) {
buf[0] = 0xC1;
buf[1] = 0xC5;
} else if (this->frequency_ > 460000000) {
buf[0] = 0x75;
buf[1] = 0x81;
} else if (this->frequency_ > 425000000) {
buf[0] = 0x6B;
buf[1] = 0x6F;
}
if (buf[0] > 0 && buf[1] > 0) {
this->write_opcode_(RADIO_CALIBRATEIMAGE, buf, 2);
}
}
void SX126x::set_mode_rx() {
uint8_t buf[8];
// configure irq params
uint16_t irq = IRQ_RX_DONE | IRQ_RX_TX_TIMEOUT | IRQ_CRC_ERROR;
buf[0] = (irq >> 8) & 0xFF;
buf[1] = (irq >> 0) & 0xFF;
buf[2] = (irq >> 8) & 0xFF;
buf[3] = (irq >> 0) & 0xFF;
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[5] = (IRQ_RADIO_NONE >> 0) & 0xFF;
buf[6] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[7] = (IRQ_RADIO_NONE >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_DIOIRQPARAMS, buf, 8);
// set timeout to 0
buf[0] = 0x00;
this->write_opcode_(RADIO_SET_LORASYMBTIMEOUT, buf, 1);
// switch to continuous mode rx
buf[0] = 0xFF;
buf[1] = 0xFF;
buf[2] = 0xFF;
this->write_opcode_(RADIO_SET_RX, buf, 3);
}
void SX126x::set_mode_tx() {
uint8_t buf[8];
// configure irq params
uint16_t irq = IRQ_TX_DONE | IRQ_RX_TX_TIMEOUT;
buf[0] = (irq >> 8) & 0xFF;
buf[1] = (irq >> 0) & 0xFF;
buf[2] = (irq >> 8) & 0xFF;
buf[3] = (irq >> 0) & 0xFF;
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[5] = (IRQ_RADIO_NONE >> 0) & 0xFF;
buf[6] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[7] = (IRQ_RADIO_NONE >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_DIOIRQPARAMS, buf, 8);
// switch to single mode tx
buf[0] = 0x00;
buf[1] = 0x00;
buf[2] = 0x00;
this->write_opcode_(RADIO_SET_TX, buf, 3);
}
void SX126x::set_mode_sleep(bool cold) {
// 0x04 = warm start (config retained), 0x00 = cold start (config lost, lowest power)
uint8_t buf[1];
buf[0] = cold ? 0x00 : 0x04;
this->write_opcode_(RADIO_SET_SLEEP, buf, 1);
}
void SX126x::set_mode_standby(SX126xStandbyMode mode) {
uint8_t buf[1];
buf[0] = mode;
this->write_opcode_(RADIO_SET_STANDBY, buf, 1);
}
void SX126x::wait_busy_() {
// wait if the device is busy, the maximum delay is only be a few ms
// with most commands taking only a few us
uint32_t start = millis();
while (this->busy_pin_->digital_read()) {
if (millis() - start > BUSY_TIMEOUT_MS) {
this->mark_failed(LOG_STR("Wait busy timeout"));
break;
}
}
}
void SX126x::dump_config() {
ESP_LOGCONFIG(TAG, "SX126x:\n Ready for operation: %s",
YESNO(!this->is_failed() && this->radio_.state() == RadioState::RADIO_STATE_IDLE));
LOG_PIN(" CS Pin: ", this->radio_.bus().get_cs_pin());
LOG_PIN(" BUSY Pin: ", this->radio_.bus().get_busy_pin());
LOG_PIN(" RST Pin: ", this->radio_.get_rst_pin());
LOG_PIN(" DIO1 Pin: ", this->radio_.get_dio1_pin());
ESP_LOGCONFIG(TAG, "SX126x:");
LOG_PIN(" CS Pin: ", this->cs_);
LOG_PIN(" BUSY Pin: ", this->busy_pin_);
LOG_PIN(" RST Pin: ", this->rst_pin_);
LOG_PIN(" DIO1 Pin: ", this->dio1_pin_);
ESP_LOGCONFIG(TAG,
" HW Version: %15s\n"
" Requested configuration:\n"
" Frequency: %" PRIu32 " Hz\n"
" Bandwidth: %" PRIu32 " Hz\n"
" PA Power: %" PRId8 " dBm\n"
" PA Ramp: %" PRIu16 " us\n"
" Payload Length: %" PRIu8 "\n"
" Payload Length: %" PRIu32 "\n"
" CRC Enable: %s\n"
" Rx Start: %s",
this->version_, this->requested_profile_.frequency,
SX126xCommands::bandwidth_hz(this->requested_profile_.bandwidth), this->requested_profile_.pa_power,
SX126xCommands::ramp_us(this->requested_profile_.pa_ramp), this->requested_profile_.payload_length,
TRUEFALSE(this->requested_profile_.crc_enable), TRUEFALSE(this->requested_profile_.rx_start));
if (this->requested_profile_.modulation == PACKET_TYPE_GFSK) {
this->version_, this->frequency_, BW_HZ[this->bandwidth_], this->pa_power_,
progmem_read_uint16(&RAMP[this->pa_ramp_]), this->payload_length_, TRUEFALSE(this->crc_enable_),
TRUEFALSE(this->rx_start_));
if (this->modulation_ == PACKET_TYPE_GFSK) {
const char *shaping = "NONE";
if (this->requested_profile_.shaping == GAUSSIAN_BT_0_3) {
if (this->shaping_ == GAUSSIAN_BT_0_3) {
shaping = "GAUSSIAN_BT_0_3";
} else if (this->requested_profile_.shaping == GAUSSIAN_BT_0_5) {
} else if (this->shaping_ == GAUSSIAN_BT_0_5) {
shaping = "GAUSSIAN_BT_0_5";
} else if (this->requested_profile_.shaping == GAUSSIAN_BT_0_7) {
} else if (this->shaping_ == GAUSSIAN_BT_0_7) {
shaping = "GAUSSIAN_BT_0_7";
} else if (this->requested_profile_.shaping == GAUSSIAN_BT_1_0) {
} else if (this->shaping_ == GAUSSIAN_BT_1_0) {
shaping = "GAUSSIAN_BT_1_0";
}
ESP_LOGCONFIG(TAG,
@@ -222,15 +539,14 @@ void SX126x::dump_config() {
" Preamble Size: %" PRIu16 "\n"
" Preamble Detect: %" PRIu16 "\n"
" Bitrate: %" PRIu32 "b/s",
this->requested_profile_.deviation, shaping, this->requested_profile_.preamble_size,
this->requested_profile_.preamble_detect, this->requested_profile_.bitrate);
} else if (this->requested_profile_.modulation == PACKET_TYPE_LORA) {
this->deviation_, shaping, this->preamble_size_, this->preamble_detect_, this->bitrate_);
} else if (this->modulation_ == PACKET_TYPE_LORA) {
const char *cr = "4/8";
if (this->requested_profile_.coding_rate == LORA_CR_4_5) {
if (this->coding_rate_ == LORA_CR_4_5) {
cr = "4/5";
} else if (this->requested_profile_.coding_rate == LORA_CR_4_6) {
} else if (this->coding_rate_ == LORA_CR_4_6) {
cr = "4/6";
} else if (this->requested_profile_.coding_rate == LORA_CR_4_7) {
} else if (this->coding_rate_ == LORA_CR_4_7) {
cr = "4/7";
}
ESP_LOGCONFIG(TAG,
@@ -238,17 +554,16 @@ void SX126x::dump_config() {
" Spreading Factor: %" PRIu8 "\n"
" Coding Rate: %s\n"
" Preamble Size: %" PRIu16,
this->requested_profile_.spreading_factor, cr, this->requested_profile_.preamble_size);
this->spreading_factor_, cr, this->preamble_size_);
}
if (this->requested_profile_.sync_size != 0) {
if (!this->sync_value_.empty()) {
char hex_buf[17]; // 8 bytes max = 16 hex chars + null
ESP_LOGCONFIG(
TAG, " Sync Value: 0x%s",
format_hex_to(hex_buf, this->requested_profile_.sync_value.data(), this->requested_profile_.sync_size));
ESP_LOGCONFIG(TAG, " Sync Value: 0x%s",
format_hex_to(hex_buf, this->sync_value_.data(), this->sync_value_.size()));
}
ESP_LOGCONFIG(TAG, " Whitening Enable: %s", TRUEFALSE(this->requested_profile_.whitening_enable));
if (this->requested_profile_.whitening_enable) {
ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->requested_profile_.whitening_initial);
ESP_LOGCONFIG(TAG, " Whitening Enable: %s", TRUEFALSE(this->whitening_enable_));
if (this->whitening_enable_) {
ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->whitening_initial_);
}
}
+114 -80
View File
@@ -1,123 +1,157 @@
#pragma once
#include "sx126x_radio.h"
#include "esphome/components/spi/spi.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "sx126x_reg.h"
#include <utility>
#include <vector>
namespace esphome::sx126x {
static constexpr size_t SX126X_MAX_PACKET_SIZE = 255;
enum SX126xBw : uint8_t {
// FSK
SX126X_BW_4800,
SX126X_BW_5800,
SX126X_BW_7300,
SX126X_BW_9700,
SX126X_BW_11700,
SX126X_BW_14600,
SX126X_BW_19500,
SX126X_BW_23400,
SX126X_BW_29300,
SX126X_BW_39000,
SX126X_BW_46900,
SX126X_BW_58600,
SX126X_BW_78200,
SX126X_BW_93800,
SX126X_BW_117300,
SX126X_BW_156200,
SX126X_BW_187200,
SX126X_BW_234300,
SX126X_BW_312000,
SX126X_BW_373600,
SX126X_BW_467000,
// LORA
SX126X_BW_7810,
SX126X_BW_10420,
SX126X_BW_15630,
SX126X_BW_20830,
SX126X_BW_31250,
SX126X_BW_41670,
SX126X_BW_62500,
SX126X_BW_125000,
SX126X_BW_250000,
SX126X_BW_500000,
};
enum class SX126xError { NONE = 0, TIMEOUT, INVALID_PARAMS, SX126X_ERROR_NOT_READY };
class SX126xListener {
public:
// Called from loop(). The packet is borrowed until this returns; copy what you keep.
// Listeners may send, change mode or configure. A send returns BUSY if another packet
// is already waiting, which is then delivered by the next loop().
virtual void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) = 0;
};
class SX126x final : public Component {
class SX126x final : public Component,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_8MHZ> {
public:
void set_spi_parent(spi::SPIComponent *parent) { this->radio_.bus().set_spi_parent(parent); }
void set_cs_pin(GPIOPin *pin) { this->radio_.bus().set_cs_pin(pin); }
void set_data_rate(uint32_t rate) { this->radio_.bus().set_data_rate(rate); }
void set_mode(spi::SPIMode mode) { this->radio_.bus().set_mode(mode); }
void set_bit_order(spi::SPIBitOrder order) { this->radio_.bus().set_bit_order(order); }
void set_release_device(bool release) { this->radio_.bus().set_release_device(release); }
void set_write_only(bool write_only) { this->radio_.bus().set_write_only(write_only); }
#ifdef USE_SPI_PSRAM_DMA
void set_psram_dma(bool enable) { this->radio_.bus().set_psram_dma(enable); }
#endif
// Releases the SPI bus, as SPIDevice::spi_teardown() did before SX126x stopped being one
void spi_teardown() { this->radio_.bus().teardown(); }
size_t get_max_packet_size();
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
void setup() override;
void loop() override;
void dump_config() override;
void set_bandwidth(SX126xBw bandwidth) { this->requested_profile_.bandwidth = bandwidth; }
void set_bitrate(uint32_t bitrate) { this->requested_profile_.bitrate = bitrate; }
void set_busy_pin(GPIOPin *busy_pin) { this->radio_.bus().set_busy_pin(busy_pin); }
void set_coding_rate(uint8_t coding_rate) { this->requested_profile_.coding_rate = coding_rate; }
void set_crc_enable(bool crc_enable) { this->requested_profile_.crc_enable = crc_enable; }
void set_crc_inverted(bool crc_inverted) { this->requested_profile_.crc_inverted = crc_inverted; }
void set_crc_size(uint8_t crc_size) { this->requested_profile_.crc_size = crc_size; }
void set_crc_polynomial(uint16_t crc_polynomial) { this->requested_profile_.crc_polynomial = crc_polynomial; }
void set_crc_initial(uint16_t crc_initial) { this->requested_profile_.crc_initial = crc_initial; }
void set_whitening_enable(bool whitening_enable) { this->requested_profile_.whitening_enable = whitening_enable; }
void set_whitening_initial(uint16_t whitening_initial) {
this->requested_profile_.whitening_initial = whitening_initial;
}
void set_deviation(uint32_t deviation) { this->requested_profile_.deviation = deviation; }
void set_dio1_pin(GPIOPin *dio1_pin) { this->radio_.set_dio1_pin(dio1_pin); }
void set_frequency(uint32_t frequency) { this->requested_profile_.frequency = frequency; }
void set_sx1261(bool sx1261) { this->requested_profile_.sx1261 = sx1261; }
// Mode changes are ignored with a warning unless the radio is idle and configured.
void set_bandwidth(SX126xBw bandwidth) { this->bandwidth_ = bandwidth; }
void set_bitrate(uint32_t bitrate) { this->bitrate_ = bitrate; }
void set_busy_pin(GPIOPin *busy_pin) { this->busy_pin_ = busy_pin; }
void set_coding_rate(uint8_t coding_rate) { this->coding_rate_ = coding_rate; }
void set_crc_enable(bool crc_enable) { this->crc_enable_ = crc_enable; }
void set_crc_inverted(bool crc_inverted) { this->crc_inverted_ = crc_inverted; }
void set_crc_size(uint8_t crc_size) { this->crc_size_ = crc_size; }
void set_crc_polynomial(uint16_t crc_polynomial) { this->crc_polynomial_ = crc_polynomial; }
void set_crc_initial(uint16_t crc_initial) { this->crc_initial_ = crc_initial; }
void set_whitening_enable(bool whitening_enable) { this->whitening_enable_ = whitening_enable; }
void set_whitening_initial(uint16_t whitening_initial) { this->whitening_initial_ = whitening_initial; }
void set_deviation(uint32_t deviation) { this->deviation_ = deviation; }
void set_dio1_pin(GPIOPin *dio1_pin) { this->dio1_pin_ = dio1_pin; }
void set_frequency(uint32_t frequency) { this->frequency_ = frequency; }
void set_sx1261(bool sx1261) { this->sx1261_ = sx1261; }
void set_mode_rx();
void set_mode_tx();
void set_mode_standby(SX126xStandbyMode mode);
void set_mode_sleep(bool cold = false);
void set_modulation(uint8_t modulation) { this->requested_profile_.modulation = modulation; }
void set_pa_power(int8_t power) { this->requested_profile_.pa_power = power; }
void set_pa_ramp(uint8_t ramp) { this->requested_profile_.pa_ramp = ramp; }
void set_payload_length(uint8_t payload_length) { this->requested_profile_.payload_length = payload_length; }
void set_preamble_detect(uint16_t preamble_detect) { this->requested_profile_.preamble_detect = preamble_detect; }
void set_preamble_size(uint16_t preamble_size) { this->requested_profile_.preamble_size = preamble_size; }
void set_rst_pin(GPIOPin *rst_pin) { this->radio_.set_rst_pin(rst_pin); }
void set_rx_start(bool rx_start) { this->requested_profile_.rx_start = rx_start; }
void set_rf_switch(bool rf_switch) { this->requested_profile_.rf_switch = rf_switch; }
void set_shaping(uint8_t shaping) { this->requested_profile_.shaping = shaping; }
void set_spreading_factor(uint8_t spreading_factor) { this->requested_profile_.spreading_factor = spreading_factor; }
void set_sync_value(const std::vector<uint8_t> &sync_value) { this->requested_profile_.set_sync_value(sync_value); }
void set_tcxo_voltage(uint8_t tcxo_voltage) { this->requested_profile_.tcxo_voltage = tcxo_voltage; }
void set_tcxo_delay(uint32_t tcxo_delay) { this->requested_profile_.tcxo_delay = tcxo_delay; }
void set_modulation(uint8_t modulation) { this->modulation_ = modulation; }
void set_pa_power(int8_t power) { this->pa_power_ = power; }
void set_pa_ramp(uint8_t ramp) { this->pa_ramp_ = ramp; }
void set_payload_length(uint8_t payload_length) { this->payload_length_ = payload_length; }
void set_preamble_detect(uint16_t preamble_detect) { this->preamble_detect_ = preamble_detect; }
void set_preamble_size(uint16_t preamble_size) { this->preamble_size_ = preamble_size; }
void set_rst_pin(GPIOPin *rst_pin) { this->rst_pin_ = rst_pin; }
void set_rx_start(bool rx_start) { this->rx_start_ = rx_start; }
void set_rf_switch(bool rf_switch) { this->rf_switch_ = rf_switch; }
void set_shaping(uint8_t shaping) { this->shaping_ = shaping; }
void set_spreading_factor(uint8_t spreading_factor) { this->spreading_factor_ = spreading_factor; }
void set_sync_value(const std::vector<uint8_t> &sync_value) { this->sync_value_ = sync_value; }
void set_tcxo_voltage(uint8_t tcxo_voltage) { this->tcxo_voltage_ = tcxo_voltage; }
void set_tcxo_delay(uint32_t tcxo_delay) { this->tcxo_delay_ = tcxo_delay; }
void run_image_cal();
// Mode changes and configuration are allowed during RX callbacks when the radio
// is available; they never modify the borrowed packet buffer.
void configure();
SX126xError transmit_packet(const uint8_t *data, size_t len);
SX126xError transmit_packet(const std::vector<uint8_t> &packet) {
return this->transmit_packet(packet.data(), packet.size());
}
// Main-loop context only. SX126X_ERROR_NONE accepts one operation and promises one completion.
// Data is copied into the chip FIFO before return; listener must outlive completion.
// Pending RX rejects admission with BUSY and is delivered by a later loop().
// Timeout: 1..262143 ms. SPI/BUSY waits are synchronous; airtime is asynchronous.
[[nodiscard]] SX126xError start_transmit_async(SX126xTransmitListener *listener, const uint8_t *data, size_t len,
uint32_t timeout_ms = 4000);
[[nodiscard]] SX126xError start_transmit_async(SX126xTransmitListener *listener, const std::vector<uint8_t> &packet,
uint32_t timeout_ms = 4000) {
return this->start_transmit_async(listener, packet.data(), packet.size(), timeout_ms);
}
// Normal completion runs in loop(); cancellation completes synchronously in its caller.
// The radio is released before callbacks, which may start another operation if recovery
// succeeded. True accepts cancellation; the completion result reports cleanup failure.
bool cancel_transmit_async(SX126xTransmitListener *listener);
bool is_transmitting() const { return this->radio_.is_transmitting(); }
void register_listener(SX126xListener *listener) { this->listeners_.push_back(listener); }
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
private:
static void IRAM_ATTR gpio_intr(SX126x *arg) { arg->enable_loop_soon_any_context(); }
void change_mode_(ModeCommand command, uint8_t arg = 0);
// The only place that runs user code for a radio operation: loop scheduling, then status,
// then the completion or packet listeners. SX126xRadio has already committed its state.
void apply_(const RadioEffects &effects);
void watch_configuration_();
SX126xError start_transmit_(SX126xTransmitListener *listener, const uint8_t *data, size_t len, uint32_t timeout_ms);
SX126xRadio radio_;
RadioProfile requested_profile_;
char version_[16]{};
protected:
static void gpio_intr(SX126x *arg);
void configure_fsk_ook_();
void configure_lora_();
void set_packet_params_(uint8_t payload_length);
uint8_t read_fifo_(uint8_t offset, std::vector<uint8_t> &packet);
void write_fifo_(uint8_t offset, const uint8_t *data, size_t len);
void write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size);
uint8_t read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size);
void write_register_(uint16_t reg, uint8_t *data, uint8_t size);
void read_register_(uint16_t reg, uint8_t *data, uint8_t size);
void call_listeners_(const std::vector<uint8_t> &packet, float rssi, float snr);
void wait_busy_();
Trigger<std::vector<uint8_t>, float, float> packet_trigger_;
std::vector<SX126xListener *> listeners_;
std::vector<uint8_t> packet_;
bool rx_dispatching_{false};
bool cold_sleep_{false};
bool reconfigure_pending_{false};
std::vector<uint8_t> sync_value_;
GPIOPin *busy_pin_{nullptr};
GPIOPin *dio1_pin_{nullptr};
GPIOPin *rst_pin_{nullptr};
uint32_t bitrate_{0};
uint32_t deviation_{0};
uint32_t frequency_{0};
uint32_t payload_length_{0};
uint32_t tcxo_delay_{0};
uint16_t crc_polynomial_{0};
uint16_t crc_initial_{0};
uint16_t whitening_initial_{0};
uint16_t preamble_detect_{0};
uint16_t preamble_size_{0};
char version_[16];
SX126xBw bandwidth_{SX126X_BW_125000};
uint8_t crc_size_{0};
uint8_t tcxo_voltage_{0};
uint8_t coding_rate_{0};
uint8_t modulation_{PACKET_TYPE_LORA};
uint8_t pa_ramp_{0};
uint8_t shaping_{0};
uint8_t spreading_factor_{0};
int8_t pa_power_{0};
bool sx1261_{false};
bool crc_enable_{false};
bool crc_inverted_{false};
bool whitening_enable_{false};
bool rx_start_{false};
bool rf_switch_{false};
};
} // namespace esphome::sx126x
@@ -1,355 +0,0 @@
#include "sx126x_commands.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <cmath>
#include <cstring>
namespace esphome::sx126x {
static constexpr uint16_t RAMP[8] PROGMEM = {10, 20, 40, 80, 200, 800, 1700, 3400};
// Aligned 32 bit loads from flash are safe on ESP8266, so this table is read directly
static constexpr uint32_t BW_HZ[31] PROGMEM = {4800, 5800, 7300, 9700, 11700, 14600, 19500, 23400,
29300, 39000, 46900, 58600, 78200, 93800, 117300, 156200,
187200, 234300, 312000, 373600, 467000, 7810, 10420, 15630,
20830, 31250, 41670, 62500, 125000, 250000, 500000};
static constexpr uint8_t BW_LORA[10] PROGMEM = {LORA_BW_7810, LORA_BW_10420, LORA_BW_15630, LORA_BW_20830,
LORA_BW_31250, LORA_BW_41670, LORA_BW_62500, LORA_BW_125000,
LORA_BW_250000, LORA_BW_500000};
static constexpr uint8_t BW_FSK[21] PROGMEM = {
FSK_BW_4800, FSK_BW_5800, FSK_BW_7300, FSK_BW_9700, FSK_BW_11700, FSK_BW_14600, FSK_BW_19500,
FSK_BW_23400, FSK_BW_29300, FSK_BW_39000, FSK_BW_46900, FSK_BW_58600, FSK_BW_78200, FSK_BW_93800,
FSK_BW_117300, FSK_BW_156200, FSK_BW_187200, FSK_BW_234300, FSK_BW_312000, FSK_BW_373600, FSK_BW_467000};
// OCP (Over Current Protection) values
static constexpr uint8_t OCP_80MA = 0x18; // 80 mA max current
static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current
// LoRa low data rate optimization threshold
static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms
void SX126xCommands::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
const uint8_t header[] = {opcode, 0};
this->spi_device_.read(header, {data, size});
}
void SX126xCommands::write_opcode_(uint8_t opcode, const uint8_t *data, uint8_t size) {
this->spi_device_.write({&opcode, 1}, {data, size});
}
void SX126xCommands::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
const uint8_t header[] = {RADIO_READ_REGISTER, static_cast<uint8_t>(reg >> 8), static_cast<uint8_t>(reg), 0};
this->spi_device_.read(header, {data, size});
}
void SX126xCommands::write_register_(uint16_t reg, const uint8_t *data, uint8_t size) {
const uint8_t header[] = {RADIO_WRITE_REGISTER, static_cast<uint8_t>(reg >> 8), static_cast<uint8_t>(reg)};
this->spi_device_.write(header, {data, size});
}
ConfigurationResult SX126xCommands::configure(const RadioProfile &profile, std::span<char, 16> version,
AppliedProfile &applied) {
applied.frequency = profile.frequency;
applied.payload_length = profile.payload_length;
applied.modulation = profile.modulation;
applied.rx_start = profile.rx_start;
encode_packet_params(profile, applied);
uint8_t buf[8];
// wakeup
this->read_opcode_(RADIO_GET_STATUS, nullptr, 0);
// config tcxo
if (profile.tcxo_voltage != TCXO_CTRL_NONE) {
uint32_t delay = profile.tcxo_delay >> 6;
buf[0] = profile.tcxo_voltage;
buf[1] = (delay >> 16) & 0xFF;
buf[2] = (delay >> 8) & 0xFF;
buf[3] = (delay >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_TCXOMODE, buf, 4);
buf[0] = 0x7F;
this->write_opcode_(RADIO_CALIBRATE, buf, 1);
}
// clear errors
buf[0] = 0x00;
buf[1] = 0x00;
this->write_opcode_(RADIO_CLR_ERROR, buf, 2);
// rf switch
if (profile.rf_switch) {
buf[0] = 0x01;
this->write_opcode_(RADIO_SET_RFSWITCHMODE, buf, 1);
}
// check silicon version to make sure hw is ok
this->read_register_(REG_VERSION_STRING, reinterpret_cast<uint8_t *>(version.data()), version.size());
if (!this->spi_device_.ok()) {
return ConfigurationResult::CONFIGURATION_RESULT_BUSY_TIMEOUT;
}
version[version.size() - 1] = '\0';
if (strncmp(version.data(), "SX126", 5) != 0 && strncmp(version.data(), "LLCC68", 6) != 0) {
return ConfigurationResult::CONFIGURATION_RESULT_UNSUPPORTED;
}
// setup packet type
buf[0] = profile.modulation;
this->write_opcode_(RADIO_SET_PACKETTYPE, buf, 1);
// calibrate image
this->calibrate_image(profile.frequency);
// set frequency
uint64_t freq = ((uint64_t) profile.frequency << 25) / XTAL_FREQ;
buf[0] = (uint8_t) ((freq >> 24) & 0xFF);
buf[1] = (uint8_t) ((freq >> 16) & 0xFF);
buf[2] = (uint8_t) ((freq >> 8) & 0xFF);
buf[3] = (uint8_t) (freq & 0xFF);
this->write_opcode_(RADIO_SET_RFFREQUENCY, buf, 4);
// configure pa
int8_t pa_power = profile.pa_power;
if (profile.sx1261) {
// the following values were taken from section 13.1.14.1 table 13-21
// in rev 2.1 of the datasheet
if (pa_power == 15) {
uint8_t cfg[4] = {0x06, 0x00, 0x01, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
} else {
uint8_t cfg[4] = {0x04, 0x00, 0x01, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
}
pa_power = clamp(pa_power, (int8_t) -3, (int8_t) 14);
buf[0] = OCP_80MA;
this->write_register_(REG_OCP, buf, 1);
} else {
// the following values were taken from section 13.1.14.1 table 13-21
// in rev 2.1 of the datasheet
uint8_t cfg[4] = {0x04, 0x07, 0x00, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
pa_power = clamp(pa_power, (int8_t) -3, (int8_t) 22);
buf[0] = OCP_140MA;
this->write_register_(REG_OCP, buf, 1);
}
buf[0] = pa_power;
buf[1] = profile.pa_ramp;
this->write_opcode_(RADIO_SET_TXPARAMS, buf, 2);
// configure modem
if (profile.modulation == PACKET_TYPE_LORA) {
// set modulation params
float duration = 1000.0f * (1UL << profile.spreading_factor) / BW_HZ[profile.bandwidth];
buf[0] = profile.spreading_factor;
buf[1] = progmem_read_byte(&BW_LORA[profile.bandwidth - SX126X_BW_7810]);
buf[2] = profile.coding_rate;
buf[3] = (duration > LOW_DATA_RATE_OPTIMIZE_THRESHOLD) ? 0x01 : 0x00;
this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 4);
// set packet params and sync word
this->write_opcode_(RADIO_SET_PACKETPARAMS, applied.packet_params.data(), applied.packet_params_size);
if (profile.sync_size == 2) {
this->write_register_(REG_LORA_SYNCWORD, profile.sync_value.data(), profile.sync_size);
}
} else {
// set modulation params
uint32_t bitrate = ((uint64_t) XTAL_FREQ * 32) / profile.bitrate;
uint32_t fdev = ((uint64_t) profile.deviation << 25) / XTAL_FREQ;
buf[0] = (bitrate >> 16) & 0xFF;
buf[1] = (bitrate >> 8) & 0xFF;
buf[2] = (bitrate >> 0) & 0xFF;
buf[3] = profile.shaping;
buf[4] = progmem_read_byte(&BW_FSK[profile.bandwidth - SX126X_BW_4800]);
buf[5] = (fdev >> 16) & 0xFF;
buf[6] = (fdev >> 8) & 0xFF;
buf[7] = (fdev >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 8);
// set crc params
if (profile.crc_enable) {
buf[0] = profile.crc_initial >> 8;
buf[1] = profile.crc_initial & 0xFF;
this->write_register_(REG_CRC_INITIAL, buf, 2);
buf[0] = profile.crc_polynomial >> 8;
buf[1] = profile.crc_polynomial & 0xFF;
this->write_register_(REG_CRC_POLYNOMIAL, buf, 2);
}
// set whitening params
if (profile.whitening_enable) {
// according to the datasheet, section 12 table 12-1 "The user should not
// change the value of the 7 MSB of this register"
this->read_register_(REG_WHITENING_INITIAL, buf, 1);
buf[0] = (buf[0] & 0xFE) | ((profile.whitening_initial >> 8) & 0x01);
buf[1] = profile.whitening_initial & 0xFF;
this->write_register_(REG_WHITENING_INITIAL, buf, 2);
}
// set packet params and sync word
this->write_opcode_(RADIO_SET_PACKETPARAMS, applied.packet_params.data(), applied.packet_params_size);
if (profile.sync_size != 0) {
this->write_register_(REG_GFSK_SYNCWORD, profile.sync_value.data(), profile.sync_size);
}
}
// switch to rx or sleep
if (profile.rx_start) {
this->receive();
} else {
this->sleep();
}
return this->spi_device_.ok() ? ConfigurationResult::CONFIGURATION_RESULT_OK
: ConfigurationResult::CONFIGURATION_RESULT_BUSY_TIMEOUT;
}
void SX126xCommands::encode_packet_params(const RadioProfile &profile, AppliedProfile &applied) {
auto &buf = applied.packet_params;
const uint8_t payload_length = profile.max_packet_size();
if (profile.modulation == PACKET_TYPE_LORA) {
buf[0] = (profile.preamble_size >> 8) & 0xFF;
buf[1] = (profile.preamble_size >> 0) & 0xFF;
buf[2] = (profile.payload_length > 0) ? 0x01 : 0x00;
buf[3] = payload_length;
buf[4] = (profile.crc_enable) ? 0x01 : 0x00;
buf[5] = 0x00;
applied.packet_params_size = 6;
} else {
uint16_t preamble_size = profile.preamble_size * 8;
buf[0] = (preamble_size >> 8) & 0xFF;
buf[1] = (preamble_size >> 0) & 0xFF;
buf[2] = (profile.preamble_detect > 0) ? ((profile.preamble_detect - 1) | 0x04) : 0x00;
buf[3] = profile.sync_size * 8;
buf[4] = 0x00;
buf[5] = (profile.payload_length > 0) ? 0x00 : 0x01;
buf[6] = payload_length;
if (profile.crc_enable) {
buf[7] = (profile.crc_inverted ? 0x04 : 0x00) + (profile.crc_size & 0x02);
} else {
buf[7] = 0x01;
}
buf[8] = (profile.whitening_enable) ? 0x01 : 0x00;
applied.packet_params_size = 9;
}
}
void SX126xCommands::set_packet_length(AppliedProfile &applied, uint8_t length) {
applied.packet_params[applied.modulation == PACKET_TYPE_LORA ? 3 : 6] = length;
this->write_opcode_(RADIO_SET_PACKETPARAMS, applied.packet_params.data(), applied.packet_params_size);
}
void SX126xCommands::calibrate_image(uint32_t frequency) {
// the following values were taken from section 9.2.1 table 9-2
// in rev 2.1 of the datasheet
uint8_t buf[2] = {0, 0};
if (frequency > 900000000) {
buf[0] = 0xE1;
buf[1] = 0xE9;
} else if (frequency > 850000000) {
buf[0] = 0xD7;
buf[1] = 0xDB;
} else if (frequency > 770000000) {
buf[0] = 0xC1;
buf[1] = 0xC5;
} else if (frequency > 460000000) {
buf[0] = 0x75;
buf[1] = 0x81;
} else if (frequency > 425000000) {
buf[0] = 0x6B;
buf[1] = 0x6F;
}
if (buf[0] > 0 && buf[1] > 0) {
this->write_opcode_(RADIO_CALIBRATEIMAGE, buf, 2);
}
}
void SX126xCommands::receive() {
uint8_t buf[8];
// configure irq params
uint16_t irq = IRQ_RX_DONE | IRQ_RX_TX_TIMEOUT | IRQ_CRC_ERROR;
buf[0] = (irq >> 8) & 0xFF;
buf[1] = (irq >> 0) & 0xFF;
buf[2] = (irq >> 8) & 0xFF;
buf[3] = (irq >> 0) & 0xFF;
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[5] = (IRQ_RADIO_NONE >> 0) & 0xFF;
buf[6] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[7] = (IRQ_RADIO_NONE >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_DIOIRQPARAMS, buf, 8);
// set timeout to 0
buf[0] = 0x00;
this->write_opcode_(RADIO_SET_LORASYMBTIMEOUT, buf, 1);
// switch to continuous mode rx
buf[0] = 0xFF;
buf[1] = 0xFF;
buf[2] = 0xFF;
this->write_opcode_(RADIO_SET_RX, buf, 3);
}
void SX126xCommands::transmit(uint32_t timeout_ms) {
uint8_t buf[8];
// configure irq params
uint16_t irq = IRQ_TX_DONE | IRQ_RX_TX_TIMEOUT;
buf[0] = (irq >> 8) & 0xFF;
buf[1] = (irq >> 0) & 0xFF;
buf[2] = (irq >> 8) & 0xFF;
buf[3] = (irq >> 0) & 0xFF;
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[5] = (IRQ_RADIO_NONE >> 0) & 0xFF;
buf[6] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[7] = (IRQ_RADIO_NONE >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_DIOIRQPARAMS, buf, 8);
const uint32_t ticks = timeout_ms * 64;
buf[0] = ticks >> 16;
buf[1] = ticks >> 8;
buf[2] = ticks;
this->write_opcode_(RADIO_SET_TX, buf, 3);
}
void SX126xCommands::sleep(bool cold) {
// 0x04 = warm start (config retained), 0x00 = cold start (config lost, lowest power)
const uint8_t mode = cold ? 0x00 : 0x04;
this->write_opcode_(RADIO_SET_SLEEP, &mode, 1);
}
void SX126xCommands::standby(SX126xStandbyMode mode) {
const uint8_t value = mode;
this->write_opcode_(RADIO_SET_STANDBY, &value, 1);
}
uint32_t SX126xCommands::bandwidth_hz(SX126xBw bw) { return BW_HZ[bw]; }
uint16_t SX126xCommands::ramp_us(uint8_t ramp) { return progmem_read_uint16(&RAMP[ramp]); }
void SX126xCommands::clear_irqs(uint16_t mask) {
const uint8_t data[] = {static_cast<uint8_t>(mask >> 8), static_cast<uint8_t>(mask)};
this->write_opcode_(RADIO_CLR_IRQSTATUS, data, 2);
}
uint16_t SX126xCommands::read_irq() {
uint8_t data[2]{};
this->read_opcode_(RADIO_GET_IRQSTATUS, data, 2);
return (uint16_t(data[0]) << 8) | data[1];
}
void SX126xCommands::write_fifo(const uint8_t *data, size_t size) {
const uint8_t header[] = {RADIO_WRITE_BUFFER, 0};
this->spi_device_.write(header, {data, size});
}
void SX126xCommands::read_packet(uint8_t modulation, std::vector<uint8_t> &packet, float &rssi, float &snr) {
uint8_t data[3]{};
this->read_opcode_(RADIO_GET_PACKETSTATUS, data, 3);
rssi = (modulation == PACKET_TYPE_LORA ? data[0] : data[2]) / -2.0f;
// FSK packets report no SNR
snr = modulation == PACKET_TYPE_LORA ? static_cast<int8_t>(data[1]) / 4.0f : NAN;
this->read_opcode_(RADIO_GET_RXBUFFERSTATUS, data, 2);
if (!this->spi_device_.ok()) {
return; // The length and offset were not read.
}
packet.resize(data[0]);
const uint8_t header[] = {RADIO_READ_BUFFER, data[1], 0};
this->spi_device_.read(header, packet);
}
} // namespace esphome::sx126x
@@ -1,49 +0,0 @@
#pragma once
#include "radio_profile.h"
#include "sx126x_spi_device.h"
#include <vector>
namespace esphome::sx126x {
enum class ConfigurationResult {
CONFIGURATION_RESULT_OK,
CONFIGURATION_RESULT_BUSY_TIMEOUT,
CONFIGURATION_RESULT_UNSUPPORTED
};
// Encodes chip commands. No ESPHome lifecycle, callbacks, or raw SPI access.
// Commands record failure in the SPI device: callers start a sequence with begin()
// and check ok() at its end, or before acting on a value that was read.
class SX126xCommands {
public:
SX126xCommands() = default;
SX126xCommands(const SX126xCommands &) = delete;
SX126xCommands &operator=(const SX126xCommands &) = delete;
SX126xSpiDevice &bus() { return this->spi_device_; }
// Fills applied as it goes; only use it if the result is OK.
[[nodiscard]] ConfigurationResult configure(const RadioProfile &profile, std::span<char, 16> version,
AppliedProfile &applied);
// Sets the payload length in the applied packet parameters and writes them.
void set_packet_length(AppliedProfile &applied, uint8_t length);
void calibrate_image(uint32_t frequency);
void standby(SX126xStandbyMode mode);
void receive();
void transmit(uint32_t timeout_ms = 0);
void sleep(bool cold = false);
void clear_irqs(uint16_t mask = 0xFFFF);
// Returns 0 if the read was skipped or failed.
uint16_t read_irq();
void write_fifo(const uint8_t *data, size_t size);
// Leaves the packet unchanged if a read fails.
void read_packet(uint8_t modulation, std::vector<uint8_t> &packet, float &rssi, float &snr);
static uint32_t bandwidth_hz(SX126xBw bw);
static uint16_t ramp_us(uint8_t ramp);
private:
void read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size);
void write_opcode_(uint8_t opcode, const uint8_t *data, uint8_t size);
void read_register_(uint16_t reg, uint8_t *data, uint8_t size);
void write_register_(uint16_t reg, const uint8_t *data, uint8_t size);
static void encode_packet_params(const RadioProfile &profile, AppliedProfile &applied);
SX126xSpiDevice spi_device_;
};
} // namespace esphome::sx126x
-253
View File
@@ -1,253 +0,0 @@
#include "sx126x_radio.h"
#include "esphome/core/log.h"
#include <utility>
namespace esphome::sx126x {
// No ESP_LOG calls in this file: log listeners are user code, see sx126x_radio.h.
static constexpr uint32_t RESET_DELAY_HIGH_US = 5000;
static constexpr uint32_t RESET_DELAY_LOW_US = 2000;
static constexpr uint32_t RECEIVE_RETRY_INTERVAL_MS = 200;
RadioEffects SX126xRadio::comm_report(bool ok) {
RadioEffects effects;
effects.report = true;
effects.warning = ok ? nullptr : LOG_STR("Wait busy timeout");
return effects;
}
RadioEffects SX126xRadio::configure(const RadioProfile &requested, std::span<char, 16> version) {
ESPHOME_DEBUG_ASSERT(this->can_configure());
this->busy_ = true;
this->rx_retry_pending_ = false;
this->pending_irq_ = 0;
RadioEffects effects;
effects.report = true;
effects.warning = this->apply_configuration_(requested, version);
this->commit_(effects.warning == nullptr ? RadioState::RADIO_STATE_IDLE
: RadioState::RADIO_STATE_NEEDS_CONFIGURATION);
return effects;
}
const LogString *SX126xRadio::apply_configuration_(const RadioProfile &requested, std::span<char, 16> version) {
if (!requested.sync_valid) {
return LOG_STR("Invalid sync value");
}
this->rst_pin_->digital_write(true);
delayMicroseconds(RESET_DELAY_HIGH_US);
this->rst_pin_->digital_write(false);
delayMicroseconds(RESET_DELAY_LOW_US);
this->rst_pin_->digital_write(true);
delayMicroseconds(RESET_DELAY_HIGH_US);
this->bus().begin();
AppliedProfile applied;
switch (this->commands_.configure(requested, version, applied)) {
case ConfigurationResult::CONFIGURATION_RESULT_OK:
this->applied_ = applied;
return nullptr;
case ConfigurationResult::CONFIGURATION_RESULT_UNSUPPORTED:
return LOG_STR("Unsupported chip version");
default:
return LOG_STR("Wait busy timeout");
}
}
RadioEffects SX126xRadio::change_mode(ModeCommand command, uint8_t arg) {
ESPHOME_DEBUG_ASSERT(this->can_change_mode());
this->busy_ = true;
this->bus().begin();
switch (command) {
case ModeCommand::MODE_COMMAND_RX:
this->commands_.receive();
break;
case ModeCommand::MODE_COMMAND_TX:
this->commands_.transmit();
break;
case ModeCommand::MODE_COMMAND_STANDBY:
this->commands_.standby(static_cast<SX126xStandbyMode>(arg));
break;
case ModeCommand::MODE_COMMAND_SLEEP:
case ModeCommand::MODE_COMMAND_COLD_SLEEP:
this->commands_.sleep(command == ModeCommand::MODE_COMMAND_COLD_SLEEP);
break;
case ModeCommand::MODE_COMMAND_IMAGE_CAL:
this->commands_.calibrate_image(this->applied_.frequency);
break;
}
const bool ok = this->bus().ok();
// Cold sleep loses the configuration, and a failed command leaves the chip state unknown.
this->commit_(ok && command != ModeCommand::MODE_COMMAND_COLD_SLEEP ? RadioState::RADIO_STATE_IDLE
: RadioState::RADIO_STATE_NEEDS_CONFIGURATION);
return comm_report(ok);
}
RadioEffects SX126xRadio::start_transmit(SX126xTransmitListener *listener, const uint8_t *data, size_t len,
uint32_t timeout_ms) {
RadioEffects effects;
if (this->state_ == RadioState::RADIO_STATE_NEEDS_CONFIGURATION) {
effects.result = SX126xError::SX126X_ERROR_NOT_READY;
return effects;
}
if (this->busy_ || this->state_ != RadioState::RADIO_STATE_IDLE) {
effects.result = SX126xError::SX126X_ERROR_BUSY;
return effects;
}
const uint8_t fixed_length = this->applied_.payload_length;
if (data == nullptr || len == 0 || len > this->max_packet_size() || (fixed_length != 0 && len != fixed_length)) {
effects.result = SX126xError::SX126X_ERROR_INVALID_PARAMS;
return effects;
}
this->busy_ = true;
this->bus().begin();
this->commands_.standby(STDBY_XOSC);
// RX cannot advance in standby. Keep a packet that completed before standby intact for
// poll(), before clearing IRQs or overwriting the shared FIFO. A failed read returns 0.
// A packet that failed its CRC is not delivered, so it does not hold up the send.
const uint16_t irq = this->commands_.read_irq() | this->pending_irq_;
if ((irq & IRQ_RX_DONE) && !(irq & IRQ_CRC_ERROR)) {
this->commit_(RadioState::RADIO_STATE_HOLDING_RX);
effects.result = SX126xError::SX126X_ERROR_BUSY;
return effects;
}
this->commands_.clear_irqs();
this->pending_irq_ = 0;
if (fixed_length == 0) {
this->commands_.set_packet_length(this->applied_, len);
}
this->commands_.write_fifo(data, len);
this->commands_.transmit(timeout_ms);
if (!this->bus().ok()) {
const bool restored = this->restore_();
this->commit_(restored ? RadioState::RADIO_STATE_IDLE : RadioState::RADIO_STATE_NEEDS_CONFIGURATION);
effects = comm_report(restored);
effects.result = SX126xError::SX126X_ERROR_BUSY_TIMEOUT;
return effects;
}
this->tx_ = {listener, millis(), timeout_ms};
this->commit_(RadioState::RADIO_STATE_TRANSMITTING);
// No status report on success, so no user code runs before the caller has the result.
// The completion reports status instead.
return effects;
}
RadioEffects SX126xRadio::poll_transmit(uint32_t now) {
if (this->busy_ || !this->is_transmitting()) {
return {};
}
bool done = false;
SX126xError outcome = SX126xError::SX126X_ERROR_NONE;
if (this->dio1_pin_->digital_read()) {
this->busy_ = true;
this->bus().begin();
const uint16_t status = this->commands_.read_irq();
this->commands_.clear_irqs(status);
this->busy_ = false;
if (!this->bus().ok()) {
outcome = SX126xError::SX126X_ERROR_BUSY_TIMEOUT;
done = true;
} else if (status & IRQ_RX_TX_TIMEOUT) {
outcome = SX126xError::SX126X_ERROR_TIMEOUT;
done = true;
} else if (status & IRQ_TX_DONE) {
done = true;
}
}
// A cached loop tick can precede admission; signed subtraction also handles wraparound.
if (!done && static_cast<int32_t>(now - this->tx_.started_ms) >= static_cast<int32_t>(this->tx_.timeout_ms)) {
outcome = SX126xError::SX126X_ERROR_TIMEOUT;
done = true;
}
return done ? this->finish_transmit_(outcome) : RadioEffects{};
}
RadioEffects SX126xRadio::cancel_transmit(SX126xTransmitListener *listener) {
if (this->busy_ || !this->is_transmitting() || listener == nullptr || listener != this->tx_.listener) {
return {};
}
return this->finish_transmit_(SX126xError::SX126X_ERROR_CANCELLED);
}
// The only place a send ends.
RadioEffects SX126xRadio::finish_transmit_(SX126xError result) {
this->busy_ = true;
const bool restored = this->restore_();
this->commit_(restored ? RadioState::RADIO_STATE_IDLE : RadioState::RADIO_STATE_NEEDS_CONFIGURATION);
RadioEffects effects = comm_report(restored);
effects.result = restored ? result : SX126xError::SX126X_ERROR_BUSY_TIMEOUT;
effects.listener = std::exchange(this->tx_.listener, nullptr);
effects.finished = true;
return effects;
}
bool SX126xRadio::restore_() {
this->bus().begin();
this->commands_.standby(STDBY_XOSC);
this->commands_.clear_irqs();
if (this->applied_.payload_length == 0) {
this->commands_.set_packet_length(this->applied_, this->max_packet_size());
}
if (this->applied_.rx_start) {
this->commands_.receive();
} else {
this->commands_.sleep();
}
return this->bus().ok();
}
RadioEffects SX126xRadio::poll(uint32_t now, std::vector<uint8_t> &packet) {
if (this->busy_) {
return {};
}
if (this->is_transmitting()) {
return this->poll_transmit(now);
}
RadioEffects effects;
if (this->state_ == RadioState::RADIO_STATE_NEEDS_CONFIGURATION) {
effects.sleep_loop = true; // Polling cannot recover the radio; configure() can.
return effects;
}
if (this->rx_retry_pending_ && now - this->rx_retry_started_ms_ < RECEIVE_RETRY_INTERVAL_MS) {
return effects;
}
const bool holding = this->state_ == RadioState::RADIO_STATE_HOLDING_RX;
if (!holding && this->pending_irq_ == 0 && !this->dio1_pin_->digital_read()) {
effects.sleep_loop = this->dio1_pin_->is_internal(); // Its interrupt wakes the loop.
return effects;
}
return this->receive_(holding, now, packet);
}
RadioEffects SX126xRadio::receive_(bool holding, uint32_t now, std::vector<uint8_t> &packet) {
this->busy_ = true;
this->bus().begin();
// A packet whose read failed has had its flags cleared, so they are kept until it is read.
const uint16_t status = this->commands_.read_irq() | this->pending_irq_;
const bool received = (status & IRQ_RX_DONE) && !(status & IRQ_CRC_ERROR);
if (!holding) {
// Cleared before the read, so a packet that completes during the read raises DIO1 again.
// A held packet keeps its IRQ until restoration.
this->commands_.clear_irqs(status);
}
float rssi = 0, snr = 0;
if (received) {
this->commands_.read_packet(this->applied_.modulation, packet, rssi, snr);
}
const bool read = this->bus().ok();
this->pending_irq_ = read ? 0 : status;
// Do not spend a BUSY timeout on every loop while a read keeps failing.
this->rx_retry_pending_ = !read;
this->rx_retry_started_ms_ = now;
RadioState next = this->state_;
if (read && holding) {
next = this->restore_() ? RadioState::RADIO_STATE_IDLE : RadioState::RADIO_STATE_NEEDS_CONFIGURATION;
}
this->commit_(next);
RadioEffects effects = comm_report(read && next != RadioState::RADIO_STATE_NEEDS_CONFIGURATION);
effects.received = read && received;
effects.rssi = rssi;
effects.snr = snr;
return effects;
}
} // namespace esphome::sx126x
-149
View File
@@ -1,149 +0,0 @@
#pragma once
#include "radio_profile.h"
#include "sx126x_commands.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <span>
#include <vector>
namespace esphome::sx126x {
enum class SX126xError {
SX126X_ERROR_NONE = 0,
SX126X_ERROR_TIMEOUT,
SX126X_ERROR_INVALID_PARAMS,
SX126X_ERROR_NOT_READY,
SX126X_ERROR_BUSY_TIMEOUT,
SX126X_ERROR_BUSY,
SX126X_ERROR_CANCELLED
};
class SX126xTransmitListener {
public:
virtual void on_transmit_complete(SX126xError result) = 0;
};
// Who may use the radio between operations. This is driver state, not the chip's mode.
enum class RadioState : uint8_t {
// Startup, cold sleep, or a failed configuration, mode command or restoration. Only
// configure() is accepted; sends return NOT_READY and the loop sleeps. SX126x configures
// again every second after a failure, and on the next mode command after cold sleep.
RADIO_STATE_NEEDS_CONFIGURATION,
// Available. The chip is receiving or sleeping, as configured, or after the TX mode command
// sending whatever the FIFO holds. That send is not tracked: a send or mode command may cut
// it short, and poll() clears its TX_DONE as a flag without a packet.
RADIO_STATE_IDLE,
// A send is on air. SPI is free between polls; only polling and cancellation are accepted.
RADIO_STATE_TRANSMITTING,
// Standby with a received packet left in the FIFO, found when a send entered standby.
// The send was rejected with BUSY; the next poll() delivers the packet and restores the
// radio. configure() may discard it to recover from repeated read failures.
RADIO_STATE_HOLDING_RX,
};
enum class ModeCommand : uint8_t {
MODE_COMMAND_RX,
MODE_COMMAND_TX,
MODE_COMMAND_STANDBY,
MODE_COMMAND_SLEEP,
MODE_COMMAND_COLD_SLEEP,
MODE_COMMAND_IMAGE_CAL,
};
// What a radio operation leaves for its caller to do. The radio has already committed its
// state; the caller reports status first, then calls the listener or delivers the packet.
struct RadioEffects {
SX126xError result{SX126xError::SX126X_ERROR_NONE};
// The completion to notify when finished is set; null for a blocking send.
SX126xTransmitListener *listener{nullptr};
// With report set: the warning to set, or null to clear it.
const LogString *warning{nullptr};
float rssi{0};
float snr{0};
bool report{false};
// A send ended, and result is its outcome.
bool finished{false};
// The packet buffer holds a packet to deliver.
bool received{false};
// Nothing to poll until the next DIO1 edge or configuration.
bool sleep_loop{false};
};
// Owns the radio state and every SPI command sequence.
//
// Status reports, completions and packet listeners can run user code, which may call back
// into the driver. Every operation therefore does its SPI work with busy() set, commits the
// next state, and returns RadioEffects; SX126x::apply_() then reports status and calls the
// listeners. This class has no access to component status or listeners, so callbacks only
// ever see a committed state. Do not log here either: log listeners are user code. busy()
// covers re-entry during a sequence, for example from a GPIO pin that logs.
//
// Each sequence starts with bus().begin(); after a BUSY timeout the rest is skipped and
// bus().ok() is false. A failed send preparation restores the radio in a new sequence. A
// failed restoration needs configuration, and its result overrides the send result. A
// successful send start reports nothing, so no user code runs before the caller has the
// result. Every send ends in finish_transmit_(), exactly once.
//
// A failed receive read keeps the packet and retries after RECEIVE_RETRY_INTERVAL_MS, so a
// stuck BUSY line does not cost a 20 ms wait on every loop.
class SX126xRadio {
public:
SX126xRadio() = default;
SX126xRadio(const SX126xRadio &) = delete;
SX126xRadio &operator=(const SX126xRadio &) = delete;
SX126xSpiDevice &bus() { return this->commands_.bus(); }
void set_dio1_pin(GPIOPin *pin) { this->dio1_pin_ = pin; }
void set_rst_pin(GPIOPin *pin) { this->rst_pin_ = pin; }
GPIOPin *get_dio1_pin() const { return this->dio1_pin_; }
GPIOPin *get_rst_pin() const { return this->rst_pin_; }
RadioState state() const { return this->state_; }
bool busy() const { return this->busy_; }
bool is_transmitting() const { return this->state_ == RadioState::RADIO_STATE_TRANSMITTING; }
// A held packet may be discarded: explicit reconfiguration is the recovery path.
bool can_configure() const { return !this->busy_ && !this->is_transmitting(); }
bool can_change_mode() const { return !this->busy_ && this->state_ == RadioState::RADIO_STATE_IDLE; }
size_t max_packet_size() const { return this->applied_.max_packet_size(); }
// Requires can_configure(). Resets the chip and applies the requested profile.
RadioEffects configure(const RadioProfile &requested, std::span<char, 16> version);
// Requires can_change_mode(). Cold sleep and failed commands need configuration afterwards.
RadioEffects change_mode(ModeCommand command, uint8_t arg);
// Result SX126X_ERROR_NONE starts a send that finishes exactly once, through poll_transmit() or
// cancel_transmit(). Any other result owes no completion.
RadioEffects start_transmit(SX126xTransmitListener *listener, const uint8_t *data, size_t len, uint32_t timeout_ms);
// Sets finished only when it ended the current send.
RadioEffects poll_transmit(uint32_t now);
// Sets finished only if listener owns the current send.
RadioEffects cancel_transmit(SX126xTransmitListener *listener);
// One loop step: polls a send, or reads a received packet into packet.
RadioEffects poll(uint32_t now, std::vector<uint8_t> &packet);
private:
struct TxOperation {
SX126xTransmitListener *listener{nullptr};
uint32_t started_ms{0};
uint32_t timeout_ms{0};
};
void commit_(RadioState state) {
this->state_ = state;
this->busy_ = false;
}
static RadioEffects comm_report(bool ok);
const LogString *apply_configuration_(const RadioProfile &requested, std::span<char, 16> version);
bool restore_();
RadioEffects finish_transmit_(SX126xError result);
RadioEffects receive_(bool holding, uint32_t now, std::vector<uint8_t> &packet);
SX126xCommands commands_;
AppliedProfile applied_;
TxOperation tx_;
uint32_t rx_retry_started_ms_{0};
uint16_t pending_irq_{0}; // flags of a received packet whose read failed after they were cleared
GPIOPin *dio1_pin_{nullptr};
GPIOPin *rst_pin_{nullptr};
RadioState state_{RadioState::RADIO_STATE_NEEDS_CONFIGURATION};
bool busy_{false};
bool rx_retry_pending_{false};
};
} // namespace esphome::sx126x
@@ -1,45 +0,0 @@
#include "sx126x_spi_device.h"
#include "esphome/core/hal.h"
namespace esphome::sx126x {
static constexpr uint32_t BUSY_TIMEOUT_MS = 20;
static constexpr uint32_t SWITCHING_DELAY_US = 1;
void SX126xSpiDevice::exchange_(std::span<const uint8_t> header, std::span<const uint8_t> tx, std::span<uint8_t> rx) {
if (!this->ok_) {
return;
}
// Selecting first wakes a sleeping radio. The guard releases CS even on timeout.
Selection selected(this->device_);
const uint32_t start = millis();
while (this->busy_pin_->digital_read()) {
if (millis() - start > BUSY_TIMEOUT_MS) {
this->ok_ = false;
return;
}
}
for (auto byte : header) {
this->device_.transfer_byte(byte);
}
for (auto byte : tx) {
this->device_.transfer_byte(byte);
}
for (auto &byte : rx) {
byte = this->device_.transfer_byte(0);
}
}
void SX126xSpiDevice::write(std::span<const uint8_t> header, std::span<const uint8_t> data) {
this->exchange_(header, data, {});
if (this->ok_) {
// This delay belongs after CS is released, not in the selected interval.
delayMicroseconds(SWITCHING_DELAY_US);
}
}
void SX126xSpiDevice::read(std::span<const uint8_t> header, std::span<uint8_t> data) {
this->exchange_(header, {}, data);
}
} // namespace esphome::sx126x
@@ -1,63 +0,0 @@
#pragma once
#include "esphome/components/spi/spi.h"
#include <span>
namespace esphome::sx126x {
// Persistent radio device. read()/write() use exchange_() for one complete selected interval.
// No raw SPI access escapes. Call only from the main loop; spans are borrowed until return.
// Failures stick: after a BUSY timeout every exchange is skipped until the next begin(),
// so a command sequence checks ok() once instead of after every command.
class SX126xSpiDevice {
public:
SX126xSpiDevice() = default;
SX126xSpiDevice(const SX126xSpiDevice &) = delete;
SX126xSpiDevice &operator=(const SX126xSpiDevice &) = delete;
void set_spi_parent(spi::SPIComponent *parent) { this->device_.set_spi_parent(parent); }
void set_cs_pin(GPIOPin *pin) { this->device_.set_cs_pin(pin); }
void set_data_rate(uint32_t rate) { this->device_.set_data_rate(rate); }
void set_mode(spi::SPIMode mode) { this->device_.set_mode(mode); }
void set_bit_order(spi::SPIBitOrder order) { this->device_.set_bit_order(order); }
void set_release_device(bool release) { this->device_.set_release_device(release); }
void set_write_only(bool write_only) { this->device_.set_write_only(write_only); }
#ifdef USE_SPI_PSRAM_DMA
void set_psram_dma(bool enable) { this->device_.set_psram_dma(enable); }
#endif
void set_busy_pin(GPIOPin *pin) { this->busy_pin_ = pin; }
GPIOPin *get_busy_pin() const { return this->busy_pin_; }
GPIOPin *get_cs_pin() const { return this->device_.get_cs_pin(); }
void setup() { this->device_.spi_setup(); }
void teardown() { this->device_.spi_teardown(); }
// Starts a command sequence with no recorded failure.
void begin() { this->ok_ = true; }
// False once any exchange since begin() timed out waiting for BUSY.
bool ok() const { return this->ok_; }
void write(std::span<const uint8_t> header, std::span<const uint8_t> data);
// Leaves data unchanged when the exchange is skipped or times out.
void read(std::span<const uint8_t> header, std::span<uint8_t> data);
private:
class Device final : public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW,
spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_8MHZ> {
public:
GPIOPin *get_cs_pin() const { return this->cs_; }
};
class Selection {
public:
explicit Selection(Device &device) : device_(device) { this->device_.enable(); }
~Selection() { this->device_.disable(); }
Selection(const Selection &) = delete;
Selection &operator=(const Selection &) = delete;
private:
Device &device_;
};
void exchange_(std::span<const uint8_t> header, std::span<const uint8_t> tx, std::span<uint8_t> rx);
Device device_;
GPIOPin *busy_pin_{nullptr};
bool ok_{true};
};
} // namespace esphome::sx126x
+17 -1
View File
@@ -614,9 +614,25 @@ async def to_code(config: ConfigType) -> None:
def FILTER_SOURCE_FILES() -> list[str]:
"""Filter out web_server_v1.cpp when version is not 1."""
"""Filter out web_server_v1.cpp when version is not 1, the ESP32 event stream elsewhere, and the server on host."""
files_to_filter: list[str] = []
# The host has no web server; it builds only sse_chunk.cpp, for its unit tests
if CORE.is_host:
files_to_filter.extend(
(
"event_source.cpp",
"list_entities.cpp",
"web_server.cpp",
"web_server_v1.cpp",
)
)
return files_to_filter
# The /events implementation is ESP32 only
if not CORE.is_esp32:
files_to_filter.extend(("event_source.cpp", "sse_chunk.cpp"))
# web_server_v1.cpp is only needed when version is 1
config = CORE.config.get(DOMAIN, {})
if config.get(CONF_VERSION, 2) != 1:
@@ -0,0 +1,491 @@
#include "event_source.h"
#if defined(USE_ESP32) && defined(USE_WEBSERVER)
#include <algorithm>
#include <cstring>
#include <iterator>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "web_server.h"
namespace esphome::web_server {
ESPHOME_LOG_TAG(TAG, "web_server_idf");
// Only prepare_json_() may hold the JSON arena: every other frame in this file is capped below one
// arena. Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it.
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic error "-Wstack-usage=2048"
#endif
AsyncEventSource::~AsyncEventSource() {
LockGuard guard{this->pending_mutex_};
for (auto *vec : {&this->sessions_, &this->pending_sessions_}) {
for (auto *ses : *vec) {
delete ses; // NOLINT(cppcoreguidelines-owning-memory)
}
}
}
void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) {
// Httpd task: set up the live httpd_req_t and park the session; main loop does the rest.
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory,clang-analyzer-cplusplus.NewDeleteLeaks)
auto *rsp = new AsyncEventSourceResponse(request, this, this->web_server_);
{
LockGuard guard{this->pending_mutex_};
this->pending_sessions_.push_back(rsp);
this->has_pending_sessions_.store(true, std::memory_order_release);
}
this->web_server_->enable_loop_soon_any_context();
}
// clang-analyzer traces a false-positive leak path from loop() through
// adopt_pending_sessions_main_loop_() into start_session_main_loop_() and
// finally ArduinoJson. Suppress along the entire in-our-code call chain.
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks)
bool AsyncEventSource::loop() {
// Fast path: one atomic load per tick. Slow path is out-of-line on connect.
if (this->has_pending_sessions_.load(std::memory_order_acquire)) {
this->adopt_pending_sessions_main_loop_();
}
// Clean up dead sessions safely
// This follows the ESP-IDF pattern where free_ctx marks resources as dead
// and the main loop handles the actual cleanup to avoid race conditions
for (size_t i = 0; i < this->sessions_.size();) {
auto *ses = this->sessions_[i];
// If the session has a dead socket (marked by destroy callback)
if (ses->safe_to_delete()) {
// destroy() already logged the close with the fd; don't double-log here.
delete ses; // NOLINT(cppcoreguidelines-owning-memory)
// Remove by swapping with last element (O(1) removal, order doesn't matter for sessions)
this->sessions_[i] = this->sessions_.back();
this->sessions_.pop_back();
} else {
ses->loop();
++i;
}
}
// A session still waiting for httpd to commit its context keeps the loop alive too
return !this->sessions_.empty() || this->has_pending_sessions_.load(std::memory_order_acquire);
}
void AsyncEventSource::adopt_pending_sessions_main_loop_() {
std::vector<AsyncEventSourceResponse *> incoming;
{
LockGuard guard{this->pending_mutex_};
incoming.swap(this->pending_sessions_);
this->has_pending_sessions_.store(false, std::memory_order_relaxed);
}
for (auto *rsp : incoming) {
// Already disconnected? Drop it; skip session start on a dead session.
if (rsp->safe_to_delete()) {
delete rsp; // NOLINT(cppcoreguidelines-owning-memory)
continue;
}
// httpd commits the session context only after the creating handler returns, so stay
// pending until then; httpd_req_cleanup() always commits it or calls destroy()
if (!rsp->is_attached()) {
LockGuard guard{this->pending_mutex_};
this->pending_sessions_.push_back(rsp);
this->has_pending_sessions_.store(true, std::memory_order_release);
continue;
}
this->sessions_.push_back(rsp);
rsp->start_session_main_loop_();
}
}
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
void AsyncEventSource::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id,
uint32_t reconnect) {
for (auto *ses : this->sessions_) {
if (ses->is_connected()) {
ses->try_send_nodefer(message, message_len, event, id, reconnect);
}
}
}
void AsyncEventSource::deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator) {
// Skip if no connected clients to avoid unnecessary processing
if (this->empty())
return;
for (auto *ses : this->sessions_) {
if (ses->is_connected()) {
ses->deferrable_send_state(source, detail_all, message_generator);
}
}
}
AsyncEventSourceResponse::AsyncEventSourceResponse(AsyncWebServerRequest *request, AsyncEventSource *server,
WebServer *ws)
: server_(server), web_server_(ws), entities_iterator_(ws, this) {
// Httpd task only. start_session_main_loop_() sends the greeting and starts the iterator.
this->begin(request, SSE_STATUS, SSE_CONTENT_TYPE, SSE_HEADERS, SSE_FIRST_CHUNK);
}
static_assert(web_server_base::stream_head_iov_count(std::size(SSE_HEADERS) + WEB_SERVER_DEFAULT_HEADERS_COUNT) <=
web_server_base::STREAM_HEAD_MAX_IOV,
"the event stream head must go out in one write");
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
void AsyncEventSourceResponse::start_session_main_loop_() {
auto *ws = this->web_server_;
// The tcp send buffer is empty on connect. A refusal is a closing session or a failed tail
// allocation; nothing retries the greeting, so close and let the client reconnect.
auto message = ws->get_config_json();
if (!this->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000)) {
ESP_LOGW(TAG, "Config not sent to fd %d", this->log_id());
this->close();
return;
}
#ifdef USE_WEBSERVER_SORTING
for (auto &group : ws->sorting_groups_) {
json::JsonBuilder builder;
JsonObject root = builder.root();
root["name"] = json::linked(group.second.name);
root["sorting_weight"] = group.second.weight;
message = builder.serialize();
// a (very) large number of these should be able to be queued initially without defer
// since the only thing in the send buffer at this point is the initial ping/config.
// A refusal means the socket is full or closing; the remaining groups are not sent. The
// session stays up on purpose: partial grouping beats a reconnect loop on a slow link.
if (!this->try_send_nodefer(message.c_str(), message.size(), "sorting_group")) {
ESP_LOGW(TAG, "Sorting groups not sent to fd %d", this->log_id());
break;
}
}
#endif
this->entities_iterator_.begin(ws->include_internal_);
}
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
// helper for allowing only unique entries in the queue
void AsyncEventSourceResponse::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) {
DeferredEvent item(source, message_generator);
// Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size
for (auto &event : this->deferred_queue_) {
if (event == item) {
return; // Already in queue, no need to update since items are equal
}
}
this->deferred_queue_.push_back(item);
}
void AsyncEventSourceResponse::process_deferred_queue_() {
if (this->is_closing()) {
return;
}
while (!deferred_queue_.empty()) {
DeferredEvent &de = deferred_queue_.front();
if (this->send_json_(de.source_, de.message_generator_)) {
if (this->is_closing() || deferred_queue_.empty()) {
return;
}
// O(n) but memory efficiency is more important than speed here which is why std::vector was chosen
deferred_queue_.erase(deferred_queue_.begin());
} else {
break;
}
}
}
void AsyncEventSourceResponse::release_buffers_() {
std::vector<DeferredEvent>().swap(this->deferred_queue_);
this->tail_.reset();
this->tail_cap_ = 0;
this->tail_len_ = 0;
this->tail_sent_ = 0;
}
void AsyncEventSourceResponse::drain_tail_() {
if (this->sending_ || this->is_closing() || this->tail_len_ == 0) {
return;
}
SendGuard guard{*this};
const size_t remaining = this->tail_len_ - this->tail_sent_;
const ssize_t sent = this->write(this->tail_.get() + this->tail_sent_, remaining);
if (sent <= 0) {
return; // write() owns the stall clock and the close
}
this->no_memory_logged_ = false;
this->tail_sent_ += sent;
if (this->tail_sent_ < this->tail_len_) {
ESP_LOGV(TAG, "Partial send: %zd/%zu bytes (total: %u/%u)", sent, remaining, this->tail_sent_, this->tail_len_);
return;
}
// Fully sent; the storage stays for the next stall
this->tail_len_ = 0;
}
bool AsyncEventSourceResponse::reserve_tail_(size_t len) {
if (this->tail_cap_ >= len) {
return true;
}
if (len > TAIL_MAX_SIZE) {
return false;
}
// Nothing is pending while the tail grows, so free the old block first. PREFER_INTERNAL keeps
// the tail where plain new put it.
this->tail_.reset();
this->tail_ = RAMAllocator<uint8_t>(RAMAllocator<uint8_t>::PREFER_INTERNAL).make_unique_array_for_overwrite(len);
this->tail_cap_ = this->tail_ ? len : 0;
return this->tail_cap_ != 0;
}
bool AsyncEventSourceResponse::stash_chunk_(const char *prefix, size_t prefix_len, const char *message,
size_t message_len, size_t total, size_t sent) {
// A log event of nothing but line breaks is the largest chunk; reserve_tail_() must never
// refuse it for size, only for memory
static_assert(SSE_SEP_LEN * LOG_EVENT_MAX_LEN + PREFIX_BUF_SIZE + SSE_SUFFIX_LEN <= TAIL_MAX_SIZE,
"the log cut in web_server.h must keep a worst case log event inside the tail ceiling");
if (!this->reserve_tail_(total)) {
if (sent != 0) {
// Part of the chunk is on the wire, so the stream is broken and the client has to go
ESP_LOGW(TAG, "Cannot buffer a %zu byte chunk, closing", total);
this->close();
} else {
this->tail_alloc_failed_(total); // nothing on the wire, the caller retries on the stall clock
}
return false;
}
uint8_t *dst = this->tail_.get();
std::memcpy(dst, prefix, prefix_len);
dst += prefix_len;
for_each_chunk_piece(
message, message_len,
[](void *ctx, const char *piece, size_t len) {
auto &out = *static_cast<uint8_t **>(ctx);
std::memcpy(out, piece, len);
out += len;
},
&dst);
this->tail_len_ = total;
this->tail_sent_ = sent;
return true;
}
void AsyncEventSourceResponse::loop() {
AsyncStreamResponse::loop();
if (this->is_closing()) {
if (this->tail_cap_ != 0 || this->deferred_queue_.capacity() != 0) {
this->release_buffers_();
}
return;
}
drain_tail_();
process_deferred_queue_();
if (this->is_closing())
return;
// One step per loop; refusals retry next pass
this->entities_iterator_.try_advance(1);
}
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the JSON arena
#endif
AsyncEventSourceResponse::JsonPrepareResult AsyncEventSourceResponse::prepare_json_(void *source,
message_generator_t *generator,
char *buffer, size_t *size_out) {
// Log lines emitted while the generator runs are dropped for this session: on the large path they would fill
// tail_ before it is overwritten, and on the small path they would go out ahead of the event being built.
SendGuard guard{*this};
json::JsonArena<json::JSON_ARENA_SIZE> arena;
json::JsonBuilder builder(&arena);
generator(this->web_server_, source, builder);
*size_out = builder.serialize_to(buffer, JSON_BUF_SIZE);
if (*size_out < JSON_BUF_SIZE) {
return JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER;
}
char prefix[PREFIX_BUF_SIZE];
const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true);
// Nothing reaches the wire until the complete document and its framing are owned by the caller.
size_t json_len = 0;
size_t cap = std::max<size_t>(JSON_BUF_SIZE * 2, this->tail_cap_);
for (;;) {
if (!this->reserve_tail_(cap)) {
*size_out = cap;
return JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY;
}
const size_t room = cap - prefix_len - SSE_SUFFIX_LEN;
json_len = builder.serialize_to(reinterpret_cast<char *>(this->tail_.get()) + prefix_len, room);
if (json_len < room) {
break;
}
if (cap >= TAIL_MAX_SIZE) {
*size_out = room;
return JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE;
}
cap = std::min<size_t>(cap * 2, TAIL_MAX_SIZE);
}
const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN;
write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN);
uint8_t *dst = this->tail_.get();
std::memcpy(dst, prefix, prefix_len);
std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN);
this->tail_len_ = total;
this->tail_sent_ = 0;
return JsonPrepareResult::JSON_PREPARE_RESULT_TAIL;
}
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic pop
#endif
bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) {
if (!this->ready_to_send_()) {
return false;
}
char buffer[JSON_BUF_SIZE];
size_t size;
const auto result = this->prepare_json_(source, generator, buffer, &size);
switch (result) {
case JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER:
return this->try_send_nodefer(buffer, size, "state");
case JsonPrepareResult::JSON_PREPARE_RESULT_TAIL:
this->drain_tail_();
return true;
case JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY: {
SendGuard guard{*this};
this->tail_alloc_failed_(size);
return false;
}
case JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE: {
SendGuard guard{*this};
ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", size);
this->tail_.reset();
this->tail_cap_ = 0;
this->stall_.clear();
this->no_memory_logged_ = false;
return true;
}
}
return false;
}
void AsyncEventSourceResponse::tail_alloc_failed_(size_t cap) {
// Same stall clock as a socket that stops draining, so a session cannot retry forever. A refused write may
// already have started it, so the warning keeps its own flag.
if (!this->no_memory_logged_) {
this->no_memory_logged_ = true;
ESP_LOGW(TAG, "No memory for a %zu byte chunk", cap);
}
const uint32_t now = App.get_loop_component_start_time();
if (!this->stall_.is_running()) {
this->stall_.start(now);
return;
}
if (this->stall_.is_expired(now, SEND_STALL_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Closing EventSource after %" PRIu32 " ms without memory", this->stall_.elapsed(now));
this->close();
}
}
bool AsyncEventSourceResponse::ready_to_send_() {
if (this->sending_ || !this->is_connected() || this->is_closing()) {
return false;
}
drain_tail_();
return !this->is_closing() && this->tail_len_ == 0;
}
bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id,
uint32_t reconnect) {
if (!this->ready_to_send_()) {
return false;
}
SendGuard guard{*this};
// Everything after the prefix goes out straight from the caller's buffer
char prefix[PREFIX_BUF_SIZE];
const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), event, id, reconnect, message != nullptr);
if (message == nullptr && prefix_len == CHUNK_HDR_LEN) {
return true; // Match ESPAsyncWebServer: nothing to send
}
if (prefix_len >= PREFIX_BUF_SIZE - 1) {
// The appenders truncate silently, which would put a malformed event on the wire
ESP_LOGW(TAG, "Event name too long, dropped");
return true;
}
// Gather list: the prefix, then the data lines and their separators from the message
struct Gather {
web_server_base::StreamIovec iov[MAX_SEND_IOV]; // left uninitialized on purpose
int iovcnt{1};
size_t total{0};
bool fits{true};
} g;
g.total = prefix_len;
for_each_chunk_piece(
message, message_len,
[](void *ctx, const char *piece, size_t len) {
auto &g = *static_cast<Gather *>(ctx);
g.total += len;
if (len == 0) {
return;
}
if (g.iovcnt == MAX_SEND_IOV) {
g.fits = false;
return;
}
g.iov[g.iovcnt++] = {const_cast<char *>(piece), len};
},
&g);
// The header and the terminator are not part of the chunk length
write_chunk_header(prefix, g.total - CHUNK_HDR_LEN - CHUNK_END_LEN);
g.iov[0] = {prefix, prefix_len};
// A message with more lines than the list holds skips straight to the tail
const ssize_t sent = g.fits ? this->write(g.iov, g.iovcnt) : 0;
if (sent < 0) {
return false;
}
if (sent > 0) {
this->no_memory_logged_ = false;
}
if (static_cast<size_t>(sent) == g.total) {
return true;
}
// The caller's buffers do not outlive this call, so keep the chunk and continue from loop()
return this->stash_chunk_(prefix, prefix_len, message, message_len, g.total, sent);
}
void AsyncEventSourceResponse::deferrable_send_state(void *source, bool detail_all,
message_generator_t *message_generator) {
// Let the detail_all listing finish before bare state events, so the web UI never shows unnamed entries and
// initial connect sends less
if (!detail_all && !this->entities_iterator_.completed())
return;
drain_tail_();
process_deferred_queue_();
if (this->is_closing()) {
return;
}
if (this->tail_len_ != 0 || !deferred_queue_.empty()) {
// outgoing event buffer or deferred queue still not empty which means downstream tcp send buffer full, no point
// trying to send first
deq_push_back_with_dedup_(source, message_generator);
} else {
// A send error closes the session and clears the queue; nothing is queued after that
if (!this->send_json_(source, message_generator) && !this->is_closing()) {
deq_push_back_with_dedup_(source, message_generator);
}
}
}
} // namespace esphome::web_server
#endif // USE_ESP32 && USE_WEBSERVER
@@ -0,0 +1,176 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_WEBSERVER)
#include "esphome/components/json/json_util.h"
#include "esphome/components/web_server_base/stream_response.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include "list_entities.h"
#include "sse_chunk.h"
#include <atomic>
#include <cstdint>
#include <vector>
namespace esphome::web_server {
class WebServer;
class AsyncEventSource;
class AsyncEventSourceResponse;
using message_generator_t = void(WebServer *, void *, json::JsonBuilder &);
/*
This class holds a pointer to the source component that wants to publish a state event, and a pointer to a function
that will lazily generate that event. The two pointers allow dedup in the deferred queue if multiple publishes for
the same component are backed up, and take up only two pointers of memory. The entry in the deferred queue (a
std::vector) is the DeferredEvent instance itself (not a pointer to one elsewhere in heap) so still only two pointers
per entry (and no heap fragmentation). Even 100 backed up events (you'd have to have at least 100 sensors publishing
because of dedup) would take up only 0.8 kB.
*/
struct DeferredEvent {
friend class AsyncEventSourceResponse;
protected:
void *source_;
message_generator_t *message_generator_;
public:
DeferredEvent(void *source, message_generator_t *message_generator)
: source_(source), message_generator_(message_generator) {}
bool operator==(const DeferredEvent &test) const {
return (source_ == test.source_ && message_generator_ == test.message_generator_);
}
};
static_assert(sizeof(DeferredEvent) == sizeof(void *) + sizeof(message_generator_t *),
"DeferredEvent should have no padding");
class AsyncEventSourceResponse : public web_server_base::AsyncStreamResponse {
friend class AsyncEventSource;
public:
bool try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0,
uint32_t reconnect = 0);
// detail_all events go out while entities are still being listed; state events wait until listing is done
void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator);
// The old event name argument would otherwise convert to true
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete;
void loop();
protected:
AsyncEventSourceResponse(AsyncWebServerRequest *request, AsyncEventSource *server, WebServer *ws);
// Main-loop only: sends initial ping/config/sorting_groups, starts entity iterator.
void start_session_main_loop_();
void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator);
void process_deferred_queue_();
// A new chunk may go out: not re-entered from a log line, session alive, tail empty
bool ready_to_send_();
// Push what is left of the chunk in tail_ to the socket
void drain_tail_();
// Grow tail_ to hold len bytes, kept at its high-water mark. False on OOM.
bool reserve_tail_(size_t len);
// Keep the whole chunk in tail_ and continue from sent; false when the tail cannot be allocated.
bool stash_chunk_(const char *prefix, size_t prefix_len, const char *message, size_t message_len, size_t total,
size_t sent);
// Send a state event; JSON too large for the stack buffer is serialized into tail_ instead
bool send_json_(void *source, message_generator_t *generator);
enum class JsonPrepareResult : uint8_t {
JSON_PREPARE_RESULT_BUFFER,
JSON_PREPARE_RESULT_TAIL,
JSON_PREPARE_RESULT_NO_MEMORY,
JSON_PREPARE_RESULT_TOO_LARGE,
};
// Keep the arena out of the caller's frame so it is gone before the state send.
// size_out: BUFFER = JSON length, NO_MEMORY = requested tail capacity, TOO_LARGE = available JSON room;
// unused for TAIL.
JsonPrepareResult __attribute__((noinline)) prepare_json_(void *source, message_generator_t *generator, char *buffer,
size_t *size_out);
// Warn once per stall, and close the session once the stall timeout passes with no memory for the tail
void tail_alloc_failed_(size_t cap);
// A log line emitted inside a send re-enters try_send_nodefer on this session; refuse it
struct SendGuard {
AsyncEventSourceResponse &owner;
explicit SendGuard(AsyncEventSourceResponse &owner) : owner(owner) { owner.sending_ = true; }
~SendGuard() { this->owner.sending_ = false; }
};
// Frees the tail and the queue's storage once the session is closing
void release_buffers_();
AsyncEventSource *server_;
std::vector<DeferredEvent> deferred_queue_;
WebServer *web_server_;
ListEntitiesIterator entities_iterator_;
// One chunk the socket did not take whole, allocated on the first stall; the only heap use
// on the send path
RAMUniquePtr<uint8_t[]> tail_;
uint16_t tail_cap_{0};
uint16_t tail_len_{0}; // Zero means nothing pending
uint16_t tail_sent_{0};
bool sending_{false};
// The no-memory warning went out during the current stall; cleared on send progress
bool no_memory_logged_{false};
// The longest multi line log message in the tree (a climate dump_config) has 22 lines; a
// longer one goes through the tail
static constexpr size_t MAX_SEND_LINES = 22;
static constexpr size_t MAX_SEND_IOV = 1 + 2 * MAX_SEND_LINES;
// Chunk header, retry/id/event lines and the first "data: "
static constexpr size_t PREFIX_BUF_SIZE = 128;
// Stack buffer for a state event's JSON; a larger document is serialized into the tail
static constexpr size_t JSON_BUF_SIZE = 1024;
// Same ceiling JsonBuilder::serialize() applies (max_heap_size in json_util.cpp); a larger
// document is dropped before anything is on the wire
static constexpr size_t JSON_MAX_SIZE = 5120;
// Most RAM a stalled session keeps: the largest state document plus any accepted framing
static constexpr size_t TAIL_MAX_SIZE = JSON_MAX_SIZE + PREFIX_BUF_SIZE + SSE_SUFFIX_LEN;
};
class AsyncEventSource : public AsyncWebHandler {
friend class AsyncEventSourceResponse;
public:
AsyncEventSource(StringRef url, WebServer *ws) : url_(url), web_server_(ws) {}
~AsyncEventSource() override;
// NOLINTNEXTLINE(readability-identifier-naming)
bool canHandle(AsyncWebServerRequest *request) const override {
if (request->method() != HTTP_GET)
return false;
char url_buf[AsyncWebServerRequest::URL_BUF_SIZE];
return request->url_to(url_buf) == this->url_;
}
// NOLINTNEXTLINE(readability-identifier-naming)
void handleRequest(AsyncWebServerRequest *request) override;
void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0,
uint32_t reconnect = 0);
void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator);
// The old event name argument would otherwise convert to true
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete;
/// Returns true if there are sessions remaining (including pending cleanup).
bool loop();
bool empty() { return this->count() == 0; }
size_t count() const { return this->sessions_.size(); }
protected:
// Cold path: move sessions from pending_sessions_ into sessions_ and greet each one.
void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_();
StringRef url_; // Must outlive this object (string literal)
// Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set.
std::vector<AsyncEventSourceResponse *> sessions_;
// Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_.
std::vector<AsyncEventSourceResponse *> pending_sessions_;
Mutex pending_mutex_;
WebServer *web_server_;
std::atomic<bool> has_pending_sessions_{false};
};
} // namespace esphome::web_server
#endif // USE_ESP32 && USE_WEBSERVER
@@ -8,14 +8,8 @@
namespace esphome::web_server {
#ifdef USE_ESP32
ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, AsyncEventSource *es) : web_server_(ws), events_(es) {}
#elif USE_ARDUINO
ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es)
: web_server_(ws), events_(es) {}
#endif
void ListEntitiesIterator::send_all_(void *source, message_generator_t *generator) {
this->events_->deferrable_send_state(source, true, generator);
this->session_->deferrable_send_state(source, true, generator);
}
#ifdef USE_BINARY_SENSOR
+9 -18
View File
@@ -1,33 +1,28 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_WEBSERVER
#if defined(USE_WEBSERVER) && (defined(USE_ESP32) || defined(USE_ARDUINO))
#include "esphome/core/component.h"
#include "esphome/core/component_iterator.h"
namespace esphome::json {
class JsonBuilder;
} // namespace esphome::json
namespace esphome::web_server_idf {
#ifdef USE_ESP32
class AsyncEventSource;
#endif
} // namespace esphome::web_server_idf
namespace esphome::web_server {
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
// The /events session the iterator lists entities to
#ifdef USE_ESP32
class AsyncEventSourceResponse;
using EventSession = AsyncEventSourceResponse;
#elif defined(USE_ARDUINO)
class DeferredUpdateEventSource;
using EventSession = DeferredUpdateEventSource;
#endif
class WebServer;
class ListEntitiesIterator final : public ComponentIterator {
public:
#ifdef USE_ESP32
ListEntitiesIterator(const WebServer *ws, esphome::web_server_idf::AsyncEventSource *es);
#elif defined(USE_ARDUINO)
ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es);
#endif
ListEntitiesIterator(const WebServer *ws, EventSession *session) : web_server_(ws), session_(session) {}
// Entity overrides (generated from entity_types.h).
// Implementations live in list_entities.cpp.
@@ -45,11 +40,7 @@ class ListEntitiesIterator final : public ComponentIterator {
void send_all_(void *source, void (*generator)(WebServer *, void *, json::JsonBuilder &));
const WebServer *web_server_;
#ifdef USE_ESP32
esphome::web_server_idf::AsyncEventSource *events_;
#elif USE_ARDUINO
DeferredUpdateEventSource *events_;
#endif
EventSession *session_;
};
} // namespace esphome::web_server
@@ -5,7 +5,7 @@
#include "esphome/core/helpers.h"
namespace esphome::web_server_idf {
namespace esphome::web_server {
void for_each_chunk_piece(const char *message, size_t message_len, ChunkPieceSink sink, void *ctx) {
if (message == nullptr) {
@@ -63,4 +63,4 @@ void write_chunk_header(char *buf, size_t chunk_len) {
buf[9] = '\n';
}
} // namespace esphome::web_server_idf
} // namespace esphome::web_server
@@ -3,7 +3,9 @@
#include <cstddef>
#include <cstdint>
namespace esphome::web_server_idf {
#include "esphome/components/web_server_base/stream_head.h"
namespace esphome::web_server {
// Wire framing of one Server-Sent-Events event inside the chunked /events response:
// "%08x\r\n" + fields + "data: " line ["\r\ndata: " line]... "\r\n" + "\r\n" + "\r\n"
@@ -22,6 +24,17 @@ constexpr size_t SSE_SEP_LEN = sizeof(SSE_SEP) - 1;
constexpr char SSE_SUFFIX[] = "\r\n\r\n\r\n";
constexpr size_t SSE_SUFFIX_LEN = sizeof(SSE_SUFFIX) - 1;
// Status, Content-Type and headers of the /events response head
constexpr char SSE_STATUS[] = "200 OK";
constexpr char SSE_CONTENT_TYPE[] = "text/event-stream";
constexpr web_server_base::HttpHeader SSE_HEADERS[] = {
{"Transfer-Encoding", "chunked"},
{"Cache-Control", "no-cache"},
{"Connection", "keep-alive"},
};
// Hand-framed first chunk holding an empty event (one blank line), as httpd_resp_send_chunk() sent it before
constexpr char SSE_FIRST_CHUNK[] = "2\r\n\r\n\r\n";
// Receives one piece of the chunk; ctx is whatever the caller passed to for_each_chunk_piece()
using ChunkPieceSink = void (*)(void *ctx, const char *piece, size_t len);
@@ -37,4 +50,4 @@ size_t build_chunk_prefix(char *buf, size_t size, const char *event, uint32_t id
// Fills the header placeholder at the start of buf once the chunk length is known
void write_chunk_header(char *buf, size_t chunk_len);
} // namespace esphome::web_server_idf
} // namespace esphome::web_server
+1 -1
View File
@@ -245,7 +245,7 @@ void DeferredUpdateEventSource::process_deferred_queue_() {
deferred_queue_.erase(deferred_queue_.begin());
this->consecutive_send_failures_ = 0; // Reset failure count on successful send
} else {
// NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::drain_tail_().
// NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncStreamResponse::write().
// The close mechanisms are platform-specific (this path calls close() directly; the IDF path is time-based and
// closes through HTTPD to preserve session ownership), but both drop a client after roughly 20 seconds without
// send progress. Keep that stall policy in sync when changing either side.
+5 -1
View File
@@ -5,7 +5,11 @@
#include "esphome/components/json/json_util.h"
#include "esphome/components/web_server_base/web_server_base.h"
#include "esphome/components/web_server_base/captive_dns.h"
#ifdef USE_WEBSERVER
// The host only builds sse_chunk.cpp for unit tests, but other tests can define USE_WEBSERVER there
#if defined(USE_WEBSERVER) && (defined(USE_ESP32) || defined(USE_ARDUINO))
#ifdef USE_ESP32
#include "event_source.h"
#endif
#include "esphome/core/component.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/entity_includes.h"
@@ -105,9 +105,9 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
cg.add(cg.RawExpression(f"{web_server_base_ns}::global_web_server_base = {var}"))
# Default headers added in init(): web_server_idf's StaticVector, or the library's DefaultHeaders
cg.add_define("WEB_SERVER_DEFAULT_HEADERS_COUNT", 1)
if CORE.is_esp32:
# Count for StaticVector in web_server_idf - matches headers added in init()
cg.add_define("WEB_SERVER_DEFAULT_HEADERS_COUNT", 1)
return
# ESP32 uses IDF web server (early return above), so this is for other Arduino platforms
@@ -0,0 +1,126 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <span>
#include <strings.h>
// Pure helpers of AsyncStreamResponse, kept free of httpd and lwIP so they build on the host.
// The iovec type is a template parameter: lwIP defines its own on the device, POSIX on the host.
namespace esphome::web_server_base {
struct HttpHeader {
const char *name;
const char *value;
};
/// Drops the first sent bytes from a gather list after a write; a short write resumes inside the
/// element it stopped in. count reaches 0 once everything went out.
template<typename Iovec> void advance_iovecs(Iovec *&iov, int &count, size_t sent) {
while (count > 0 && sent >= iov->iov_len) {
sent -= iov->iov_len;
++iov;
--count;
}
if (count > 0) {
iov->iov_base = static_cast<char *>(iov->iov_base) + sent;
iov->iov_len -= sent;
}
}
/// Pieces in a head: 4 for the status line and Content-Type, 4 per header, the blank line and the body
constexpr size_t stream_head_iov_count(size_t header_count) { return 4 + 4 * header_count + 2; }
/// Pieces per batch: a head with up to four headers goes out in one write; 192 bytes on 32-bit
static constexpr int STREAM_HEAD_MAX_IOV = 24;
/// True if headers holds a header named name, compared case insensitively
inline bool has_header(std::span<const HttpHeader> headers, const char *name) {
for (const auto &header : headers) {
if (strcasecmp(header.name, name) == 0) {
return true;
}
}
return false;
}
/// Gathers the head of a stream response: "HTTP/1.1 <status>", Content-Type, headers, the default_headers that
/// headers does not replace, the blank line and body (may be null). send(Iovec *iov, int count) gets each full batch of
/// STREAM_HEAD_MAX_IOV pieces and then the rest; once it returns false the later pieces are skipped. Returns false if
/// any send failed.
template<typename Iovec, typename Send>
bool write_stream_head(Send &&send, const char *status, const char *content_type, std::span<const HttpHeader> headers,
std::span<const HttpHeader> default_headers, const char *body) {
struct Batch {
Send &send;
int count{0};
bool ok{true};
Iovec iov[STREAM_HEAD_MAX_IOV]; // left uninitialized on purpose
void flush() {
if (this->ok && this->count > 0) {
this->ok = this->send(this->iov, this->count);
}
this->count = 0;
}
void add(const char *buf, size_t len) {
if (this->count == STREAM_HEAD_MAX_IOV) {
this->flush();
}
this->iov[this->count++] = {const_cast<char *>(buf), len};
}
void add(const char *str) { this->add(str, strlen(str)); }
void add_header(const HttpHeader &header) {
this->add("\r\n", 2);
this->add(header.name);
this->add(": ", 2);
this->add(header.value);
}
} batch{send};
batch.add("HTTP/1.1 ", 9);
batch.add(status);
batch.add("\r\nContent-Type: ", 16);
batch.add(content_type);
for (const auto &header : headers) {
batch.add_header(header);
}
for (const auto &header : default_headers) {
if (!has_header(headers, header.name)) {
batch.add_header(header);
}
}
batch.add("\r\n\r\n", 4);
if (body != nullptr) {
batch.add(body);
}
batch.flush();
return batch.ok;
}
/// How long sends have made no progress. Zero means not running, so a start at millis() == 0 records 1.
/// The comparison is wraparound safe across a millis() rollover.
class StallClock {
public:
/// Starts the clock; a running clock keeps its start time.
void start(uint32_t now) {
if (this->started_ms_ == 0) {
this->started_ms_ = now != 0 ? now : 1;
}
}
void clear() { this->started_ms_ = 0; }
bool is_running() const { return this->started_ms_ != 0; }
/// True once the clock has run for timeout_ms
bool is_expired(uint32_t now, uint32_t timeout_ms) const {
return this->started_ms_ != 0 && static_cast<int32_t>(now - (this->started_ms_ + timeout_ms)) >= 0;
}
uint32_t elapsed(uint32_t now) const { return now - this->started_ms_; }
protected:
uint32_t started_ms_{0};
};
} // namespace esphome::web_server_base
@@ -0,0 +1,16 @@
#pragma once
#include "esphome/core/defines.h"
// The long lived stream response the /events layer writes to, for the platform's web server
#ifdef USE_ESP32
#include "esphome/components/web_server_idf/web_server_idf.h"
#include <sys/socket.h>
namespace esphome::web_server_base {
using web_server_idf::AsyncStreamResponse;
// One entry of the gather list AsyncStreamResponse::write() takes
using StreamIovec = ::iovec;
} // namespace esphome::web_server_base
#endif
@@ -17,7 +17,6 @@
#include <freertos/task.h>
#include "utils.h"
#include "sse_chunk.h"
#include "web_server_idf.h"
#ifdef USE_WEBSERVER_AUTH_DIGEST
@@ -30,17 +29,15 @@
#include "multipart.h" // For parse_multipart_boundary and other utils
#endif
#ifdef USE_WEBSERVER
#include "esphome/components/web_server/web_server.h"
#include "esphome/components/web_server/list_entities.h"
#endif // USE_WEBSERVER
// Include socket headers after Arduino headers to avoid IPADDR_NONE/INADDR_NONE macro conflicts
#include <cerrno>
#include <sys/socket.h>
namespace esphome::web_server_idf {
using web_server_base::advance_iovecs;
using web_server_base::write_stream_head;
// Status strings not provided by esp_http_server.h
#ifndef HTTPD_401
#define HTTPD_401 "401 Unauthorized"
@@ -55,13 +52,10 @@ namespace esphome::web_server_idf {
#define HTTPD_422 "422 Unprocessable Entity"
#endif
#define CRLF_STR "\r\n"
#define CRLF_LEN (sizeof(CRLF_STR) - 1)
ESPHOME_LOG_TAG(TAG, "web_server_idf");
// Only prepare_json_() may hold the JSON arena: every other frame in this file is capped below one
// arena. Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it.
// Every frame in this file is capped below one JSON arena.
// Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it.
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic error "-Wstack-usage=2048"
#endif
@@ -140,6 +134,8 @@ void AsyncWebServer::safe_close_with_shutdown(httpd_handle_t hd, int sockfd) {
//
// See: https://github.com/esphome/esphome-webserver/issues/163
AsyncStreamResponse::socket_closing(hd, sockfd);
// Attempt shutdown - ignore errors as socket may already be closed
shutdown(sockfd, SHUT_RD);
@@ -736,224 +732,84 @@ void AsyncResponseStream::printf(const char *fmt, ...) {
this->print(str);
}
#ifdef USE_WEBSERVER
AsyncEventSource::~AsyncEventSource() {
LockGuard guard{this->pending_mutex_};
for (auto *vec : {&this->sessions_, &this->pending_sessions_}) {
for (auto *ses : *vec) {
delete ses; // NOLINT(cppcoreguidelines-owning-memory)
}
}
}
void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) {
// Httpd task: set up the live httpd_req_t and park the session; main loop does the rest.
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory,clang-analyzer-cplusplus.NewDeleteLeaks)
auto *rsp = new AsyncEventSourceResponse(request, this, this->web_server_);
{
LockGuard guard{this->pending_mutex_};
this->pending_sessions_.push_back(rsp);
this->has_pending_sessions_.store(true, std::memory_order_release);
}
this->web_server_->enable_loop_soon_any_context();
}
// clang-analyzer traces a false-positive leak path from loop() through
// adopt_pending_sessions_main_loop_() into start_session_main_loop_() and
// finally ArduinoJson. Suppress along the entire in-our-code call chain.
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks)
bool AsyncEventSource::loop() {
// Fast path: one atomic load per tick. Slow path is out-of-line on connect.
if (this->has_pending_sessions_.load(std::memory_order_acquire)) {
this->adopt_pending_sessions_main_loop_();
}
// Clean up dead sessions safely
// This follows the ESP-IDF pattern where free_ctx marks resources as dead
// and the main loop handles the actual cleanup to avoid race conditions
for (size_t i = 0; i < this->sessions_.size();) {
auto *ses = this->sessions_[i];
// If the session has a dead socket (marked by destroy callback)
if (ses->safe_to_delete_()) {
// destroy() already logged the close with the fd; don't double-log here.
delete ses; // NOLINT(cppcoreguidelines-owning-memory)
// Remove by swapping with last element (O(1) removal, order doesn't matter for sessions)
this->sessions_[i] = this->sessions_.back();
this->sessions_.pop_back();
} else {
ses->loop();
++i;
}
}
// A session still waiting for httpd to commit its context keeps the loop alive too
return !this->sessions_.empty() || this->has_pending_sessions_.load(std::memory_order_acquire);
}
void AsyncEventSource::adopt_pending_sessions_main_loop_() {
std::vector<AsyncEventSourceResponse *> incoming;
{
LockGuard guard{this->pending_mutex_};
incoming.swap(this->pending_sessions_);
this->has_pending_sessions_.store(false, std::memory_order_relaxed);
}
for (auto *rsp : incoming) {
// Already disconnected? Drop it; skip session start on a dead session.
if (rsp->safe_to_delete_()) {
delete rsp; // NOLINT(cppcoreguidelines-owning-memory)
continue;
}
// httpd commits the session context only after the creating handler returns, so stay
// pending until then; httpd_req_cleanup() always commits it or calls destroy()
if (httpd_sess_get_ctx(rsp->hd_, rsp->fd_.load()) != rsp) {
LockGuard guard{this->pending_mutex_};
this->pending_sessions_.push_back(rsp);
this->has_pending_sessions_.store(true, std::memory_order_release);
continue;
}
this->sessions_.push_back(rsp);
rsp->start_session_main_loop_();
}
}
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
void AsyncEventSource::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id,
uint32_t reconnect) {
for (auto *ses : this->sessions_) {
if (ses->fd_.load() != 0) { // Skip dead sessions
ses->try_send_nodefer(message, message_len, event, id, reconnect);
}
}
}
void AsyncEventSource::deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator) {
// Skip if no connected clients to avoid unnecessary processing
if (this->empty())
return;
for (auto *ses : this->sessions_) {
if (ses->fd_.load() != 0) { // Skip dead sessions
ses->deferrable_send_state(source, detail_all, message_generator);
}
}
}
AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *request,
esphome::web_server_idf::AsyncEventSource *server,
esphome::web_server::WebServer *ws)
: server_(server), web_server_(ws), entities_iterator_(ws, server) {
// Httpd task only. start_session_main_loop_() sends the greeting and starts the iterator.
void AsyncStreamResponse::begin(AsyncWebServerRequest *request, const char *status, const char *content_type,
std::span<const HttpHeader> headers, const char *body) {
httpd_req_t *req = *request;
httpd_resp_set_status(req, HTTPD_200);
httpd_resp_set_type(req, "text/event-stream");
httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
httpd_resp_set_hdr(req, "Connection", "keep-alive");
DefaultHeaders::Instance().apply_(req);
httpd_resp_send_chunk(req, CRLF_STR, CRLF_LEN);
// Same head httpd_resp_send_chunk() writes, without its first chunk. Blocking sends: the session's non-blocking
// send override is not installed yet.
const int fd = httpd_req_to_sockfd(req);
const auto &defaults = DefaultHeaders::Instance().headers_;
const bool head_sent = write_stream_head<struct iovec>(
[fd](struct iovec *iov, int count) {
while (count > 0) {
struct msghdr msg{};
msg.msg_iov = iov;
msg.msg_iovlen = count;
const ssize_t sent = sendmsg(fd, &msg, 0);
if (sent <= 0) {
return false;
}
advance_iovecs(iov, count, sent);
}
return true;
},
status, content_type, headers, std::span<const HttpHeader>(defaults.data(), defaults.size()), body);
req->sess_ctx = this;
req->free_ctx = AsyncEventSourceResponse::destroy;
req->free_ctx = AsyncStreamResponse::destroy;
this->hd_ = req->handle;
this->fd_.store(httpd_req_to_sockfd(req));
this->fd_.store(fd);
// Use non-blocking send to prevent watchdog timeouts when TCP buffers are full
httpd_sess_set_send_override(this->hd_, this->fd_.load(), nonblocking_send);
httpd_sess_set_send_override(this->hd_, fd, nonblocking_send);
if (!head_sent) {
// A partly sent head must not be continued by later writes. close() is main loop only, so shut the socket
// down here; the first write() then fails and closes the stream, and httpd frees the session as usual.
shutdown(fd, SHUT_RDWR);
}
}
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
void AsyncEventSourceResponse::start_session_main_loop_() {
auto *ws = this->web_server_;
// Held around a stream's ownership check and send, and while its socket is marked closed.
// Created at boot, so no allocation can fail once requests arrive.
static Mutex stream_send_mutex; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// The tcp send buffer is empty on connect. A refusal is a closing session or a failed tail
// allocation; nothing retries the greeting, so close and let the client reconnect.
auto message = ws->get_config_json();
if (!this->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000)) {
ESP_LOGW(TAG, "Config not sent to fd %d", this->fd_.load());
this->request_close_();
void AsyncStreamResponse::socket_closing(httpd_handle_t hd, int fd) {
// Only streams set a session context
auto *rsp = static_cast<AsyncStreamResponse *>(httpd_sess_get_ctx(hd, fd));
if (rsp == nullptr)
return;
}
#ifdef USE_WEBSERVER_SORTING
for (auto &group : ws->sorting_groups_) {
json::JsonBuilder builder;
JsonObject root = builder.root();
root["name"] = json::linked(group.second.name);
root["sorting_weight"] = group.second.weight;
message = builder.serialize();
// a (very) large number of these should be able to be queued initially without defer
// since the only thing in the send buffer at this point is the initial ping/config.
// A refusal means the socket is full or closing; the remaining groups are not sent. The
// session stays up on purpose: partial grouping beats a reconnect loop on a slow link.
if (!this->try_send_nodefer(message.c_str(), message.size(), "sorting_group")) {
ESP_LOGW(TAG, "Sorting groups not sent to fd %d", this->fd_.load());
break;
}
}
#endif
this->entities_iterator_.begin(ws->include_internal_);
LockGuard guard{stream_send_mutex};
rsp->socket_closed_ = true;
}
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
void AsyncEventSourceResponse::destroy(void *ptr) {
auto *rsp = static_cast<AsyncEventSourceResponse *>(ptr);
void AsyncStreamResponse::destroy(void *ptr) {
auto *rsp = static_cast<AsyncStreamResponse *>(ptr);
int fd = rsp->fd_.exchange(0); // Atomically get and clear fd
ESP_LOGD(TAG, "Event source connection closed (fd: %d)", fd);
// Mark as dead - will be cleaned up in the main loop
// Note: We don't delete or remove from set here to avoid race conditions
ESP_LOGD(TAG, "Stream connection closed (fd: %d)", fd);
// Mark as dead - the owner deletes it from the main loop once safe_to_delete().
// httpd will call our custom close_fn (safe_close_with_shutdown) which handles
// shutdown() before close() to prevent lwIP race conditions
}
// helper for allowing only unique entries in the queue
void AsyncEventSourceResponse::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) {
DeferredEvent item(source, message_generator);
// Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size
for (auto &event : this->deferred_queue_) {
if (event == item) {
return; // Already in queue, no need to update since items are equal
}
}
this->deferred_queue_.push_back(item);
}
void AsyncEventSourceResponse::process_deferred_queue_() {
if (this->close_requested_) {
return;
}
while (!deferred_queue_.empty()) {
DeferredEvent &de = deferred_queue_.front();
if (this->send_json_(de.source_, de.message_generator_)) {
if (this->close_requested_ || deferred_queue_.empty()) {
return;
}
// O(n) but memory efficiency is more important than speed here which is why std::vector was chosen
deferred_queue_.erase(deferred_queue_.begin());
} else {
break;
}
}
}
void AsyncEventSourceResponse::request_close_() {
void AsyncStreamResponse::close() {
if (!this->close_requested_) {
this->close_requested_ = true;
this->deferred_queue_.clear();
this->tail_.reset();
this->tail_cap_ = 0;
this->tail_len_ = 0;
this->tail_sent_ = 0;
this->next_close_attempt_ms_ = App.get_loop_component_start_time();
}
this->process_close_();
}
void AsyncEventSourceResponse::process_close_() {
void AsyncStreamResponse::loop() {
if (this->close_requested_) {
this->process_close_();
} else {
this->refresh_lru_();
}
}
void AsyncStreamResponse::process_close_() {
if (!this->close_requested_ || this->close_work_queued_.load(std::memory_order_acquire)) {
return;
}
@@ -971,7 +827,7 @@ void AsyncEventSourceResponse::process_close_() {
// httpd_sess_trigger_close() queues only a reusable fd/session slot and can
// therefore close a new client if the original peer disconnects meanwhile.
this->close_work_queued_.store(true, std::memory_order_release);
const esp_err_t err = httpd_queue_work(this->hd_, &AsyncEventSourceResponse::close_session_work, this);
const esp_err_t err = httpd_queue_work(this->hd_, &AsyncStreamResponse::close_session_work, this);
this->next_close_attempt_ms_ = now + (err == ESP_OK ? CLOSE_CONFIRM_INTERVAL_MS : CLOSE_RETRY_INTERVAL_MS);
if (err == ESP_OK) {
return;
@@ -979,17 +835,17 @@ void AsyncEventSourceResponse::process_close_() {
this->close_work_queued_.store(false, std::memory_order_release);
if (!this->close_retry_warning_logged_) {
ESP_LOGW(TAG, "Failed to queue EventSource close (%s); retrying", esp_err_to_name(err));
ESP_LOGW(TAG, "Failed to queue stream close (%s); retrying", esp_err_to_name(err));
this->close_retry_warning_logged_ = true;
}
}
void AsyncEventSourceResponse::refresh_lru_() {
// httpd only advances a session's LRU counter when a request arrives, and an event stream receives one
void AsyncStreamResponse::refresh_lru_() {
// httpd only advances a session's LRU counter when a request arrives, and a stream receives one
// request ever, so the purge that frees a socket for a new client would evict the live streams first.
// The HTTPD task owns the session table, so the refresh runs there.
// A stalled stream is the socket the purge should take, and a zeroed fd has no session left to refresh.
if (this->send_failure_started_ms_ != 0 || this->fd_.load() == 0)
if (this->stall_.is_running() || this->fd_.load() == 0)
return;
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_lru_refresh_ms_ < LRU_REFRESH_INTERVAL_MS ||
@@ -998,13 +854,13 @@ void AsyncEventSourceResponse::refresh_lru_() {
}
this->last_lru_refresh_ms_ = now; // a full queue also waits for the next interval
this->lru_work_queued_.store(true, std::memory_order_release);
if (httpd_queue_work(this->hd_, &AsyncEventSourceResponse::refresh_lru_work, this) != ESP_OK) {
if (httpd_queue_work(this->hd_, &AsyncStreamResponse::refresh_lru_work, this) != ESP_OK) {
this->lru_work_queued_.store(false, std::memory_order_release);
}
}
void AsyncEventSourceResponse::refresh_lru_work(void *arg) {
auto *response = static_cast<AsyncEventSourceResponse *>(arg);
void AsyncStreamResponse::refresh_lru_work(void *arg) {
auto *response = static_cast<AsyncStreamResponse *>(arg);
const int fd = response->fd_.load();
if (response->owns_session_(fd)) {
httpd_sess_update_lru_counter(response->hd_, fd);
@@ -1013,8 +869,8 @@ void AsyncEventSourceResponse::refresh_lru_work(void *arg) {
response->lru_work_queued_.store(false, std::memory_order_release);
}
void AsyncEventSourceResponse::close_session_work(void *arg) {
auto *response = static_cast<AsyncEventSourceResponse *>(arg);
void AsyncStreamResponse::close_session_work(void *arg) {
auto *response = static_cast<AsyncStreamResponse *>(arg);
const int fd = response->fd_.load();
if (response->owns_session_(fd)) {
// The HTTPD task remains the session owner. Shutting the socket down makes
@@ -1026,320 +882,61 @@ void AsyncEventSourceResponse::close_session_work(void *arg) {
response->close_work_queued_.store(false, std::memory_order_release);
}
ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) {
// httpd frees a session before closing its socket, so the fd may already be a new client's.
ssize_t AsyncStreamResponse::write(struct iovec *iov, int iovcnt) {
if (this->close_requested_) {
return -1;
}
// Nothing to send is not a stall
size_t total = 0;
for (int i = 0; i < iovcnt; i++) {
total += iov[i].iov_len;
}
if (total == 0) {
return 0;
}
// httpd closes the socket before it clears the session, so the fd number may already be a new client's.
// Treated as would-block; the stall timer ends a session that never becomes ours again.
const int fd = this->fd_.load();
if (!this->owns_session_(fd)) {
return 0;
ssize_t sent = 0;
int err = 0;
{
// Held across the check and the send, so socket_closing() cannot close the socket in between
LockGuard guard{stream_send_mutex};
if (!this->socket_closed_ && this->owns_session_(fd)) {
struct msghdr msg{};
msg.msg_iov = iov;
msg.msg_iovlen = iovcnt;
sent = sendmsg(fd, &msg, MSG_DONTWAIT);
err = errno;
}
}
struct msghdr msg{};
msg.msg_iov = iov;
msg.msg_iovlen = iovcnt;
const ssize_t sent = sendmsg(fd, &msg, MSG_DONTWAIT);
if (sent >= 0) {
if (sent > 0) {
this->stall_.clear();
return sent;
}
const int err = errno;
if (err == EAGAIN || err == EWOULDBLOCK) {
return 0;
if (sent < 0 && err != EAGAIN && err != EWOULDBLOCK) {
ESP_LOGD(TAG, "send error: errno %d", err);
// A hard error is not a timeout, so is_stalled() must not report one
this->stall_.clear();
this->close();
return -1;
}
ESP_LOGD(TAG, "send error: errno %d", err);
this->request_close_();
return -1;
}
void AsyncEventSourceResponse::drain_tail_() {
if (this->sending_ || this->close_requested_ || this->tail_len_ == 0) {
return;
}
SendGuard guard{*this};
const size_t remaining = this->tail_len_ - this->tail_sent_;
struct iovec iov = {this->tail_.get() + this->tail_sent_, remaining};
const ssize_t sent = this->send_(&iov, 1);
if (sent < 0) {
return;
}
if (sent == 0) {
// Socket buffer full, try again later
// NOTE: Similar logic exists in web_server/web_server.cpp in DeferredUpdateEventSource::process_deferred_queue_().
// The IDF path is intentionally time-based and closes through HTTPD to preserve session ownership.
const uint32_t now = App.get_loop_component_start_time();
if (this->send_failure_started_ms_ == 0) {
this->send_failure_started_ms_ = now != 0 ? now : 1; // Reserve zero for no stall.
}
if (static_cast<int32_t>(now - (this->send_failure_started_ms_ + SEND_STALL_TIMEOUT_MS)) >= 0) {
ESP_LOGW(TAG, "Closing stuck EventSource connection after %" PRIu32 " ms without send progress",
now - this->send_failure_started_ms_);
this->request_close_();
}
return;
}
this->send_failure_started_ms_ = 0;
this->tail_sent_ += sent;
if (this->tail_sent_ < this->tail_len_) {
ESP_LOGV(TAG, "Partial send: %zd/%zu bytes (total: %u/%u)", sent, remaining, this->tail_sent_, this->tail_len_);
return;
}
// Fully sent; the storage stays for the next stall
this->tail_len_ = 0;
}
bool AsyncEventSourceResponse::reserve_tail_(size_t len) {
if (this->tail_cap_ >= len) {
return true;
}
if (len > TAIL_MAX_SIZE) {
return false;
}
// Nothing is pending while the tail grows, so free the old block first. PREFER_INTERNAL keeps
// the tail where plain new put it.
this->tail_.reset();
this->tail_ = RAMAllocator<uint8_t>(RAMAllocator<uint8_t>::PREFER_INTERNAL).make_unique_array_for_overwrite(len);
this->tail_cap_ = this->tail_ ? len : 0;
return this->tail_cap_ != 0;
}
bool AsyncEventSourceResponse::stash_chunk_(const char *prefix, size_t prefix_len, const char *message,
size_t message_len, size_t total, size_t sent) {
// A log event of nothing but line breaks is the largest chunk; reserve_tail_() must never
// refuse it for size, only for memory
static_assert(SSE_SEP_LEN * web_server::LOG_EVENT_MAX_LEN + PREFIX_BUF_SIZE + SSE_SUFFIX_LEN <= TAIL_MAX_SIZE,
"the log cut in web_server.h must keep a worst case log event inside the tail ceiling");
if (!this->reserve_tail_(total)) {
if (sent != 0) {
// Part of the chunk is on the wire, so the stream is broken and the client has to go
ESP_LOGW(TAG, "Cannot buffer a %zu byte chunk, closing", total);
this->request_close_();
} else {
this->tail_alloc_failed_(total); // nothing on the wire, the caller retries on the stall clock
}
return false;
}
uint8_t *dst = this->tail_.get();
std::memcpy(dst, prefix, prefix_len);
dst += prefix_len;
for_each_chunk_piece(
message, message_len,
[](void *ctx, const char *piece, size_t len) {
auto &out = *static_cast<uint8_t **>(ctx);
std::memcpy(out, piece, len);
out += len;
},
&dst);
this->tail_len_ = total;
this->tail_sent_ = sent;
return true;
}
void AsyncEventSourceResponse::loop() {
if (this->close_requested_) {
this->process_close_();
return;
}
this->refresh_lru_();
drain_tail_();
process_deferred_queue_();
if (this->close_requested_)
return;
// One step per loop; refusals retry next pass
this->entities_iterator_.try_advance(1);
}
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the JSON arena
#endif
AsyncEventSourceResponse::JsonPrepareResult AsyncEventSourceResponse::prepare_json_(void *source,
message_generator_t *generator,
char *buffer, size_t *size_out) {
// Log lines emitted while the generator runs are dropped for this session: on the large path they would fill
// tail_ before it is overwritten, and on the small path they would go out ahead of the event being built.
SendGuard guard{*this};
json::JsonArena<json::JSON_ARENA_SIZE> arena;
json::JsonBuilder builder(&arena);
generator(this->web_server_, source, builder);
*size_out = builder.serialize_to(buffer, JSON_BUF_SIZE);
if (*size_out < JSON_BUF_SIZE) {
return JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER;
}
char prefix[PREFIX_BUF_SIZE];
const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true);
// Nothing reaches the wire until the complete document and its framing are owned by the caller.
size_t json_len = 0;
size_t cap = std::max<size_t>(JSON_BUF_SIZE * 2, this->tail_cap_);
for (;;) {
if (!this->reserve_tail_(cap)) {
*size_out = cap;
return JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY;
}
const size_t room = cap - prefix_len - SSE_SUFFIX_LEN;
json_len = builder.serialize_to(reinterpret_cast<char *>(this->tail_.get()) + prefix_len, room);
if (json_len < room) {
break;
}
if (cap >= TAIL_MAX_SIZE) {
*size_out = room;
return JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE;
}
cap = std::min<size_t>(cap * 2, TAIL_MAX_SIZE);
}
const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN;
write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN);
uint8_t *dst = this->tail_.get();
std::memcpy(dst, prefix, prefix_len);
std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN);
this->tail_len_ = total;
this->tail_sent_ = 0;
return JsonPrepareResult::JSON_PREPARE_RESULT_TAIL;
}
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic pop
#endif
bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) {
if (!this->ready_to_send_()) {
return false;
}
char buffer[JSON_BUF_SIZE];
size_t size;
const auto result = this->prepare_json_(source, generator, buffer, &size);
switch (result) {
case JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER:
return this->try_send_nodefer(buffer, size, "state");
case JsonPrepareResult::JSON_PREPARE_RESULT_TAIL:
this->drain_tail_();
return true;
case JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY: {
SendGuard guard{*this};
this->tail_alloc_failed_(size);
return false;
}
case JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE: {
SendGuard guard{*this};
ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", size);
this->tail_.reset();
this->tail_cap_ = 0;
this->send_failure_started_ms_ = 0;
return true;
}
}
return false;
}
void AsyncEventSourceResponse::tail_alloc_failed_(size_t cap) {
// Same stall clock as a socket that stops draining, so a session cannot retry forever
// Socket buffer full, try again later; a socket that stays full past the stall timeout is closed
const uint32_t now = App.get_loop_component_start_time();
if (this->send_failure_started_ms_ == 0) {
this->send_failure_started_ms_ = now != 0 ? now : 1; // Reserve zero for no stall.
ESP_LOGW(TAG, "No memory for a %zu byte chunk", cap);
return;
}
if (static_cast<int32_t>(now - (this->send_failure_started_ms_ + SEND_STALL_TIMEOUT_MS)) >= 0) {
ESP_LOGW(TAG, "Closing EventSource after %" PRIu32 " ms without memory", now - this->send_failure_started_ms_);
this->request_close_();
this->stall_.start(now);
if (this->stall_.is_expired(now, SEND_STALL_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Closing stuck stream after %" PRIu32 " ms without send progress", this->stall_.elapsed(now));
this->close();
return -1;
}
return 0;
}
bool AsyncEventSourceResponse::ready_to_send_() {
if (this->sending_ || this->fd_.load() == 0 || this->close_requested_) {
return false;
}
drain_tail_();
return !this->close_requested_ && this->tail_len_ == 0;
ssize_t AsyncStreamResponse::write(const void *data, size_t len) {
struct iovec iov = {const_cast<void *>(data), len};
return this->write(&iov, 1);
}
bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id,
uint32_t reconnect) {
if (!this->ready_to_send_()) {
return false;
}
SendGuard guard{*this};
// Everything after the prefix goes out straight from the caller's buffer
char prefix[PREFIX_BUF_SIZE];
const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), event, id, reconnect, message != nullptr);
if (message == nullptr && prefix_len == CHUNK_HDR_LEN) {
return true; // Match ESPAsyncWebServer: nothing to send
}
if (prefix_len >= PREFIX_BUF_SIZE - 1) {
// The appenders truncate silently, which would put a malformed event on the wire
ESP_LOGW(TAG, "Event name too long, dropped");
return true;
}
// Gather list: the prefix, then the data lines and their separators from the message
struct Gather {
struct iovec iov[MAX_SEND_IOV]; // left uninitialized on purpose
int iovcnt{1};
size_t total{0};
bool fits{true};
} g;
g.total = prefix_len;
for_each_chunk_piece(
message, message_len,
[](void *ctx, const char *piece, size_t len) {
auto &g = *static_cast<Gather *>(ctx);
g.total += len;
if (len == 0) {
return;
}
if (g.iovcnt == MAX_SEND_IOV) {
g.fits = false;
return;
}
g.iov[g.iovcnt++] = {const_cast<char *>(piece), len};
},
&g);
// The header and the terminator are not part of the chunk length
write_chunk_header(prefix, g.total - CHUNK_HDR_LEN - CHUNK_END_LEN);
g.iov[0] = {prefix, prefix_len};
// A message with more lines than the list holds skips straight to the tail
const ssize_t sent = g.fits ? this->send_(g.iov, g.iovcnt) : 0;
if (sent < 0) {
return false;
}
if (static_cast<size_t>(sent) == g.total) {
this->send_failure_started_ms_ = 0; // progress, whichever stall clock was running
return true;
}
// The caller's buffers do not outlive this call, so keep the chunk and continue from loop()
return this->stash_chunk_(prefix, prefix_len, message, message_len, g.total, sent);
}
void AsyncEventSourceResponse::deferrable_send_state(void *source, bool detail_all,
message_generator_t *message_generator) {
// Let the detail_all listing finish before bare state events, so the web UI never shows unnamed entries and
// initial connect sends less
if (!detail_all && !this->entities_iterator_.completed())
return;
drain_tail_();
process_deferred_queue_();
if (this->close_requested_) {
return;
}
if (this->tail_len_ != 0 || !deferred_queue_.empty()) {
// outgoing event buffer or deferred queue still not empty which means downstream tcp send buffer full, no point
// trying to send first
deq_push_back_with_dedup_(source, message_generator);
} else {
// A send error closes the session and clears the queue; nothing is queued after that
if (!this->send_json_(source, message_generator) && !this->close_requested_) {
deq_push_back_with_dedup_(source, message_generator);
}
}
}
#endif
#ifdef USE_WEBSERVER_OTA
esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *content_type) {
// Parse boundary and create reader
@@ -15,21 +15,14 @@
#include <utility>
#include <vector>
#ifdef USE_WEBSERVER
#include "esphome/components/json/json_util.h"
#include "esphome/components/web_server/list_entities.h"
#include "sse_chunk.h"
#endif
#include "esphome/components/web_server_base/stream_head.h"
struct iovec; // NOLINT(readability-identifier-naming) - forward decl of lwip's gather list entry
namespace esphome {
#ifdef USE_WEBSERVER
namespace web_server {
class WebServer;
}; // namespace web_server
#endif
namespace web_server_idf {
namespace esphome::web_server_idf {
using web_server_base::HttpHeader;
using web_server_base::StallClock;
class AsyncWebParameter {
public:
@@ -191,6 +184,7 @@ class AsyncWebServerRequest {
void onDisconnect(std::function<void()> fn) { this->on_disconnect_ = std::move(fn); }
#endif
/// sess_ctx is reserved for AsyncStreamResponse: the server's close_fn treats any session context as one.
operator httpd_req_t *() const { return this->req_; }
optional<std::string> get_header(const char *name) const;
/// Copy a header value into buf without heap allocation.
@@ -269,204 +263,85 @@ class AsyncWebHandler {
virtual bool isRequestHandlerTrivial() const { return true; }
};
#ifdef USE_WEBSERVER
class AsyncEventSource;
class AsyncEventSourceResponse;
using message_generator_t = void(esphome::web_server::WebServer *, void *, json::JsonBuilder &);
/*
This class holds a pointer to the source component that wants to publish a state event, and a pointer to a function
that will lazily generate that event. The two pointers allow dedup in the deferred queue if multiple publishes for
the same component are backed up, and take up only two pointers of memory. The entry in the deferred queue (a
std::vector) is the DeferredEvent instance itself (not a pointer to one elsewhere in heap) so still only two pointers
per entry (and no heap fragmentation). Even 100 backed up events (you'd have to have at least 100 sensors publishing
because of dedup) would take up only 0.8 kB.
*/
struct DeferredEvent {
friend class AsyncEventSourceResponse;
protected:
void *source_;
message_generator_t *message_generator_;
/// A response that keeps its connection open and is written from the main loop, such as an event stream or
/// an audio stream. Use it directly or derive from it. Call loop() and delete the object only through the type
/// it was created as: subclasses hide loop() instead of overriding it, and the destructor is not virtual.
///
/// Lifecycle:
/// 1. Httpd task, inside handleRequest(): create it and call begin(), then hand the pointer to the main loop.
/// 2. Main loop: once is_attached() is true, write() may go through. Call loop() on every pass.
/// 3. Main loop: delete it once safe_to_delete() is true. httpd owns the socket throughout.
class AsyncStreamResponse {
public:
DeferredEvent(void *source, message_generator_t *message_generator)
: source_(source), message_generator_(message_generator) {}
bool operator==(const DeferredEvent &test) const {
return (source_ == test.source_ && message_generator_ == test.message_generator_);
}
};
static_assert(sizeof(DeferredEvent) == sizeof(void *) + sizeof(message_generator_t *),
"DeferredEvent should have no padding");
/// Httpd task, once. Sends the status line (for example HTTPD_200), Content-Type, headers, the default headers
/// and body (a C string or null, sent as is), then makes this object the session context. A chunked response
/// passes {"Transfer-Encoding", "chunked"} as its first header and frames each chunk itself.
void begin(AsyncWebServerRequest *request, const char *status, const char *content_type,
std::span<const HttpHeader> headers, const char *body = nullptr);
class AsyncEventSourceResponse {
friend class AsyncEventSource;
public:
bool try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0,
uint32_t reconnect = 0);
// detail_all events go out while entities are still being listed; state events wait until listing is done
void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator);
// The old event name argument would otherwise convert to true
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete;
/// Main loop. Non-blocking gather write. Returns the bytes accepted, which may be fewer than offered: the caller
/// keeps the rest and continues from there. 0 means the socket is full or not ours yet; -1 means the stream failed
/// and is closing. Refused writes for SEND_STALL_TIMEOUT_MS in a row also close it.
ssize_t write(struct iovec *iov, int iovcnt);
ssize_t write(const void *data, size_t len);
/// Main loop. Shuts the connection down through httpd; safe to call more than once.
void close();
/// Main loop, every pass. Keeps the session fresh in httpd's LRU and retries a pending close.
void loop();
/// Httpd task, from the server's close_fn: no write() reaches the socket after this returns.
static void socket_closing(httpd_handle_t hd, int fd);
protected:
AsyncEventSourceResponse(const AsyncWebServerRequest *request, esphome::web_server_idf::AsyncEventSource *server,
esphome::web_server::WebServer *ws);
// Main-loop only: sends initial ping/config/sorting_groups, starts entity iterator.
void start_session_main_loop_();
void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator);
void process_deferred_queue_();
// A new chunk may go out: not re-entered from a log line, session alive, tail empty
bool ready_to_send_();
// Non-blocking gather write. Returns bytes written, 0 on would-block or a socket that is not
// ours, -1 after requesting the close on any other error.
ssize_t send_(struct iovec *iov, int iovcnt);
// Push what is left of the chunk in tail_ to the socket; owns the stall timer.
void drain_tail_();
// Grow tail_ to hold len bytes, kept at its high-water mark. False on OOM.
bool reserve_tail_(size_t len);
// Keep the whole chunk in tail_ and continue from sent; false when the tail cannot be allocated.
bool stash_chunk_(const char *prefix, size_t prefix_len, const char *message, size_t message_len, size_t total,
size_t sent);
// Send a state event; JSON too large for the stack buffer is serialized into tail_ instead
bool send_json_(void *source, message_generator_t *generator);
enum class JsonPrepareResult : uint8_t {
JSON_PREPARE_RESULT_BUFFER,
JSON_PREPARE_RESULT_TAIL,
JSON_PREPARE_RESULT_NO_MEMORY,
JSON_PREPARE_RESULT_TOO_LARGE,
};
// Keep the arena out of the caller's frame so it is gone before the state send.
// size_out: BUFFER = JSON length, NO_MEMORY = requested tail capacity, TOO_LARGE = available JSON room;
// unused for TAIL.
JsonPrepareResult __attribute__((noinline)) prepare_json_(void *source, message_generator_t *generator, char *buffer,
size_t *size_out);
// Warn once, and close the session once the stall timeout passes with no memory for the tail
void tail_alloc_failed_(size_t cap);
void request_close_();
// A log line emitted inside a send re-enters try_send_nodefer on this session; refuse it
struct SendGuard {
AsyncEventSourceResponse &owner;
explicit SendGuard(AsyncEventSourceResponse &owner) : owner(owner) { owner.sending_ = true; }
~SendGuard() { this->owner.sending_ = false; }
};
void process_close_();
static void close_session_work(void *arg);
void refresh_lru_();
static void refresh_lru_work(void *arg);
// httpd frees a session before closing its socket, so the fd may already belong to a new client
bool owns_session_(int fd) const { return fd != 0 && httpd_sess_get_ctx(this->hd_, fd) == this; }
// Deletable only after destroy() zeroed fd_ and no queued HTTPD work still references this object.
bool safe_to_delete_() const {
/// httpd holds this response as the session context; begin() has sent the whole head by then.
bool is_attached() const { return this->owns_session_(this->fd_.load()); }
/// The client is still connected; httpd has not freed the session.
bool is_connected() const { return this->fd_.load() != 0; }
bool is_closing() const { return this->close_requested_; }
/// The socket fd, for log lines only
int log_id() const { return this->fd_.load(); }
/// Sends are making no progress; after write() returns -1 this tells a send timeout from a send error.
bool is_stalled() const { return this->stall_.is_running(); }
/// The session is gone and no queued httpd work still references this object.
bool safe_to_delete() const {
return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire) &&
!this->lru_work_queued_.load(std::memory_order_acquire);
}
static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000;
protected:
void process_close_();
static void close_session_work(void *arg);
void refresh_lru_();
static void refresh_lru_work(void *arg);
// httpd closes the socket before it clears the session, so this can still match a closed fd; write() also checks
// socket_closed_
bool owns_session_(int fd) const { return fd != 0 && httpd_sess_get_ctx(this->hd_, fd) == this; }
static void destroy(void *p);
AsyncEventSource *server_;
httpd_handle_t hd_{};
std::atomic<int> fd_{};
std::vector<DeferredEvent> deferred_queue_;
esphome::web_server::WebServer *web_server_;
esphome::web_server::ListEntitiesIterator entities_iterator_;
// One chunk the socket did not take whole, allocated on the first stall; the only heap use
// on the send path
RAMUniquePtr<uint8_t[]> tail_;
uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress.
// Runs while sends make no progress; a running clock keeps its start time
StallClock stall_;
uint32_t next_close_attempt_ms_{0};
uint32_t last_lru_refresh_ms_{0};
uint16_t tail_cap_{0};
uint16_t tail_len_{0}; // Zero means nothing pending
uint16_t tail_sent_{0};
// Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done.
std::atomic<bool> close_work_queued_{false};
std::atomic<bool> lru_work_queued_{false};
// Guarded by the stream send mutex
bool socket_closed_{false};
// Main-loop only; the HTTPD task never reads or writes these flags.
bool close_requested_{false};
bool close_retry_warning_logged_{false};
bool sending_{false};
// The longest multi line log message in the tree (a climate dump_config) has 22 lines; a
// longer one goes through the tail
static constexpr size_t MAX_SEND_LINES = 22;
static constexpr size_t MAX_SEND_IOV = 1 + 2 * MAX_SEND_LINES;
// Chunk header, retry/id/event lines and the first "data: "
static constexpr size_t PREFIX_BUF_SIZE = 128;
// Stack buffer for a state event's JSON; a larger document is serialized into the tail
static constexpr size_t JSON_BUF_SIZE = 1024;
// Same ceiling JsonBuilder::serialize() applies (max_heap_size in json_util.cpp); a larger
// document is dropped before anything is on the wire
static constexpr size_t JSON_MAX_SIZE = 5120;
// Most RAM a stalled session keeps: the largest state document plus any accepted framing
static constexpr size_t TAIL_MAX_SIZE = JSON_MAX_SIZE + PREFIX_BUF_SIZE + SSE_SUFFIX_LEN;
static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000;
static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250;
static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000;
static constexpr uint32_t LRU_REFRESH_INTERVAL_MS = 1000;
};
using AsyncEventSourceClient = AsyncEventSourceResponse;
class AsyncEventSource : public AsyncWebHandler {
friend class AsyncEventSourceResponse;
public:
AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {}
~AsyncEventSource() override;
// NOLINTNEXTLINE(readability-identifier-naming)
bool canHandle(AsyncWebServerRequest *request) const override {
if (request->method() != HTTP_GET)
return false;
char url_buf[AsyncWebServerRequest::URL_BUF_SIZE];
return request->url_to(url_buf) == this->url_;
}
// NOLINTNEXTLINE(readability-identifier-naming)
void handleRequest(AsyncWebServerRequest *request) override;
void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0,
uint32_t reconnect = 0);
void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator);
// The old event name argument would otherwise convert to true
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete;
/// Returns true if there are sessions remaining (including pending cleanup).
bool loop();
bool empty() { return this->count() == 0; }
size_t count() const { return this->sessions_.size(); }
protected:
// Cold path: move sessions from pending_sessions_ into sessions_ and greet each one.
void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_();
StringRef url_; // Must outlive this object (string literal)
// Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set.
std::vector<AsyncEventSourceResponse *> sessions_;
// Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_.
std::vector<AsyncEventSourceResponse *> pending_sessions_;
Mutex pending_mutex_;
esphome::web_server::WebServer *web_server_;
std::atomic<bool> has_pending_sessions_{false};
};
#endif // USE_WEBSERVER
struct HttpHeader {
const char *name;
const char *value;
};
class DefaultHeaders {
friend class AsyncWebServerRequest;
friend class AsyncStreamResponse;
friend class AsyncWebServerResponse;
#ifdef USE_WEBSERVER
friend class AsyncEventSourceResponse;
#endif
static_assert(WEB_SERVER_DEFAULT_HEADERS_COUNT <= 8, "AsyncWebServerResponse::replaced_defaults_ holds 8 bits");
public:
@@ -484,8 +359,7 @@ class DefaultHeaders {
void apply_(httpd_req_t *req, uint8_t skip_mask = 0) const;
};
} // namespace web_server_idf
} // namespace esphome
} // namespace esphome::web_server_idf
using namespace esphome::web_server_idf; // NOLINT(google-global-names-in-headers)
+2 -6
View File
@@ -5,15 +5,13 @@ from esphome.const import CONF_MODEL
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CODEOWNERS = ["@glmnet", "@arpena", "@Rodrigoah"]
CODEOWNERS = ["@glmnet", "@arpena"]
whirlpool_ns = cg.esphome_ns.namespace("whirlpool")
WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR)
Model = whirlpool_ns.enum("Model")
MODELS = Model.options(
"DG11J1-3A", "DG11J1-91", "HISENSE_ARG", "HITACHI_HCRA31NEWH", prefix="MODEL_"
)
MODELS = Model.options("DG11J1-3A", "DG11J1-91", "HISENSE_ARG", prefix="MODEL_")
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend(
{
@@ -28,5 +26,3 @@ async def to_code(config: ConfigType) -> None:
# cv.enum keeps the YAML key as a str subclass; the MockObj value cannot be compared
if config[CONF_MODEL] == "HISENSE_ARG":
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
elif config[CONF_MODEL] == "HITACHI_HCRA31NEWH":
cg.add_define("USE_WHIRLPOOL_HITACHI_HCRA31NEWH")
+15 -61
View File
@@ -2,8 +2,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome::whirlpool {
ESPHOME_LOG_TAG(TAG, "whirlpool.climate");
@@ -46,17 +44,6 @@ static constexpr uint8_t HISENSE_ARG_POWER_TOGGLE = 0x01; // byte 15
static constexpr uint8_t HISENSE_ARG_SETTINGS_ONLY = 0x02; // byte 15
static constexpr uint8_t HISENSE_ARG_FIXED_TEMP_STEP = 7; // auto and fan-only send 23 C
// Hitachi HCRA31NEWH model: power is a state in every frame
static constexpr uint8_t HITACHI_BYTE10 = 0x20; // byte 10
static constexpr uint8_t HITACHI_COOL = 0x02; // byte 15
static constexpr uint8_t HITACHI_OFF = 0x01; // byte 15
static constexpr uint8_t HITACHI_BYTE18_ON = 0x38; // byte 18
static constexpr uint8_t HITACHI_BYTE18_OFF = 0x28; // byte 18
static constexpr uint8_t HITACHI_BYTE19 = 0x08; // byte 19
// Header, 21 bytes of 8 bits, two dividers and the footer mark
static constexpr uint32_t WHIRLPOOL_FRAME_TIMINGS = 2 + WHIRLPOOL_STATE_LENGTH * 16 + 2 * 2 + 1;
WhirlpoolClimate::WhirlpoolClimate()
: climate_ir::ClimateIR(
WHIRLPOOL_DG11J1_3A_TEMP_MIN, WHIRLPOOL_DG11J1_3A_TEMP_MAX, 1.0f, true, true,
@@ -68,19 +55,19 @@ void WhirlpoolClimate::transmit_state() {
this->last_transmit_time_ = millis(); // setting the time of the last transmission.
uint8_t remote_state[WHIRLPOOL_STATE_LENGTH];
memset(remote_state, 0, sizeof(remote_state));
put_be16(remote_state, this->remote_id_());
remote_state[0] = 0x83;
remote_state[1] = 0x06;
remote_state[6] = 0x80;
remote_state[18] = 0x08;
remote_state[18] = 0x08; // the same on every supported model
const bool powered_on = this->mode != climate::CLIMATE_MODE_OFF;
// Hitachi sends power as a state instead of a toggle
const bool power_toggle = !this->is_hitachi_hcra31newh_() && powered_on != this->powered_on_assumed;
auto powered_on = this->mode != climate::CLIMATE_MODE_OFF;
const bool power_toggle = powered_on != this->powered_on_assumed;
if (power_toggle) {
// Set power toggle command
remote_state[2] = 4;
remote_state[15] = 1;
this->powered_on_assumed = powered_on;
}
this->powered_on_assumed = powered_on;
switch (this->mode) {
case climate::CLIMATE_MODE_HEAT_COOL:
// set fan auto
@@ -106,10 +93,6 @@ void WhirlpoolClimate::transmit_state() {
remote_state[15] = 6;
break;
case climate::CLIMATE_MODE_OFF:
// These remotes keep the cool code in the power-off frame
if (this->is_hisense_arg_() || this->is_hitachi_hcra31newh_())
remote_state[3] = WHIRLPOOL_COOL;
break;
default:
break;
}
@@ -119,7 +102,6 @@ void WhirlpoolClimate::transmit_state() {
// Temperature
auto temp = (uint8_t) roundf(clamp(this->target_temperature, this->temperature_min_(), this->temperature_max_()));
const auto temp_step = (uint8_t) (temp - this->temperature_min_());
this->target_temperature = temp; // publish what the unit was sent
if (hisense_arg && this->fixed_temp_mode_()) {
remote_state[3] |= HISENSE_ARG_FIXED_TEMP_STEP << 4;
} else {
@@ -149,6 +131,7 @@ void WhirlpoolClimate::transmit_state() {
remote_state[2] |= WHIRLPOOL_SWING_MASK;
remote_state[8] = WHIRLPOOL_SWING_BYTE8;
}
this->encode_hisense_arg_(remote_state, power_toggle, temp_step);
} else if (this->send_swing_cmd_) {
if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL || this->swing_mode == climate::CLIMATE_SWING_OFF) {
remote_state[2] |= WHIRLPOOL_SWING_MASK;
@@ -156,11 +139,6 @@ void WhirlpoolClimate::transmit_state() {
}
}
if (hisense_arg)
this->encode_hisense_arg_(remote_state, power_toggle, temp_step);
if (this->is_hitachi_hcra31newh_())
this->encode_hitachi_hcra31newh_(remote_state);
// Checksum
remote_state[13] = xor_bytes(&remote_state[2], 11, remote_state[13]);
remote_state[20] = xor_bytes(&remote_state[14], 6, remote_state[20]);
@@ -170,7 +148,6 @@ void WhirlpoolClimate::transmit_state() {
// Send code
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
data->reserve(WHIRLPOOL_FRAME_TIMINGS);
data->set_carrier_frequency(38000);
@@ -203,6 +180,10 @@ void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_tog
remote_state[15] = power_toggle ? HISENSE_ARG_POWER_TOGGLE : HISENSE_ARG_SETTINGS_ONLY;
switch (this->mode) {
case climate::CLIMATE_MODE_OFF:
// The remote keeps the cool code in the power-off frame
remote_state[3] = (remote_state[3] & 0xF0) | WHIRLPOOL_COOL;
break;
case climate::CLIMATE_MODE_DRY:
// The unit runs these fan speeds itself; the user's choice is kept for the next mode change
remote_state[2] &= ~WHIRLPOOL_FAN_MASK;
@@ -231,27 +212,6 @@ void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_tog
#endif
}
void WhirlpoolClimate::encode_hitachi_hcra31newh_(uint8_t *remote_state) const {
#ifdef USE_WHIRLPOOL_HITACHI_HCRA31NEWH
remote_state[10] = HITACHI_BYTE10;
remote_state[18] = HITACHI_BYTE18_ON;
remote_state[19] = HITACHI_BYTE19;
// The unit takes the whole state from every frame: only the off frame carries the power bit
switch (this->mode) {
case climate::CLIMATE_MODE_COOL:
remote_state[15] = HITACHI_COOL;
break;
case climate::CLIMATE_MODE_OFF:
remote_state[2] |= WHIRLPOOL_POWER;
remote_state[15] = HITACHI_OFF;
remote_state[18] = HITACHI_BYTE18_OFF;
break;
default:
break;
}
#endif
}
bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
// Check if the esp isn't currently transmitting.
if (millis() - this->last_transmit_time_ < 500) {
@@ -274,8 +234,7 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
if (!data.expect_item(WHIRLPOOL_BIT_MARK, WHIRLPOOL_GAP))
return false;
}
// The Hitachi remote always sends all 21 bytes; byte 18 holds its power state
if (i == WHIRLPOOL_SECOND_DIVIDER && !data.is_valid() && !this->is_hitachi_hcra31newh_()) {
if (i == WHIRLPOOL_SECOND_DIVIDER && !data.is_valid()) {
// Remote control only sent 14 bytes, nothing more to read, not even the footer
ESP_LOGV(TAG, "Remote control only sent %d bytes", i);
skip_footer = true;
@@ -302,18 +261,13 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
ESP_LOGV_HEX(TAG, remote_state, WHIRLPOOL_STATE_LENGTH, WHIRLPOOL_STATE_LENGTH, "Received: %s");
// verify header remote code
if (encode_uint16(remote_state[0], remote_state[1]) != this->remote_id_())
if (remote_state[0] != 0x83 || remote_state[1] != 0x06)
return false;
// powr on/off button
ESP_LOGV(TAG, "Power: %02X", (remote_state[2] & WHIRLPOOL_POWER));
if (this->is_hitachi_hcra31newh_()) {
// Power is a state: byte 18 says whether the unit is off
this->powered_on_assumed = remote_state[18] != HITACHI_BYTE18_OFF;
if (!this->powered_on_assumed)
this->mode = climate::CLIMATE_MODE_OFF;
} else if ((remote_state[2] & WHIRLPOOL_POWER) == WHIRLPOOL_POWER) {
if ((remote_state[2] & WHIRLPOOL_POWER) == WHIRLPOOL_POWER) {
auto powered_on = this->mode != climate::CLIMATE_MODE_OFF;
if (powered_on) {
@@ -355,7 +309,7 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
int temp = remote_state[3] & 0xF0;
ESP_LOGVV(TAG, "Temperature Raw: %02X", temp);
temp = (uint8_t) temp >> 4;
temp = std::min(temp + static_cast<int>(this->temperature_min_()), static_cast<int>(this->temperature_max_()));
temp += static_cast<int>(this->temperature_min_());
ESP_LOGVV(TAG, "Temperature Climate: %u", temp);
this->target_temperature = temp;
}
+4 -19
View File
@@ -6,10 +6,9 @@ namespace esphome::whirlpool {
/// Simple enum to represent models.
enum Model {
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30
MODEL_HITACHI_HCRA31NEWH = 3, /// Hitachi with the HCRA31NEWH remote, 16 to 30
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30
};
// Temperature
@@ -30,9 +29,7 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR {
/// Override control to change settings of the climate device.
void control(const climate::ClimateCall &call) override {
// The toggle remotes flip swing on every press, so only send one when the mode really changes
auto swing = call.get_swing_mode();
this->send_swing_cmd_ = swing.has_value() && *swing != this->swing_mode;
send_swing_cmd_ = call.get_swing_mode().has_value();
climate_ir::ClimateIR::control(call);
}
@@ -58,22 +55,10 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR {
return false;
#endif
}
/// False when the model is compiled out, so the compiler drops the Hitachi branches.
bool is_hitachi_hcra31newh_() const {
#ifdef USE_WHIRLPOOL_HITACHI_HCRA31NEWH
return this->model_ == MODEL_HITACHI_HCRA31NEWH;
#else
return false;
#endif
}
/// The remote ID sent in bytes 0 and 1.
uint16_t remote_id_() const { return this->is_hitachi_hcra31newh_() ? 0xA959 : 0x8306; }
/// Modes in which the Hisense ARG model sends a fixed temperature.
bool fixed_temp_mode_() const;
/// Add the Hisense ARG bytes on top of the common whirlpool frame.
void encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const;
/// Add the Hitachi HCRA31NEWH bytes on top of the common whirlpool frame.
void encode_hitachi_hcra31newh_(uint8_t *remote_state) const;
/// Set the time of the last transmission.
uint32_t last_transmit_time_{};
+1 -1
View File
@@ -563,7 +563,7 @@ class WiFiComponent final : public Component {
// (In most use cases you won't need these)
/// Setup WiFi interface.
void setup() override;
#if defined(USE_ESP32) || defined(USE_LN882X) || defined(USE_BK72XX)
#if defined(USE_LN882X) || defined(USE_BK72XX)
void on_powerdown() override;
#endif
void start();
@@ -1635,21 +1635,6 @@ bool WiFiComponent::wifi_dpp_start_listen_() {
void WiFiComponent::wifi_dpp_stop_listen_() { esp_supp_dpp_stop_listen(); }
#endif
void WiFiComponent::on_powerdown() {
// Leave the AP before esp_restart() or deep sleep stops Wi-Fi. esp_wifi_stop() while associated
// makes the ESP32 retransmit its Disassociation up to the retry limit although the AP ACKs it
// (32 copies with protected management frames), which some APs handle badly.
// Upstream bug: https://github.com/espressif/esp-idf/issues/19443 - remove this once it is fixed
// in the ESP-IDF versions we support.
// Ask the driver: s_sta_connected lags until the main loop has handled the connect event.
wifi_ap_record_t ap_info;
if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK)
return;
ESP_LOGD(TAG, "Disconnecting before powerdown");
esp_wifi_disconnect();
delay(100); // NOLINT: once per reboot or deep sleep, lets the disassociation go out
}
bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); }
bssid_t WiFiComponent::wifi_bssid() {
-2
View File
@@ -294,8 +294,6 @@ void Application::reboot() {
for (auto &component : std::ranges::reverse_view(this->components_)) {
component->on_shutdown();
}
// No teardown on a forced reboot, but still power hardware down (Wi-Fi leaves the AP)
this->run_powerdown_hooks();
arch_restart();
}
void Application::safe_reboot() {
+2 -4
View File
@@ -185,12 +185,10 @@ class Component {
*/
virtual bool teardown() { return true; }
/** Called last before a reboot or deep sleep to power down hardware.
/** Called after teardown is complete to power down hardware.
*
* On a safe shutdown this is called after all components have finished their teardown process,
* This is called after all components have finished their teardown process,
* making it safe to power down hardware like ethernet PHY.
* On a forced reboot (Application::reboot()) it is called right after on_shutdown() with no
* teardown, so implementations must not assume teardown() has run.
*/
virtual void on_powerdown() {}
-4
View File
@@ -70,7 +70,6 @@
#define USE_BUTTON
#define USE_CAMERA
#define USE_CANBUS_FD
#define USE_CC1101_LONG_PACKET
#define USE_CLIMATE
#define USE_CLIMATE_VISUAL_OVERRIDES
#define USE_CONTROLLER_REGISTRY
@@ -320,8 +319,6 @@
#define USE_NEXTION_TRIGGER_CUSTOM_SWITCH
#define USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR
#define USE_NEXTION_WAVEFORM
#define USE_NPM1300_DATA_PUBLISHER
#define USE_NPM1300_STATUS_PUBLISHER
#define USE_NUMBER
#define USE_OTA_STATE_LISTENER
#define USE_OUTPUT
@@ -390,7 +387,6 @@
#define USE_WATER_HEATER
#define USE_WATER_HEATER_VISUAL_OVERRIDES
#define USE_WHIRLPOOL_HISENSE_ARG
#define USE_WHIRLPOOL_HITACHI_HCRA31NEWH
#define USE_ZWAVE_PROXY
// Feature flags which do not work for zephyr
+1 -11
View File
@@ -1155,16 +1155,6 @@ bool random_bytes(uint8_t *data, size_t len);
/// @name Bit manipulation
///@{
/// The high 4 bits of a byte, e.g. high_nibble(0xA5) == 0xA.
constexpr uint8_t high_nibble(uint8_t byte) { return byte >> 4; }
/// The low 4 bits of a byte, e.g. low_nibble(0xA5) == 0x5.
constexpr uint8_t low_nibble(uint8_t byte) { return byte & 0x0F; }
/// Encode a 12-bit value from its high 8 and low 4 bits, e.g. encode_uint12(0x12, 0x3) == 0x123.
/// Bits above the low 4 of \p low_bits are ignored.
constexpr uint16_t encode_uint12(uint8_t high_bits, uint8_t low_bits) {
return (static_cast<uint16_t>(high_bits) << 4) | low_nibble(low_bits);
}
/// Encode a 16-bit value given the most and least significant byte.
constexpr uint16_t encode_uint16(uint8_t msb, uint8_t lsb) {
return (static_cast<uint16_t>(msb) << 8) | (static_cast<uint16_t>(lsb));
@@ -1267,7 +1257,7 @@ constexpr uint8_t *put_le32(uint8_t *dst, uint32_t value) {
}
/// Decode a two-digit packed BCD byte (tens in the high nibble) to its value, e.g. 0x59 to 59.
constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return high_nibble(bcd) * 10 + low_nibble(bcd); }
constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); }
/// Encode a value from 0 to 99 as a two-digit packed BCD byte, e.g. 59 to 0x59.
constexpr uint8_t uint8_to_bcd(uint8_t value) { return ((value / 10) << 4) | (value % 10); }
@@ -1,62 +0,0 @@
"""Tests for cc1101 packet_length validation."""
from typing import Any
import pytest
from esphome.components.cc1101 import _validate_packet_mode
import esphome.config_validation as cv
from esphome.core import Lambda
LENGTH_LAMBDA = Lambda("return x.size() < 3 ? 0 : x[2] + 12;")
@pytest.mark.parametrize(
"config",
[
{"packet_mode": True, "gdo0_pin": 2, "packet_length": LENGTH_LAMBDA},
{"packet_mode": True, "gdo0_pin": 2, "packet_length": 1024},
{"packet_mode": True, "gdo0_pin": 2, "packet_length": 64, "crc_enable": True},
{"packet_mode": False, "packet_length": 200, "crc_enable": True},
],
)
def test_packet_length_valid(config: dict[str, Any]) -> None:
"""Fixed lengths, a lambda, and CRC on packets that fit the FIFO are accepted."""
assert _validate_packet_mode(config) is config
@pytest.mark.parametrize(
("config", "message"),
[
(
{"packet_mode": False, "packet_length": LENGTH_LAMBDA},
"packet_mode is required when packet_length is a lambda",
),
(
{"packet_mode": True, "gdo0_pin": 2},
"packet_length is required when packet_mode is enabled",
),
(
{
"packet_mode": True,
"gdo0_pin": 2,
"packet_length": 65,
"crc_enable": True,
},
"crc_enable is not supported",
),
(
{
"packet_mode": True,
"gdo0_pin": 2,
"packet_length": LENGTH_LAMBDA,
"crc_enable": True,
},
"crc_enable is not supported",
),
],
)
def test_packet_length_invalid(config: dict[str, Any], message: str) -> None:
"""Invalid packet_length combinations are rejected with a clear message."""
with pytest.raises(cv.Invalid, match=message):
_validate_packet_mode(config)
Whitespace-only changes.
@@ -1,114 +0,0 @@
"""Tests for the npm1300 register encoders and validators."""
import pytest
from esphome import config_validation as cv
from esphome.components.const import CONF_ENABLED
from esphome.components.npm1300 import (
CHARGER_SCHEMA,
NPM1300_UNSET,
OUTPUT_VOLTAGE,
VBUS_CURRENT_LIMIT,
_charger_config,
_output_config,
_termination_voltage_code,
_vbus_current_limit_code,
)
from esphome.const import CONF_MODE, CONF_VOLTAGE
from esphome.cpp_generator import StructInitializer
from esphome.types import ConfigType
def _fields(init: StructInitializer) -> dict[str, str]:
return {key: str(value) for key, value in init.args.items()}
def _charger(config: ConfigType) -> dict[str, str]:
return _fields(_charger_config(CHARGER_SCHEMA(config)))
@pytest.mark.parametrize(
("current", "code"), [("32mA", 16), ("150mA", 75), ("800mA", 400)]
)
def test_charge_current_code(current: str, code: int) -> None:
assert _charger({"charge_current": current})["current"] == str(code)
@pytest.mark.parametrize("current", ["30mA", "802mA", "151mA"])
def test_charge_current_rejected(current: str) -> None:
with pytest.raises(cv.Invalid):
CHARGER_SCHEMA({"charge_current": current})
@pytest.mark.parametrize(
("voltage", "code"),
[(3.5, 0), (3.65, 3), (4.0, 4), (4.2, 8), (4.45, 13)],
)
def test_termination_voltage_code(voltage: float, code: int) -> None:
assert _termination_voltage_code(voltage) == code
@pytest.mark.parametrize("voltage", ["3.7V", "3.95V", "4.5V"])
def test_termination_voltage_gap_rejected(voltage: str) -> None:
with pytest.raises(cv.Invalid):
CHARGER_SCHEMA({"charge_current": "100mA", "termination_voltage": voltage})
def test_warm_termination_defaults_to_termination() -> None:
fields = _charger({"charge_current": "100mA", "termination_voltage": "4.1V"})
assert fields["termination_voltage"] == "6"
assert fields["warm_termination_voltage"] == "6"
def test_warm_termination_code() -> None:
fields = _charger(
{
"charge_current": "100mA",
"termination_voltage": "4.2V",
"warm_termination_voltage": "3.6V",
}
)
assert fields["termination_voltage"] == "8"
assert fields["warm_termination_voltage"] == "2"
@pytest.mark.parametrize(("voltage", "code"), [("1.0V", 0), ("1.8V", 8), ("3.3V", 23)])
def test_output_voltage_code(voltage: str, code: int) -> None:
config = {CONF_VOLTAGE: OUTPUT_VOLTAGE(voltage)}
assert _fields(_output_config(config))["voltage"] == str(code)
@pytest.mark.parametrize("voltage", ["0.9V", "3.4V", "1.85V"])
def test_output_voltage_rejected(voltage: str) -> None:
with pytest.raises(cv.Invalid):
OUTPUT_VOLTAGE(voltage)
@pytest.mark.parametrize("config", [None, {}])
def test_unset_output_fields(config: ConfigType | None) -> None:
unset = str(NPM1300_UNSET)
assert _fields(_output_config(config)) == {
"voltage": unset,
"ldo_mode": unset,
"enabled": unset,
}
def test_output_mode_and_enabled() -> None:
fields = _fields(_output_config({CONF_MODE: "load_switch", CONF_ENABLED: True}))
assert fields["ldo_mode"] == "0"
assert fields["enabled"] == "1"
assert fields["voltage"] == str(NPM1300_UNSET)
@pytest.mark.parametrize(
("limit", "code"), [("100mA", 1), ("300mA", 3), ("500mA", 5), ("1.5A", 15)]
)
def test_vbus_current_limit_code(limit: str, code: int) -> None:
assert _vbus_current_limit_code(VBUS_CURRENT_LIMIT(limit)) == code
@pytest.mark.parametrize("limit", ["50mA", "1600mA", "550mA"])
def test_vbus_current_limit_rejected(limit: str) -> None:
with pytest.raises(cv.Invalid):
VBUS_CURRENT_LIMIT(limit)
@@ -1,18 +0,0 @@
"""Validate the sync-word limit before firmware generation."""
import pytest
from esphome.components.sx126x import CONF_SYNC_VALUE, CONFIG_SCHEMA
import esphome.config_validation as cv
@pytest.mark.parametrize("size", [0, 1, 2, 8])
def test_supported_sync_lengths(size: int) -> None:
value = [0x14] * size
assert CONFIG_SCHEMA.schema[CONF_SYNC_VALUE](value) == value
@pytest.mark.parametrize("size", [9, 256])
def test_oversized_sync_word(size: int) -> None:
with pytest.raises(cv.Invalid, match="at most 8"):
CONFIG_SCHEMA.schema[CONF_SYNC_VALUE]([0x14] * size)
@@ -1,26 +0,0 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
cc1101:
# Above 255 bytes: infinite length mode, then fixed for the remainder
- id: transceiver_long_fixed
cs_pin: GPIO5
gdo0_pin: GPIO4
packet_mode: true
packet_length: 1024
on_packet:
then:
- lambda: 'ESP_LOGD("cc1101", "long packet %u bytes", x.size());'
# Length taken from the packet header
- id: transceiver_long_lambda
cs_pin: GPIO15
gdo0_pin: GPIO2
packet_mode: true
packet_length: !lambda |-
if (x.size() < 3) return 0;
if (x[0] != 0x54) return -1;
return x[2] + 12;
on_packet:
then:
- lambda: 'ESP_LOGD("cc1101", "lambda packet %u bytes rssi %.1f", x.size(), rssi);'
@@ -1,24 +0,0 @@
packages:
spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml
cc1101:
- id: transceiver_long_fixed
cs_pin: GPIO5
gdo0_pin: GPIO4
packet_mode: true
packet_length: 1024
on_packet:
then:
- lambda: 'ESP_LOGD("cc1101", "long packet %u bytes", x.size());'
- id: transceiver_long_lambda
cs_pin: GPIO15
gdo0_pin: GPIO2
packet_mode: true
packet_length: !lambda |-
if (x.size() < 3) return 0;
if (x[0] != 0x54) return -1;
return x[2] + 12;
on_packet:
then:
- lambda: 'ESP_LOGD("cc1101", "lambda packet %u bytes rssi %.1f", x.size(), rssi);'
@@ -1,7 +0,0 @@
substitutions:
cs_pin: GPIO5
gdo0_pin: GPIO4
packages:
spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml
cc1101: !include common.yaml
-20
View File
@@ -55,26 +55,6 @@ TEST(HelpersTest, Ilog10RoundTripMatchesLog10) {
}
}
TEST(HelpersTest, Nibbles) {
EXPECT_EQ(high_nibble(0xA5), 0xA);
EXPECT_EQ(low_nibble(0xA5), 0x5);
EXPECT_EQ(high_nibble(0x0F), 0x0);
EXPECT_EQ(low_nibble(0xF0), 0x0);
static_assert(high_nibble(0xFF) == 0xF && low_nibble(0xFF) == 0xF, "usable in constant expressions");
}
TEST(HelpersTest, EncodeUint12) {
EXPECT_EQ(encode_uint12(0x12, 0x3), 0x123);
EXPECT_EQ(encode_uint12(0xFF, 0xF), 0xFFF);
EXPECT_EQ(encode_uint12(0x00, 0x0), 0x000);
EXPECT_EQ(encode_uint12(0x12, 0xA3), 0x123);
// A touch record byte packing the low nibbles of X (high) and Y (low)
const uint8_t shared = 0x5C;
EXPECT_EQ(encode_uint12(0x34, high_nibble(shared)), 0x345);
EXPECT_EQ(encode_uint12(0x67, low_nibble(shared)), 0x67C);
static_assert(encode_uint12(0xAB, 0xC) == 0xABC, "usable in constant expressions");
}
TEST(HelpersTest, SignExtend) {
EXPECT_EQ(sign_extend<24>(0x000000), 0);
EXPECT_EQ(sign_extend<24>(0x7FFFFF), 8388607);
+2 -9
View File
@@ -20,14 +20,7 @@ class FakePin final : public GPIOPin {
bool level{false};
};
// Fills a 16-bit register read: the firmware check code read gets a valid code, other registers zeros.
inline void answer_setup_read(const uint8_t *data, size_t size, uint8_t *out, size_t out_size) {
std::memset(out, 0, out_size);
if (size == 2 && encode_uint16(data[0], data[1]) == cstxxx::REG_FW_CHECK_CODE && out_size == 4)
out[2] = out[3] = 0xCA;
}
// Answers the 8-bit status read with a set report and 16-bit setup reads through answer_setup_read().
// Answers the 8-bit status read with a set report and 16-bit setup reads with zeros.
class StatusBus final : public i2c::I2CBus {
public:
std::array<uint8_t, 28> status{};
@@ -42,7 +35,7 @@ class StatusBus final : public i2c::I2CBus {
if (size == 1 && data[0] == CST226_REG_STATUS) {
std::memcpy(out, this->status.data(), std::min(out_size, this->status.size()));
} else {
answer_setup_read(data, size, out, out_size);
std::memset(out, 0, out_size);
}
return i2c::ERROR_OK;
}
@@ -1,8 +1,5 @@
#include <gtest/gtest.h>
#include <cstring>
#include <vector>
#include "esphome/components/cst226/touchscreen/cst226_touchscreen.h"
#include "../common.h"
@@ -17,45 +14,6 @@ TEST(CST226TouchscreenTest, SetupCompletes) {
EXPECT_FALSE(hub.is_failed());
}
// Records the writes that carry no read, and answers reads through answer_setup_read().
class WriteLogBus final : public i2c::I2CBus {
public:
std::vector<std::vector<uint8_t>> writes;
bool bad_check_code{false};
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out,
size_t out_size) override {
if (out_size == 0) {
this->writes.emplace_back(data, data + size);
} else {
answer_setup_read(data, size, out, out_size);
if (this->bad_check_code)
std::memset(out, 0, out_size);
}
return i2c::ERROR_OK;
}
};
TEST(CST226TouchscreenTest, SetupEntersCommandModeAndLeavesIt) {
WriteLogBus bus;
CST226Touchscreen hub;
hub.set_i2c_bus(&bus);
hub.setup();
ASSERT_GE(bus.writes.size(), 2u);
EXPECT_EQ(bus.writes.front(), (std::vector<uint8_t>{0xD1, 0x01}));
EXPECT_EQ(bus.writes.back(), (std::vector<uint8_t>{0xD1, 0x09}));
EXPECT_FALSE(hub.is_failed());
}
TEST(CST226TouchscreenTest, SetupFailsOnAWrongCheckCode) {
WriteLogBus bus;
bus.bad_check_code = true;
CST226Touchscreen hub;
hub.set_i2c_bus(&bus);
hub.setup();
EXPECT_TRUE(hub.is_failed());
}
TEST(CST226TouchscreenTest, SetupFailsWithoutTheChip) {
StatusBus bus;
bus.fail = true;
@@ -1,32 +0,0 @@
#include <gtest/gtest.h>
#include <algorithm>
#include "esphome/components/cstxxx/cstxxx.h"
namespace esphome::cstxxx::testing {
TEST(CSTxxxDecodeTouch, FirstRecordStartsTheReport) {
const uint8_t report[report_size(1)] = {0x16, 0x12, 0x34, 0x56, 0x78, 0x01, SYNC_BYTE};
auto touch = decode_touch(report, 0);
EXPECT_EQ(touch.id, 1);
EXPECT_EQ(touch.event, 0x06);
EXPECT_EQ(touch.x, 0x125);
EXPECT_EQ(touch.y, 0x346);
EXPECT_EQ(touch.z, 0x78);
}
TEST(CSTxxxDecodeTouch, LaterRecordsSkipTheStatusBytes) {
uint8_t report[report_size(3)]{};
report[FINGER_COUNT_IDX] = 3;
report[SYNC_IDX] = SYNC_BYTE;
const uint8_t third[5] = {0x26, 0xFF, 0x00, 0xF0, 0x05};
std::copy(third, third + 5, report + 12);
auto touch = decode_touch(report, 2);
EXPECT_EQ(touch.id, 2);
EXPECT_EQ(touch.x, 0xFFF);
EXPECT_EQ(touch.y, 0x000);
EXPECT_EQ(touch.z, 5);
}
} // namespace esphome::cstxxx::testing
@@ -2,7 +2,3 @@ packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
# IPv6 with an IP sensor: compiles the IPv6 event handler's listener notification
network:
enable_ipv6: true
-84
View File
@@ -1,84 +0,0 @@
npm1300:
id: pmic
i2c_id: i2c_bus
vbus_current_limit: 500mA
thermistor_resistance: 10kOhm
thermistor_beta: 3380
charger:
charge_current: 150mA
termination_voltage: 4.2V
warm_termination_voltage: 4.0V
termination_current: 20%
trickle_voltage: 2.5V
discharge_current_limit: 1000mA
recharge: true
buck2:
voltage: 3.3V
ldo1:
voltage: 3.3V
mode: ldo
enabled: true
ldo2:
mode: load_switch
enabled: false
sensor:
- platform: npm1300
battery_voltage:
name: Battery Voltage
battery_current:
name: Battery Current
battery_temperature:
name: Battery Temperature
die_temperature:
name: PMIC Temperature
system_voltage:
name: System Voltage
vbus_voltage:
name: VBUS Voltage
vbus_current_limit:
name: VBUS Current Limit
binary_sensor:
- platform: npm1300
vbus_present:
name: VBUS Present
vbus_current_limit_active:
name: VBUS Current Limit Active
charging:
name: Charging
charge_complete:
name: Charge Complete
charger_error:
name: Charger Error
text_sensor:
- platform: npm1300
charger_status:
name: Charger Status
switch:
- platform: npm1300
charger:
name: Charger
buck1:
name: BUCK1
ldo1:
name: LDO1
ldo2:
name: LDO2
button:
- platform: template
name: Ship Mode
on_press:
- npm1300.enter_ship_mode: pmic
- platform: template
name: Hibernate
on_press:
- npm1300.hibernate:
id: pmic
duration: 1h
- npm1300.hibernate:
id: pmic
duration: !lambda return 60000;
@@ -1,3 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
npm1300: !include common.yaml
-3
View File
@@ -1,3 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/host.yaml
npm1300: !include common.yaml
@@ -1,3 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/nrf52.yaml
npm1300: !include common.yaml
-16
View File
@@ -1,16 +0,0 @@
"""Enable real logger callbacks for radio reentrancy regression tests."""
import esphome.codegen as cg
from esphome.components.logger import request_log_listener
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config: ConfigType) -> None:
request_log_listener()
# The tests build a software SPI bus in C++, which no configured bus asks for
cg.add_define("USE_SPI_SOFTWARE")
manifest.multi_conf = False
manifest.to_code = to_code
@@ -7,22 +7,3 @@ button:
cold: true
- sx126x.set_mode_sleep:
cold: !lambda return id(sx126x_my_number).state > 1;
- platform: template
name: "SX126x Async Send or Cancel"
on_press:
- lambda: |-
class Listener : public sx126x::SX126xTransmitListener {
public:
void on_transmit_complete(sx126x::SX126xError result) override {
ESP_LOGD("sx126x_test", "Async completion: %d", static_cast<int>(result));
}
};
static Listener listener;
if (id(sx126x_radio).is_transmitting()) {
id(sx126x_radio).cancel_transmit_async(&listener);
} else {
const uint8_t data[] = {0x11, 0x22, 0x33};
auto result = id(sx126x_radio).start_transmit_async(&listener, data, sizeof(data), 4000);
ESP_LOGD("sx126x_test", "Async admission: %d", static_cast<int>(result));
}
-1
View File
@@ -1,5 +1,4 @@
sx126x:
id: sx126x_radio
dio1_pin: ${dio1_pin}
cs_pin: ${cs_pin}
busy_pin: ${busy_pin}
@@ -1,643 +0,0 @@
#include "sx126x_test_helpers.h"
#include "esphome/core/application.h"
#include <tuple>
namespace esphome::sx126x::testing {
class TransmitListener : public SX126xTransmitListener {
public:
void on_transmit_complete(SX126xError result) override {
this->results.push_back(result);
if (this->on_complete) {
this->on_complete(result);
}
}
std::vector<SX126xError> results;
std::function<void(SX126xError)> on_complete;
};
class ReceiveCallback : public SX126xListener {
public:
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override { this->on_packet_callback(); }
std::function<void()> on_packet_callback;
};
class SX126xAsyncModes : public ::testing::TestWithParam<std::tuple<bool, bool, bool>> {};
TEST_P(SX126xAsyncModes, ReturnsBeforeCompletionAndRestoresMode) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware hw;
TransmitListener listener;
hw.configure(lora, rx, fixed);
hw.auto_complete = false;
hw.clear_trace();
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 1234), SX126xError::SX126X_ERROR_NONE);
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_TRUE(listener.results.empty());
EXPECT_EQ(hw.sends, 1u);
const auto &tx = hw.transactions.back();
EXPECT_EQ(tx, (std::vector<uint8_t>{RADIO_SET_TX, 0x01, 0x34, 0x80}));
const auto fifo = std::find_if(hw.transactions.begin(), hw.transactions.end(),
[](const auto &cmd) { return cmd[0] == RADIO_WRITE_BUFFER; });
ASSERT_NE(fifo, hw.transactions.end());
EXPECT_EQ(*fifo, (std::vector<uint8_t>{RADIO_WRITE_BUFFER, 0, 0x11, 0x22, 0x33}));
hw.packet.assign(3, 0x99); // The driver must not retain the caller's payload.
hw.irq = IRQ_TX_DONE;
listener.on_complete = [&](SX126xError result) {
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_EQ(hw.receiving, rx);
EXPECT_TRUE(hw.cs.level);
};
hw.radio.loop();
hw.radio.loop();
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST_P(SX126xAsyncModes, RejectsEveryFailedStartWithoutCallbackAndCanRetry) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware reference;
TransmitListener listener;
reference.configure(lora, rx, fixed);
reference.clear_trace();
ASSERT_EQ(reference.radio.start_transmit_async(&listener, reference.packet), SX126xError::SX126X_ERROR_NONE);
const size_t count = reference.transactions.size();
reference.radio.cancel_transmit_async(&listener);
for (size_t fault = 1; fault <= count; fault++) {
SCOPED_TRACE(fault);
RadioHardware hw;
TransmitListener result;
hw.configure(lora, rx, fixed);
hw.clear_trace();
hw.fail_at = fault;
EXPECT_EQ(hw.radio.start_transmit_async(&result, hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(result.results.empty());
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_TRUE(hw.cs.level);
EXPECT_TRUE(hw.transactions[fault - 1].empty());
EXPECT_EQ(hw.sends, 0u);
EXPECT_EQ(hw.receiving, rx); // One-shot fault permits failed-start cleanup.
hw.clear_trace();
hw.radio.configure();
ASSERT_EQ(hw.radio.start_transmit_async(&result, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
EXPECT_EQ(result.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
}
TEST_P(SX126xAsyncModes, ReportsEveryCompletionAndCleanupFailureOnce) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware reference;
TransmitListener listener;
reference.configure(lora, rx, fixed);
ASSERT_EQ(reference.radio.start_transmit_async(&listener, reference.packet), SX126xError::SX126X_ERROR_NONE);
reference.clear_trace();
reference.radio.loop();
const size_t count = reference.transactions.size();
for (size_t fault = 1; fault <= count; fault++) {
SCOPED_TRACE(fault);
RadioHardware hw;
TransmitListener result;
hw.configure(lora, rx, fixed);
ASSERT_EQ(hw.radio.start_transmit_async(&result, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.clear_trace();
hw.fail_at = fault;
hw.radio.loop();
EXPECT_EQ(result.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_BUSY_TIMEOUT}));
EXPECT_TRUE(hw.transactions[fault - 1].empty());
EXPECT_TRUE(hw.cs.level);
EXPECT_FALSE(hw.radio.is_transmitting());
hw.radio.loop();
EXPECT_EQ(result.results.size(), 1u);
hw.clear_trace();
hw.radio.configure();
ASSERT_EQ(hw.radio.start_transmit_async(&result, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
ASSERT_EQ(result.results.size(), 2u);
EXPECT_EQ(result.results.back(), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(hw.receiving, rx);
}
}
TEST_P(SX126xAsyncModes, BlockingAndAsyncUseTheSameCommandSequence) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware blocking, async;
TransmitListener listener;
blocking.configure(lora, rx, fixed);
async.configure(lora, rx, fixed);
blocking.clear_trace();
async.clear_trace();
ASSERT_EQ(blocking.radio.transmit_packet(blocking.packet), SX126xError::SX126X_ERROR_NONE);
ASSERT_EQ(async.radio.start_transmit_async(&listener, async.packet), SX126xError::SX126X_ERROR_NONE);
async.radio.loop();
EXPECT_EQ(blocking.transactions, async.transactions);
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
EXPECT_EQ(blocking.receiving, async.receiving);
}
INSTANTIATE_TEST_SUITE_P(LoRaAndFSKWithRxOrSleepAndFixedOrVariablePayload, SX126xAsyncModes,
::testing::Combine(::testing::Bool(), ::testing::Bool(), ::testing::Bool()));
TEST(SX126xAsync, SoftwareDeadlineWithoutDioAndCachedTickBeforeStart) {
RadioHardware hw;
TransmitListener listener;
hw.configure(true, true);
hw.auto_complete = false;
hw.dio.internal = true;
const uint32_t before = millis();
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 50), SX126xError::SX126X_ERROR_NONE);
const uint32_t after = millis();
{
LoopBlockingGuard clock{nullptr, nullptr, before - 1};
hw.radio.call();
EXPECT_TRUE(listener.results.empty()); // No unsigned underflow on the admitting loop tick.
}
{
LoopBlockingGuard clock{nullptr, nullptr, after + 51};
hw.radio.call(); // Still enabled despite no IRQ or ISR edge.
}
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_TIMEOUT}));
EXPECT_TRUE(hw.receiving);
EXPECT_FALSE(hw.radio.is_transmitting());
}
TEST(SX126xAsync, UnrelatedIrqIsNotSuccessAndTimeoutWinsOverTxDone) {
RadioHardware hw;
TransmitListener listener;
hw.configure(true, true);
hw.auto_complete = false;
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.irq = IRQ_RX_DONE;
{
LoopBlockingGuard clock{nullptr, nullptr, millis()};
hw.radio.loop();
}
EXPECT_TRUE(listener.results.empty());
hw.irq = IRQ_TX_DONE | IRQ_RX_TX_TIMEOUT;
hw.radio.loop();
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_TIMEOUT}));
}
TEST(SX126xAsync, CancelRequiresOwnerAndCompletesSynchronouslyOnce) {
RadioHardware hw;
TransmitListener listener, other;
hw.configure(true, true);
hw.auto_complete = false;
EXPECT_FALSE(hw.radio.cancel_transmit_async(&listener));
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_FALSE(hw.radio.cancel_transmit_async(nullptr));
EXPECT_FALSE(hw.radio.cancel_transmit_async(&other));
EXPECT_TRUE(hw.radio.cancel_transmit_async(&listener));
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_CANCELLED}));
EXPECT_FALSE(hw.radio.cancel_transmit_async(&listener));
hw.irq = IRQ_TX_DONE; // A late IRQ cannot deliver a second completion.
hw.radio.loop();
EXPECT_EQ(listener.results.size(), 1u);
EXPECT_TRUE(hw.receiving);
hw.irq = IRQ_TX_DONE; // Also cleared before a new send starts.
ASSERT_EQ(hw.radio.start_transmit_async(&other, hw.packet), SX126xError::SX126X_ERROR_NONE);
{
LoopBlockingGuard clock{nullptr, nullptr, millis()};
hw.radio.loop();
}
EXPECT_TRUE(other.results.empty());
EXPECT_TRUE(hw.radio.cancel_transmit_async(&other));
}
TEST(SX126xAsync, CompletionCallbackCanStartNextSend) {
RadioHardware hw;
TransmitListener first, second;
hw.configure(true, true);
hw.auto_complete = false;
first.on_complete = [&](SX126xError result) {
EXPECT_EQ(hw.radio.start_transmit_async(&second, hw.packet), SX126xError::SX126X_ERROR_NONE);
};
ASSERT_EQ(hw.radio.start_transmit_async(&first, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
EXPECT_EQ(first.results.size(), 1u);
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_TRUE(second.results.empty());
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
EXPECT_EQ(second.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
static void expect_conflicting_calls_rejected(RadioHardware &hw, TransmitListener &listener) {
const size_t count = hw.transactions.size();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_BUSY);
hw.radio.set_mode_rx();
hw.radio.set_mode_tx();
hw.radio.set_mode_standby(STDBY_XOSC);
hw.radio.set_mode_sleep(true);
hw.radio.configure();
hw.radio.run_image_cal();
EXPECT_EQ(hw.transactions.size(), count);
}
TEST(SX126xAsync, PendingReceiveRejectsAdmissionWithoutDispatchOrAcknowledgment) {
RadioHardware hw;
TransmitListener listener;
PacketListener receiver;
hw.configure(true, true);
hw.radio.register_listener(&receiver);
hw.irq = IRQ_RX_DONE;
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_TRUE(receiver.packets.empty());
EXPECT_TRUE(listener.results.empty());
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
hw.radio.loop();
EXPECT_EQ(receiver.packets.size(), 1u);
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
expect_conflicting_calls_rejected(hw, listener);
hw.radio.loop();
EXPECT_EQ(listener.results.size(), 1u);
}
TEST(SX126xAsync, ValidatesReadinessPayloadAndTimeoutWithoutSpi) {
RadioHardware hw;
TransmitListener listener;
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hw.transactions.empty());
hw.configure(true, true, true);
hw.clear_trace();
EXPECT_EQ(hw.radio.start_transmit_async(nullptr, hw.packet), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, nullptr, 3), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet.data(), 0), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet.data(), 2), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet.data(), 256), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 0), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 262144), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(listener.results.empty());
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 262143), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(hw.transactions.back(), (std::vector<uint8_t>{RADIO_SET_TX, 0xFF, 0xFF, 0xC0}));
hw.radio.loop();
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 1), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(hw.transactions.back(), (std::vector<uint8_t>{RADIO_SET_TX, 0, 0, 64}));
hw.radio.loop();
}
TEST(SX126xAsync, PersistentBusyReportsFailedCleanupAndAllowsLaterRecovery) {
RadioHardware hw;
TransmitListener listener;
hw.configure(true, true);
hw.auto_complete = false;
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.busy.on_read = []() { return true; };
ASSERT_TRUE(hw.radio.cancel_transmit_async(&listener));
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_BUSY_TIMEOUT}));
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_TRUE(hw.radio.status_has_warning());
EXPECT_TRUE(hw.cs.level);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_EQ(listener.results.size(), 1u); // Failed admission never invokes completion.
hw.busy.on_read = []() { return false; };
hw.radio.configure();
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
ASSERT_EQ(listener.results.size(), 2u);
EXPECT_EQ(listener.results.back(), SX126xError::SX126X_ERROR_NONE);
EXPECT_TRUE(hw.receiving);
EXPECT_FALSE(hw.radio.status_has_warning());
}
TEST(SX126xAsync, CancellationCallbackMayStartAnotherSend) {
RadioHardware hw;
TransmitListener first, second;
hw.configure(true, true);
hw.auto_complete = false;
first.on_complete = [&](SX126xError result) {
EXPECT_EQ(result, SX126xError::SX126X_ERROR_CANCELLED);
EXPECT_EQ(hw.radio.start_transmit_async(&second, hw.packet), SX126xError::SX126X_ERROR_NONE);
};
ASSERT_EQ(hw.radio.start_transmit_async(&first, hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_TRUE(hw.radio.cancel_transmit_async(&first));
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_FALSE(hw.radio.cancel_transmit_async(&first));
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
EXPECT_EQ(second.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST(SX126xAsync, RejectsAfterFailedSetupWithoutBusAccess) {
RadioHardware hw;
TransmitListener listener;
hw.fail_at = 1;
hw.configure(true, true);
ASSERT_TRUE(hw.radio.is_failed());
hw.clear_trace();
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(listener.results.empty());
}
TEST(SX126xAsync, TwoRadiosHaveIndependentOwnersAndCompletion) {
RadioHardware a, b;
TransmitListener first, second;
a.configure(true, true);
b.configure(false, false);
a.auto_complete = b.auto_complete = false;
ASSERT_EQ(a.radio.start_transmit_async(&first, a.packet), SX126xError::SX126X_ERROR_NONE);
ASSERT_EQ(b.radio.start_transmit_async(&second, b.packet), SX126xError::SX126X_ERROR_NONE);
a.radio.cancel_transmit_async(&first);
EXPECT_TRUE(b.radio.is_transmitting());
EXPECT_TRUE(second.results.empty());
b.irq = IRQ_TX_DONE;
b.radio.loop();
EXPECT_EQ(first.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_CANCELLED}));
EXPECT_EQ(second.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST(SX126xAsync, BackToBackReceiveEchoPreservesBorrowedPacketForAllListeners) {
class EchoListener : public SX126xListener {
public:
std::function<void(const std::vector<uint8_t> &)> callback;
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override { callback(packet); }
} echo;
RadioHardware hw;
TransmitListener tx;
PacketListener observer;
hw.configure(true, true);
hw.auto_complete = false;
hw.radio.register_listener(&echo);
hw.radio.register_listener(&observer);
const auto first = hw.packet;
const std::vector<uint8_t> second(200, 0xA5);
unsigned calls = 0;
echo.callback = [&](const std::vector<uint8_t> &packet) {
if (++calls == 1) {
hw.packet = second; // Larger back-to-back RX could reallocate the driver's packet_.
hw.irq = IRQ_RX_DONE;
const auto count = hw.transactions.size();
hw.radio.loop(); // Explicit loop reentry must not overwrite the borrowed RX buffer either.
// The send stops in standby and holds the new packet instead of overwriting the FIFO.
EXPECT_EQ(hw.radio.start_transmit_async(&tx, packet), SX126xError::SX126X_ERROR_BUSY);
ASSERT_EQ(hw.transactions.size(), count + 2);
EXPECT_EQ(hw.transactions[count][0], RADIO_SET_STANDBY);
EXPECT_EQ(hw.transactions.back()[0], RADIO_GET_IRQSTATUS);
EXPECT_EQ(packet, first);
} else {
EXPECT_EQ(packet, second);
EXPECT_EQ(hw.radio.start_transmit_async(&tx, packet), SX126xError::SX126X_ERROR_NONE);
}
};
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
ASSERT_EQ(observer.packets.size(), 1u);
EXPECT_EQ(observer.packets[0], first);
EXPECT_TRUE(tx.results.empty());
EXPECT_FALSE(hw.radio.is_transmitting());
hw.radio.loop();
ASSERT_EQ(observer.packets.size(), 2u);
EXPECT_EQ(observer.packets[1], second);
ASSERT_EQ(calls, 2u);
auto fifo = std::find_if(hw.transactions.rbegin(), hw.transactions.rend(),
[](const auto &cmd) { return !cmd.empty() && cmd[0] == RADIO_WRITE_BUFFER; });
ASSERT_NE(fifo, hw.transactions.rend());
EXPECT_EQ(std::vector<uint8_t>(fifo->begin() + 2, fifo->end()), second);
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
EXPECT_EQ(tx.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST(SX126xAsync, FailedReconfigurationRejectsAsyncUntilReconfigured) {
RadioHardware hw;
TransmitListener listener;
hw.configure(true, true);
hw.clear_trace();
hw.fail_at = 3;
hw.radio.configure();
hw.clear_trace();
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(listener.results.empty());
hw.radio.configure();
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST(SX126xAsync, BlockingSendUsesIrqResultInsteadOfTreatingAnyDioAsSuccess) {
RadioHardware hw;
hw.configure(true, true);
auto write = hw.cs.on_write;
hw.cs.on_write = [&](bool high) {
write(high);
if (high && !hw.transactions.back().empty() && hw.transactions.back()[0] == RADIO_SET_TX) {
hw.irq = IRQ_RX_TX_TIMEOUT;
}
};
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_TIMEOUT);
EXPECT_TRUE(hw.receiving);
}
TEST(SX126xAsync, FailedAdmissionRestoresReceivePollingWithoutAnotherTransmission) {
RadioHardware hw;
PacketListener rx;
TransmitListener tx;
hw.configure(true, true);
hw.radio.register_listener(&rx);
const auto configured_params = hw.packet_params;
hw.dio.internal = true;
hw.radio.call();
ASSERT_TRUE(hw.radio.is_idle());
hw.clear_trace();
hw.fail_at = 3;
EXPECT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(tx.results.empty());
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.packet_params, configured_params);
hw.clear_trace();
hw.irq = IRQ_RX_DONE; // No simulated ISR edge: restoration must re-enable polling.
hw.radio.call();
ASSERT_EQ(rx.packets.size(), 1u);
EXPECT_EQ(rx.packets[0], hw.packet);
EXPECT_EQ(hw.sends, 0u);
}
TEST_P(SX126xAsyncModes, ReceiveAtStandbyRejectsSendAndSurvivesReadFailure) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware hw;
PacketListener received;
TransmitListener tx;
hw.configure(lora, rx, fixed);
hw.radio.call(); // Advance from setup into ESPHome loop scheduling.
hw.radio.register_listener(&received);
hw.clear_trace();
auto write = hw.cs.on_write;
bool inject = true;
hw.cs.on_write = [&](bool high) {
write(high);
if (high && inject && !hw.transactions.back().empty() && hw.transactions.back()[0] == RADIO_SET_STANDBY) {
inject = false;
hw.irq = IRQ_RX_DONE;
}
};
EXPECT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_TRUE(tx.results.empty());
EXPECT_TRUE(received.packets.empty());
EXPECT_FALSE(hw.receiving);
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
ASSERT_EQ(hw.transactions.size(), 2u);
EXPECT_EQ(hw.transactions[0][0], RADIO_SET_STANDBY);
EXPECT_EQ(hw.transactions[1][0], RADIO_GET_IRQSTATUS);
const auto resets = hw.rst.writes;
hw.radio.set_mode_rx();
EXPECT_EQ(hw.rst.writes, resets);
EXPECT_EQ(hw.transactions.size(), 2u);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_FALSE(hw.radio.cancel_transmit_async(&tx));
hw.clear_trace();
hw.fail_at = 2; // Packet metadata read fails after the held IRQ is read.
hw.radio.loop();
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_TRUE(received.packets.empty());
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
EXPECT_FALSE(hw.receiving);
hw.clear_trace();
LoopBlockingGuard retry{nullptr, nullptr, App.get_loop_component_start_time() + 200};
hw.radio.loop();
hw.radio.loop();
ASSERT_EQ(received.packets.size(), 1u);
EXPECT_EQ(received.packets[0], hw.packet);
EXPECT_EQ(hw.receiving, rx);
EXPECT_EQ(hw.sends, 0u);
EXPECT_TRUE(tx.results.empty());
ASSERT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
EXPECT_EQ(tx.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST_P(SX126xAsyncModes, FailedPreparationAndRestorationRequireSuccessfulConfiguration) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware hw;
TransmitListener listener;
hw.configure(lora, rx, fixed);
hw.clear_trace();
const auto busy = hw.busy.on_read;
hw.busy.on_read = [&]() { return hw.transactions.size() >= 3 || busy(); };
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(listener.results.empty());
EXPECT_EQ(hw.sends, 0u);
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_TRUE(hw.cs.level);
ASSERT_EQ(hw.transactions.size(), 4u);
EXPECT_TRUE(hw.transactions[2].empty()); // Failed preparation.
EXPECT_TRUE(hw.transactions[3].empty()); // Failed restoration.
hw.clear_trace();
hw.busy.on_read = busy;
hw.radio.set_mode_rx();
hw.radio.loop();
EXPECT_TRUE(hw.radio.is_idle());
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hw.transactions.empty());
hw.fail_at = 1;
hw.radio.configure();
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.clear_trace();
hw.radio.configure();
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_EQ(hw.receiving, rx);
EXPECT_TRUE(listener.results.empty());
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST(SX126xAsync, ExplicitConfigurationCanRecoverAnUnreadableHeldPacket) {
RadioHardware hw;
TransmitListener listener;
hw.configure(true, true);
auto write = hw.cs.on_write;
hw.cs.on_write = [&](bool high) {
write(high);
if (high && !hw.transactions.back().empty() && hw.transactions.back()[0] == RADIO_SET_STANDBY) {
hw.irq = IRQ_RX_DONE;
}
};
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_BUSY);
hw.cs.on_write = write;
hw.busy.on_read = []() { return true; };
hw.radio.loop();
const auto resets = hw.rst.writes;
hw.radio.configure();
EXPECT_GT(hw.rst.writes, resets);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.busy.on_read = []() { return false; };
hw.radio.configure();
EXPECT_TRUE(hw.receiving);
EXPECT_TRUE(listener.results.empty());
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
class SX126xReceiveActions : public ::testing::TestWithParam<unsigned> {};
TEST_P(SX126xReceiveActions, ModeAndConfigurationPreserveBorrowedPacket) {
RadioHardware hw;
ReceiveCallback action;
PacketListener observer;
hw.configure(true, true);
hw.radio.register_listener(&action);
hw.radio.register_listener(&observer);
const auto original = hw.packet;
const unsigned selected_action = GetParam();
unsigned callbacks = 0;
action.on_packet_callback = [&]() {
callbacks++;
hw.clear_trace();
switch (selected_action) {
case 0:
hw.radio.set_mode_sleep();
break;
case 1:
hw.radio.set_mode_sleep(true);
break;
case 2:
hw.radio.set_rx_start(false);
hw.radio.configure();
break;
case 3:
hw.radio.set_mode_standby(STDBY_XOSC);
break;
case 4:
hw.radio.set_mode_rx();
break;
case 5:
hw.radio.run_image_cal();
break;
default:
FAIL() << "Unknown action " << selected_action;
}
EXPECT_FALSE(hw.transactions.empty()); // The requested operation must actually reach the chip.
EXPECT_EQ(hw.receiving, selected_action >= 4);
const auto commands = hw.transactions.size();
hw.packet.assign(19, 0x99);
hw.irq = IRQ_RX_DONE;
hw.radio.loop(); // Recursive packet delivery is still forbidden, including after configure().
EXPECT_EQ(hw.transactions.size(), commands);
};
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
EXPECT_EQ(callbacks, 1u);
ASSERT_EQ(observer.packets.size(), 1u);
EXPECT_EQ(observer.packets.front(), original);
EXPECT_EQ(hw.irq, IRQ_RX_DONE); // The next packet is left for a later loop.
if (selected_action == 1) {
TransmitListener tx;
EXPECT_EQ(hw.radio.start_transmit_async(&tx, original), SX126xError::SX126X_ERROR_NOT_READY);
}
}
INSTANTIATE_TEST_SUITE_P(SleepColdSleepConfigureStandbyRxAndCalibration, SX126xReceiveActions,
::testing::Range(0u, 6u));
} // namespace esphome::sx126x::testing
@@ -1,788 +0,0 @@
#include "sx126x_test_helpers.h"
#include <cmath>
#include "esphome/core/application.h"
#include <tuple>
#include <type_traits>
#ifdef USE_HOST
#include <string>
#include "esphome/components/logger/logger.h"
#endif
namespace esphome::sx126x::testing {
class SX126xBusySetup : public ::testing::TestWithParam<std::tuple<bool, bool>> {};
TEST_P(SX126xBusySetup, StopsAtEveryConfigurationCommand) {
const auto [lora, rx] = GetParam();
RadioHardware reference;
reference.configure(lora, rx);
ASSERT_FALSE(reference.radio.is_failed());
ASSERT_FALSE(reference.radio.status_has_warning());
const size_t count = reference.transactions.size();
ASSERT_GT(count, 10u);
for (size_t fault = 1; fault <= count; fault++) {
SCOPED_TRACE(fault);
RadioHardware hardware;
hardware.fail_at = fault;
hardware.configure(lora, rx);
hardware.expect_aborted(fault);
EXPECT_TRUE(hardware.radio.is_failed());
hardware.radio.dump_config(); // Also safe if the version read never happened.
hardware.clear_trace();
hardware.radio.configure();
hardware.radio.set_mode_rx();
hardware.radio.set_mode_tx();
hardware.radio.set_mode_sleep();
hardware.radio.set_mode_standby(STDBY_XOSC);
hardware.radio.run_image_cal();
hardware.radio.loop();
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hardware.transactions.empty());
}
}
INSTANTIATE_TEST_SUITE_P(LoRaAndFSKWithRxOrSleep, SX126xBusySetup,
::testing::Combine(::testing::Bool(), ::testing::Bool()));
class SX126xBusyTransmit : public ::testing::TestWithParam<std::tuple<bool, bool, bool>> {};
TEST_P(SX126xBusyTransmit, StopsAtEverySendAndCleanupCommandAndCanRetry) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware reference;
reference.configure(lora, rx, fixed);
reference.clear_trace();
ASSERT_EQ(reference.radio.transmit_packet(reference.packet), SX126xError::SX126X_ERROR_NONE);
const size_t count = reference.transactions.size();
for (size_t fault = 1; fault <= count; fault++) {
SCOPED_TRACE(fault);
RadioHardware hardware;
hardware.configure(lora, rx, fixed);
hardware.clear_trace();
hardware.fail_at = fault;
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet.data(), hardware.packet.size()),
SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(hardware.transactions[fault - 1].empty());
EXPECT_TRUE(hardware.cs.level);
EXPECT_FALSE(hardware.radio.is_failed());
hardware.clear_trace();
hardware.radio.configure();
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_FALSE(hardware.radio.status_has_warning());
EXPECT_EQ(hardware.receiving, rx);
}
}
TEST_P(SX126xBusyTransmit, RestoresEveryPreparationFailureWithoutAnotherTransmission) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware reference;
reference.configure(lora, rx, fixed);
reference.clear_trace();
ASSERT_EQ(reference.radio.transmit_packet(reference.packet), SX126xError::SX126X_ERROR_NONE);
size_t tx_command = 0;
for (size_t i = 0; i < reference.transactions.size(); i++) {
if (reference.transactions[i][0] == RADIO_SET_TX) {
tx_command = i + 1;
break;
}
}
ASSERT_NE(tx_command, 0u);
for (size_t fault = 1; fault <= tx_command; fault++) {
SCOPED_TRACE(fault);
RadioHardware hw;
PacketListener listener;
hw.configure(lora, rx, fixed);
hw.radio.register_listener(&listener);
const auto configured_params = hw.packet_params;
hw.clear_trace();
hw.fail_at = fault;
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(hw.transactions[fault - 1].empty());
EXPECT_TRUE(hw.cs.level);
EXPECT_EQ(hw.ends, hw.transactions.size());
EXPECT_EQ(hw.receiving, rx);
EXPECT_EQ(hw.packet_params, configured_params);
EXPECT_EQ(hw.irq, 0u);
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_FALSE(hw.radio.is_failed());
const auto sends = hw.sends;
hw.clear_trace();
if (rx) {
if (!fixed) {
hw.packet.resize(9, 0x55); // RX must accept a different length than the attempted TX.
}
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets[0], hw.packet);
}
EXPECT_EQ(hw.sends, sends);
}
}
TEST_P(SX126xBusyTransmit, FailedRestorationRequiresConfigureBeforeReceiveRecovery) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware reference;
reference.configure(lora, rx, fixed);
reference.clear_trace();
ASSERT_EQ(reference.radio.transmit_packet(reference.packet), SX126xError::SX126X_ERROR_NONE);
size_t restoration = 0;
bool sent = false;
for (size_t i = 0; i < reference.transactions.size(); i++) {
if (reference.transactions[i][0] == RADIO_SET_TX) {
sent = true;
}
if (sent && reference.transactions[i][0] == RADIO_SET_STANDBY) {
restoration = i + 1;
break;
}
}
ASSERT_NE(restoration, 0u);
for (size_t fault = restoration; fault <= reference.transactions.size(); fault++) {
SCOPED_TRACE(fault);
RadioHardware hw;
PacketListener listener;
hw.configure(lora, rx, fixed);
hw.radio.register_listener(&listener);
const auto configured_params = hw.packet_params;
hw.clear_trace();
hw.fail_at = fault;
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(hw.cs.level);
EXPECT_TRUE(hw.radio.status_has_warning());
EXPECT_FALSE(hw.radio.is_failed());
const auto sends = hw.sends;
hw.clear_trace(); // Clearing BUSY alone must not re-admit partial configuration.
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
hw.radio.set_mode_rx();
hw.radio.set_mode_tx();
hw.radio.set_mode_standby(STDBY_XOSC);
hw.radio.set_mode_sleep();
hw.radio.run_image_cal();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(listener.packets.empty());
EXPECT_TRUE(hw.radio.status_has_warning());
hw.irq = 0; // Hardware reset clears IRQs; the GPIO fake does not model reset.
hw.radio.configure();
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_EQ(hw.packet_params, configured_params);
EXPECT_EQ(hw.receiving, rx);
if (rx) {
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets[0], hw.packet);
}
EXPECT_EQ(hw.sends, sends); // Recovery must not depend on sending another packet.
}
}
TEST(SX126xBusy, PersistentBusyDuringPreparationAndRestorationRequiresConfigure) {
RadioHardware hw;
PacketListener listener;
hw.configure(true, true);
hw.radio.register_listener(&listener);
const auto configured_params = hw.packet_params;
hw.clear_trace();
auto normal_busy = hw.busy.on_read;
hw.busy.on_read = [&]() { return hw.transactions.size() >= 3 || normal_busy(); };
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_FALSE(hw.receiving);
EXPECT_TRUE(hw.radio.status_has_warning());
EXPECT_EQ(hw.sends, 0u);
hw.busy.on_read = normal_busy;
hw.clear_trace();
hw.radio.set_mode_rx();
hw.radio.run_image_cal();
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(hw.radio.status_has_warning());
hw.radio.configure();
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.packet_params, configured_params);
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets[0], hw.packet);
EXPECT_EQ(hw.sends, 0u);
}
INSTANTIATE_TEST_SUITE_P(LoRaAndFSKWithRxOrSleepAndFixedOrVariablePayload, SX126xBusyTransmit,
::testing::Combine(::testing::Bool(), ::testing::Bool(), ::testing::Bool()));
class SX126xBusyReceive : public ::testing::TestWithParam<bool> {};
TEST_P(SX126xBusyReceive, KeepsAPacketWhoseReadFailedAndRetries) {
const bool lora = GetParam();
for (size_t fault = 1; fault <= 5; fault++) {
SCOPED_TRACE(fault);
RadioHardware hardware;
PacketListener listener;
hardware.configure(lora, true);
hardware.radio.register_listener(&listener);
hardware.clear_trace();
hardware.irq = IRQ_RX_DONE;
hardware.fail_at = fault;
hardware.radio.loop();
hardware.expect_aborted(fault);
EXPECT_TRUE(listener.packets.empty());
EXPECT_TRUE(hardware.radio.status_has_warning());
EXPECT_FALSE(hardware.radio.is_failed());
// The flags may already be cleared; the packet is still read on the retry.
hardware.clear_trace();
LoopBlockingGuard retry{nullptr, nullptr, App.get_loop_component_start_time() + 200};
hardware.radio.loop();
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets[0], hardware.packet);
EXPECT_FLOAT_EQ(listener.rssi, lora ? -40 : -42);
if (lora) {
EXPECT_FLOAT_EQ(listener.snr, 10);
} else {
EXPECT_TRUE(std::isnan(listener.snr));
}
EXPECT_FALSE(hardware.radio.status_has_warning());
}
}
INSTANTIATE_TEST_SUITE_P(LoRaAndFSK, SX126xBusyReceive, ::testing::Bool());
// A packet whose flags were cleared before its read failed, then held by a send, survives a
// second failed read
TEST(SX126xBusy, HeldPacketSurvivesASecondFailedRead) {
RadioHardware hw;
PacketListener listener;
hw.configure(true, true);
hw.radio.register_listener(&listener);
hw.clear_trace();
hw.irq = IRQ_RX_DONE;
hw.fail_at = 3; // the first read after the flags were cleared
hw.radio.loop();
ASSERT_EQ(hw.irq, 0);
ASSERT_TRUE(listener.packets.empty());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY);
hw.clear_trace();
hw.fail_at = 2; // the held packet's read
LoopBlockingGuard first{nullptr, nullptr, App.get_loop_component_start_time() + 200};
hw.radio.loop();
ASSERT_TRUE(listener.packets.empty());
hw.clear_trace();
LoopBlockingGuard second{nullptr, nullptr, App.get_loop_component_start_time() + 200};
hw.radio.loop();
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets[0], hw.packet);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
TEST(SX126xBusy, KeepsPollingAnUnclearedInterruptAfterBusyTimeout) {
RadioHardware hardware;
PacketListener listener;
hardware.configure(true, true);
hardware.radio.register_listener(&listener);
hardware.dio.internal = true; // No new ISR edge while the pending IRQ stays high.
hardware.clear_trace();
hardware.fail_at = 1;
hardware.irq = IRQ_RX_DONE;
hardware.radio.call();
EXPECT_TRUE(listener.packets.empty());
hardware.clear_trace();
LoopBlockingGuard retry{nullptr, nullptr, App.get_loop_component_start_time() + 200};
hardware.radio.call();
EXPECT_EQ(listener.packets.size(), 1u);
}
TEST(SX126xBusy, AssertsChipSelectBeforeWaitingForWakeup) {
RadioHardware hardware;
hardware.configure(true, true);
hardware.clear_trace();
hardware.busy.level = true; // The CS callback releases BUSY only after selecting the chip.
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_TRUE(hardware.cs.level);
EXPECT_EQ(hardware.ends, hardware.transactions.size());
}
TEST(SX126xBusy, RejectsSendBeforeSetupWithoutTouchingPins) {
RadioHardware hardware;
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hardware.transactions.empty());
EXPECT_EQ(hardware.busy.reads, 0u);
EXPECT_EQ(hardware.dio.reads, 0u);
}
TEST(SX126xBusy, UnsupportedVersionFailsSetup) {
RadioHardware hardware;
hardware.supported_version = false;
hardware.configure(true, true);
EXPECT_TRUE(hardware.radio.is_failed());
}
TEST(SX126xBusy, RuntimeVersionFailureRequiresConfigureWithoutPermanentlyFailing) {
RadioHardware hardware;
hardware.configure(true, true);
hardware.supported_version = false;
hardware.radio.configure();
EXPECT_FALSE(hardware.radio.is_failed());
EXPECT_TRUE(hardware.radio.status_has_warning());
hardware.clear_trace();
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hardware.transactions.empty());
hardware.supported_version = true;
hardware.radio.configure();
EXPECT_FALSE(hardware.radio.status_has_warning());
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NONE);
}
TEST(SX126xBusy, FailedReconfigurationRequiresSuccessfulConfigureBeforeOperations) {
RadioHardware reference;
reference.configure(true, true);
const size_t count = reference.transactions.size();
for (size_t fault = 1; fault <= count; fault++) {
SCOPED_TRACE(fault);
RadioHardware hw;
PacketListener listener;
hw.configure(true, true);
hw.radio.register_listener(&listener);
hw.clear_trace();
hw.fail_at = fault;
hw.radio.configure();
EXPECT_FALSE(hw.radio.is_failed());
hw.clear_trace();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
hw.radio.set_mode_rx();
hw.radio.set_mode_tx();
hw.radio.set_mode_standby(STDBY_XOSC);
hw.radio.set_mode_sleep();
hw.radio.run_image_cal();
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(listener.packets.empty());
hw.radio.configure();
hw.irq = 0;
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
}
TEST(SX126xBusy, RetryClearsStaleTxDoneBeforeWaitingForDelayedCompletion) {
RadioHardware reference;
reference.configure(true, true);
reference.clear_trace();
reference.radio.transmit_packet(reference.packet);
size_t cleanup = 0;
bool sent = false;
for (size_t i = 0; i < reference.transactions.size(); i++) {
if (reference.transactions[i][0] == RADIO_SET_TX) {
sent = true;
}
if (sent && reference.transactions[i][0] == RADIO_CLR_IRQSTATUS) {
cleanup = i + 1;
break;
}
}
ASSERT_NE(cleanup, 0u);
RadioHardware hw;
hw.configure(true, true);
hw.clear_trace();
hw.fail_at = cleanup;
ASSERT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_EQ(hw.irq & IRQ_TX_DONE, 0); // Shared restoration retries the failed IRQ clear.
hw.irq = IRQ_TX_DONE; // A stale flag must still not finish the next TX.
hw.clear_trace();
hw.auto_complete = false;
unsigned polls = 0;
hw.dio.on_read = [&]() {
if (++polls == 5) {
hw.irq |= IRQ_TX_DONE;
}
return hw.irq != 0;
};
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(polls, 5u);
EXPECT_TRUE(hw.receiving);
}
} // namespace esphome::sx126x::testing
namespace esphome::sx126x::testing {
template<typename T>
concept RawSpiAccessible = requires(T &device) { device.transfer_byte(0); };
template<typename T>
concept ManualSelectionAccessible = requires(T &device) {
device.enable();
device.disable();
};
static_assert(!RawSpiAccessible<SX126x>);
static_assert(!RawSpiAccessible<SX126xSpiDevice>);
static_assert(!ManualSelectionAccessible<SX126x>);
static_assert(!ManualSelectionAccessible<SX126xSpiDevice>);
// The radio runs every command sequence and state change. Without component status or
// listeners it cannot run user code mid-operation; SX126x::apply_() does that afterwards.
static_assert(!std::is_base_of_v<Component, SX126xRadio>);
} // namespace esphome::sx126x::testing
namespace esphome::sx126x::testing {
TEST(SX126xBusy, FailedModeChangeCannotBeClearedByAnotherModeCommand) {
for (size_t fault = 1; fault <= 3; fault++) {
RadioHardware hw;
hw.configure(true, true);
hw.clear_trace();
hw.fail_at = fault;
hw.radio.set_mode_rx();
EXPECT_TRUE(hw.radio.status_has_warning());
hw.clear_trace();
hw.radio.set_mode_standby(STDBY_XOSC);
hw.radio.run_image_cal();
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(hw.radio.status_has_warning());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.radio.configure();
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_TRUE(hw.receiving);
}
}
TEST(SX126xBusy, ConfigurationCannotReenterSendPreparationOrRestoration) {
RadioHardware hw;
hw.configure(true, true);
hw.radio.call(); // Advance from setup into ESPHome loop scheduling.
hw.clear_trace();
auto write = hw.cs.on_write;
unsigned guarded = 0;
hw.cs.on_write = [&](bool high) {
write(high);
if (!high || hw.transactions.back().empty() || hw.transactions.back()[0] != RADIO_SET_STANDBY) {
return;
}
guarded++;
const auto commands = hw.transactions.size();
hw.radio.configure();
hw.radio.set_mode_rx();
hw.radio.loop();
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_EQ(hw.transactions.size(), commands);
};
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(guarded, 2u);
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_TRUE(hw.receiving);
}
} // namespace esphome::sx126x::testing
namespace esphome::sx126x::testing {
TEST(SX126xBusy, InvalidPayloadDoesNotEnterAnOperation) {
RadioHardware hw;
hw.configure(true, true);
hw.clear_trace();
EXPECT_EQ(hw.radio.transmit_packet(nullptr, 3), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet.data(), 0), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
TEST(SX126xBusy, ColdSleepIsWokenByAModeCommandAndWarmSleepRetainsIt) {
RadioHardware hw;
hw.configure(true, true);
hw.radio.set_mode_sleep(false);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.set_mode_sleep(true);
hw.clear_trace();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
// Sleeping again leaves it asleep
hw.radio.set_mode_sleep(false);
EXPECT_TRUE(hw.transactions.empty());
// Any other mode command applies the lost configuration first
hw.radio.set_mode_rx();
EXPECT_FALSE(hw.transactions.empty());
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
// Runs the scheduler for ms milliseconds of real time, as the main loop would
static void run_scheduler(uint32_t ms) {
const uint32_t start = millis();
while (millis() - start < ms) {
App.scheduler.call(millis());
delay(5);
}
}
TEST(SX126xBusy, FailedModeChangeIsReconfiguredWithoutBeingAsked) {
RadioHardware hw;
hw.configure(true, true);
hw.clear_trace();
hw.fail_at = 1;
hw.radio.set_mode_rx();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
run_scheduler(1100);
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
// Once configured, the retry stops
hw.clear_trace();
run_scheduler(1100);
EXPECT_TRUE(hw.transactions.empty());
}
TEST(SX126xBusy, ColdSleepIsNotReconfiguredWithoutBeingAsked) {
RadioHardware hw;
hw.configure(true, true);
hw.radio.set_mode_sleep(true);
hw.clear_trace();
run_scheduler(1100);
EXPECT_TRUE(hw.transactions.empty());
}
TEST(SX126xBusy, ConfigureBeforeSetupDoesNothing) {
RadioHardware hw;
hw.radio.configure();
EXPECT_TRUE(hw.transactions.empty());
EXPECT_EQ(hw.rst.writes, 0u);
hw.configure(true, true);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
// The TX mode command sends whatever the FIFO holds without tracking it; its TX_DONE is
// cleared as a flag without a packet, and a send still works afterwards
TEST(SX126xBusy, RawTxModeLeavesTheRadioUsable) {
RadioHardware hw;
PacketListener listener;
hw.configure(true, true);
hw.radio.register_listener(&listener);
hw.radio.set_mode_tx();
ASSERT_EQ(hw.irq & IRQ_TX_DONE, IRQ_TX_DONE);
hw.radio.loop();
EXPECT_EQ(hw.irq, 0);
EXPECT_TRUE(listener.packets.empty());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
TEST(SX126xBusy, PacketThatFailedItsCrcDoesNotHoldUpASend) {
RadioHardware hw;
hw.configure(true, true);
hw.irq = IRQ_RX_DONE | IRQ_CRC_ERROR;
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
TEST(SX126xBusy, PacketCompletingDuringAReadIsReadNext) {
RadioHardware hw;
PacketListener listener;
hw.configure(true, true);
hw.radio.register_listener(&listener);
bool second = false;
hw.on_command = [&](uint8_t opcode) {
if (opcode == RADIO_READ_BUFFER && !second) {
second = true;
hw.irq |= IRQ_RX_DONE;
}
};
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
EXPECT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(hw.irq & IRQ_RX_DONE, IRQ_RX_DONE); // DIO1 stays high for the second packet
hw.radio.loop();
EXPECT_EQ(listener.packets.size(), 2u);
}
TEST(SX126xBusy, UnavailableLoopStopsAndConfigurationResumesReceiveWithoutAnInterrupt) {
for (bool internal : {false, true}) {
for (unsigned cause = 0; cause < 3; cause++) {
SCOPED_TRACE(internal);
SCOPED_TRACE(cause);
RadioHardware hw;
PacketListener received;
hw.configure(true, true);
hw.radio.register_listener(&received);
// Set after setup: the fake pin cannot attach a hardware interrupt.
hw.dio.internal = internal;
hw.clear_trace();
if (cause == 0) {
hw.radio.set_mode_sleep(true);
} else if (cause == 1) {
hw.fail_at = 1;
hw.radio.configure();
} else {
hw.fail_at = 1;
hw.radio.set_mode_rx();
}
hw.clear_trace();
hw.radio.call();
ASSERT_TRUE(hw.radio.is_idle());
EXPECT_TRUE(hw.transactions.empty());
EXPECT_EQ(hw.dio.reads, 0u);
hw.radio.configure();
ASSERT_TRUE(hw.radio.is_in_loop_state());
hw.irq = IRQ_RX_DONE; // No ISR edge: configure() must resume polling itself.
hw.radio.call();
ASSERT_EQ(received.packets.size(), 1u);
EXPECT_EQ(received.packets[0], hw.packet);
}
}
}
} // namespace esphome::sx126x::testing
namespace esphome::sx126x::testing {
TEST(SX126xProfile, SettersDoNotChangeAppliedPacketOrRestorationPolicy) {
RadioHardware hw;
hw.configure(true, true);
hw.radio.set_payload_length(2);
hw.radio.set_rx_start(false);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_TRUE(hw.receiving);
hw.radio.configure();
EXPECT_EQ(hw.radio.get_max_packet_size(), 2u);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet.data(), 2), SX126xError::SX126X_ERROR_NONE);
EXPECT_FALSE(hw.receiving);
}
TEST(SX126xProfile, RejectsOversizedSyncValueBeforeResetOrSpi) {
RadioHardware hw;
hw.configure(true, true);
hw.radio.set_sync_value(std::vector<uint8_t>(9, 1));
hw.clear_trace();
const auto reset_writes = hw.rst.writes;
hw.radio.configure();
EXPECT_EQ(hw.rst.writes, reset_writes);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.radio.set_sync_value({0x14, 0x24});
hw.radio.configure();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
} // namespace esphome::sx126x::testing
#ifdef USE_HOST
namespace esphome::sx126x::testing {
template<typename F> static std::string capture_radio_logs(F action) {
const auto previous_baud_rate = logger::global_logger->get_baud_rate();
::testing::internal::CaptureStdout();
logger::global_logger->set_baud_rate(115200);
action();
logger::global_logger->set_baud_rate(previous_baud_rate);
return ::testing::internal::GetCapturedStdout();
}
TEST(SX126xDiagnostics, BlockingRejectionsExplainWhyNoPacketWasSent) {
RadioHardware hw;
auto output = capture_radio_logs(
[&]() { EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY); });
EXPECT_NE(output.find("Packet send failed: radio not ready"), std::string::npos);
hw.configure(true, true, true);
hw.clear_trace();
output = capture_radio_logs(
[&]() { EXPECT_EQ(hw.radio.transmit_packet(hw.packet.data(), 2), SX126xError::SX126X_ERROR_INVALID_PARAMS); });
EXPECT_NE(output.find("Packet send failed: invalid payload"), std::string::npos);
EXPECT_TRUE(hw.transactions.empty());
hw.irq = IRQ_RX_DONE;
output =
capture_radio_logs([&]() { EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY); });
EXPECT_NE(output.find("Packet send failed: radio busy"), std::string::npos);
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
EXPECT_EQ(hw.sends, 0u);
hw.radio.loop(); // Deliver the held packet, which also clears its IRQ.
EXPECT_EQ(hw.irq, 0u);
hw.clear_trace();
hw.fail_at = 1;
output = capture_radio_logs(
[&]() { EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT); });
EXPECT_NE(output.find("Packet send failed: bus timeout"), std::string::npos);
EXPECT_EQ(hw.sends, 0u);
}
TEST(SX126xDiagnostics, AsyncAdmissionLeavesRejectionReportingToItsCaller) {
class Completion final : public SX126xTransmitListener {
public:
void on_transmit_complete(SX126xError result) override { ADD_FAILURE() << "Rejected send completed"; }
} completion;
RadioHardware hw;
const auto output = capture_radio_logs([&]() {
EXPECT_EQ(hw.radio.start_transmit_async(&completion, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.configure(true, true, true);
EXPECT_EQ(hw.radio.start_transmit_async(&completion, hw.packet.data(), 2),
SX126xError::SX126X_ERROR_INVALID_PARAMS);
hw.irq = IRQ_RX_DONE;
EXPECT_EQ(hw.radio.start_transmit_async(&completion, hw.packet), SX126xError::SX126X_ERROR_BUSY);
});
EXPECT_EQ(output.find("Packet send failed"), std::string::npos);
EXPECT_EQ(hw.sends, 0u);
}
TEST(SX126xDiagnostics, FailedSetupPrintsRequestedConfiguration) {
RadioHardware hw;
hw.fail_at = 1;
hw.radio.set_pa_power(10);
hw.configure(true, true, true);
ASSERT_TRUE(hw.radio.is_failed());
const auto output = capture_radio_logs([&]() { hw.radio.dump_config(); });
EXPECT_NE(output.find("Ready for operation: NO"), std::string::npos);
EXPECT_NE(output.find("Requested configuration:"), std::string::npos);
EXPECT_NE(output.find("Frequency: 915000000 Hz"), std::string::npos);
EXPECT_NE(output.find("PA Power: 10 dBm"), std::string::npos);
EXPECT_NE(output.find("Payload Length: 3"), std::string::npos);
EXPECT_NE(output.find("Sync Value: 0x1424"), std::string::npos);
}
TEST(SX126xDiagnostics, StagedConfigurationIsLabelledAndDoesNotAlterAppliedSettings) {
RadioHardware hw;
hw.configure(true, true, true);
hw.radio.set_frequency(868000000);
hw.radio.set_payload_length(2);
auto output = capture_radio_logs([&]() { hw.radio.dump_config(); });
EXPECT_NE(output.find("Ready for operation: YES"), std::string::npos);
EXPECT_NE(output.find("Requested configuration:"), std::string::npos);
EXPECT_NE(output.find("Frequency: 868000000 Hz"), std::string::npos);
EXPECT_NE(output.find("Payload Length: 2"), std::string::npos);
EXPECT_EQ(hw.radio.get_max_packet_size(), 3u);
hw.clear_trace();
hw.fail_at = 1;
hw.radio.configure();
output = capture_radio_logs([&]() { hw.radio.dump_config(); });
EXPECT_NE(output.find("Ready for operation: NO"), std::string::npos);
EXPECT_NE(output.find("Frequency: 868000000 Hz"), std::string::npos);
EXPECT_EQ(hw.radio.get_max_packet_size(), 3u);
}
class SX126xTimeoutDiagnostics : public ::testing::TestWithParam<bool> {};
TEST_P(SX126xTimeoutDiagnostics, BlockingTimeoutLogsFailureAfterRestoration) {
RadioHardware hw;
hw.configure(true, true);
hw.auto_complete = false;
if (GetParam()) {
hw.dio.on_read = [&]() {
if (hw.sends > 0) {
hw.irq = IRQ_RX_TX_TIMEOUT;
}
return hw.irq != 0;
};
}
const auto output =
capture_radio_logs([&]() { EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_TIMEOUT); });
EXPECT_NE(output.find("Packet send failed: timeout"), std::string::npos);
EXPECT_TRUE(hw.receiving);
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_FALSE(hw.radio.status_has_warning()); // Restoration itself succeeded.
}
INSTANTIATE_TEST_SUITE_P(SoftwareDeadlineAndRadioTimeout, SX126xTimeoutDiagnostics, ::testing::Bool());
TEST(SX126xDiagnostics, AsyncTimeoutLeavesCompletionReportingToItsCaller) {
struct Completion final : SX126xTransmitListener {
void on_transmit_complete(SX126xError result) override {
this->count++;
EXPECT_EQ(result, SX126xError::SX126X_ERROR_TIMEOUT);
}
unsigned count{0};
} completion;
RadioHardware hw;
hw.configure(true, true);
const auto output = capture_radio_logs([&]() {
ASSERT_EQ(hw.radio.start_transmit_async(&completion, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.irq = IRQ_RX_TX_TIMEOUT;
hw.radio.loop();
});
EXPECT_EQ(completion.count, 1u);
EXPECT_EQ(output.find("Packet send failed"), std::string::npos);
}
} // namespace esphome::sx126x::testing
#endif
@@ -1,182 +0,0 @@
#include "sx126x_test_helpers.h"
#include "esphome/components/logger/logger.h"
#include "esphome/core/application.h"
#include <string>
#include <tuple>
namespace esphome::sx126x::testing {
class RadioLogCallback {
public:
RadioLogCallback() : previous_(logger::global_logger) {
this->logger_.pre_setup();
this->logger_.set_log_level(ESPHOME_LOG_LEVEL_WARN);
this->logger_.add_log_callback(this,
[](void *instance, uint8_t level, const char *tag, const char *message, size_t len) {
auto *self = static_cast<RadioLogCallback *>(instance);
if (self->callback_) {
self->callback_(std::string(message, len));
}
});
}
~RadioLogCallback() { logger::global_logger = this->previous_; }
void set_callback(std::function<void(const std::string &)> callback) { this->callback_ = std::move(callback); }
private:
std::function<void(const std::string &)> callback_;
logger::Logger *previous_;
logger::Logger logger_{0};
};
struct ReentryCompletion final : SX126xTransmitListener {
void on_transmit_complete(SX126xError result) override { this->results.push_back(result); }
std::vector<SX126xError> results;
};
// Setup reports why it failed, not a generic reason
TEST(SX126xReentry, FailedSetupKeepsItsReason) {
RadioLogCallback log;
std::vector<std::string> messages;
log.set_callback([&](const std::string &message) { messages.push_back(message); });
RadioHardware hw;
hw.supported_version = false;
hw.configure(true, true);
ASSERT_TRUE(hw.radio.is_failed());
bool found = false;
for (const auto &message : messages)
found |= message.find("set Error flag: Unsupported chip version") != std::string::npos;
EXPECT_TRUE(found);
}
// Status logging is the only user code that can run inside a radio operation. It runs only
// after the operation has committed its state, so a callback sees either a running send or a
// released radio, and the interrupted operation touches nothing after the callback returns.
TEST(SX126xReentry, SuccessfulStartRunsNoUserCode) {
RadioHardware hw;
RadioLogCallback log;
ReentryCompletion outer, nested;
hw.configure(true, true);
hw.auto_complete = false;
hw.radio.status_set_warning(LOG_STR("Previous communication failure"));
hw.irq = IRQ_TX_DONE; // A stale IRQ must not complete the new send either.
unsigned callbacks = 0;
log.set_callback([&](const std::string &message) {
if (message.find("cleared Warning flag") != std::string::npos) {
callbacks++;
EXPECT_FALSE(hw.radio.is_transmitting()); // Only the completion clears the warning.
EXPECT_EQ(hw.radio.start_transmit_async(&nested, hw.packet), SX126xError::SX126X_ERROR_NONE);
}
});
ASSERT_EQ(hw.radio.start_transmit_async(&outer, hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(callbacks, 0u);
EXPECT_TRUE(outer.results.empty());
LoopBlockingGuard clock{nullptr, nullptr, millis()};
hw.radio.loop();
EXPECT_TRUE(outer.results.empty()); // The stale IRQ was cleared before the send.
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
ASSERT_EQ(callbacks, 1u);
EXPECT_EQ(outer.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
EXPECT_TRUE(nested.results.empty());
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_EQ(hw.sends, 2u);
ASSERT_TRUE(hw.radio.cancel_transmit_async(&nested));
EXPECT_EQ(nested.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_CANCELLED}));
EXPECT_EQ(outer.results.size(), 1u);
}
class SX126xStartReentry : public ::testing::TestWithParam<bool> {};
TEST_P(SX126xStartReentry, WarningCallbackAfterFailedStartSeesReleasedRadio) {
const bool cold_sleep = GetParam();
RadioHardware hw;
RadioLogCallback log;
ReentryCompletion outer, nested;
hw.configure(true, true);
hw.auto_complete = false;
hw.radio.status_set_warning(LOG_STR("Previous communication failure"));
hw.clear_trace();
hw.fail_at = 3; // Preparation fails; restoration succeeds and clears the warning.
unsigned callbacks = 0;
size_t commands_after_callback = 0;
log.set_callback([&](const std::string &message) {
if (message.find("cleared Warning flag") != std::string::npos) {
callbacks++;
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_EQ(hw.receiving, true); // Restoration finished before the callback.
if (cold_sleep) {
hw.radio.set_mode_sleep(true);
} else {
EXPECT_EQ(hw.radio.start_transmit_async(&nested, hw.packet), SX126xError::SX126X_ERROR_NONE);
}
commands_after_callback = hw.transactions.size();
}
});
EXPECT_EQ(hw.radio.start_transmit_async(&outer, hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
ASSERT_EQ(callbacks, 1u);
EXPECT_EQ(hw.transactions.size(), commands_after_callback); // The failed start touched nothing after it.
EXPECT_TRUE(outer.results.empty()); // A rejected start never completes.
if (cold_sleep) {
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_EQ(hw.radio.start_transmit_async(&outer, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_EQ(hw.sends, 0u);
return;
}
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_EQ(hw.sends, 1u);
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
EXPECT_EQ(nested.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
EXPECT_TRUE(outer.results.empty());
}
INSTANTIATE_TEST_SUITE_P(NestedSendOrColdSleep, SX126xStartReentry, ::testing::Bool());
class SX126xPollingReentry : public ::testing::TestWithParam<std::tuple<unsigned, bool>> {};
TEST_P(SX126xPollingReentry, WarningCallbackCannotCancelOrDisturbAFinishedSend) {
const auto [failed_command, reuse_listener] = GetParam();
RadioHardware hw;
RadioLogCallback log;
ReentryCompletion first, second;
auto *replacement = reuse_listener ? &first : &second;
hw.configure(true, true);
hw.auto_complete = false;
ASSERT_EQ(hw.radio.start_transmit_async(&first, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.clear_trace();
hw.fail_at = failed_command; // 0: success, 1: IRQ read fails, 2: IRQ clear fails.
hw.irq = IRQ_TX_DONE;
hw.radio.status_set_warning(LOG_STR("Previous communication failure"));
unsigned callbacks = 0;
size_t commands_after_replacement = 0;
log.set_callback([&](const std::string &message) {
if (callbacks == 0 && message.find("cleared Warning flag") != std::string::npos) {
callbacks++;
EXPECT_TRUE(first.results.empty()); // Status is reported before the completion.
EXPECT_FALSE(hw.radio.cancel_transmit_async(&first)); // Already finished: no second completion.
EXPECT_EQ(hw.radio.start_transmit_async(replacement, hw.packet), SX126xError::SX126X_ERROR_NONE);
commands_after_replacement = hw.transactions.size();
}
});
{
LoopBlockingGuard clock{nullptr, nullptr, millis()};
hw.radio.loop();
}
ASSERT_EQ(callbacks, 1u);
const auto expected = failed_command == 0 ? SX126xError::SX126X_ERROR_NONE : SX126xError::SX126X_ERROR_BUSY_TIMEOUT;
EXPECT_EQ(first.results, (std::vector<SX126xError>{expected}));
EXPECT_TRUE(second.results.empty());
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_EQ(hw.transactions.size(), commands_after_replacement); // No stale IRQ clear or restoration.
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
ASSERT_EQ(replacement->results.size(), reuse_listener ? 2u : 1u);
EXPECT_EQ(replacement->results.back(), SX126xError::SX126X_ERROR_NONE);
EXPECT_FALSE(hw.radio.is_transmitting());
}
INSTANTIATE_TEST_SUITE_P(ReadSuccessReadFailureAndClearFailure, SX126xPollingReentry,
::testing::Combine(::testing::Values(0u, 1u, 2u), ::testing::Bool()));
} // namespace esphome::sx126x::testing
@@ -1,127 +0,0 @@
#include "sx126x_test_helpers.h"
#include "esphome/core/application.h"
#include <tuple>
namespace esphome::sx126x::testing {
struct RejectedCompletion final : SX126xTransmitListener {
void on_transmit_complete(SX126xError result) override { ADD_FAILURE() << "Rejected transmission completed"; }
};
class SX126xReceiveRetry : public ::testing::TestWithParam<std::tuple<bool, unsigned, bool>> {};
TEST_P(SX126xReceiveRetry, PersistentBusyIsRateLimitedAndRecoversWithoutNewEdge) {
const auto [held_receive, failed_command, rollover] = GetParam();
RadioHardware hw;
PacketListener listener;
RejectedCompletion tx;
hw.configure(true, true);
hw.radio.register_listener(&listener);
hw.radio.call();
hw.dio.internal = true;
if (held_receive) {
auto write = hw.cs.on_write;
hw.cs.on_write = [&](bool high) {
write(high);
if (high && !hw.transactions.back().empty() && hw.transactions.back()[0] == RADIO_SET_STANDBY) {
hw.irq = IRQ_RX_DONE;
}
};
EXPECT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_BUSY);
hw.cs.on_write = write;
}
hw.clear_trace();
hw.irq = IRQ_RX_DONE;
hw.fail_at = failed_command;
const uint32_t start = rollover ? 0xFFFFFFF0u : 1000;
{
LoopBlockingGuard clock{nullptr, nullptr, start};
hw.radio.call();
}
hw.expect_aborted(failed_command);
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
const auto normal_busy = hw.busy.on_read;
hw.busy.on_read = []() { return true; };
for (uint32_t elapsed = 1; elapsed < 200; elapsed++) {
LoopBlockingGuard clock{nullptr, nullptr, start + elapsed};
hw.radio.call();
}
EXPECT_EQ(hw.transactions.size(), failed_command); // No SPI/BUSY waits inside the retry interval.
{
LoopBlockingGuard clock{nullptr, nullptr, start + 200};
hw.radio.call();
}
ASSERT_EQ(hw.transactions.size(), failed_command + 1);
EXPECT_TRUE(hw.transactions.back().empty());
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_TRUE(hw.cs.level);
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
EXPECT_TRUE(listener.packets.empty());
hw.busy.on_read = normal_busy;
hw.fail_at = 0;
{
LoopBlockingGuard clock{nullptr, nullptr, start + 399};
hw.radio.call();
EXPECT_EQ(hw.transactions.size(), failed_command + 1);
}
{
LoopBlockingGuard clock{nullptr, nullptr, start + 400};
hw.radio.call();
}
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets.front(), hw.packet);
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.sends, 0u);
hw.radio.call();
EXPECT_EQ(listener.packets.size(), 1u);
}
INSTANTIATE_TEST_SUITE_P(IdleAndHeldRxWithIrqAndPacketFailuresAcrossRollover, SX126xReceiveRetry,
::testing::Combine(::testing::Bool(), ::testing::Values(1u, 2u), ::testing::Bool()));
TEST(SX126xReceiveRetry, SuccessfulConfigurationClearsBackoffAndOtherRadiosRemainIndependent) {
RadioHardware failed, other;
PacketListener recovered, unaffected;
failed.configure(true, true);
other.configure(true, true);
failed.radio.register_listener(&recovered);
other.radio.register_listener(&unaffected);
failed.clear_trace();
failed.fail_at = 1;
failed.irq = IRQ_RX_DONE;
LoopBlockingGuard clock{nullptr, nullptr, 1000};
failed.radio.loop();
other.irq = IRQ_RX_DONE;
other.radio.loop();
ASSERT_EQ(unaffected.packets.size(), 1u);
EXPECT_TRUE(recovered.packets.empty());
failed.clear_trace();
failed.radio.configure();
failed.irq = IRQ_RX_DONE;
failed.radio.loop();
ASSERT_EQ(recovered.packets.size(), 1u); // Explicit recovery does not wait for the old retry deadline.
}
TEST(SX126xReceiveRetry, ReceiveBackoffDoesNotDelayTransmitCompletion) {
struct Completion final : SX126xTransmitListener {
void on_transmit_complete(SX126xError result) override { this->results.push_back(result); }
std::vector<SX126xError> results;
} tx;
RadioHardware hw;
hw.configure(true, true);
hw.clear_trace();
hw.fail_at = 1;
hw.irq = IRQ_TX_DONE; // Failed read of an unrelated stale IRQ starts the RX retry gate.
LoopBlockingGuard clock{nullptr, nullptr, millis()};
hw.radio.loop();
EXPECT_TRUE(hw.radio.status_has_warning());
hw.clear_trace();
hw.irq = 0;
ASSERT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop(); // Same cached tick, before the RX retry gate opens.
EXPECT_EQ(tx.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
EXPECT_TRUE(hw.receiving);
}
} // namespace esphome::sx126x::testing
@@ -1,237 +0,0 @@
#pragma once
#include <gtest/gtest.h>
#include <functional>
#include <vector>
#include "esphome/components/sx126x/sx126x.h"
namespace esphome::sx126x::testing {
class TestPin : public GPIOPin {
public:
void setup() override {}
void pin_mode(gpio::Flags flags) override {}
gpio::Flags get_flags() const override { return gpio::FLAG_NONE; }
size_t dump_summary(char *buffer, size_t len) const override { return snprintf(buffer, len, "test"); }
bool digital_read() override {
this->reads++;
return this->on_read ? this->on_read() : this->level;
}
void digital_write(bool level) override {
this->writes++;
this->level = level;
if (this->on_write) {
this->on_write(level);
}
}
bool is_internal() override { return this->internal; }
bool level{false};
bool internal{false};
unsigned reads{0};
unsigned writes{0};
std::function<bool()> on_read;
std::function<void(bool)> on_write;
};
// Exercise the real software SPI adapter and driver through GPIO pins. Each CS
// assertion starts a command; BUSY can reject any one of those commands.
class RadioHardware {
public:
RadioHardware() {
this->cs.level = true;
this->cs.on_write = [this](bool high) {
if (!high) {
EXPECT_FALSE(this->selected_);
this->selected_ = true;
this->transactions.emplace_back();
this->bit_ = 0;
this->byte_ = 0;
this->busy.level = this->transactions.size() == this->fail_at;
} else if (this->selected_) {
this->selected_ = false;
this->ends++;
const auto &command = this->transactions.back();
if (command.empty()) {
return;
}
switch (command[0]) {
case RADIO_SET_PACKETPARAMS:
this->packet_params = command;
break;
case RADIO_CLR_IRQSTATUS:
this->irq &= ~((uint16_t(command[1]) << 8) | command[2]);
break;
case RADIO_SET_TX:
this->sends++;
if (this->auto_complete) {
this->irq |= IRQ_TX_DONE;
}
break;
case RADIO_SET_RX:
this->receiving = true;
break;
case RADIO_SET_SLEEP:
case RADIO_SET_STANDBY:
this->receiving = false;
break;
default:
break;
}
if (this->on_command) {
this->on_command(command[0]);
}
}
};
this->busy.on_read = [this]() {
EXPECT_TRUE(this->selected_); // NSS must wake the chip before waiting.
return this->busy.level;
};
this->miso.on_read = [this]() {
EXPECT_TRUE(this->selected_);
EXPECT_FALSE(this->busy.level); // No clocks/data may follow a BUSY timeout.
auto &command = this->transactions.back();
const bool bit = (this->reply_byte_(command) >> (7 - this->bit_)) & 1;
this->byte_ = (this->byte_ << 1) | this->mosi.level;
if (++this->bit_ == 8) {
command.push_back(this->byte_);
this->byte_ = 0;
this->bit_ = 0;
}
return bit;
};
this->dio.on_read = [this]() { return this->irq != 0; };
this->rst.on_write = [this](bool high) {
if (!high) {
this->irq = 0;
}
};
this->spi.set_clk(&this->clk);
this->spi.set_mosi(&this->mosi);
this->spi.set_miso(&this->miso);
// call() runs setup() and marks the bus set up, so it accepts devices
this->spi.call();
this->radio.set_spi_parent(&this->spi);
this->radio.set_cs_pin(&this->cs);
this->radio.set_busy_pin(&this->busy);
this->radio.set_dio1_pin(&this->dio);
this->radio.set_rst_pin(&this->rst);
this->radio.set_frequency(915000000);
this->radio.set_tcxo_voltage(TCXO_CTRL_1_8V);
this->radio.set_rf_switch(true);
this->radio.set_crc_enable(true);
this->radio.set_sync_value({0x14, 0x24});
}
~RadioHardware() {
// The configuration retry interval (id 0) must not outlive the radio
App.scheduler.cancel_interval(&this->radio, uint32_t{0});
this->radio.spi_teardown();
}
void configure(bool lora, bool rx, bool fixed = false) {
this->radio.set_modulation(lora ? PACKET_TYPE_LORA : PACKET_TYPE_GFSK);
this->radio.set_bandwidth(lora ? SX126X_BW_125000 : SX126X_BW_4800);
this->radio.set_bitrate(4800);
this->radio.set_rx_start(rx);
this->radio.set_payload_length(fixed ? 3 : 0);
this->radio.call(); // The normal component lifecycle runs setup().
}
void clear_trace() {
this->transactions.clear();
this->ends = 0;
this->fail_at = 0;
this->busy.level = false;
this->dio.reads = 0;
}
void expect_aborted(size_t command) {
ASSERT_EQ(this->transactions.size(), command);
EXPECT_TRUE(this->transactions.back().empty());
EXPECT_EQ(this->ends, command);
EXPECT_TRUE(this->cs.level);
}
SX126x radio;
spi::SPIComponent spi;
TestPin cs, busy, dio, rst, clk, mosi, miso;
std::vector<std::vector<uint8_t>> transactions;
std::vector<uint8_t> packet{0x11, 0x22, 0x33};
std::vector<uint8_t> packet_params;
size_t fail_at{0};
size_t ends{0};
unsigned sends{0};
uint16_t irq{0};
// Called as each command ends, with its opcode
std::function<void(uint8_t)> on_command;
bool receiving{false};
bool auto_complete{true};
bool supported_version{true};
protected:
uint8_t reply_byte_(const std::vector<uint8_t> &command) {
if (command.empty()) {
return 0;
}
const size_t pos = command.size();
switch (command[0]) {
case RADIO_READ_REGISTER:
if (pos >= 4) {
static constexpr char VERSION[16] = "SX1262";
return this->supported_version ? VERSION[pos - 4] : 0;
}
break;
case RADIO_GET_IRQSTATUS:
if (pos == 2) {
return this->irq >> 8;
}
if (pos == 3) {
return this->irq & 0xFF;
}
break;
case RADIO_GET_PACKETSTATUS:
if (pos == 2) {
return 80;
}
if (pos == 3) {
return 40;
}
if (pos == 4) {
return 84;
}
break;
case RADIO_GET_RXBUFFERSTATUS:
if (pos == 2) {
return this->packet.size();
}
if (pos == 3) {
return 7;
}
break;
case RADIO_READ_BUFFER:
if (pos >= 3) {
EXPECT_EQ(command[1], 7);
return this->packet[pos - 3];
}
break;
default:
break;
}
return 0;
}
bool selected_{false};
uint8_t bit_{0};
uint8_t byte_{0};
};
class PacketListener : public SX126xListener {
public:
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override {
this->packets.push_back(packet);
this->rssi = rssi;
this->snr = snr;
}
std::vector<std::vector<uint8_t>> packets;
float rssi{};
float snr{};
};
} // namespace esphome::sx126x::testing
@@ -6,9 +6,9 @@
#include <random>
#include <string>
#include "esphome/components/web_server_idf/sse_chunk.h"
#include "esphome/components/web_server/sse_chunk.h"
namespace esphome::web_server_idf::testing {
namespace esphome::web_server::testing {
// The chunk as try_send_nodefer lays it out: header, fields, first "data: ", then the pieces
static std::string build_chunk(const char *message, size_t message_len, const char *event, uint32_t id,
@@ -128,4 +128,4 @@ TEST(SseChunk, MatchesReferenceOnRandomMessages) {
}
}
} // namespace esphome::web_server_idf::testing
} // namespace esphome::web_server::testing
@@ -0,0 +1,300 @@
#ifdef USE_HOST
#include <gtest/gtest.h>
#include <sys/uio.h>
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
#include "esphome/components/web_server/sse_chunk.h"
#include "esphome/components/web_server_base/stream_head.h"
namespace esphome::web_server::testing {
using namespace web_server_base; // NOLINT(google-build-using-namespace)
// Collects every batch write_stream_head() sends
struct Capture {
std::string bytes;
std::vector<int> batch_sizes;
int attempts{0};
int fail_after_batches{-1};
bool operator()(struct iovec *iov, int count) {
this->attempts++;
if (static_cast<int>(this->batch_sizes.size()) == this->fail_after_batches) {
return false;
}
this->batch_sizes.push_back(count);
for (int i = 0; i < count; i++) {
this->bytes.append(static_cast<const char *>(iov[i].iov_base), iov[i].iov_len);
}
return true;
}
};
static std::string build_head(Capture &cap, const char *status, const char *content_type,
std::span<const HttpHeader> headers, std::span<const HttpHeader> defaults,
const char *body) {
EXPECT_TRUE(write_stream_head<struct iovec>(cap, status, content_type, headers, defaults, body));
return cap.bytes;
}
// What httpd_resp_send_chunk() put on the wire for its first chunk (esp_http_server/src/httpd_txrx.c in
// ESP-IDF 5.5): the chunked head with Transfer-Encoding, each httpd_resp_set_hdr() header, the blank line,
// then "%lx\r\n", the chunk data and "\r\n".
static std::string reference_send_chunk(const char *status, const char *content_type,
const std::vector<HttpHeader> &resp_hdrs, const char *buf, size_t buf_len) {
char head[256];
std::string out(
head, snprintf(head, sizeof(head), "HTTP/1.1 %s\r\nContent-Type: %s\r\nTransfer-Encoding: chunked\r\n", status,
content_type));
for (const auto &hdr : resp_hdrs) {
out.append(hdr.name).append(": ").append(hdr.value).append("\r\n");
}
out.append("\r\n");
char len_str[10];
out.append(len_str, snprintf(len_str, sizeof(len_str), "%lx\r\n", static_cast<long>(buf_len)));
out.append(buf, buf_len);
out.append("\r\n");
return out;
}
static constexpr HttpHeader DEFAULTS[] = {
{"Access-Control-Allow-Origin", "*"},
{"Access-Control-Allow-Private-Network", "true"},
};
TEST(StreamHead, SseHeadMatchesHttpdRespSendChunk) {
// The old event source set Cache-Control and Connection, then the default headers, and sent "\r\n" as a chunk
const std::string expected = reference_send_chunk(
"200 OK", "text/event-stream",
{{"Cache-Control", "no-cache"}, {"Connection", "keep-alive"}, DEFAULTS[0], DEFAULTS[1]}, "\r\n", 2);
Capture cap;
EXPECT_EQ(build_head(cap, SSE_STATUS, SSE_CONTENT_TYPE, SSE_HEADERS, DEFAULTS, SSE_FIRST_CHUNK), expected);
}
TEST(StreamHead, HeaderReplacesDefaultOfSameName) {
// A default the caller also sets, in any case, goes out once with the caller's value
static constexpr HttpHeader HEADERS[] = {
{"access-control-allow-origin", "http://example.com"},
};
Capture cap;
EXPECT_EQ(build_head(cap, "200 OK", "text/plain", HEADERS, DEFAULTS, nullptr),
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\naccess-control-allow-origin: http://example.com"
"\r\nAccess-Control-Allow-Private-Network: true\r\n\r\n");
}
TEST(StreamHead, RawHeadWithCustomHeadersAndNoBody) {
static constexpr HttpHeader HEADERS[] = {
{"Cache-Control", "no-store"},
{"X-Stream", "audio"},
};
Capture cap;
EXPECT_EQ(build_head(cap, "200 OK", "audio/wav", HEADERS, {}, nullptr), "HTTP/1.1 200 OK\r\n"
"Content-Type: audio/wav\r\n"
"Cache-Control: no-store\r\n"
"X-Stream: audio\r\n"
"\r\n");
}
TEST(StreamHead, BareHead) {
Capture cap;
EXPECT_EQ(build_head(cap, "204 No Content", "text/plain", {}, {}, nullptr),
"HTTP/1.1 204 No Content\r\nContent-Type: text/plain\r\n\r\n");
EXPECT_EQ(cap.batch_sizes, std::vector<int>{5});
}
TEST(StreamHead, ManyHeadersSplitIntoBatchesWithSameBytes) {
// 4 + 12 * 4 + 2 = 54 pieces, more than two batches hold
static constexpr HttpHeader HEADERS[] = {
{"X-Header-0", "value-0"}, {"X-Header-1", "value-1"}, {"X-Header-2", "value-2"}, {"X-Header-3", "value-3"},
{"X-Header-4", "value-4"}, {"X-Header-5", "value-5"}, {"X-Header-6", "value-6"}, {"X-Header-7", "value-7"},
{"X-Header-8", "value-8"}, {"X-Header-9", "value-9"},
};
const std::span<const HttpHeader> headers{HEADERS};
Capture batched;
const std::string bytes = build_head(batched, "200 OK", "text/plain", headers, DEFAULTS, "body");
EXPECT_EQ(batched.batch_sizes, (std::vector<int>{STREAM_HEAD_MAX_IOV, STREAM_HEAD_MAX_IOV, 6}));
std::string expected = "HTTP/1.1 200 OK\r\nContent-Type: text/plain";
const auto append_headers = [&expected](std::span<const HttpHeader> list) {
for (const auto &h : list) {
expected.append("\r\n").append(h.name).append(": ").append(h.value);
}
};
append_headers(headers);
append_headers(DEFAULTS);
expected.append("\r\n\r\nbody");
EXPECT_EQ(bytes, expected);
}
TEST(StreamHead, BatchBoundary) {
// 4 + 4 * 4 + 1 + 1 = 22 pieces fit one batch; a fifth header makes 4 + 5 * 4 = 24 before the blank line
static constexpr HttpHeader HEADERS[] = {{"A", "1"}, {"B", "2"}, {"C", "3"}, {"D", "4"}, {"E", "5"}};
Capture four;
build_head(four, "200 OK", "text/plain", std::span(HEADERS, 4), {}, "x");
EXPECT_EQ(four.batch_sizes, std::vector<int>{22});
EXPECT_EQ(stream_head_iov_count(4), 22u);
Capture five;
const std::string bytes = build_head(five, "200 OK", "text/plain", HEADERS, {}, "x");
EXPECT_EQ(five.batch_sizes, (std::vector<int>{STREAM_HEAD_MAX_IOV, 2}));
EXPECT_EQ(bytes, "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nA: 1\r\nB: 2\r\nC: 3\r\nD: 4\r\nE: 5\r\n\r\nx");
}
TEST(StreamHead, FailedSendSkipsLaterBatches) {
// Three batches; the second fails, so the third is never sent
std::vector<HttpHeader> headers(12, HttpHeader{"X", "y"});
Capture full;
build_head(full, "200 OK", "text/plain", headers, {}, nullptr);
ASSERT_EQ(full.batch_sizes.size(), 3u);
Capture cap;
cap.fail_after_batches = 1;
EXPECT_FALSE(write_stream_head<struct iovec>(cap, "200 OK", "text/plain", headers, {}, nullptr));
EXPECT_EQ(cap.attempts, 2);
EXPECT_EQ(cap.batch_sizes.size(), 1u);
}
// Three elements of 3, 4 and 5 bytes
struct IovFixture {
char a[4] = "abc";
char b[5] = "defg";
char c[6] = "hijkl";
struct iovec iov[3] = {{a, 3}, {b, 4}, {c, 5}};
struct iovec *cur = iov;
int count = 3;
std::string rest() const {
std::string out;
for (int i = 0; i < this->count; i++) {
out.append(static_cast<const char *>(this->cur[i].iov_base), this->cur[i].iov_len);
}
return out;
}
};
TEST(AdvanceIovecs, ZeroLengthWriteChangesNothing) {
IovFixture f;
advance_iovecs(f.cur, f.count, 0);
EXPECT_EQ(f.cur, f.iov);
EXPECT_EQ(f.count, 3);
EXPECT_EQ(f.rest(), "abcdefghijkl");
}
TEST(AdvanceIovecs, PartialInsideFirstElement) {
IovFixture f;
advance_iovecs(f.cur, f.count, 2);
EXPECT_EQ(f.cur, f.iov);
EXPECT_EQ(f.count, 3);
EXPECT_EQ(f.cur->iov_len, 1u);
EXPECT_EQ(f.rest(), "cdefghijkl");
}
TEST(AdvanceIovecs, ExactlyOnElementBoundary) {
IovFixture f;
advance_iovecs(f.cur, f.count, 3);
EXPECT_EQ(f.cur, &f.iov[1]);
EXPECT_EQ(f.count, 2);
EXPECT_EQ(f.rest(), "defghijkl");
}
TEST(AdvanceIovecs, AcrossElementBoundaries) {
IovFixture f;
advance_iovecs(f.cur, f.count, 9);
EXPECT_EQ(f.cur, &f.iov[2]);
EXPECT_EQ(f.count, 1);
EXPECT_EQ(f.rest(), "jkl");
}
TEST(AdvanceIovecs, RepeatedShortWrites) {
IovFixture f;
std::string sent;
while (f.count > 0) {
const std::string before = f.rest();
advance_iovecs(f.cur, f.count, 2);
sent += before.substr(0, before.size() - f.rest().size());
}
EXPECT_EQ(sent, "abcdefghijkl");
}
TEST(AdvanceIovecs, EverythingWritten) {
IovFixture f;
advance_iovecs(f.cur, f.count, 12);
EXPECT_EQ(f.count, 0);
EXPECT_EQ(f.rest(), "");
}
TEST(AdvanceIovecs, SkipsZeroLengthElements) {
char a[] = "ab";
char c[] = "cd";
struct iovec iov[3] = {{a, 2}, {nullptr, 0}, {c, 2}};
struct iovec *cur = iov;
int count = 3;
advance_iovecs(cur, count, 2);
EXPECT_EQ(cur, &iov[2]);
EXPECT_EQ(count, 1);
}
static constexpr uint32_t TIMEOUT = 20000;
TEST(StallClock, NotStarted) {
StallClock clock;
EXPECT_FALSE(clock.is_running());
EXPECT_FALSE(clock.is_expired(0, TIMEOUT));
EXPECT_FALSE(clock.is_expired(UINT32_MAX, TIMEOUT));
}
TEST(StallClock, ExpiresAtExactlyTheTimeout) {
StallClock clock;
clock.start(1000);
EXPECT_TRUE(clock.is_running());
EXPECT_FALSE(clock.is_expired(1000, TIMEOUT));
EXPECT_FALSE(clock.is_expired(1000 + TIMEOUT - 1, TIMEOUT));
EXPECT_TRUE(clock.is_expired(1000 + TIMEOUT, TIMEOUT));
EXPECT_TRUE(clock.is_expired(1000 + TIMEOUT + 1, TIMEOUT));
EXPECT_EQ(clock.elapsed(1000 + TIMEOUT), TIMEOUT);
}
TEST(StallClock, RunningClockKeepsItsStart) {
StallClock clock;
clock.start(1000);
clock.start(5000);
EXPECT_TRUE(clock.is_expired(1000 + TIMEOUT, TIMEOUT));
}
TEST(StallClock, Cleared) {
StallClock clock;
clock.start(1000);
clock.clear();
EXPECT_FALSE(clock.is_running());
EXPECT_FALSE(clock.is_expired(1000 + TIMEOUT, TIMEOUT));
clock.start(50000);
EXPECT_FALSE(clock.is_expired(50000 + TIMEOUT - 1, TIMEOUT));
EXPECT_TRUE(clock.is_expired(50000 + TIMEOUT, TIMEOUT));
}
TEST(StallClock, StartAtZeroStillRuns) {
StallClock clock;
clock.start(0);
EXPECT_TRUE(clock.is_running());
// Recorded as 1, so it expires one millisecond late
EXPECT_FALSE(clock.is_expired(TIMEOUT, TIMEOUT));
EXPECT_TRUE(clock.is_expired(TIMEOUT + 1, TIMEOUT));
}
TEST(StallClock, AcrossMillisWraparound) {
StallClock clock;
const uint32_t start = UINT32_MAX - 5000;
clock.start(start);
EXPECT_FALSE(clock.is_expired(UINT32_MAX, TIMEOUT));
EXPECT_FALSE(clock.is_expired(start + TIMEOUT - 1, TIMEOUT)); // wrapped to 14998
EXPECT_TRUE(clock.is_expired(start + TIMEOUT, TIMEOUT));
EXPECT_EQ(clock.elapsed(start + TIMEOUT), TIMEOUT);
}
} // namespace esphome::web_server::testing
#endif // USE_HOST
+1 -2
View File
@@ -10,8 +10,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.auto_load = ["climate_ir", "climate"]
async def to_code_testing(config: ConfigType) -> None:
# Compiles in the Hisense ARG and Hitachi models, which climate.py adds only when configured
# Compiles in the Hisense ARG model, which climate.py adds only when it is configured
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
cg.add_define("USE_WHIRLPOOL_HITACHI_HCRA31NEWH")
manifest.to_code = to_code_testing
-5
View File
@@ -8,8 +8,3 @@ climate:
model: HISENSE_ARG
transmitter_id: xmitr
receiver_id: rcvr
- platform: whirlpool
name: Whirlpool Hitachi HCRA31NEWH Climate
model: HITACHI_HCRA31NEWH
transmitter_id: xmitr
receiver_id: rcvr
@@ -252,9 +252,6 @@ TEST(WhirlpoolHisenseArg, SwingIsSentOnEveryTransmit) {
TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF);
EXPECT_EQ(f.last_frame()[2] & 0x80, 0) << "swing is already off";
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0x80);
@@ -264,9 +261,6 @@ TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) {
frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0);
EXPECT_EQ(frame[8], 0x00);
f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
EXPECT_EQ(f.last_frame()[2] & 0x80, 0) << "swing is already vertical";
}
// Cool 24 C (step 8), fan low, powered on assumed
@@ -391,90 +385,4 @@ TEST(WhirlpoolHisenseArg, EveryTransmitHasValidChecksums) {
}
}
// Cool 24 C (step 8), fan low, unit on
static Frame hitachi_cool_frame() {
return with_checksums({0xA9, 0x59, 0x03, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x20,
0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x38, 0x08, 0x00});
}
// The off frame: power bit, cool code, byte 15 0x01 and byte 18 0x28
static Frame hitachi_off_frame() {
return with_checksums({0xA9, 0x59, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x20,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x28, 0x08, 0x00});
}
TEST(WhirlpoolHitachiHCRA31NEWH, CoolAndOffMatchKnownFrames) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW);
// Turning on sends the full state with no power bit
expect_frame(f.last_frame(), hitachi_cool_frame());
f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW);
expect_frame(f.last_frame(), hitachi_off_frame());
// A second off command still sends the power bit
f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW);
EXPECT_EQ(f.last_frame()[2] & 0x04, 0x04);
f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW);
const Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x04, 0);
EXPECT_EQ(frame[3], 0x80);
EXPECT_EQ(frame[15], 0x06);
EXPECT_EQ(frame[18], 0x38);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceivePowerIsAState) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
ASSERT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
// An on frame restores the mode even though it has no power bit
EXPECT_TRUE(receive(f.climate, hitachi_cool_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW);
// The same frame again keeps it on
EXPECT_TRUE(receive(f.climate, hitachi_cool_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_TRUE(receive(f.climate, hitachi_off_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
// A repeated off frame does not toggle
EXPECT_TRUE(receive(f.climate, hitachi_off_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceiveChecksRemoteId) {
Fixture hitachi(MODEL_HITACHI_HCRA31NEWH);
EXPECT_FALSE(receive(hitachi.climate, hisense_cool_frame()));
Fixture whirlpool(MODEL_DG11J1_91);
EXPECT_FALSE(receive(whirlpool.climate, hitachi_cool_frame()));
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceivedTemperatureIsClamped) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
Frame frame = hitachi_cool_frame();
frame[3] = 0xF2; // step 15 would be 31 C
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_FLOAT_EQ(f.climate.target_temperature, 30);
}
TEST(WhirlpoolDG11J13A, TargetIsClampedAndRoundedToWhatWasSent) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 35, climate::CLIMATE_FAN_LOW);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 32);
f.command(climate::CLIMATE_MODE_COOL, 24.4f, climate::CLIMATE_FAN_LOW);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceiveRejectsShortFrame) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
remote_base::RawTimings raw = waveform(hitachi_cool_frame());
// Cut after the second divider: 14 bytes, no footer
raw.resize(2 + 14 * 16 + 2 * 2);
remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
EXPECT_FALSE(static_cast<remote_base::RemoteReceiverListener &>(f.climate).on_receive(data));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
}
} // namespace esphome::whirlpool::testing
@@ -1,23 +0,0 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_NAME
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/tests"]
MULTI_CONF = True
reboot_hook_recorder_ns = cg.esphome_ns.namespace("reboot_hook_recorder")
RebootHookRecorder = reboot_hook_recorder_ns.class_("RebootHookRecorder", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(RebootHookRecorder),
cv.Required(CONF_NAME): cv.string,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_name(config[CONF_NAME]))
@@ -1,10 +0,0 @@
#include "reboot_hook_recorder.h"
#include "esphome/core/log.h"
namespace esphome::reboot_hook_recorder {
ESPHOME_LOG_TAG(TAG, "reboot_hook_recorder");
void RebootHookRecorder::record_(const char *hook) { ESP_LOGI(TAG, "HOOK %s %s", this->name_, hook); }
} // namespace esphome::reboot_hook_recorder
@@ -1,33 +0,0 @@
#pragma once
#include "esphome/core/component.h"
namespace esphome::reboot_hook_recorder {
/// Logs every shutdown lifecycle hook it receives, so a test can check their order.
class RebootHookRecorder : public Component {
public:
void set_name(const char *name) { this->name_ = name; }
void on_safe_shutdown() override { this->record_("on_safe_shutdown"); }
void on_shutdown() override { this->record_("on_shutdown"); }
// Needs a second call to finish, like a component waiting for a connection to close
bool teardown() override {
if (++this->teardown_calls_ < 2) {
this->record_("teardown_pending");
return false;
}
if (this->teardown_calls_ == 2)
this->record_("teardown_done");
return true;
}
void on_powerdown() override { this->record_("on_powerdown"); }
protected:
void record_(const char *hook);
const char *name_{""};
uint8_t teardown_calls_{0};
};
} // namespace esphome::reboot_hook_recorder
@@ -1,19 +0,0 @@
esphome:
name: reboot-hooks-forced
host:
logger:
level: DEBUG
api:
# Reboot once the test has connected, so that it sees every hook
on_client_connected:
- delay: 100ms
- lambda: App.reboot();
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
reboot_hook_recorder:
- name: first
- name: second
@@ -1,19 +0,0 @@
esphome:
name: reboot-hooks-safe
host:
logger:
level: DEBUG
api:
# Reboot once the test has connected, so that it sees every hook
on_client_connected:
- delay: 100ms
- lambda: App.safe_reboot();
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
reboot_hook_recorder:
- name: first
- name: second
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more