[ld2420] Wait for data from the module before configuring it

The LD2420 locks up until power cycled if it receives any data before
it has sent its first frame after powering on. Setup previously
transmitted immediately, blocking for up to 3 seconds and marking the
component failed on the first timeout. Setup is now a non blocking
state machine that listens for the module before transmitting, retries
the handshake, and degrades to a warning instead of failing.
This commit is contained in:
J. Nick Koston
2026-08-16 21:12:58 -05:00
parent 61d2632851
commit 3402ee8bb1
7 changed files with 871 additions and 111 deletions
+209 -74
View File
@@ -67,6 +67,15 @@ static constexpr uint16_t REFRESH_RATE_MS = 1000;
static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 1000;
static constexpr uint8_t CMD_MAX_RETRIES = 3;
// Startup state machine timing. The module starts transmitting ~3.5 s after a
// power cycle; the listen window is three times that to be safe. The ack
// timeout and command retry count match the blocking command engine.
static constexpr uint32_t STARTUP_LISTEN_TIMEOUT_MS = 10000;
static constexpr uint32_t STARTUP_RETRY_LISTEN_MS = 3000;
static constexpr uint32_t STARTUP_ACK_TIMEOUT_MS = 1000;
static constexpr uint8_t STARTUP_CMD_MAX_RETRIES = 3;
static constexpr uint8_t STARTUP_SEQUENCE_MAX_RETRIES = 3;
// Command sets
static constexpr uint16_t CMD_DISABLE_CONF = 0x00FE;
static constexpr uint16_t CMD_ENABLE_CONF = 0x00FF;
@@ -85,7 +94,6 @@ static constexpr uint16_t CMD_SYSTEM_MODE_GR = 0x0003;
static constexpr uint16_t CMD_SYSTEM_MODE_MTT = 0x0001;
static constexpr uint16_t CMD_SYSTEM_MODE_SIMPLE = 0x0064;
static constexpr uint16_t CMD_SYSTEM_MODE_DEBUG = 0x0000;
static constexpr uint16_t CMD_SYSTEM_MODE_ENERGY = 0x0004;
static constexpr uint16_t CMD_SYSTEM_MODE_VS = 0x0002;
static constexpr uint16_t CMD_WRITE_ABD_PARAM = 0x0007;
static constexpr uint16_t CMD_WRITE_REGISTER = 0x0001;
@@ -212,60 +220,192 @@ void LD2420Component::dump_config() {
ESP_LOGCONFIG(TAG, "Select:");
LOG_SELECT(" ", "Operating Mode", this->operating_selector_);
#endif
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
const int32_t firmware = ld2420::get_firmware_int(this->firmware_ver_);
if (firmware > 0 && firmware < CALIBRATE_VERSION_MIN) {
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
}
}
void LD2420Component::setup() {
if (this->set_config_mode(true) == LD2420_ERROR_TIMEOUT) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->mark_failed();
return;
}
this->get_min_max_distances_timeout_();
#ifdef USE_NUMBER
this->init_gate_config_numbers();
#endif
this->get_firmware_version_();
const char *pfw = this->firmware_ver_;
std::string fw_str(pfw);
void LD2420Component::setup() { this->begin_startup_(STARTUP_LISTEN_TIMEOUT_MS); }
for (auto &listener : this->listeners_) {
listener->on_fw_version(fw_str);
}
void LD2420Component::begin_startup_(uint32_t listen_timeout_ms) {
this->startup_sequence_retries_ = 0;
this->begin_listen_(listen_timeout_ms);
}
for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) {
delay_microseconds_safe(125);
this->get_gate_threshold_(gate);
}
void LD2420Component::begin_listen_(uint32_t listen_timeout_ms) {
this->rx_seen_ = false;
this->buffer_pos_ = 0;
this->listen_timeout_ms_ = listen_timeout_ms;
this->phase_start_ms_ = millis();
this->startup_state_ = StartupState::STARTUP_STATE_LISTEN;
}
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
this->set_operating_mode(OP_SIMPLE_MODE_STRING);
#ifdef USE_SELECT
if (this->operating_selector_ != nullptr) {
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
void LD2420Component::drain_rx_() {
while (this->available()) {
this->read();
}
this->buffer_pos_ = 0;
}
void LD2420Component::send_cmd_async_(const CmdFrameT &frame) {
this->cmd_reply_.ack = false;
this->write_cmd_frame_(frame);
this->phase_start_ms_ = millis();
}
void LD2420Component::start_startup_cmd_(StartupState state) {
this->startup_cmd_retries_ = 1;
this->startup_state_ = state;
this->send_cmd_async_(this->startup_frame_);
}
// Common ack handling for the startup commands: returns true once the reply to
// the current startup frame arrived; resends on timeout, and after too many
// failed sends either restarts the whole sequence or gives up with a warning.
bool LD2420Component::startup_ack_check_() {
if (this->cmd_reply_.ack && this->cmd_reply_.command == (uint8_t) this->startup_frame_.command) {
return true;
}
if (millis() - this->phase_start_ms_ <= STARTUP_ACK_TIMEOUT_MS) {
return false;
}
if (this->startup_cmd_retries_ < STARTUP_CMD_MAX_RETRIES) {
this->startup_cmd_retries_++;
ESP_LOGV(TAG, "No reply to startup command %2X; resending", this->startup_frame_.command);
this->send_cmd_async_(this->startup_frame_);
return false;
}
if (this->startup_sequence_retries_ < STARTUP_SEQUENCE_MAX_RETRIES) {
this->startup_sequence_retries_++;
ESP_LOGW(TAG, "Module setup attempt %u failed; retrying", this->startup_sequence_retries_);
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
this->begin_listen_(STARTUP_RETRY_LISTEN_MS);
return false;
}
// Give up on configuration but keep parsing the stream; a module that is
// still streaming keeps publishing sensor data even without a config read.
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
this->startup_state_ = StartupState::STARTUP_STATE_RUNNING;
return false;
}
void LD2420Component::loop_startup_() {
switch (this->startup_state_) {
case StartupState::STARTUP_STATE_LISTEN:
// The module locks up until power cycled if it receives data before it
// has sent its first frame after powering on, so wait until it has
// provably transmitted before sending anything. A module stuck in some
// other state stays quiet, so fall through after the listen window.
if (!this->rx_seen_) {
if (millis() - this->phase_start_ms_ < this->listen_timeout_ms_) {
return;
}
ESP_LOGW(TAG, "No data received from the module; attempting configuration anyway");
}
// Drop any partial frame so the ack parser starts clean
this->drain_rx_();
this->build_config_mode_frame_(this->startup_frame_, true);
this->start_startup_cmd_(StartupState::STARTUP_STATE_ENTER_CONFIG);
return;
case StartupState::STARTUP_STATE_ENTER_CONFIG:
if (!this->startup_ack_check_()) {
return;
}
this->build_min_max_timeout_frame_(this->startup_frame_);
this->start_startup_cmd_(StartupState::STARTUP_STATE_READ_LIMITS);
return;
case StartupState::STARTUP_STATE_READ_LIMITS:
if (!this->startup_ack_check_()) {
return;
}
this->current_config.min_gate = (uint16_t) this->cmd_reply_.data[0];
this->current_config.max_gate = (uint16_t) this->cmd_reply_.data[1];
this->current_config.timeout = (uint16_t) this->cmd_reply_.data[2];
this->build_version_frame_(this->startup_frame_);
this->start_startup_cmd_(StartupState::STARTUP_STATE_READ_VERSION);
return;
case StartupState::STARTUP_STATE_READ_VERSION: {
if (!this->startup_ack_check_()) {
return;
}
std::string fw_str(this->firmware_ver_);
for (auto &listener : this->listeners_) {
listener->on_fw_version(fw_str);
}
this->startup_gate_ = 0;
this->build_gate_threshold_frame_(this->startup_frame_, this->startup_gate_);
this->start_startup_cmd_(StartupState::STARTUP_STATE_READ_GATES);
return;
}
#endif
this->set_mode_(CMD_SYSTEM_MODE_SIMPLE);
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
} else {
this->set_mode_(CMD_SYSTEM_MODE_ENERGY);
case StartupState::STARTUP_STATE_READ_GATES:
if (!this->startup_ack_check_()) {
return;
}
this->current_config.move_thresh[this->startup_gate_] = this->cmd_reply_.data[0];
this->current_config.still_thresh[this->startup_gate_] = this->cmd_reply_.data[1];
if (++this->startup_gate_ < TOTAL_GATES) {
this->build_gate_threshold_frame_(this->startup_frame_, this->startup_gate_);
this->start_startup_cmd_(StartupState::STARTUP_STATE_READ_GATES);
return;
}
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
this->set_operating_mode(OP_SIMPLE_MODE_STRING);
#ifdef USE_SELECT
if (this->operating_selector_ != nullptr) {
this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING);
}
if (this->operating_selector_ != nullptr) {
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
}
#endif
}
this->set_mode_(CMD_SYSTEM_MODE_SIMPLE);
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
} else {
this->set_mode_(CMD_SYSTEM_MODE_ENERGY);
#ifdef USE_SELECT
if (this->operating_selector_ != nullptr) {
this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING);
}
#endif
}
#ifdef USE_NUMBER
this->init_gate_config_numbers();
this->init_gate_config_numbers();
#endif
this->set_system_mode(this->system_mode_);
this->set_config_mode(false);
this->build_system_mode_frame_(this->startup_frame_, this->system_mode_);
this->start_startup_cmd_(StartupState::STARTUP_STATE_SET_MODE);
return;
case StartupState::STARTUP_STATE_SET_MODE:
if (!this->startup_ack_check_()) {
return;
}
this->build_config_mode_frame_(this->startup_frame_, false);
this->start_startup_cmd_(StartupState::STARTUP_STATE_EXIT_CONFIG);
return;
case StartupState::STARTUP_STATE_EXIT_CONFIG:
if (!this->startup_ack_check_()) {
return;
}
this->status_clear_warning();
this->startup_state_ = StartupState::STARTUP_STATE_RUNNING;
ESP_LOGI(TAG, "Module setup complete; firmware %s", this->firmware_ver_);
return;
case StartupState::STARTUP_STATE_RUNNING:
return;
}
}
void LD2420Component::apply_config_action() {
if (this->startup_state_ != StartupState::STARTUP_STATE_RUNNING) {
ESP_LOGW(TAG, "Module is still starting up; ignoring");
return;
}
const uint8_t checksum = calc_checksum(&this->new_config, sizeof(this->new_config));
if (checksum == calc_checksum(&this->current_config, sizeof(this->current_config))) {
ESP_LOGD(TAG, "No configuration change detected");
@@ -274,7 +414,7 @@ void LD2420Component::apply_config_action() {
ESP_LOGD(TAG, "Reconfiguring");
if (this->set_config_mode(true) == LD2420_ERROR_TIMEOUT) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->mark_failed();
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
return;
}
this->set_min_max_distances_timeout(this->new_config.max_gate, this->new_config.min_gate, this->new_config.timeout);
@@ -292,10 +432,14 @@ void LD2420Component::apply_config_action() {
}
void LD2420Component::factory_reset_action() {
if (this->startup_state_ != StartupState::STARTUP_STATE_RUNNING) {
ESP_LOGW(TAG, "Module is still starting up; ignoring");
return;
}
ESP_LOGD(TAG, "Setting factory defaults");
if (this->set_config_mode(true) == LD2420_ERROR_TIMEOUT) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->mark_failed();
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
return;
}
this->set_min_max_distances_timeout(FACTORY_MAX_GATE, FACTORY_MIN_GATE, FACTORY_TIMEOUT);
@@ -322,11 +466,10 @@ void LD2420Component::factory_reset_action() {
void LD2420Component::restart_module_action() {
ESP_LOGD(TAG, "Restarting");
this->send_module_restart();
this->set_timeout(250, [this]() {
this->set_config_mode(true);
this->set_system_mode(this->system_mode_);
this->set_config_mode(false);
});
// The module is silent while it boots and locks up if it receives data
// before it has sent its first frame, so re-run the listen-first startup
// sequence instead of transmitting into the boot window.
this->begin_startup_(STARTUP_LISTEN_TIMEOUT_MS);
}
void LD2420Component::revert_config_action() {
@@ -343,6 +486,9 @@ void LD2420Component::loop() {
return;
}
this->read_batch_(this->buffer_data_);
if (this->startup_state_ != StartupState::STARTUP_STATE_RUNNING) {
this->loop_startup_();
}
}
void LD2420Component::update_radar_data(uint16_t const *gate_energy, uint8_t sample_number) {
@@ -552,6 +698,9 @@ void LD2420Component::handle_simple_mode_(const uint8_t *inbuf, int len) {
void LD2420Component::read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer) {
// Read all available bytes in batches to reduce UART call overhead.
size_t avail = this->available();
if (avail > 0) {
this->rx_seen_ = true;
}
uint8_t buf[MAX_LINE_LENGTH];
while (avail > 0) {
size_t to_read = std::min(avail, sizeof(buf));
@@ -761,17 +910,6 @@ void LD2420Component::build_gate_threshold_frame_(CmdFrameT &frame, uint8_t gate
ESP_LOGV(TAG, "Sending read gate %d high/low threshold command: %2X", gate, frame.command);
}
int LD2420Component::get_gate_threshold_(uint8_t gate) {
CmdFrameT cmd_frame;
this->build_gate_threshold_frame_(cmd_frame, gate);
uint8_t error = this->send_cmd_from_array(cmd_frame);
if (error == 0) {
this->current_config.move_thresh[gate] = cmd_reply_.data[0];
this->current_config.still_thresh[gate] = cmd_reply_.data[1];
}
return error;
}
void LD2420Component::build_min_max_timeout_frame_(CmdFrameT &frame) {
frame.data_length = 0;
frame.header = CMD_FRAME_HEADER;
@@ -789,16 +927,19 @@ void LD2420Component::build_min_max_timeout_frame_(CmdFrameT &frame) {
ESP_LOGV(TAG, "Sending read gate min max and timeout command: %2X", frame.command);
}
int LD2420Component::get_min_max_distances_timeout_() {
CmdFrameT cmd_frame;
this->build_min_max_timeout_frame_(cmd_frame);
uint8_t error = this->send_cmd_from_array(cmd_frame);
if (error == 0) {
this->current_config.min_gate = (uint16_t) cmd_reply_.data[0];
this->current_config.max_gate = (uint16_t) cmd_reply_.data[1];
this->current_config.timeout = (uint16_t) cmd_reply_.data[2];
}
return error;
void LD2420Component::build_system_mode_frame_(CmdFrameT &frame, uint16_t mode) {
uint16_t unknown_parm = 0x0000;
frame.data_length = 0;
frame.header = CMD_FRAME_HEADER;
frame.command = CMD_WRITE_SYS_PARAM;
memcpy(&frame.data[frame.data_length], &CMD_SYSTEM_MODE, sizeof(CMD_SYSTEM_MODE));
frame.data_length += sizeof(CMD_SYSTEM_MODE);
memcpy(&frame.data[frame.data_length], &mode, sizeof(mode));
frame.data_length += sizeof(mode);
memcpy(&frame.data[frame.data_length], &unknown_parm, sizeof(unknown_parm));
frame.data_length += sizeof(unknown_parm);
frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending write system mode command: %2X", frame.command);
}
void LD2420Component::build_system_mode_frame_(CmdFrameT &frame, uint16_t mode) {
@@ -832,12 +973,6 @@ void LD2420Component::build_version_frame_(CmdFrameT &frame) {
ESP_LOGV(TAG, "Sending read firmware version command: %2X", frame.command);
}
void LD2420Component::get_firmware_version_() {
CmdFrameT cmd_frame;
this->build_version_frame_(cmd_frame);
this->send_cmd_from_array(cmd_frame);
}
void LD2420Component::set_min_max_distances_timeout(uint32_t max_gate_distance, uint32_t min_gate_distance, // NOLINT
uint32_t timeout) {
// Header H, Length L, Register R, Value V, Footer F
+34 -4
View File
@@ -25,6 +25,7 @@ static constexpr uint8_t CALIBRATE_SAMPLES = 64;
// inside the buffer during footer-based resynchronization after losing sync.
static constexpr uint8_t MAX_LINE_LENGTH = 50;
static constexpr uint8_t TOTAL_GATES = 16;
static constexpr uint16_t CMD_SYSTEM_MODE_ENERGY = 0x0004;
enum OpMode : uint8_t {
OP_NORMAL_MODE = 1,
@@ -152,15 +153,36 @@ class LD2420Component final : public Component, public uart::UARTDevice {
volatile bool ack;
};
void get_firmware_version_();
int get_gate_threshold_(uint8_t gate);
int get_min_max_distances_timeout_();
// Startup runs as a non-blocking state machine driven from loop(). The module
// locks up until power cycled if it receives any data before it has sent its
// first frame after powering on, so the state machine listens for data from
// the module before transmitting anything.
enum class StartupState : uint8_t {
STARTUP_STATE_LISTEN = 0,
STARTUP_STATE_ENTER_CONFIG,
STARTUP_STATE_READ_LIMITS,
STARTUP_STATE_READ_VERSION,
STARTUP_STATE_READ_GATES,
STARTUP_STATE_SET_MODE,
STARTUP_STATE_EXIT_CONFIG,
STARTUP_STATE_RUNNING,
};
void begin_startup_(uint32_t listen_timeout_ms);
void begin_listen_(uint32_t listen_timeout_ms);
void loop_startup_();
void start_startup_cmd_(StartupState state);
bool startup_ack_check_();
void drain_rx_();
void write_cmd_frame_(const CmdFrameT &frame);
void send_cmd_async_(const CmdFrameT &frame);
void build_config_mode_frame_(CmdFrameT &frame, bool enable);
void build_min_max_timeout_frame_(CmdFrameT &frame);
void build_gate_threshold_frame_(CmdFrameT &frame, uint8_t gate);
void build_version_frame_(CmdFrameT &frame);
void build_system_mode_frame_(CmdFrameT &frame, uint16_t mode);
void get_reg_value_(uint16_t reg);
uint16_t get_mode_() { return this->system_mode_; };
void set_mode_(uint16_t mode) { this->system_mode_ = mode; };
bool get_presence_() { return this->presence_; };
@@ -187,8 +209,16 @@ class LD2420Component final : public Component, public uart::UARTDevice {
#endif
uint16_t distance_{0};
uint16_t system_mode_;
uint16_t system_mode_{CMD_SYSTEM_MODE_ENERGY};
uint16_t gate_energy_[TOTAL_GATES];
uint32_t phase_start_ms_{0};
uint32_t listen_timeout_ms_{0};
CmdFrameT startup_frame_;
StartupState startup_state_{StartupState::STARTUP_STATE_LISTEN};
uint8_t startup_cmd_retries_{0};
uint8_t startup_sequence_retries_{0};
uint8_t startup_gate_{0};
bool rx_seen_{false};
uint8_t buffer_pos_{0}; // where to resume processing/populating buffer
uint8_t buffer_data_[MAX_LINE_LENGTH];
char firmware_ver_[8]{"v0.0.0"};
@@ -50,19 +50,10 @@ uart_mock:
0x04, 0x03, 0x02, 0x01,
]
# Catch-all response: match any command footer (04 03 02 01).
# Returns a generic ACK with cmd=0xFF (CMD_ENABLE_CONF case in switch).
# All commands get unblocked via cmd_reply_.ack = true.
# Data fields stay zeroed (min_gate=0, max_gate=0, timeout=0, thresholds=0).
#
# Response layout:
# [0-3] FD FC FB FA = header
# [4-5] 04 00 = length 4
# [6] FF = cmd (handled as CMD_ENABLE_CONF)
# [7] 01 = status (ACK)
# [8-9] 00 00 = error = 0
# [10-13] 04 03 02 01 = footer
- expect_tx: [0x04, 0x03, 0x02, 0x01]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
# TX = FD FC FB FA 04 00 FF 00 02 00 04 03 02 01
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
@@ -72,6 +63,53 @@ uart_mock:
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the remaining commands, which are all CMD_READ_ABD_PARAM
# (0x0008) reads: min/max/timeout limits and the 16 gate threshold reads.
# The reply carries three zeroed uint32 values (data length 16 = 4 + 12),
# so limits and thresholds all read as 0.
#
# Response layout:
# [0-3] FD FC FB FA = header
# [4-5] 10 00 = length 16
# [6] 08 = cmd (CMD_READ_ABD_PARAM)
# [7] 01 = status (ACK)
# [8-9] 00 00 = error = 0
# [10-21] 00 x12 = three zeroed uint32 data values
# [22-25] 04 03 02 01 = footer
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
injections:
# Phase 1 (t=100ms): Valid LD2420 energy mode data frame - happy path
# Buffer is clean (buffer_pos_=0). This frame should parse correctly.
@@ -98,13 +136,15 @@ uart_mock:
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 2 (t=300ms): Garbage bytes
# Phase 2 (t=800ms): Garbage bytes
# LD2420's readline_ does NOT check frame headers at position 0 (unlike LD2412),
# so these bytes accumulate in the buffer. buffer_pos_ goes from 0 to 7.
- delay: 200ms
# Delay=700ms leaves time for the setup handshake (triggered by Phase 1,
# the first data seen from the module) to finish first.
- delay: 700ms
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
# Phase 3 (t=400ms): Truncated energy frame WITH footer (13 bytes)
# Phase 3 (t=900ms): Truncated energy frame WITH footer (13 bytes)
# This tests PR #14458 bug #3: missing length validation in handle_energy_mode_.
# The 7 garbage bytes from Phase 2 are still in the buffer (buffer_pos_=7).
# These 13 bytes are appended at positions 7-19 (buffer_pos_=20).
@@ -126,7 +166,7 @@ uart_mock:
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 4 (t=600ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# Phase 4 (t=1100ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# After Phase 3, buffer_pos_=0 (reset after energy footer detection).
# 49 bytes fill positions 0-48 (buffer_pos_=49), 50th byte triggers overflow.
# Logs "Max command length exceeded; ignoring", buffer_pos_=0.
@@ -143,8 +183,8 @@ uart_mock:
# Phase 5 (t=1500ms): Valid frame after overflow - recovery test
# Buffer was reset by overflow. This valid frame should parse correctly.
# Presence: 1 (target), Distance: 50cm
# Delay=900ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 900ms
# Delay=400ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 400ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
@@ -0,0 +1,149 @@
esphome:
name: uart-mock-ld2420-boot-test
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
uart:
baud_rate: 115200
port: /dev/null
# Simulates a cold boot where the LD2420 module boots slower than the ESP:
# the module is silent for 2 seconds and then sends its first energy frame.
# The module locks up until power cycled if it receives any data before it
# has sent its first frame, so the component must stay quiet for the full
# 2 seconds and only start its setup handshake after the first frame.
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: true
injections:
# Module boot finished (t=2000ms): first energy frame
# (presence=1, distance=100). Any TX from the component before this
# point would have locked up real hardware.
- delay: 2000ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Post-setup frame (t=3300ms): distance=50 proves streaming still works
# after the setup handshake. Delay=1300ms keeps >1000ms publish throttle
# gap from the first frame.
- delay: 1300ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x32, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
responses:
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
# gate threshold reads. Three zeroed uint32 data values.
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
ld2420:
id: ld2420_dev
uart_id: mock_uart
sensor:
- platform: ld2420
ld2420_id: ld2420_dev
moving_distance:
name: "Moving Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
binary_sensor:
- platform: ld2420
ld2420_id: ld2420_dev
has_target:
name: "Has Target"
filters:
- settle: 50ms
@@ -22,10 +22,24 @@ uart_mock:
baud_rate: 115200
auto_start: false
responses:
# Catch-all response only (no version-specific response).
# Without a version response, firmware_ver_ stays at default "v0.0.0".
# get_firmware_int("v0.0.0") = 0 < 154 → simple mode (CMD_SYSTEM_MODE_SIMPLE).
- expect_tx: [0x04, 0x03, 0x02, 0x01]
# Version response with an old firmware version "v1.5.3".
# get_firmware_int("v1.5.3") = 153 < 154 → simple mode (CMD_SYSTEM_MODE_SIMPLE).
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x31, 0x2E, 0x35, 0x2E, 0x33,
0x04, 0x03, 0x02, 0x01,
]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
@@ -35,10 +49,47 @@ uart_mock:
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = simple (0x0064)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
# gate threshold reads. Three zeroed uint32 data values.
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
injections:
# Phase 1 (t=100ms): Valid simple mode text frame - happy path
# "ON Range 0100\r\n" → presence=true, distance=100
# Simple mode frames end with \r\n (0x0D 0x0A), triggering handle_simple_mode_.
# Phase 0 (t=100ms): Wake-up frame. The component listens for data from the
# module before transmitting anything, so this frame starts the setup
# handshake. It is not parsed as simple mode because the component's system
# mode is only switched to simple after the firmware version is read.
- delay: 100ms
inject_rx:
[
@@ -47,12 +98,24 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 2 (t=300ms): Garbage bytes
# Phase 1 (t=800ms): Valid simple mode text frame - happy path
# "ON Range 0100\r\n" → presence=true, distance=100
# Simple mode frames end with \r\n (0x0D 0x0A), triggering handle_simple_mode_.
# Delay=700ms leaves time for the setup handshake to finish first.
- delay: 700ms
inject_rx:
[
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
0x30, 0x31, 0x30, 0x30,
0x0D, 0x0A,
]
# Phase 2 (t=1000ms): Garbage bytes
# LD2420's readline_ stores all bytes regardless of header. buffer_pos_ = 7.
- delay: 200ms
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
# Phase 3 (t=500ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# Phase 3 (t=1200ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# buffer_pos_ starts at 7 (from Phase 2 garbage).
# Positions 7-48 fill (42 bytes), byte 43 triggers overflow (buffer_pos_=49).
# After overflow: buffer_pos_=0, remaining 7 bytes fill positions 0-6.
@@ -67,13 +130,13 @@ uart_mock:
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
]
# Phase 4 (t=1400ms): Recovery after overflow
# Phase 4 (t=1900ms): Recovery after overflow
# buffer_pos_ = 7 (from overflow remainder). These 15 bytes fill positions 7-21.
# At position 21 (0x0A), \r\n detected → handle_simple_mode_(buffer, 22).
# Parser skips 0xFF bytes at positions 0-6, finds "ON" at positions 7-8,
# parses digits "0050" → distance=50.
# Delay=900ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 900ms
# Delay=700ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 700ms
inject_rx:
[
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
@@ -81,7 +144,7 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 5 (t=2500ms): 16-digit distance - tests PR #14458 bug #1
# Phase 5 (t=3000ms): 16-digit distance - tests PR #14458 bug #1
# "ON Range 0000000000000000\r\n" has 16 digit characters.
# handle_simple_mode_ outbuf is 16 bytes, can hold 15 digits (index 0-14).
#
@@ -100,7 +163,7 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 6 (t=3700ms): Post-bug-trigger recovery
# Phase 6 (t=4200ms): Post-bug-trigger recovery
# If Phase 5 didn't hang, this frame should parse correctly.
# "ON Range 0025\r\n" → distance=25
# Delay=1200ms ensures >1000ms gap from Phase 5 for throttle.
@@ -0,0 +1,129 @@
esphome:
name: uart-mock-ld2420-warm-test
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
uart:
baud_rate: 115200
port: /dev/null
# Simulates a warm restart: the ESP rebooted but the LD2420 module stayed
# powered and keeps streaming energy frames from the moment the firmware
# starts. The component must not transmit anything until it has seen data
# from the module, then run its setup handshake against the live stream.
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: true
# Module streams a valid energy frame (presence=1, distance=100) continuously
periodic_rx:
- interval: 250ms
data:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
responses:
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
# gate threshold reads. Three zeroed uint32 data values.
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
ld2420:
id: ld2420_dev
uart_id: mock_uart
sensor:
- platform: ld2420
ld2420_id: ld2420_dev
moving_distance:
name: "Moving Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
binary_sensor:
- platform: ld2420
ld2420_id: ld2420_dev
has_target:
name: "Has Target"
filters:
- settle: 50ms
+214
View File
@@ -15,6 +15,18 @@ test_uart_mock_ld2420_simple (simple mode):
3. Buffer overflow recovery
4. 16-digit distance triggers infinite loop pre-fix (PR #14458 bug #1)
5. Post-bug-trigger recovery proves the parser survived
test_uart_mock_ld2420_warm_restart (module streaming at boot):
Simulates a warm restart where the module stayed powered and streams energy
frames from the moment the firmware starts. Asserts the component never
transmits before receiving data from the module, completes setup against
the live stream, and publishes sensor data.
test_uart_mock_ld2420_delayed_boot (module boots slower than the ESP):
Simulates a cold boot where the module is silent for 2 seconds. The module
locks up until power cycled if it receives data before sending its first
frame, so the component must stay quiet until the module talks, then
complete setup and keep parsing the stream.
"""
from __future__ import annotations
@@ -160,6 +172,208 @@ async def test_uart_mock_ld2420(
)
@pytest.mark.asyncio
async def test_uart_mock_ld2420_warm_restart(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Module streams from boot; component must listen first, then set up."""
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
loop = asyncio.get_running_loop()
setup_complete = loop.create_future()
rx_seen = False
tx_before_rx = False
failure_lines: list[str] = []
def line_callback(line: str) -> None:
nonlocal rx_seen, tx_before_rx
if "uart_mock" in line:
if "RX inject" in line or "Injecting" in line:
rx_seen = True
elif "TX " in line and not rx_seen:
tx_before_rx = True
if (
"Module setup complete; firmware v2.0.0" in line
and not setup_complete.done()
):
setup_complete.set_result(True)
if (
"marked FAILED" in line
or "Communication failed" in line
or "No data received from the module" in line
):
failure_lines.append(line)
collector = SensorStateCollector(
sensor_names=["moving_distance"],
binary_sensor_names=["has_target"],
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Setup handshake must complete against the live stream
try:
await asyncio.wait_for(setup_complete, timeout=10.0)
except TimeoutError:
pytest.fail(
"Timeout waiting for 'Module setup complete' log line. "
"The startup state machine did not finish its handshake."
)
# Sensor data must flow from the stream
try:
await collector.wait_for_all(timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for sensor data. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" binary_states: {collector.binary_states}"
)
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
assert collector.binary_states["has_target"][0] is True
# The component must never transmit before the module has talked;
# real hardware locks up until power cycled if it does.
assert not tx_before_rx, (
"Component transmitted on the UART before receiving any data "
"from the module; this locks up real LD2420 hardware"
)
assert not failure_lines, f"Unexpected failure log lines: {failure_lines}"
@pytest.mark.asyncio
async def test_uart_mock_ld2420_delayed_boot(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Module silent for 2 s; component must not transmit into the boot window."""
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
loop = asyncio.get_running_loop()
setup_complete = loop.create_future()
rx_seen = False
tx_before_rx = False
failure_lines: list[str] = []
def line_callback(line: str) -> None:
nonlocal rx_seen, tx_before_rx
if "uart_mock" in line:
if "RX inject" in line or "Injecting" in line:
rx_seen = True
elif "TX " in line and not rx_seen:
tx_before_rx = True
if (
"Module setup complete; firmware v2.0.0" in line
and not setup_complete.done()
):
setup_complete.set_result(True)
if (
"marked FAILED" in line
or "Communication failed" in line
or "No data received from the module" in line
):
failure_lines.append(line)
collector = SensorStateCollector(
sensor_names=["moving_distance"],
binary_sensor_names=["has_target"],
)
# The second injected frame (distance=50) proves streaming works post-setup
post_setup_received = collector.add_waiter(
lambda: pytest.approx(50.0) in collector.sensor_states["moving_distance"]
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Module's first frame arrives at t=2000ms; handshake follows
try:
await asyncio.wait_for(setup_complete, timeout=10.0)
except TimeoutError:
pytest.fail(
"Timeout waiting for 'Module setup complete' log line. "
"The startup state machine did not finish its handshake."
)
try:
await collector.wait_for_all(timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for sensor data. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" binary_states: {collector.binary_states}"
)
# First frame (t=2000ms) publishes distance=100
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
assert collector.binary_states["has_target"][0] is True
# Second frame (t=3300ms, after setup) publishes distance=50
try:
await asyncio.wait_for(post_setup_received, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for post-setup frame (distance=50). Received:\n"
f" moving_distance: {collector.sensor_states['moving_distance']}"
)
# The component must have stayed quiet for the module's whole 2 s boot
# window; transmitting into it locks up real LD2420 hardware.
assert not tx_before_rx, (
"Component transmitted on the UART before the module sent its "
"first frame; this locks up real LD2420 hardware"
)
assert not failure_lines, f"Unexpected failure log lines: {failure_lines}"
@pytest.mark.asyncio
async def test_uart_mock_ld2420_simple(
yaml_config: str,