[ld2410] Wait for complete query parameter acknowledgements (#21145)

This commit is contained in:
Igor Stadnyk
2026-10-19 11:29:39 -10:00
committed by GitHub
parent 65ba64b9a6
commit 01524a2ffd
3 changed files with 293 additions and 0 deletions
+4
View File
@@ -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
@@ -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();'
@@ -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}"