diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index 948dd9656e..5afe218aa5 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -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 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 diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 2cd7c582a7..3b200a3faf 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -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"}; diff --git a/tests/integration/fixtures/uart_mock_ld2420.yaml b/tests/integration/fixtures/uart_mock_ld2420.yaml index ee22f807d4..c10885db8c 100644 --- a/tests/integration/fixtures/uart_mock_ld2420.yaml +++ b/tests/integration/fixtures/uart_mock_ld2420.yaml @@ -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, diff --git a/tests/integration/fixtures/uart_mock_ld2420_delayed_boot.yaml b/tests/integration/fixtures/uart_mock_ld2420_delayed_boot.yaml new file mode 100644 index 0000000000..5aee2b792b --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2420_delayed_boot.yaml @@ -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 diff --git a/tests/integration/fixtures/uart_mock_ld2420_simple.yaml b/tests/integration/fixtures/uart_mock_ld2420_simple.yaml index d3b6ad5d92..7195222cc1 100644 --- a/tests/integration/fixtures/uart_mock_ld2420_simple.yaml +++ b/tests/integration/fixtures/uart_mock_ld2420_simple.yaml @@ -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. diff --git a/tests/integration/fixtures/uart_mock_ld2420_warm_restart.yaml b/tests/integration/fixtures/uart_mock_ld2420_warm_restart.yaml new file mode 100644 index 0000000000..d318a7e576 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2420_warm_restart.yaml @@ -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 diff --git a/tests/integration/test_uart_mock_ld2420.py b/tests/integration/test_uart_mock_ld2420.py index ae28da4d3e..fc3f033d68 100644 --- a/tests/integration/test_uart_mock_ld2420.py +++ b/tests/integration/test_uart_mock_ld2420.py @@ -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,