Compare commits

...
Author SHA1 Message Date
J. Nick Koston 6a0e5ffce8 [ld2420] Address fourth review round
Sync new_config with current_config on the give-up path and gate the
config writing actions on an explicit config read complete flag so a
partial read can never be written to the module's NVM, raise the retry
listen window above the module boot silence, treat replies shorter
than the expected data length as silence, apply the parser mode only
after the mode write is acknowledged, propagate write errors in the
apply and factory reset actions before adopting the new config or
clearing the warning, log drain failures and unbuildable startup
frames, watch the runtime marked as failed log string in the tests,
give the first enable command in the retry fixture genuine silence,
and alternate the warm restart stream values so state deduplication
cannot starve a late subscriber.
2026-08-16 21:59:17 -05:00
J. Nick Koston 713b3b2bc9 [ld2420] Simplify after review rounds
Model the listen settle window as its own state instead of two flags,
pass reception into the state machine as a parameter instead of the
rx_seen_ member, consolidate the duplicated button action guards into
a shared precondition helper, extract the give-up path from the retry
ladder, batch the receive drain reads, rename the command attempt
counter, and deduplicate the newest integration tests with a shared
log watcher and subscription helpers.
2026-08-16 21:13:54 -05:00
J. Nick Koston d6179b6d56 [ld2420] Address third review round
Guarantee a drain pass in every listen phase so bytes that arrived
before it can never satisfy the settle window even when the main loop
stalls, publish the number entities on the give-up path, zero the
reply data before each startup send so short acks cannot store stale
values, zero initialize the config threshold arrays, refuse config
writes when the configuration was never read, guard the revert button
during startup, gate the apply and factory reset warning clear on the
final ack, report the firmware version as unknown in dump_config until
it is read, and add tests for the command resend and the sequence
retry and give-up paths.
2026-08-16 21:12:58 -05:00
J. Nick Koston 73a95c411d [ld2420] Add module restart regression test
Presses the restart button while the module is mid transmission: the
in-flight frame tail bytes arriving right after the restart command
must not count as proof the module is up, and the setup handshake must
only run again after the module's first post-boot frame.
2026-08-16 21:12:58 -05:00
J. Nick Koston de7af3865b [ld2420] Address second review round
Ignore reception during a settle window at the start of each listen
phase so in-flight bytes from a restarting module cannot count as
proof it is up, drain stale replies before each startup send so acks
cannot be matched to the wrong command, always send the blind config
mode exit when abandoning a sequence, log module error replies and an
unknown firmware version on the give-up path, skip transmitting for
states with no command frame, and clear the warning status when a
later apply or factory reset succeeds. Fixture timings adjusted for
the settle window.
2026-08-16 21:12:58 -05:00
J. Nick Koston 338e498960 [ld2420] Address review findings
Clear cmd_active_ when the blocking engine exhausts retries so loop()
does not stay skipped forever, send config mode exit blind before
abandoning a startup sequence so the module resumes streaming, and
repeat the first frame in the delayed boot fixture so the initial
state swallow cannot make the test flaky.
2026-08-16 21:12:58 -05:00
J. Nick Koston 15e1ac2500 [ld2420] Simplify startup state machine and tests
Rebuild startup frames on demand instead of keeping a frame member,
share the ack timeout and retry constants with the blocking engine,
guard the restart button during startup, drop dead code, and factor
the duplicated integration test bodies into a shared helper.
2026-08-16 21:12:58 -05:00
J. Nick Koston 3402ee8bb1 [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.
2026-08-16 21:12:58 -05:00
J. Nick Koston 61d2632851 [ld2420] Extract command frame builders from the blocking senders
Behavior free refactor: split the frame serialization out of
send_cmd_from_array, split the frame construction out of the blocking
command functions into build helpers, name the ack timeout and retry
constants, remove the unused CmdFrameT length field and the unused
get_reg_value_ method. No functional change.
2026-08-16 21:12:28 -05:00
J. Nick Koston e9d940aca4 [ld2420] Drop the setup priority override so setup runs after the UART bus
The component declared setup_priority::BUS, the same tier as the UART
bus itself. At a tie the setup order falls back to registration order,
so on ESP-IDF the component could run setup before uart_driver_install
and the failed write marked the UART bus failed for the whole boot.
Delete the override so the component uses the default DATA priority
like ld2410 and ld2450.
2026-08-16 21:00:05 -05:00
10 changed files with 1823 additions and 216 deletions
+452 -165
View File
@@ -64,6 +64,22 @@ static const char *const TAG = "ld2420";
// Local const's
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 first listen window is roughly three times that to be
// safe, and the shorter retry window still stays above the boot silence in
// case the module reset itself between attempts.
static constexpr uint32_t STARTUP_LISTEN_TIMEOUT_MS = 10000;
static constexpr uint32_t STARTUP_RETRY_LISTEN_MS = 5000;
static constexpr uint32_t STARTUP_LISTEN_SETTLE_MS = 500;
static constexpr uint8_t STARTUP_SEQUENCE_MAX_RETRIES = 3;
// Minimum reply data lengths for the startup reads: the limits read returns
// three values and each gate read returns two, four bytes each plus the four
// status bytes counted in the reply length field
static constexpr uint8_t REPLY_MIN_LEN_LIMITS = 16;
static constexpr uint8_t REPLY_MIN_LEN_GATE = 12;
// Command sets
static constexpr uint16_t CMD_DISABLE_CONF = 0x00FE;
@@ -184,13 +200,14 @@ static int32_t get_firmware_int(const char *version_string) {
return result;
}
float LD2420Component::get_setup_priority() const { return setup_priority::BUS; }
void LD2420Component::dump_config() {
// Setup no longer blocks, so the config dump usually runs before the
// version is read; do not present the "v0.0.0" placeholder as real
const int32_t firmware = ld2420::get_firmware_int(this->firmware_ver_);
ESP_LOGCONFIG(TAG,
"LD2420:\n"
" Firmware version: %7s",
this->firmware_ver_);
firmware > 0 ? this->firmware_ver_ : "unknown");
#ifdef USE_NUMBER
ESP_LOGCONFIG(TAG, "Number:");
LOG_NUMBER(" ", "Gate Timeout:", this->gate_timeout_number_);
@@ -212,60 +229,318 @@ 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) {
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();
void LD2420Component::setup() { this->begin_startup_(); }
void LD2420Component::begin_startup_() {
// Default to energy mode so the stream parser can frame data from a module
// that kept streaming across a soft restart, before the mode is negotiated.
this->system_mode_ = CMD_SYSTEM_MODE_ENERGY;
this->startup_sequence_retries_ = 0;
this->config_read_complete_ = false;
this->begin_listen_();
}
void LD2420Component::begin_listen_() {
this->phase_start_ms_ = millis();
this->startup_state_ = StartupState::STARTUP_STATE_LISTEN_SETTLE;
}
void LD2420Component::drain_rx_() {
uint8_t buf[MAX_LINE_LENGTH];
size_t avail;
while ((avail = this->available()) > 0) {
if (!this->read_array(buf, std::min(avail, sizeof(buf)))) {
ESP_LOGV(TAG, "Failed to drain the receive buffer");
break;
}
}
this->buffer_pos_ = 0;
}
// Builds the command frame for the current startup state; returns false when
// the state has no associated command
bool LD2420Component::build_startup_frame_(CmdFrameT &frame) {
switch (this->startup_state_) {
case StartupState::STARTUP_STATE_ENTER_CONFIG:
this->build_config_mode_frame_(frame, true);
return true;
case StartupState::STARTUP_STATE_READ_LIMITS:
this->build_min_max_timeout_frame_(frame);
return true;
case StartupState::STARTUP_STATE_READ_VERSION:
this->build_version_frame_(frame);
return true;
case StartupState::STARTUP_STATE_READ_GATES:
this->build_gate_threshold_frame_(frame, this->startup_gate_);
return true;
case StartupState::STARTUP_STATE_SET_MODE:
this->build_system_mode_frame_(frame, this->startup_target_mode_);
return true;
case StartupState::STARTUP_STATE_EXIT_CONFIG:
this->build_config_mode_frame_(frame, false);
return true;
default:
return false;
}
}
void LD2420Component::send_startup_cmd_() {
CmdFrameT frame;
if (!this->build_startup_frame_(frame)) {
// Programming error: a command state without a frame would otherwise look
// exactly like a module timeout
ESP_LOGE(TAG, "No command frame for startup state %u", (unsigned) this->startup_state_);
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);
// Discard anything still buffered (including a late reply to a previous
// send of the same command) so a stale ack cannot be matched to this one.
// READ_LIMITS and all gate reads share the same command byte, so a late
// reply accepted for the wrong request would shift every following gate's
// thresholds by one.
this->drain_rx_();
this->startup_cmd_ = (uint8_t) frame.command;
this->cmd_reply_.ack = false;
this->cmd_reply_.error = 0;
// A short reply acks without filling every data word; zero them so stale
// values from the previous command cannot be stored as this command's data
memset(this->cmd_reply_.data, 0, sizeof(this->cmd_reply_.data));
this->write_cmd_frame_(frame);
this->phase_start_ms_ = millis();
}
for (auto &listener : this->listeners_) {
listener->on_fw_version(fw_str);
void LD2420Component::start_startup_cmd_(StartupState state) {
this->startup_state_ = state;
this->startup_cmd_attempts_ = 1;
this->send_startup_cmd_();
}
// 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.
// A reply shorter than min_data_len is treated like silence so a truncated
// read cannot be stored as zeroed configuration.
bool LD2420Component::startup_ack_check_(uint8_t min_data_len) {
if (this->cmd_reply_.ack && this->cmd_reply_.command == this->startup_cmd_ &&
this->cmd_reply_.length >= min_data_len) {
return true;
}
for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) {
delay_microseconds_safe(125);
this->get_gate_threshold_(gate);
if (this->cmd_reply_.error > 0) {
// The module explicitly rejected the command; log why instead of letting
// it look like silence. The normal retry cadence still applies.
this->handle_cmd_error(this->cmd_reply_.error);
this->cmd_reply_.error = 0;
}
if (millis() - this->phase_start_ms_ <= CMD_ACK_TIMEOUT_MS) {
return false;
}
if (this->startup_cmd_attempts_ < CMD_MAX_RETRIES) {
this->startup_cmd_attempts_++;
ESP_LOGV(TAG, "No reply to startup command %2X; resending", this->startup_cmd_);
this->send_startup_cmd_();
return false;
}
this->abort_startup_cmd_();
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_();
return false;
}
this->abandon_startup_();
return false;
}
// Gives up on configuration but keeps parsing the stream; a module that is
// still streaming keeps publishing sensor data even without a config read.
void LD2420Component::abandon_startup_() {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
if (ld2420::get_firmware_int(this->firmware_ver_) == 0) {
// Old firmware streams text frames that are only parsed in simple mode;
// without a version read the mode was never negotiated, so such a module
// will not publish sensor data either.
ESP_LOGE(TAG, "Firmware version and operating mode were never read");
} else if (this->startup_state_ == StartupState::STARTUP_STATE_SET_MODE) {
ESP_LOGE(TAG, "Operating mode write was not acknowledged; sensor data may not be parsed");
}
// Keep the editable config in sync with what was actually read so a later
// Apply Config cannot write values that were never read from the module
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);
}
#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
// Publish whatever was read before giving up so the number entities show
// values next to the warning status instead of staying unknown forever
this->init_gate_config_numbers();
#endif
this->set_system_mode(this->system_mode_);
this->set_config_mode(false);
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
this->startup_state_ = StartupState::STARTUP_STATE_RUNNING;
}
void LD2420Component::abort_startup_cmd_() {
// If the module already acknowledged config mode it stops streaming until
// config mode is exited, so send the exit command blind before abandoning
// the sequence; otherwise the stream never resumes and neither passive
// parsing nor the next listen phase would ever see data. This is also sent
// when config mode was never acknowledged: the ack may merely have been
// lost, and the frame is harmless to a module that is not in config mode.
CmdFrameT frame;
this->build_config_mode_frame_(frame, false);
this->write_cmd_frame_(frame);
}
void LD2420Component::loop_startup_(bool got_data) {
switch (this->startup_state_) {
case StartupState::STARTUP_STATE_LISTEN_SETTLE:
// Bytes can already be in flight when the listen phase starts: the tail
// of a frame the module was transmitting when it was told to restart,
// stale data buffered before setup, or the ack to the blind config mode
// exit. Discard everything received during this settle window so only
// data the module sends afterwards counts as proof that it is up and
// streaming. The state runs at least one drain pass even when the main
// loop stalls past the whole window, so bytes that arrived before the
// listen phase can never be mistaken for fresh data.
this->drain_rx_();
if (millis() - this->phase_start_ms_ >= STARTUP_LISTEN_SETTLE_MS) {
this->phase_start_ms_ = millis();
this->startup_state_ = StartupState::STARTUP_STATE_LISTEN;
}
return;
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 full-frame check
// cannot serve as that proof: old-firmware text frames are only
// recognized once the operating mode is known, which requires the very
// handshake this phase gates.) A module stuck in some other state
// stays quiet, so fall through after the listen window.
if (!got_data) {
const uint32_t listen_timeout_ms =
this->startup_sequence_retries_ == 0 ? STARTUP_LISTEN_TIMEOUT_MS : STARTUP_RETRY_LISTEN_MS;
if (millis() - this->phase_start_ms_ < 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->start_startup_cmd_(StartupState::STARTUP_STATE_ENTER_CONFIG);
return;
case StartupState::STARTUP_STATE_ENTER_CONFIG:
if (!this->startup_ack_check_()) {
return;
}
this->start_startup_cmd_(StartupState::STARTUP_STATE_READ_LIMITS);
return;
case StartupState::STARTUP_STATE_READ_LIMITS:
if (!this->startup_ack_check_(REPLY_MIN_LEN_LIMITS)) {
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->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->start_startup_cmd_(StartupState::STARTUP_STATE_READ_GATES);
return;
}
case StartupState::STARTUP_STATE_READ_GATES:
if (!this->startup_ack_check_(REPLY_MIN_LEN_GATE)) {
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->start_startup_cmd_(StartupState::STARTUP_STATE_READ_GATES);
return;
}
this->config_read_complete_ = true;
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);
}
#endif
this->startup_target_mode_ = CMD_SYSTEM_MODE_SIMPLE;
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
} else {
this->startup_target_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();
#endif
this->start_startup_cmd_(StartupState::STARTUP_STATE_SET_MODE);
return;
case StartupState::STARTUP_STATE_SET_MODE:
if (!this->startup_ack_check_()) {
return;
}
// Switch the parser only after the module acknowledged the mode write,
// so both sides stay in the same mode when the write is never acked
this->set_mode_(this->startup_target_mode_);
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;
}
}
// Common precondition for the button actions: the startup handshake must have
// finished, and actions that write configuration additionally require that
// every limit and gate threshold was actually read (setup may have given up
// partway through; writing the unread config to the module's NVM would wipe
// its stored thresholds).
bool LD2420Component::action_allowed_(bool needs_config) {
if (this->startup_state_ != StartupState::STARTUP_STATE_RUNNING) {
ESP_LOGW(TAG, "Module is still starting up; ignoring");
return false;
}
if (needs_config && !this->config_read_complete_) {
ESP_LOGW(TAG, "Module configuration was never fully read; ignoring");
return false;
}
return true;
}
void LD2420Component::apply_config_action() {
if (!this->action_allowed_(true)) {
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,31 +549,44 @@ 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);
uint8_t error = this->set_min_max_distances_timeout(this->new_config.max_gate, this->new_config.min_gate,
this->new_config.timeout);
for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) {
delay_microseconds_safe(125);
this->set_gate_threshold(gate);
error |= this->set_gate_threshold(gate);
}
if (error == LD2420_ERROR_NONE) {
// Only adopt the new values as current once every write was acknowledged
memcpy(&current_config, &new_config, sizeof(new_config));
}
memcpy(&current_config, &new_config, sizeof(new_config));
#ifdef USE_NUMBER
this->init_gate_config_numbers();
#endif
this->set_system_mode(this->system_mode_);
this->set_config_mode(false); // Disable config mode to save new values in LD2420 nvm
// Disable config mode to save the new values in the LD2420 nvm
if (this->set_config_mode(false) == LD2420_ERROR_NONE && error == LD2420_ERROR_NONE) {
this->status_clear_warning();
} else {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
}
this->set_operating_mode(OP_NORMAL_MODE_STRING);
}
void LD2420Component::factory_reset_action() {
if (!this->action_allowed_(true)) {
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);
uint8_t error = this->set_min_max_distances_timeout(FACTORY_MAX_GATE, FACTORY_MIN_GATE, FACTORY_TIMEOUT);
#ifdef USE_NUMBER
this->gate_timeout_number_->state = FACTORY_TIMEOUT;
this->min_gate_distance_number_->state = FACTORY_MIN_GATE;
@@ -308,11 +596,18 @@ void LD2420Component::factory_reset_action() {
this->new_config.move_thresh[gate] = FACTORY_MOVE_THRESH[gate];
this->new_config.still_thresh[gate] = FACTORY_STILL_THRESH[gate];
delay_microseconds_safe(125);
this->set_gate_threshold(gate);
error |= this->set_gate_threshold(gate);
}
if (error == LD2420_ERROR_NONE) {
memcpy(&this->current_config, &this->new_config, sizeof(this->new_config));
}
memcpy(&this->current_config, &this->new_config, sizeof(this->new_config));
this->set_system_mode(this->system_mode_);
this->set_config_mode(false);
if (this->set_config_mode(false) == LD2420_ERROR_NONE && error == LD2420_ERROR_NONE) {
this->status_clear_warning();
} else {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
}
#ifdef USE_NUMBER
this->init_gate_config_numbers();
this->refresh_gate_config_numbers();
@@ -320,16 +615,21 @@ void LD2420Component::factory_reset_action() {
}
void LD2420Component::restart_module_action() {
if (!this->action_allowed_(false)) {
return;
}
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_();
}
void LD2420Component::revert_config_action() {
if (!this->action_allowed_(false)) {
return;
}
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
#ifdef USE_NUMBER
this->init_gate_config_numbers();
@@ -342,7 +642,10 @@ void LD2420Component::loop() {
if (this->cmd_active_) {
return;
}
this->read_batch_(this->buffer_data_);
const bool got_data = this->read_batch_(this->buffer_data_);
if (this->startup_state_ != StartupState::STARTUP_STATE_RUNNING) {
this->loop_startup_(got_data);
}
}
void LD2420Component::update_radar_data(uint16_t const *gate_energy, uint8_t sample_number) {
@@ -549,9 +852,10 @@ void LD2420Component::handle_simple_mode_(const uint8_t *inbuf, int len) {
}
}
void LD2420Component::read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer) {
bool 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();
const bool got_data = avail > 0;
uint8_t buf[MAX_LINE_LENGTH];
while (avail > 0) {
size_t to_read = std::min(avail, sizeof(buf));
@@ -564,6 +868,7 @@ void LD2420Component::read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer) {
this->readline_(buf[i], buffer.data(), buffer.size());
}
}
return got_data;
}
void LD2420Component::handle_ack_data_(uint8_t *buffer, int len) {
@@ -633,37 +938,39 @@ void LD2420Component::handle_ack_data_(uint8_t *buffer, int len) {
}
}
void LD2420Component::write_cmd_frame_(const CmdFrameT &frame) {
uint8_t cmd_buffer[MAX_LINE_LENGTH];
uint16_t length = 0;
const uint16_t frame_data_bytes = frame.data_length + 2; // Always add two bytes for the cmd size
memcpy(&cmd_buffer[length], &frame.header, sizeof(frame.header));
length += sizeof(frame.header);
memcpy(&cmd_buffer[length], &frame_data_bytes, sizeof(frame.data_length));
length += sizeof(frame.data_length);
memcpy(&cmd_buffer[length], &frame.command, sizeof(frame.command));
length += sizeof(frame.command);
memcpy(&cmd_buffer[length], frame.data, frame.data_length);
length += frame.data_length;
memcpy(&cmd_buffer[length], &frame.footer, sizeof(frame.footer));
length += sizeof(frame.footer);
this->write_array(cmd_buffer, length);
}
int LD2420Component::send_cmd_from_array(CmdFrameT frame) {
uint32_t start_millis = millis();
uint8_t error = 0;
uint8_t ack_buffer[MAX_LINE_LENGTH];
uint8_t cmd_buffer[MAX_LINE_LENGTH];
this->cmd_reply_.ack = false;
if (frame.command != CMD_RESTART) {
this->cmd_active_ = true;
} // Restart does not reply, thus no ack state required
uint8_t retry = 3;
uint8_t retry = CMD_MAX_RETRIES;
while (retry) {
frame.length = 0;
uint16_t frame_data_bytes = frame.data_length + 2; // Always add two bytes for the cmd size
memcpy(&cmd_buffer[frame.length], &frame.header, sizeof(frame.header));
frame.length += sizeof(frame.header);
memcpy(&cmd_buffer[frame.length], &frame_data_bytes, sizeof(frame.data_length));
frame.length += sizeof(frame.data_length);
memcpy(&cmd_buffer[frame.length], &frame.command, sizeof(frame.command));
frame.length += sizeof(frame.command);
for (uint16_t index = 0; index < frame.data_length; index++) {
memcpy(&cmd_buffer[frame.length], &frame.data[index], sizeof(frame.data[index]));
frame.length += sizeof(frame.data[index]);
}
memcpy(cmd_buffer + frame.length, &frame.footer, sizeof(frame.footer));
frame.length += sizeof(frame.footer);
this->write_array(cmd_buffer, frame.length);
this->write_cmd_frame_(frame);
error = 0;
if (frame.command == CMD_RESTART) {
@@ -676,7 +983,7 @@ int LD2420Component::send_cmd_from_array(CmdFrameT frame) {
}
delay_microseconds_safe(1450);
// Wait on an Rx from the LD2420 for up to 3 1 second loops, otherwise it could trigger a WDT.
if ((millis() - start_millis) > 1000) {
if ((millis() - start_millis) > CMD_ACK_TIMEOUT_MS) {
start_millis = millis();
error = LD2420_ERROR_TIMEOUT;
retry--;
@@ -690,19 +997,26 @@ int LD2420Component::send_cmd_from_array(CmdFrameT frame) {
this->handle_cmd_error(this->cmd_reply_.error);
}
}
// On ack the reply parser already cleared this; clear it here as well so an
// exhausted retry loop cannot leave loop() skipping all processing forever.
this->cmd_active_ = false;
return error;
}
void LD2420Component::build_config_mode_frame_(CmdFrameT &frame, bool enable) {
frame.data_length = 0;
frame.header = CMD_FRAME_HEADER;
frame.command = enable ? CMD_ENABLE_CONF : CMD_DISABLE_CONF;
if (enable) {
memcpy(&frame.data[0], &CMD_PROTOCOL_VER, sizeof(CMD_PROTOCOL_VER));
frame.data_length += sizeof(CMD_PROTOCOL_VER);
}
frame.footer = CMD_FRAME_FOOTER;
}
uint8_t LD2420Component::set_config_mode(bool enable) {
CmdFrameT cmd_frame;
cmd_frame.data_length = 0;
cmd_frame.header = CMD_FRAME_HEADER;
cmd_frame.command = enable ? CMD_ENABLE_CONF : CMD_DISABLE_CONF;
if (enable) {
memcpy(&cmd_frame.data[0], &CMD_PROTOCOL_VER, sizeof(CMD_PROTOCOL_VER));
cmd_frame.data_length += sizeof(CMD_PROTOCOL_VER);
}
cmd_frame.footer = CMD_FRAME_FOOTER;
this->build_config_mode_frame_(cmd_frame, enable);
ESP_LOGV(TAG, "Sending set config %s command: %2X", enable ? "enable" : "disable", cmd_frame.command);
return this->send_cmd_from_array(cmd_frame);
}
@@ -720,18 +1034,6 @@ void LD2420Component::ld2420_restart() {
this->send_cmd_from_array(cmd_frame);
}
void LD2420Component::get_reg_value_(uint16_t reg) {
CmdFrameT cmd_frame;
cmd_frame.data_length = 0;
cmd_frame.header = CMD_FRAME_HEADER;
cmd_frame.command = CMD_READ_REGISTER;
cmd_frame.data[1] = reg;
cmd_frame.data_length += 2;
cmd_frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending read register %4X command: %2X", reg, cmd_frame.command);
this->send_cmd_from_array(cmd_frame);
}
void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) {
CmdFrameT cmd_frame;
cmd_frame.data_length = 0;
@@ -755,84 +1057,69 @@ void LD2420Component::handle_cmd_error(uint16_t error) {
}
}
int LD2420Component::get_gate_threshold_(uint8_t gate) {
uint8_t error;
CmdFrameT cmd_frame;
cmd_frame.data_length = 0;
cmd_frame.header = CMD_FRAME_HEADER;
cmd_frame.command = CMD_READ_ABD_PARAM;
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_GATE_MOVE_THRESH[gate], sizeof(CMD_GATE_MOVE_THRESH[gate]));
cmd_frame.data_length += 2;
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_GATE_STILL_THRESH[gate], sizeof(CMD_GATE_STILL_THRESH[gate]));
cmd_frame.data_length += 2;
cmd_frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending read gate %d high/low threshold command: %2X", gate, cmd_frame.command);
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_gate_threshold_frame_(CmdFrameT &frame, uint8_t gate) {
frame.data_length = 0;
frame.header = CMD_FRAME_HEADER;
frame.command = CMD_READ_ABD_PARAM;
memcpy(&frame.data[frame.data_length], &CMD_GATE_MOVE_THRESH[gate], sizeof(CMD_GATE_MOVE_THRESH[gate]));
frame.data_length += 2;
memcpy(&frame.data[frame.data_length], &CMD_GATE_STILL_THRESH[gate], sizeof(CMD_GATE_STILL_THRESH[gate]));
frame.data_length += 2;
frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending read gate %d high/low threshold command: %2X", gate, frame.command);
}
int LD2420Component::get_min_max_distances_timeout_() {
uint8_t error;
CmdFrameT cmd_frame;
cmd_frame.data_length = 0;
cmd_frame.header = CMD_FRAME_HEADER;
cmd_frame.command = CMD_READ_ABD_PARAM;
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_MIN_GATE_REG,
void LD2420Component::build_min_max_timeout_frame_(CmdFrameT &frame) {
frame.data_length = 0;
frame.header = CMD_FRAME_HEADER;
frame.command = CMD_READ_ABD_PARAM;
memcpy(&frame.data[frame.data_length], &CMD_MIN_GATE_REG,
sizeof(CMD_MIN_GATE_REG)); // Register: global min detect gate number
cmd_frame.data_length += sizeof(CMD_MIN_GATE_REG);
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_MAX_GATE_REG,
frame.data_length += sizeof(CMD_MIN_GATE_REG);
memcpy(&frame.data[frame.data_length], &CMD_MAX_GATE_REG,
sizeof(CMD_MAX_GATE_REG)); // Register: global max detect gate number
cmd_frame.data_length += sizeof(CMD_MAX_GATE_REG);
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_TIMEOUT_REG,
frame.data_length += sizeof(CMD_MAX_GATE_REG);
memcpy(&frame.data[frame.data_length], &CMD_TIMEOUT_REG,
sizeof(CMD_TIMEOUT_REG)); // Register: global delay time
cmd_frame.data_length += sizeof(CMD_TIMEOUT_REG);
cmd_frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending read gate min max and timeout command: %2X", cmd_frame.command);
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;
frame.data_length += sizeof(CMD_TIMEOUT_REG);
frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending read gate min max and timeout command: %2X", frame.command);
}
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::set_system_mode(uint16_t mode) {
CmdFrameT cmd_frame;
uint16_t unknown_parm = 0x0000;
cmd_frame.data_length = 0;
cmd_frame.header = CMD_FRAME_HEADER;
cmd_frame.command = CMD_WRITE_SYS_PARAM;
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_SYSTEM_MODE, sizeof(CMD_SYSTEM_MODE));
cmd_frame.data_length += sizeof(CMD_SYSTEM_MODE);
memcpy(&cmd_frame.data[cmd_frame.data_length], &mode, sizeof(mode));
cmd_frame.data_length += sizeof(mode);
memcpy(&cmd_frame.data[cmd_frame.data_length], &unknown_parm, sizeof(unknown_parm));
cmd_frame.data_length += sizeof(unknown_parm);
cmd_frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending write system mode command: %2X", cmd_frame.command);
this->build_system_mode_frame_(cmd_frame, mode);
if (this->send_cmd_from_array(cmd_frame) == 0) {
this->set_mode_(mode);
}
}
void LD2420Component::get_firmware_version_() {
CmdFrameT cmd_frame;
cmd_frame.data_length = 0;
cmd_frame.header = CMD_FRAME_HEADER;
cmd_frame.command = CMD_READ_VERSION;
cmd_frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending read firmware version command: %2X", cmd_frame.command);
this->send_cmd_from_array(cmd_frame);
void LD2420Component::build_version_frame_(CmdFrameT &frame) {
frame.data_length = 0;
frame.header = CMD_FRAME_HEADER;
frame.command = CMD_READ_VERSION;
frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending read firmware version command: %2X", frame.command);
}
void LD2420Component::set_min_max_distances_timeout(uint32_t max_gate_distance, uint32_t min_gate_distance, // NOLINT
uint32_t timeout) {
uint8_t 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
// |Min Gate |Max Gate |Timeout |
// HH HH HH HH LL LL CC CC RR RR VV VV VV VV RR RR VV VV VV VV RR RR VV VV VV VV FF FF FF FF
@@ -861,10 +1148,10 @@ void LD2420Component::set_min_max_distances_timeout(uint32_t max_gate_distance,
cmd_frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending write gate min max and timeout command: %2X", cmd_frame.command);
this->send_cmd_from_array(cmd_frame);
return this->send_cmd_from_array(cmd_frame);
}
void LD2420Component::set_gate_threshold(uint8_t gate) {
uint8_t LD2420Component::set_gate_threshold(uint8_t gate) {
// Header H, Length L, Command C, Register R, Value V, Footer F
// HH HH HH HH LL LL CC CC RR RR VV VV VV VV RR RR VV VV VV VV FF FF FF FF
// FD FC FB FA 14 00 07 00 10 00 00 FF 00 00 00 01 00 0F 00 00 04 03 02 01
@@ -887,7 +1174,7 @@ void LD2420Component::set_gate_threshold(uint8_t gate) {
cmd_frame.data_length += sizeof(this->new_config.still_thresh[gate]);
cmd_frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending set gate %4X sensitivity command: %2X", gate, cmd_frame.command);
this->send_cmd_from_array(cmd_frame);
return this->send_cmd_from_array(cmd_frame);
}
#ifdef USE_NUMBER
+49 -13
View File
@@ -45,15 +45,14 @@ class LD2420Component final : public Component, public uart::UARTDevice {
struct CmdFrameT {
uint32_t header{0};
uint32_t footer{0};
uint16_t length{0};
uint16_t command{0};
uint16_t data_length{0};
uint8_t data[18];
};
struct RegConfigT {
uint32_t move_thresh[TOTAL_GATES];
uint32_t still_thresh[TOTAL_GATES];
uint32_t move_thresh[TOTAL_GATES]{};
uint32_t still_thresh[TOTAL_GATES]{};
uint16_t min_gate{0};
uint16_t max_gate{0};
uint16_t timeout{0};
@@ -105,7 +104,6 @@ class LD2420Component final : public Component, public uart::UARTDevice {
void apply_config_action();
void factory_reset_action();
void revert_config_action();
float get_setup_priority() const override;
int send_cmd_from_array(CmdFrameT cmd_frame);
void report_gate_data();
void handle_cmd_error(uint16_t error);
@@ -113,8 +111,8 @@ class LD2420Component final : public Component, public uart::UARTDevice {
void auto_calibrate_sensitivity();
void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number);
uint8_t set_config_mode(bool enable);
void set_min_max_distances_timeout(uint32_t max_gate_distance, uint32_t min_gate_distance, uint32_t timeout);
void set_gate_threshold(uint8_t gate);
uint8_t set_min_max_distances_timeout(uint32_t max_gate_distance, uint32_t min_gate_distance, uint32_t timeout);
uint8_t set_gate_threshold(uint8_t gate);
void set_reg_value(uint16_t reg, uint16_t value);
void set_system_mode(uint16_t mode);
void ld2420_restart();
@@ -154,10 +152,40 @@ class LD2420Component final : public Component, public uart::UARTDevice {
volatile bool ack;
};
void get_firmware_version_();
int get_gate_threshold_(uint8_t gate);
void get_reg_value_(uint16_t reg);
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_SETTLE = 0,
STARTUP_STATE_LISTEN,
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_();
void begin_listen_();
void loop_startup_(bool got_data);
void start_startup_cmd_(StartupState state);
void send_startup_cmd_();
void abort_startup_cmd_();
void abandon_startup_();
bool startup_ack_check_(uint8_t min_data_len = 0);
bool action_allowed_(bool needs_config);
void drain_rx_();
void write_cmd_frame_(const CmdFrameT &frame);
bool build_startup_frame_(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);
uint16_t get_mode_() { return this->system_mode_; };
void set_mode_(uint16_t mode) { this->system_mode_ = mode; };
bool get_presence_() { return this->presence_; };
@@ -168,7 +196,7 @@ class LD2420Component final : public Component, public uart::UARTDevice {
void handle_energy_mode_(uint8_t *buffer, int len);
void handle_ack_data_(uint8_t *buffer, int len);
void readline_(int rx_data, uint8_t *buffer, int len);
void read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer);
bool read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer);
void set_calibration_(bool state) { this->calibration_ = state; };
bool get_calibration_() { return this->calibration_; };
@@ -184,9 +212,17 @@ class LD2420Component final : public Component, public uart::UARTDevice {
#endif
uint16_t distance_{0};
uint16_t system_mode_;
uint16_t system_mode_{0}; // Set to the energy mode default in begin_startup_()
uint16_t startup_target_mode_{0}; // Mode the startup handshake writes; applied to system_mode_ once acked
uint16_t gate_energy_[TOTAL_GATES];
uint8_t buffer_pos_{0}; // where to resume processing/populating buffer
uint32_t phase_start_ms_{0};
StartupState startup_state_{StartupState::STARTUP_STATE_LISTEN_SETTLE};
uint8_t startup_cmd_{0}; // Command byte of the in-flight startup command, for ack matching
uint8_t startup_cmd_attempts_{0};
uint8_t startup_sequence_retries_{0};
uint8_t startup_gate_{0};
bool config_read_complete_{false}; // All limits and gate thresholds were read from the module
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"};
bool cmd_active_{false};
@@ -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,8 +63,58 @@ 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
# Phase 1 (t=700ms): Valid LD2420 energy mode data frame - happy path
# Delay=700ms keeps it outside the component's 500ms listen settle window,
# which is measured from boot and ignores earlier reception; this frame is
# both the happy path data and the wake-up that starts the setup handshake.
# Buffer is clean (buffer_pos_=0). This frame should parse correctly.
# Presence: 1 (target detected), Distance: 100cm, Gate energies: all 0
#
@@ -84,7 +125,7 @@ uart_mock:
# [7-8] 64 00 = distance 100 (uint16_t LE)
# [9-40] 00 00 x16 = 16 gate energies (uint16_t LE each)
# [41-44] F8 F7 F6 F5 = energy frame footer
- delay: 100ms
- delay: 700ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
@@ -98,13 +139,15 @@ uart_mock:
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 2 (t=300ms): Garbage bytes
# Phase 2 (t=1600ms): 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=900ms leaves time for the setup handshake (triggered by Phase 1,
# the first data seen from the module) to finish first.
- delay: 900ms
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
# Phase 3 (t=400ms): Truncated energy frame WITH footer (13 bytes)
# Phase 3 (t=1700ms): 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 +169,7 @@ uart_mock:
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 4 (t=600ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# Phase 4 (t=1900ms): 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.
@@ -140,11 +183,11 @@ uart_mock:
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
]
# Phase 5 (t=1500ms): Valid frame after overflow - recovery test
# Phase 5 (t=2300ms): 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,137 @@
esphome:
name: uart-mock-ld2420-retry-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
# Exercises the per-command retry path: the module ignores the first config
# mode enable command and only answers the resend, so the startup handshake
# must time out once, resend, and then complete normally.
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: true
injections:
# Wake-up frame (t=700ms): energy frame (presence=1, distance=100).
# Delay=700ms keeps it outside the component's 500ms listen settle window.
- delay: 700ms
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,
]
# The config mode enable command is matched by an empty responder so the
# catch-all cannot answer it (responders match on the TX suffix and every
# command ends with the frame footer); the on_tx hook below acks it from
# the second attempt on, so the first attempt is genuine silence.
responses:
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx: []
# 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,
]
# 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,
]
# Ignore the first config mode enable command; ack every one after it
on_tx:
- lambda: |-
static int enable_count = 0;
if (data.size() == 14 && data[6] == 0xFF) {
enable_count++;
if (enable_count >= 2) {
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{
0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x01, 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
@@ -0,0 +1,168 @@
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,
]
# Repeat frame (t=3100ms): distance=100 again. If the API client happens
# to subscribe after the first frame, the first published state is
# swallowed as the entity's initial state; repeating the value makes the
# test's first collected state deterministic. Delay=1100ms keeps >1000ms
# publish throttle gap from the first frame.
- delay: 1100ms
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=4200ms): distance=50 proves streaming still works
# after the setup handshake. Delay=1100ms keeps >1000ms publish throttle
# gap from the repeat frame.
- delay: 1100ms
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
@@ -0,0 +1,128 @@
esphome:
name: uart-mock-ld2420-giveup-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
# Exercises the sequence retry and give-up path: the module streams energy
# frames and answers every command except the firmware version read. The
# startup handshake must retry the whole sequence, eventually give up with a
# warning instead of marking the component failed, and keep parsing the
# stream afterwards. Runs for roughly 16 seconds of retry cadence.
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,
]
injections:
# Post-give-up parser probe (t=22s): 50 bytes of 0xFF overflow the frame
# buffer, which the parser answers with a "Max command length exceeded"
# warning. The give-up happens around t=16s, so seeing that warning after
# the give-up proves the stream parser is still running in the degraded
# state. (A distinct sensor value cannot serve as the probe: the 1s
# publish throttle races the constant periodic stream, and the API
# deduplicates repeated identical states.)
- delay: 22000ms
inject_rx:
[
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
]
responses:
# Version read: matched so the catch-all cannot answer it, but never
# replied to; this is the command the handshake gives up on
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx: []
# 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,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE), sent blind before each
# sequence retry and on the final give-up
- 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
- 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
@@ -0,0 +1,167 @@
esphome:
name: uart-mock-ld2420-restart-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
# Exercises the module restart path. The restart command hits the module while
# it is mid transmission, so a few tail bytes of the in-flight frame arrive
# right after the restart. The module is then silent for 2 seconds while it
# boots, and it locks up until power cycled if it receives any data in that
# window. The component must not treat the tail bytes as proof the module is
# up, and must only re-run its setup handshake after the module's first
# post-boot frame.
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: true
injections:
# Initial wake-up frame (t=700ms): energy frame (presence=1, distance=100).
# Delay=700ms keeps it outside the component's 500ms listen settle window,
# which is measured from boot and ignores earlier reception.
- delay: 700ms
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,
]
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,
]
# The module restart command (CMD_RESTART, 0x0068) gets no reply; a real
# module goes silent and reboots. Matching it here prevents the catch-all
# below from answering it.
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x68, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx: []
# 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,
]
button:
- platform: template
name: "Restart Module"
on_press:
- lambda: 'id(ld2420_dev).restart_module_action();'
# Tail of the energy frame the module was transmitting when the restart
# command hit it; must not count as proof the module is up
- uart_mock.inject_rx:
id: mock_uart
data: [0x00, 0x00, 0x00, 0xF8, 0xF7, 0xF6, 0xF5]
# The module's first frame after its ~2s boot (presence=1, distance=100)
- uart_mock.inject_rx:
id: mock_uart
delay: 2000ms
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,
]
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,11 +49,50 @@ 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_.
- delay: 100ms
# Phase 0 (t=700ms): 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=700ms keeps it outside the component's 500ms listen settle window,
# which is measured from boot and ignores earlier reception.
- delay: 700ms
inject_rx:
[
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
@@ -47,12 +100,24 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 2 (t=300ms): Garbage bytes
# Phase 1 (t=1600ms): 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=900ms leaves time for the setup handshake to finish first.
- delay: 900ms
inject_rx:
[
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
0x30, 0x31, 0x30, 0x30,
0x0D, 0x0A,
]
# Phase 2 (t=1800ms): 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=2000ms): 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 +132,13 @@ uart_mock:
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
]
# Phase 4 (t=1400ms): Recovery after overflow
# Phase 4 (t=2700ms): 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 +146,7 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 5 (t=2500ms): 16-digit distance - tests PR #14458 bug #1
# Phase 5 (t=3800ms): 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 +165,7 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 6 (t=3700ms): Post-bug-trigger recovery
# Phase 6 (t=5000ms): 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,147 @@
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 valid energy frames continuously. Two alternating frames
# are used (presence=1/distance=100 and presence=0/distance=75) so states
# keep changing: with a constant frame the API deduplicates the repeated
# identical states, and a client that subscribes after the first publish
# would swallow the only transition as the initial state and never see an
# update.
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,
]
- interval: 1050ms
data:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x00,
0x4B, 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
+430 -1
View File
@@ -15,6 +15,36 @@ 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.
test_uart_mock_ld2420_restart_button (module restart action):
Presses the restart button after setup. The restart hits the module mid
transmission, so a few tail bytes of the in-flight frame arrive right after
the restart command, then the module is silent for 2 seconds while it
boots. The component must not treat the tail bytes as proof the module is
up and must only re-run its handshake after the module's first post-boot
frame; transmitting into the boot window locks up real hardware.
test_uart_mock_ld2420_cmd_retry (per-command resend):
The module ignores the first config mode enable command and only answers
the resend. The handshake must time out once, resend, and complete.
test_uart_mock_ld2420_give_up (sequence retry and give-up):
The module streams and answers everything except the firmware version
read. The handshake must retry the whole sequence, eventually give up with
a warning instead of marking the component failed, and keep publishing
sensor data from the stream afterwards.
"""
from __future__ import annotations
@@ -25,7 +55,12 @@ from pathlib import Path
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, find_entity
from .state_utils import (
InitialStateHelper,
SensorStateCollector,
find_entity,
require_entity,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -160,6 +195,400 @@ async def test_uart_mock_ld2420(
)
SETUP_COMPLETE_LOG = "Module setup complete; firmware v2.0.0"
class _LogWatcher:
"""Resolves futures when watched substrings appear in device log lines.
Use as the run_compiled line_callback. watch() returns a future that
resolves once a line containing all given substrings has been seen `count`
times; `after` gates matching on another future being done, and `until`
stops matching once another future is done. collect() gathers every line
containing any of the given substrings into `self.collected`.
"""
def __init__(self) -> None:
self._loop = asyncio.get_running_loop()
self._watches: list[dict] = []
self._collect_substrings: tuple[str, ...] = ()
self.collected: list[str] = []
def watch(
self,
substrings: str | list[str],
*,
count: int = 1,
after: asyncio.Future | None = None,
until: asyncio.Future | None = None,
) -> asyncio.Future:
subs = [substrings] if isinstance(substrings, str) else substrings
watch = {
"subs": subs,
"count": count,
"after": after,
"until": until,
"future": self._loop.create_future(),
"seen": 0,
}
self._watches.append(watch)
return watch["future"]
def collect(self, *substrings: str) -> None:
self._collect_substrings = substrings
def __call__(self, line: str) -> None:
for watch in self._watches:
if watch["future"].done():
continue
if watch["after"] is not None and not watch["after"].done():
continue
if watch["until"] is not None and watch["until"].done():
continue
if all(s in line for s in watch["subs"]):
watch["seen"] += 1
if watch["seen"] >= watch["count"]:
watch["future"].set_result(True)
if any(s in line for s in self._collect_substrings):
self.collected.append(line)
async def _wait_or_fail(awaitable, timeout: float, message) -> None:
"""Await with a timeout, translating TimeoutError into pytest.fail.
`message` may be a string or a zero-argument callable evaluated at
failure time (for messages that embed the current collector state).
"""
try:
await asyncio.wait_for(awaitable, timeout=timeout)
except TimeoutError:
pytest.fail(message() if callable(message) else message)
async def _subscribe_and_wait(client, collector: SensorStateCollector | None = None):
"""List entities, subscribe states, and wait for the initial state flood."""
entities, _ = await client.list_entities_services()
if collector is not None:
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
on_state = collector.on_state if collector is not None else (lambda s: None)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await _wait_or_fail(
initial_state_helper.wait_for_initial_states(),
11.0,
"Timeout waiting for initial states",
)
return entities
async def _run_listen_first_test(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
*,
post_setup_distance: float | None = None,
strict_first: bool = True,
) -> None:
"""Shared body for the listen-first startup tests.
Asserts the component never transmits before the module has sent data
(real hardware locks up until power cycled if it does), that the setup
handshake completes, and that sensor data publishes. When
post_setup_distance is given, additionally waits for that value to prove
streaming still works after the handshake. strict_first asserts on the
first collected state; pass False for fixtures whose stream alternates
values, where the first collected state depends on subscribe timing.
"""
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 SETUP_COMPLETE_LOG in line and not setup_complete.done():
setup_complete.set_result(True)
if (
"marked FAILED" in line
or "was marked as 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"],
)
post_setup_received = None
if post_setup_distance is not None:
post_setup_received = collector.add_waiter(
lambda: (
pytest.approx(post_setup_distance)
in collector.sensor_states["moving_distance"]
)
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
await _subscribe_and_wait(client, collector)
# Setup handshake must complete once the module has talked
await _wait_or_fail(
setup_complete,
10.0,
"Timeout waiting for 'Module setup complete' log line. "
"The startup state machine did not finish its handshake.",
)
# Sensor data must flow from the stream
await _wait_or_fail(
collector.wait_for_all(timeout=5.0),
6.0,
lambda: (
f"Timeout waiting for sensor data. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" binary_states: {collector.binary_states}"
),
)
if strict_first:
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
assert collector.binary_states["has_target"][0] is True
else:
assert pytest.approx(100.0) in collector.sensor_states["moving_distance"]
assert True in collector.binary_states["has_target"]
if post_setup_received is not None:
await _wait_or_fail(
post_setup_received,
5.0,
lambda: (
f"Timeout waiting for post-setup frame "
f"(distance={post_setup_distance}). Received:\n"
f" moving_distance: {collector.sensor_states['moving_distance']}"
),
)
# 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_warm_restart(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Module streams from boot; component must listen first, then set up."""
await _run_listen_first_test(
yaml_config, run_compiled, api_client_connected, strict_first=False
)
@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."""
await _run_listen_first_test(
yaml_config, run_compiled, api_client_connected, post_setup_distance=50.0
)
@pytest.mark.asyncio
async def test_uart_mock_ld2420_cmd_retry(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""First config command gets no reply; the resend must recover."""
watcher = _LogWatcher()
resend_seen = watcher.watch("No reply to startup command")
setup_complete = watcher.watch(SETUP_COMPLETE_LOG)
watcher.collect(
"marked FAILED",
"was marked as failed",
"Communication failed",
"Module setup attempt",
)
collector = SensorStateCollector(
sensor_names=["moving_distance"],
binary_sensor_names=["has_target"],
)
async with (
run_compiled(yaml_config, line_callback=watcher),
api_client_connected() as client,
):
await _subscribe_and_wait(client, collector)
# The first enable command is ignored, so a resend must happen
await _wait_or_fail(
resend_seen, 10.0, "Timeout waiting for the startup command resend log line"
)
# The resend gets an ack and the handshake completes normally
await _wait_or_fail(
setup_complete,
10.0,
"Timeout waiting for 'Module setup complete' after the resend",
)
await _wait_or_fail(
collector.wait_for_all(timeout=5.0),
6.0,
lambda: (
f"Timeout waiting for sensor data. Received:\n"
f" sensor_states: {collector.sensor_states}"
),
)
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
# A single command resend must not burn a whole sequence retry or
# produce any failure log line
assert not watcher.collected, (
f"Unexpected failure log lines: {watcher.collected}"
)
@pytest.mark.asyncio
async def test_uart_mock_ld2420_give_up(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Version read never answers; retries then give-up, stream keeps working."""
watcher = _LogWatcher()
sequence_retry_seen = watcher.watch("Module setup attempt 1 failed; retrying")
give_up_seen = watcher.watch("Firmware version and operating mode were never read")
# The overflow probe injected at t=22s (after the give-up) makes the
# parser log this warning only if it is still running
parser_alive_after_give_up = watcher.watch(
"Max command length exceeded", after=give_up_seen
)
watcher.collect("marked FAILED", "was marked as failed")
collector = SensorStateCollector(
sensor_names=["moving_distance"],
binary_sensor_names=["has_target"],
)
async with (
run_compiled(yaml_config, line_callback=watcher),
api_client_connected() as client,
):
await _subscribe_and_wait(client, collector)
# The version read times out three times, then the sequence retries
await _wait_or_fail(
sequence_retry_seen, 15.0, "Timeout waiting for the sequence retry log line"
)
# After all sequence retries the component gives up with a warning
await _wait_or_fail(
give_up_seen, 30.0, "Timeout waiting for the give-up log line"
)
# The stream must still be parsed after giving up
await _wait_or_fail(
parser_alive_after_give_up,
20.0,
"No parser activity after the give-up; the stream parser "
"must keep running in the degraded state",
)
# The stream published sensor data while the handshake was failing
assert pytest.approx(100.0) in collector.sensor_states["moving_distance"], (
f"Expected the stream to publish distance=100, "
f"got: {collector.sensor_states['moving_distance']}"
)
# The whole point of the degraded state: the component keeps running
assert not watcher.collected, (
f"Component was marked failed: {watcher.collected}"
)
@pytest.mark.asyncio
async def test_uart_mock_ld2420_restart_button(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Restart action must not transmit into the module's boot window."""
watcher = _LogWatcher()
first_setup_complete = watcher.watch(SETUP_COMPLETE_LOG)
second_setup_complete = watcher.watch(SETUP_COMPLETE_LOG, count=2)
restart_seen = watcher.watch(["[ld2420", "Restarting"])
# The module's first frame after its simulated 2 s boot
module_frame_after_restart = watcher.watch("RX inject 45 bytes", after=restart_seen)
# Config mode enable transmitted before the module's first post-boot
# frame; on real hardware this locks the module up
tx_into_boot_window = watcher.watch(
["uart_mock", "TX ", "FF:00:02:00"],
after=restart_seen,
until=module_frame_after_restart,
)
watcher.collect("marked FAILED", "was marked as failed", "Communication failed")
async with (
run_compiled(yaml_config, line_callback=watcher),
api_client_connected() as client,
):
entities = await _subscribe_and_wait(client)
# Wait for the initial startup handshake to finish
await _wait_or_fail(
first_setup_complete,
10.0,
"Timeout waiting for the initial 'Module setup complete'",
)
# Restart the module; the button automation also injects the in-flight
# frame tail immediately and the module's first frame 2 s later
restart_btn = require_entity(entities, "restart_module", ButtonInfo)
client.button_command(restart_btn.key)
# The handshake must complete again after the module comes back
await _wait_or_fail(
second_setup_complete,
15.0,
"Timeout waiting for 'Module setup complete' after the restart. "
"The component did not recover from the module restart.",
)
assert not tx_into_boot_window.done(), (
"Component transmitted the config handshake into the module's "
"boot window after a restart; the in-flight frame tail bytes must "
"not count as proof the module is up"
)
assert not watcher.collected, (
f"Unexpected failure log lines: {watcher.collected}"
)
@pytest.mark.asyncio
async def test_uart_mock_ld2420_simple(
yaml_config: str,