[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.
This commit is contained in:
J. Nick Koston
2026-08-16 21:12:58 -05:00
parent de7af3865b
commit 73a95c411d
2 changed files with 263 additions and 0 deletions
@@ -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
@@ -27,6 +27,14 @@ test_uart_mock_ld2420_delayed_boot (module boots slower than the ESP):
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.
"""
from __future__ import annotations
@@ -308,6 +316,94 @@ async def test_uart_mock_ld2420_delayed_boot(
)
@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."""
loop = asyncio.get_running_loop()
setup_complete_count = 0
first_setup_complete = loop.create_future()
second_setup_complete = loop.create_future()
restart_seen = False
module_frame_after_restart_seen = False
tx_into_boot_window = False
failure_lines: list[str] = []
def line_callback(line: str) -> None:
nonlocal setup_complete_count, restart_seen, module_frame_after_restart_seen
nonlocal tx_into_boot_window
if "Module setup complete; firmware v2.0.0" in line:
setup_complete_count += 1
if setup_complete_count == 1 and not first_setup_complete.done():
first_setup_complete.set_result(True)
elif setup_complete_count == 2 and not second_setup_complete.done():
second_setup_complete.set_result(True)
if "[ld2420" in line and "Restarting" in line:
restart_seen = True
if restart_seen and "RX inject 45 bytes" in line:
# The module's first frame after its simulated 2 s boot
module_frame_after_restart_seen = True
if (
restart_seen
and not module_frame_after_restart_seen
and "uart_mock" in line
and "TX " in line
and "FF:00:02:00" in line
):
# Config mode enable transmitted before the module's first
# post-boot frame; on real hardware this locks the module up
tx_into_boot_window = True
if "marked FAILED" in line or "Communication failed" in line:
failure_lines.append(line)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(lambda s: None))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Wait for the initial startup handshake to finish
try:
await asyncio.wait_for(first_setup_complete, timeout=10.0)
except TimeoutError:
pytest.fail("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 = find_entity(entities, "restart_module", ButtonInfo)
assert restart_btn is not None, "Restart Module button not found"
client.button_command(restart_btn.key)
# The handshake must complete again after the module comes back
try:
await asyncio.wait_for(second_setup_complete, timeout=15.0)
except TimeoutError:
pytest.fail(
"Timeout waiting for 'Module setup complete' after the restart. "
"The component did not recover from the module restart."
)
assert not tx_into_boot_window, (
"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 failure_lines, f"Unexpected failure log lines: {failure_lines}"
@pytest.mark.asyncio
async def test_uart_mock_ld2420_simple(
yaml_config: str,