From 888f3d804b9550036c20153218e4286d38c88775 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Mar 2026 13:22:50 -1000 Subject: [PATCH] [ld2420] Add integration tests with mock UART (#14471) --- .../uart_mock/uart_mock.cpp | 8 +- .../fixtures/uart_mock_ld2420.yaml | 187 ++++++++++++ .../fixtures/uart_mock_ld2420_simple.yaml | 141 +++++++++ tests/integration/test_uart_mock_ld2420.py | 273 ++++++++++++++++++ 4 files changed, 605 insertions(+), 4 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_ld2420.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2420_simple.yaml create mode 100644 tests/integration/test_uart_mock_ld2420.py diff --git a/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp b/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp index affcc8d908..e8c07d632d 100644 --- a/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp +++ b/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp @@ -104,12 +104,12 @@ void MockUartComponent::write_array(const uint8_t *data, size_t len) { } #endif - if (this->scenario_active_) { - this->try_match_response_(); - } + // Responses are always active - they are request-response pairs triggered by + // component TX, not timed injections. No race condition with test subscription. + this->try_match_response_(); // This directly calls a tx_hook (lambda) as an alternative to the simpler match_response mechanism. - if (this->tx_hook_ && this->scenario_active_) { + if (this->tx_hook_) { std::vector buf(data, data + len); this->tx_hook_(buf); } diff --git a/tests/integration/fixtures/uart_mock_ld2420.yaml b/tests/integration/fixtures/uart_mock_ld2420.yaml new file mode 100644 index 0000000000..5380b81071 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2420.yaml @@ -0,0 +1,187 @@ +esphome: + name: uart-mock-ld2420-test + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + responses: + # Version-specific response: match the complete firmware version command TX. + # CMD_READ_VERSION (0x0000) TX = FD FC FB FA 02 00 00 00 04 03 02 01 + # Returns "v2.0.0" → get_firmware_int = 200 >= 154 → energy mode + # + # Response layout: + # [0-3] FD FC FB FA = header + # [4-5] 0C 00 = length 12 + # [6] 00 = cmd (CMD_READ_VERSION) + # [7] 01 = status (ACK) + # [8-9] 00 00 = error = 0 + # [10] 06 = ver_len = 6 + # [11] 00 = padding + # [12-17] "v2.0.0" = version string + # [18-21] 04 03 02 01 = footer + - 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, + ] + + # 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] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 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. + # Presence: 1 (target detected), Distance: 100cm, Gate energies: all 0 + # + # Energy frame layout (45 bytes): + # [0-3] F4 F3 F2 F1 = energy frame header + # [4-5] 23 00 = length 35 (1+2+32) + # [6] 01 = presence (1 = target) + # [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 + 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, + ] + + # Phase 2 (t=300ms): 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 + inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22] + + # Phase 3 (t=400ms): 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). + # Energy footer at positions 16-19 triggers handle_energy_mode_(buffer, 20). + # + # Pre-fix: handle_energy_mode_ reads 32 bytes of gate energy from buffer[9:40], + # which is past the 20 actual bytes. Reads uninitialized data. + # No "Energy frame too short" warning exists. + # Post-fix: len=20 < 41 → logs "Energy frame too short: 20 bytes", returns early. + # + # Frame: header + length + presence + distance + footer (no gate data) + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, + 0x23, 0x00, + 0x01, + 0x64, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + + # Phase 4 (t=600ms): 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. + - delay: 200ms + 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, + ] + + # 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 + 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, + ] + +button: + - platform: template + name: "Start Scenario" + on_press: + - lambda: 'id(mock_uart).start_scenario();' + +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 new file mode 100644 index 0000000000..2ceca5d35d --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2420_simple.yaml @@ -0,0 +1,141 @@ +esphome: + name: uart-mock-ld2420-simple-test + +host: +api: +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 + +uart_mock: + id: mock_uart + 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] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 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 + inject_rx: + [ + 0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20, + 0x30, 0x31, 0x30, 0x30, + 0x0D, 0x0A, + ] + + # Phase 2 (t=300ms): 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) + # 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. + # Final buffer_pos_ = 7. + - delay: 200ms + 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, + ] + + # Phase 4 (t=1400ms): 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 + inject_rx: + [ + 0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20, + 0x30, 0x30, 0x35, 0x30, + 0x0D, 0x0A, + ] + + # Phase 5 (t=2500ms): 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). + # + # Pre-fix: At the 16th digit, index=15, (index < bufsize-1) is false. + # The digit branch doesn't increment pos. The else branch is skipped. + # pos stays at the 16th digit FOREVER → INFINITE LOOP. + # The binary hangs, no more state updates, test times out. + # Post-fix: pos always increments (moved outside digit branch). + # 16th digit skipped, loop continues to \r\n. distance=0. + - delay: 1100ms + inject_rx: + [ + 0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20, + 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, + 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, + 0x0D, 0x0A, + ] + + # Phase 6 (t=3700ms): 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. + - delay: 1200ms + inject_rx: + [ + 0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20, + 0x30, 0x30, 0x32, 0x35, + 0x0D, 0x0A, + ] + +button: + - platform: template + name: "Start Scenario" + on_press: + - lambda: 'id(mock_uart).start_scenario();' + +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 new file mode 100644 index 0000000000..ae28da4d3e --- /dev/null +++ b/tests/integration/test_uart_mock_ld2420.py @@ -0,0 +1,273 @@ +"""Integration test for LD2420 component with mock UART. + +Tests: +test_uart_mock_ld2420 (energy mode): + 1. Happy path - valid energy frame publishes correct sensor values + 2. Garbage resilience - random bytes don't crash the component + 3. Truncated energy frame - triggers "Energy frame too short" warning (PR #14458 bug #3) + 4. Buffer overflow recovery - overflow resets the parser + 5. Post-overflow parsing - next valid frame after overflow is parsed correctly + 6. TX logging - verifies LD2420 sends expected setup commands + +test_uart_mock_ld2420_simple (simple mode): + 1. Happy path - valid simple mode text frame publishes correct values + 2. Garbage resilience + 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 +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, find_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_ld2420( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LD2420 energy mode: happy path, truncated frame, overflow, and recovery.""" + # Replace external component path placeholder + 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() + + # Track overflow warning in logs + overflow_seen = loop.create_future() + + # Track "Energy frame too short" warning (PR #14458 bug #3 fix) + # This message ONLY exists after the fix. Pre-fix, handle_energy_mode_ + # silently reads past the buffer without any warning. + truncated_frame_warning_seen = loop.create_future() + + # Track TX data logged by the mock for assertions + tx_log_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "Max command length exceeded" in line and not overflow_seen.done(): + overflow_seen.set_result(True) + if "Energy frame too short" in line and not truncated_frame_warning_seen.done(): + truncated_frame_warning_seen.set_result(True) + # Capture all TX log lines from uart_mock + if "uart_mock" in line and "TX " in line: + tx_log_lines.append(line) + + collector = SensorStateCollector( + sensor_names=["moving_distance"], + binary_sensor_names=["has_target"], + ) + + # Signal when we see recovery frame values + recovery_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) + + # Set up initial state helper + 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") + + # Start the UART mock scenario now that we're subscribed + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + + # Wait for Phase 1 - all sensors and binary sensors have at least one value + try: + await collector.wait_for_all(timeout=3.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for Phase 1 frame. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" binary_states: {collector.binary_states}" + ) + + # Phase 1 values: moving=100, has_target=true + assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0) + assert collector.binary_states["has_target"][0] is True + + # Wait for the recovery frame (Phase 5) to be parsed + # This proves the component survived garbage + truncated + overflow + try: + await asyncio.wait_for(recovery_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for recovery frame. Received:\n" + f" sensor_states: {collector.sensor_states}" + ) + + # Verify overflow warning was logged + assert overflow_seen.done(), ( + "Expected 'Max command length exceeded' warning in logs" + ) + + # Verify truncated frame warning was logged (PR #14458 bug #3) + # This assertion FAILS before PR #14458 because the length check + # and warning message did not exist. + assert truncated_frame_warning_seen.done(), ( + "Expected 'Energy frame too short' warning in logs. " + "This indicates PR #14458 fix for handle_energy_mode_ length " + "validation is missing." + ) + + # Verify LD2420 sent setup commands (TX logging) + assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock" + tx_data = " ".join(tx_log_lines) + # Verify command frame header appears (FD:FC:FB:FA) + assert "FD.FC.FB.FA" in tx_data or "FD:FC:FB:FA" in tx_data, ( + "Expected LD2420 command frame header FD:FC:FB:FA in TX log" + ) + # Verify command frame footer appears (04:03:02:01) + assert "04.03.02.01" in tx_data or "04:03:02:01" in tx_data, ( + "Expected LD2420 command frame footer 04:03:02:01 in TX log" + ) + + # Recovery frame values (Phase 5, after overflow) + recovery_values = [ + v + for v in collector.sensor_states["moving_distance"] + if v == pytest.approx(50.0) + ] + assert len(recovery_values) >= 1, ( + f"Expected moving_distance=50 in recovery, got: {collector.sensor_states['moving_distance']}" + ) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2420_simple( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LD2420 simple mode: happy path, overflow, and 16-digit bug trigger.""" + 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() + + # Track overflow warning in logs + overflow_seen = loop.create_future() + + def line_callback(line: str) -> None: + if "Max command length exceeded" in line and not overflow_seen.done(): + overflow_seen.set_result(True) + + collector = SensorStateCollector( + sensor_names=["moving_distance"], + binary_sensor_names=["has_target"], + ) + + # Signal for recovery frames + recovery_received = collector.add_waiter( + lambda: pytest.approx(50.0) in collector.sensor_states["moving_distance"] + ) + post_bug_received = collector.add_waiter( + lambda: pytest.approx(25.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") + + # Start the UART mock scenario now that we're subscribed + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + + # Wait for Phase 1 - all sensors and binary sensors have at least one value + try: + await collector.wait_for_all(timeout=3.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for Phase 1 frame. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" binary_states: {collector.binary_states}" + ) + + # Phase 1: simple mode "ON Range 0100\r\n" → distance=100, presence=true + assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0) + assert collector.binary_states["has_target"][0] is True + + # Wait for Phase 4 recovery (distance=50) after overflow + try: + await asyncio.wait_for(recovery_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for recovery frame. Received:\n" + f" moving_distance: {collector.sensor_states['moving_distance']}" + ) + + # Verify overflow warning was logged + assert overflow_seen.done(), ( + "Expected 'Max command length exceeded' warning in logs" + ) + + # Wait for Phase 6: distance=25 (post-16-digit-bug recovery) + # This assertion FAILS before PR #14458 because the 16-digit frame + # in Phase 5 causes an infinite loop in handle_simple_mode_ pre-fix. + # The binary hangs, Phase 6 never fires, and this wait times out. + try: + await asyncio.wait_for(post_bug_received, timeout=8.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for post-bug recovery (distance=25). " + f"This likely means Phase 5 (16-digit frame) caused an infinite " + f"loop in handle_simple_mode_, indicating PR #14458 bug #1 fix " + f"is missing.\n" + f" moving_distance values: {collector.sensor_states['moving_distance']}" + ) + + # Verify post-bug value + post_bug_values = [ + v + for v in collector.sensor_states["moving_distance"] + if v == pytest.approx(25.0) + ] + assert len(post_bug_values) >= 1, ( + f"Expected moving_distance=25 after 16-digit test, " + f"got: {collector.sensor_states['moving_distance']}" + )