diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 7461a2ade40..74660d7f2e9 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -549,6 +549,10 @@ bool LD2410Component::handle_ack_data_() { break; case CMD_QUERY: { // Query parameters response + // Config values can contain the footer bytes, so wait for the whole 38 byte frame + if (this->buffer_pos_ < 38) { + return false; + } if (this->buffer_data_[10] != HEADER) return true; // value head=0xAA #ifdef USE_NUMBER diff --git a/tests/integration/fixtures/uart_mock_ld2410_query_ack.yaml b/tests/integration/fixtures/uart_mock_ld2410_query_ack.yaml new file mode 100644 index 00000000000..6776c0fde9e --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410_query_ack.yaml @@ -0,0 +1,124 @@ +esphome: + name: uart-mock-ld2410-query-ack + +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 ld2410's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Synthetic query parameters ACKs (command 0x61), built from the layout the component parses. +# They are not device captures. Layout (38 bytes): +# [0-3] header, [4-5] length 28, [6-7] 61 01, [8-9] 00 00, [10] AA, [11] gate count, +# [12] max move gate, [13] max still gate, [14-22] move thresholds, [23-31] still thresholds, +# [32-33] timeout (low, high), [34-37] footer +uart_mock: + id: mock_uart + baud_rate: 256000 + auto_start: false + injections: + # Ordinary normal mode data frame, as in uart_mock_ld2410.yaml Phase 1 + # Moving 100cm energy 50, still 120cm energy 25, detection 300cm + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, + 0x0D, 0x00, + 0x02, 0xAA, + 0x03, + 0x64, 0x00, + 0x32, + 0x78, 0x00, + 0x19, + 0x2C, 0x01, + 0x00, + 0x55, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + + # Seed: a whole ACK, so every byte the next ACK is read from holds a known value + # Max gates 8/8, move 50 50 40 30 20 15 15 15 15, still 0 0 40 40 40 40 15 15 15, timeout 5 + - delay: 100ms + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x61, 0x01, 0x00, 0x00, + 0xAA, 0x08, 0x08, 0x08, + 0x32, 0x32, 0x28, 0x1E, 0x14, 0x0F, 0x0F, 0x0F, 0x0F, + 0x00, 0x00, 0x28, 0x28, 0x28, 0x28, 0x0F, 0x0F, 0x0F, + 0x05, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + # Move thresholds of gates 0 to 3 are the footer bytes 04 03 02 01 + # Max gates 6/5, move 4 3 2 1 60 45 46 47 48, still 20 to 28, timeout 300 + - delay: 100ms + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x61, 0x01, 0x00, 0x00, + 0xAA, 0x08, 0x06, 0x05, + 0x04, 0x03, 0x02, 0x01, 0x3C, 0x2D, 0x2E, 0x2F, 0x30, + 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, + 0x2C, 0x01, + 0x04, 0x03, 0x02, 0x01, + ] + +ld2410: + id: ld2410_dev + uart_id: mock_uart + +sensor: + - platform: ld2410 + ld2410_id: ld2410_dev + moving_distance: + name: "Moving Distance" + still_distance: + name: "Still Distance" + moving_energy: + name: "Moving Energy" + still_energy: + name: "Still Energy" + detection_distance: + name: "Detection Distance" + +number: + - platform: ld2410 + ld2410_id: ld2410_dev + timeout: + name: "Timeout" + max_move_distance_gate: + name: "Max Move Distance Gate" + max_still_distance_gate: + name: "Max Still Distance Gate" + g0: + move_threshold: + name: "G0 Move Threshold" + still_threshold: + name: "G0 Still Threshold" + g4: + move_threshold: + name: "G4 Move Threshold" + still_threshold: + name: "G4 Still Threshold" + g8: + move_threshold: + name: "G8 Move Threshold" + still_threshold: + name: "G8 Still Threshold" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/test_uart_mock_ld2410_query_ack.py b/tests/integration/test_uart_mock_ld2410_query_ack.py new file mode 100644 index 00000000000..a077b9aa16a --- /dev/null +++ b/tests/integration/test_uart_mock_ld2410_query_ack.py @@ -0,0 +1,165 @@ +"""Integration test for the LD2410 query parameters ACK with mock UART. + +An ordinary data frame comes first, then two query ACKs. Move thresholds 4, 3, 2, 1 +put the command footer inside the ACK values. Every value must come from the whole +38 byte frame, not from bytes left by the previous frame. +The ACKs are synthetic, built from the layout the component parses, not device captures. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ( + ButtonInfo, + EntityState, + NumberInfo, + NumberState, + SensorInfo, + SensorState, +) +import pytest + +from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Number object_id: (value from the seed ACK, value from the ACK under test) +EXPECTED: dict[str, tuple[float, float]] = { + "max_move_distance_gate": (8.0, 6.0), + "max_still_distance_gate": (8.0, 5.0), + "timeout": (5.0, 300.0), + "g0_move_threshold": (50.0, 4.0), + "g0_still_threshold": (0.0, 20.0), + "g4_move_threshold": (20.0, 60.0), + "g4_still_threshold": (40.0, 24.0), + "g8_move_threshold": (15.0, 48.0), + "g8_still_threshold": (15.0, 28.0), +} + +# Sensor object_id: value from the data frame +SENSOR_EXPECTED: dict[str, float] = { + "moving_distance": 100.0, + "still_distance": 120.0, + "moving_energy": 50.0, + "still_energy": 25.0, + "detection_distance": 300.0, +} + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410_query_ack( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a data frame, then a query ACK with the footer bytes in its values.""" + 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() + ack_errors: list[str] = [] + waited_for_frame_end = loop.create_future() + + def line_callback(line: str) -> None: + if "[ld2410" not in line: + return + if "Invalid " in line: + ack_errors.append(line) + if "Ack Data incomplete" in line and not waited_for_frame_end.done(): + waited_for_frame_end.set_result(True) + + number_states: dict[str, list[float]] = {name: [] for name in EXPECTED} + sensor_states: dict[str, list[float]] = {name: [] for name in SENSOR_EXPECTED} + key_to_name: dict[int, str] = {} + sensor_key_to_name: dict[int, str] = {} + data_received = loop.create_future() + final_received = loop.create_future() + + def on_state(state: EntityState) -> None: + if isinstance(state, SensorState) and not state.missing_state: + if (name := sensor_key_to_name.get(state.key)) is None: + return + sensor_states[name].append(state.state) + if not data_received.done() and all(sensor_states.values()): + data_received.set_result(True) + return + if not isinstance(state, NumberState) or state.missing_state: + return + if (name := key_to_name.get(state.key)) is None: + return + number_states[name].append(state.state) + if not final_received.done() and all( + values and values[-1] == pytest.approx(EXPECTED[n][1]) + for n, values in number_states.items() + ): + final_received.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + # Map each kind separately; sensor names are substrings of number names + key_to_name.update( + build_key_to_entity_mapping( + [e for e in entities if isinstance(e, NumberInfo)], list(EXPECTED) + ) + ) + sensor_key_to_name.update( + build_key_to_entity_mapping( + [e for e in entities if isinstance(e, SensorInfo)], + list(SENSOR_EXPECTED), + ) + ) + assert set(key_to_name.values()) == set(EXPECTED), ( + f"Number entities missing, found: {sorted(key_to_name.values())}" + ) + assert set(sensor_key_to_name.values()) == set(SENSOR_EXPECTED), ( + f"Sensor entities missing, found: {sorted(sensor_key_to_name.values())}" + ) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(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) + + try: + await asyncio.wait_for(data_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the data frame values. Received:\n" + f" sensor_states: {sensor_states}" + ) + + try: + await asyncio.wait_for(final_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the query ACK values. Received:\n" + f" number_states: {number_states}\n" + f" ack_errors: {ack_errors}" + ) + + # One data frame, so each sensor is published once + for name, value in SENSOR_EXPECTED.items(): + assert sensor_states[name] == pytest.approx([value]), name + # Each number is published once per ACK: the seed value, then the value under test + for name, (seed, value) in EXPECTED.items(): + assert number_states[name] == pytest.approx([seed, value]), name + assert waited_for_frame_end.done(), ( + "Expected the parser to wait past the footer bytes in the values" + ) + assert not ack_errors, f"Unexpected ACK errors: {ack_errors}"