From 73a95c411dc7be69aa8f12cc864910094064b87e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:29:49 -0500 Subject: [PATCH] [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. --- .../uart_mock_ld2420_restart_button.yaml | 167 ++++++++++++++++++ tests/integration/test_uart_mock_ld2420.py | 96 ++++++++++ 2 files changed, 263 insertions(+) create mode 100644 tests/integration/fixtures/uart_mock_ld2420_restart_button.yaml diff --git a/tests/integration/fixtures/uart_mock_ld2420_restart_button.yaml b/tests/integration/fixtures/uart_mock_ld2420_restart_button.yaml new file mode 100644 index 0000000000..d529101645 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2420_restart_button.yaml @@ -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 diff --git a/tests/integration/test_uart_mock_ld2420.py b/tests/integration/test_uart_mock_ld2420.py index 5da44d8e31..37e24fcaee 100644 --- a/tests/integration/test_uart_mock_ld2420.py +++ b/tests/integration/test_uart_mock_ld2420.py @@ -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,