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

The LD2420 locks up until power cycled if it receives any data before
it has sent its first frame after powering on. Setup previously
transmitted immediately, blocking for up to 3 seconds and marking the
component failed on the first timeout. Setup is now a non blocking
state machine that listens for the module before transmitting, retries
the handshake, and degrades to a warning instead of failing.
This commit is contained in:
J. Nick Koston
2026-08-16 21:12:58 -05:00
parent 61d2632851
commit 3402ee8bb1
7 changed files with 871 additions and 111 deletions
@@ -50,19 +50,10 @@ uart_mock:
0x04, 0x03, 0x02, 0x01,
]
# Catch-all response: match any command footer (04 03 02 01).
# Returns a generic ACK with cmd=0xFF (CMD_ENABLE_CONF case in switch).
# All commands get unblocked via cmd_reply_.ack = true.
# Data fields stay zeroed (min_gate=0, max_gate=0, timeout=0, thresholds=0).
#
# Response layout:
# [0-3] FD FC FB FA = header
# [4-5] 04 00 = length 4
# [6] FF = cmd (handled as CMD_ENABLE_CONF)
# [7] 01 = status (ACK)
# [8-9] 00 00 = error = 0
# [10-13] 04 03 02 01 = footer
- expect_tx: [0x04, 0x03, 0x02, 0x01]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
# TX = FD FC FB FA 04 00 FF 00 02 00 04 03 02 01
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
@@ -72,6 +63,53 @@ uart_mock:
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the remaining commands, which are all CMD_READ_ABD_PARAM
# (0x0008) reads: min/max/timeout limits and the 16 gate threshold reads.
# The reply carries three zeroed uint32 values (data length 16 = 4 + 12),
# so limits and thresholds all read as 0.
#
# Response layout:
# [0-3] FD FC FB FA = header
# [4-5] 10 00 = length 16
# [6] 08 = cmd (CMD_READ_ABD_PARAM)
# [7] 01 = status (ACK)
# [8-9] 00 00 = error = 0
# [10-21] 00 x12 = three zeroed uint32 data values
# [22-25] 04 03 02 01 = footer
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
injections:
# Phase 1 (t=100ms): Valid LD2420 energy mode data frame - happy path
# Buffer is clean (buffer_pos_=0). This frame should parse correctly.
@@ -98,13 +136,15 @@ uart_mock:
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 2 (t=300ms): Garbage bytes
# Phase 2 (t=800ms): Garbage bytes
# LD2420's readline_ does NOT check frame headers at position 0 (unlike LD2412),
# so these bytes accumulate in the buffer. buffer_pos_ goes from 0 to 7.
- delay: 200ms
# Delay=700ms leaves time for the setup handshake (triggered by Phase 1,
# the first data seen from the module) to finish first.
- delay: 700ms
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
# Phase 3 (t=400ms): Truncated energy frame WITH footer (13 bytes)
# Phase 3 (t=900ms): Truncated energy frame WITH footer (13 bytes)
# This tests PR #14458 bug #3: missing length validation in handle_energy_mode_.
# The 7 garbage bytes from Phase 2 are still in the buffer (buffer_pos_=7).
# These 13 bytes are appended at positions 7-19 (buffer_pos_=20).
@@ -126,7 +166,7 @@ uart_mock:
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 4 (t=600ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# Phase 4 (t=1100ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# After Phase 3, buffer_pos_=0 (reset after energy footer detection).
# 49 bytes fill positions 0-48 (buffer_pos_=49), 50th byte triggers overflow.
# Logs "Max command length exceeded; ignoring", buffer_pos_=0.
@@ -143,8 +183,8 @@ uart_mock:
# Phase 5 (t=1500ms): Valid frame after overflow - recovery test
# Buffer was reset by overflow. This valid frame should parse correctly.
# Presence: 1 (target), Distance: 50cm
# Delay=900ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 900ms
# Delay=400ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 400ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
@@ -0,0 +1,149 @@
esphome:
name: uart-mock-ld2420-boot-test
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
uart:
baud_rate: 115200
port: /dev/null
# Simulates a cold boot where the LD2420 module boots slower than the ESP:
# the module is silent for 2 seconds and then sends its first energy frame.
# The module locks up until power cycled if it receives any data before it
# has sent its first frame, so the component must stay quiet for the full
# 2 seconds and only start its setup handshake after the first frame.
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: true
injections:
# Module boot finished (t=2000ms): first energy frame
# (presence=1, distance=100). Any TX from the component before this
# point would have locked up real hardware.
- delay: 2000ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Post-setup frame (t=3300ms): distance=50 proves streaming still works
# after the setup handshake. Delay=1300ms keeps >1000ms publish throttle
# gap from the first frame.
- delay: 1300ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x32, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
responses:
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
# gate threshold reads. Three zeroed uint32 data values.
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
ld2420:
id: ld2420_dev
uart_id: mock_uart
sensor:
- platform: ld2420
ld2420_id: ld2420_dev
moving_distance:
name: "Moving Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
binary_sensor:
- platform: ld2420
ld2420_id: ld2420_dev
has_target:
name: "Has Target"
filters:
- settle: 50ms
@@ -22,10 +22,24 @@ uart_mock:
baud_rate: 115200
auto_start: false
responses:
# Catch-all response only (no version-specific response).
# Without a version response, firmware_ver_ stays at default "v0.0.0".
# get_firmware_int("v0.0.0") = 0 < 154 → simple mode (CMD_SYSTEM_MODE_SIMPLE).
- expect_tx: [0x04, 0x03, 0x02, 0x01]
# Version response with an old firmware version "v1.5.3".
# get_firmware_int("v1.5.3") = 153 < 154 → simple mode (CMD_SYSTEM_MODE_SIMPLE).
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x31, 0x2E, 0x35, 0x2E, 0x33,
0x04, 0x03, 0x02, 0x01,
]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
@@ -35,10 +49,47 @@ uart_mock:
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = simple (0x0064)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
# gate threshold reads. Three zeroed uint32 data values.
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
injections:
# Phase 1 (t=100ms): Valid simple mode text frame - happy path
# "ON Range 0100\r\n" → presence=true, distance=100
# Simple mode frames end with \r\n (0x0D 0x0A), triggering handle_simple_mode_.
# Phase 0 (t=100ms): Wake-up frame. The component listens for data from the
# module before transmitting anything, so this frame starts the setup
# handshake. It is not parsed as simple mode because the component's system
# mode is only switched to simple after the firmware version is read.
- delay: 100ms
inject_rx:
[
@@ -47,12 +98,24 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 2 (t=300ms): Garbage bytes
# Phase 1 (t=800ms): Valid simple mode text frame - happy path
# "ON Range 0100\r\n" → presence=true, distance=100
# Simple mode frames end with \r\n (0x0D 0x0A), triggering handle_simple_mode_.
# Delay=700ms leaves time for the setup handshake to finish first.
- delay: 700ms
inject_rx:
[
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
0x30, 0x31, 0x30, 0x30,
0x0D, 0x0A,
]
# Phase 2 (t=1000ms): Garbage bytes
# LD2420's readline_ stores all bytes regardless of header. buffer_pos_ = 7.
- delay: 200ms
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
# Phase 3 (t=500ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# Phase 3 (t=1200ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=50)
# buffer_pos_ starts at 7 (from Phase 2 garbage).
# Positions 7-48 fill (42 bytes), byte 43 triggers overflow (buffer_pos_=49).
# After overflow: buffer_pos_=0, remaining 7 bytes fill positions 0-6.
@@ -67,13 +130,13 @@ uart_mock:
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
]
# Phase 4 (t=1400ms): Recovery after overflow
# Phase 4 (t=1900ms): Recovery after overflow
# buffer_pos_ = 7 (from overflow remainder). These 15 bytes fill positions 7-21.
# At position 21 (0x0A), \r\n detected → handle_simple_mode_(buffer, 22).
# Parser skips 0xFF bytes at positions 0-6, finds "ON" at positions 7-8,
# parses digits "0050" → distance=50.
# Delay=900ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 900ms
# Delay=700ms ensures >1000ms gap from Phase 1 for REFRESH_RATE_MS throttle.
- delay: 700ms
inject_rx:
[
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
@@ -81,7 +144,7 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 5 (t=2500ms): 16-digit distance - tests PR #14458 bug #1
# Phase 5 (t=3000ms): 16-digit distance - tests PR #14458 bug #1
# "ON Range 0000000000000000\r\n" has 16 digit characters.
# handle_simple_mode_ outbuf is 16 bytes, can hold 15 digits (index 0-14).
#
@@ -100,7 +163,7 @@ uart_mock:
0x0D, 0x0A,
]
# Phase 6 (t=3700ms): Post-bug-trigger recovery
# Phase 6 (t=4200ms): Post-bug-trigger recovery
# If Phase 5 didn't hang, this frame should parse correctly.
# "ON Range 0025\r\n" → distance=25
# Delay=1200ms ensures >1000ms gap from Phase 5 for throttle.
@@ -0,0 +1,129 @@
esphome:
name: uart-mock-ld2420-warm-test
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
uart:
baud_rate: 115200
port: /dev/null
# Simulates a warm restart: the ESP rebooted but the LD2420 module stayed
# powered and keeps streaming energy frames from the moment the firmware
# starts. The component must not transmit anything until it has seen data
# from the module, then run its setup handshake against the live stream.
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: true
# Module streams a valid energy frame (presence=1, distance=100) continuously
periodic_rx:
- interval: 250ms
data:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x01,
0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
responses:
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# Config mode enable: CMD_ENABLE_CONF (0x00FF)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# System mode write: CMD_WRITE_SYS_PARAM (0x0012), mode = energy (0x0004)
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Config mode disable: CMD_DISABLE_CONF (0x00FE)
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFE, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Catch-all for the CMD_READ_ABD_PARAM (0x0008) reads: limits and the 16
# gate threshold reads. Three zeroed uint32 data values.
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x10, 0x00,
0x08, 0x01,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
ld2420:
id: ld2420_dev
uart_id: mock_uart
sensor:
- platform: ld2420
ld2420_id: ld2420_dev
moving_distance:
name: "Moving Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
binary_sensor:
- platform: ld2420
ld2420_id: ld2420_dev
has_target:
name: "Has Target"
filters:
- settle: 50ms
+214
View File
@@ -15,6 +15,18 @@ test_uart_mock_ld2420_simple (simple mode):
3. Buffer overflow recovery
4. 16-digit distance triggers infinite loop pre-fix (PR #14458 bug #1)
5. Post-bug-trigger recovery proves the parser survived
test_uart_mock_ld2420_warm_restart (module streaming at boot):
Simulates a warm restart where the module stayed powered and streams energy
frames from the moment the firmware starts. Asserts the component never
transmits before receiving data from the module, completes setup against
the live stream, and publishes sensor data.
test_uart_mock_ld2420_delayed_boot (module boots slower than the ESP):
Simulates a cold boot where the module is silent for 2 seconds. The module
locks up until power cycled if it receives data before sending its first
frame, so the component must stay quiet until the module talks, then
complete setup and keep parsing the stream.
"""
from __future__ import annotations
@@ -160,6 +172,208 @@ async def test_uart_mock_ld2420(
)
@pytest.mark.asyncio
async def test_uart_mock_ld2420_warm_restart(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Module streams from boot; component must listen first, then set up."""
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
loop = asyncio.get_running_loop()
setup_complete = loop.create_future()
rx_seen = False
tx_before_rx = False
failure_lines: list[str] = []
def line_callback(line: str) -> None:
nonlocal rx_seen, tx_before_rx
if "uart_mock" in line:
if "RX inject" in line or "Injecting" in line:
rx_seen = True
elif "TX " in line and not rx_seen:
tx_before_rx = True
if (
"Module setup complete; firmware v2.0.0" in line
and not setup_complete.done()
):
setup_complete.set_result(True)
if (
"marked FAILED" in line
or "Communication failed" in line
or "No data received from the module" in line
):
failure_lines.append(line)
collector = SensorStateCollector(
sensor_names=["moving_distance"],
binary_sensor_names=["has_target"],
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Setup handshake must complete against the live stream
try:
await asyncio.wait_for(setup_complete, timeout=10.0)
except TimeoutError:
pytest.fail(
"Timeout waiting for 'Module setup complete' log line. "
"The startup state machine did not finish its handshake."
)
# Sensor data must flow from the stream
try:
await collector.wait_for_all(timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for sensor data. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" binary_states: {collector.binary_states}"
)
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
assert collector.binary_states["has_target"][0] is True
# The component must never transmit before the module has talked;
# real hardware locks up until power cycled if it does.
assert not tx_before_rx, (
"Component transmitted on the UART before receiving any data "
"from the module; this locks up real LD2420 hardware"
)
assert not failure_lines, f"Unexpected failure log lines: {failure_lines}"
@pytest.mark.asyncio
async def test_uart_mock_ld2420_delayed_boot(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Module silent for 2 s; component must not transmit into the boot window."""
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
loop = asyncio.get_running_loop()
setup_complete = loop.create_future()
rx_seen = False
tx_before_rx = False
failure_lines: list[str] = []
def line_callback(line: str) -> None:
nonlocal rx_seen, tx_before_rx
if "uart_mock" in line:
if "RX inject" in line or "Injecting" in line:
rx_seen = True
elif "TX " in line and not rx_seen:
tx_before_rx = True
if (
"Module setup complete; firmware v2.0.0" in line
and not setup_complete.done()
):
setup_complete.set_result(True)
if (
"marked FAILED" in line
or "Communication failed" in line
or "No data received from the module" in line
):
failure_lines.append(line)
collector = SensorStateCollector(
sensor_names=["moving_distance"],
binary_sensor_names=["has_target"],
)
# The second injected frame (distance=50) proves streaming works post-setup
post_setup_received = collector.add_waiter(
lambda: pytest.approx(50.0) in collector.sensor_states["moving_distance"]
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Module's first frame arrives at t=2000ms; handshake follows
try:
await asyncio.wait_for(setup_complete, timeout=10.0)
except TimeoutError:
pytest.fail(
"Timeout waiting for 'Module setup complete' log line. "
"The startup state machine did not finish its handshake."
)
try:
await collector.wait_for_all(timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for sensor data. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" binary_states: {collector.binary_states}"
)
# First frame (t=2000ms) publishes distance=100
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
assert collector.binary_states["has_target"][0] is True
# Second frame (t=3300ms, after setup) publishes distance=50
try:
await asyncio.wait_for(post_setup_received, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for post-setup frame (distance=50). Received:\n"
f" moving_distance: {collector.sensor_states['moving_distance']}"
)
# The component must have stayed quiet for the module's whole 2 s boot
# window; transmitting into it locks up real LD2420 hardware.
assert not tx_before_rx, (
"Component transmitted on the UART before the module sent its "
"first frame; this locks up real LD2420 hardware"
)
assert not failure_lines, f"Unexpected failure log lines: {failure_lines}"
@pytest.mark.asyncio
async def test_uart_mock_ld2420_simple(
yaml_config: str,