[ld2420] Resume UART reads after a silent command timeout (#21131)

This commit is contained in:
Igor Stadnyk
2026-10-19 08:22:32 -10:00
committed by GitHub
parent 85dd0f71b3
commit f250fa73d0
3 changed files with 177 additions and 0 deletions
+1
View File
@@ -683,6 +683,7 @@ int LD2420Component::send_cmd_from_array(CmdFrameT frame) {
this->handle_cmd_error(this->cmd_reply_.error);
}
}
this->cmd_active_ = false; // Resend and give-up are done; let loop() read the UART again
return error;
}
@@ -0,0 +1,97 @@
esphome:
name: uart-mock-ld2420-config-exit
host:
api:
batch_delay: 0ms
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
# Energy reports with distance 100, then 101, more than the 1 s report interval apart.
# Each delay counts from the previous injection.
injections:
- delay: 1200ms
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]
- delay: 1500ms
inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x23, 0x00, 0x01, 0x65, 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]
on_tx:
- then:
- lambda: |-
// Rebuild command frames however the writes are split, and answer each by its command byte.
// Reply to config exit (0xFE): 0 none, 1 failure status, 2 too short, 3 success
static constexpr int MODE = EXIT_REPLY_MODE;
static std::vector<uint8_t> buf;
static int exit_sends = 0;
buf.insert(buf.end(), data.begin(), data.end());
while (buf.size() >= 6) {
if (buf[0] != 0xFD || buf[1] != 0xFC || buf[2] != 0xFB || buf[3] != 0xFA) {
buf.erase(buf.begin());
continue;
}
size_t len = buf[4] | (buf[5] << 8);
if (buf.size() < len + 10)
return;
uint8_t cmd = buf[6];
size_t params = len >= 2 ? (len - 2) / 2 : 0;
buf.erase(buf.begin(), buf.begin() + len + 10);
std::vector<uint8_t> body = {cmd, 0x01, 0x00, 0x00}; // reply word, status
if (cmd == 0x68) {
continue; // restart has no reply
} else if (cmd == 0x00) {
body.insert(body.end(), {0x06, 0x00, 0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30}); // "v2.0.0"
} else if (cmd == 0x08) {
body.insert(body.end(), params * 4, 0x00);
} else if (cmd == 0xFE) {
ESP_LOGI("ld2420_test", "Config exit sent %d", ++exit_sends);
if (MODE == 0)
continue;
if (MODE == 1)
body[2] = 0x01;
if (MODE == 2)
body.resize(1);
}
std::vector<uint8_t> reply = {0xFD, 0xFC, 0xFB, 0xFA, static_cast<uint8_t>(body.size()), 0x00};
reply.insert(reply.end(), body.begin(), body.end());
reply.insert(reply.end(), {0x04, 0x03, 0x02, 0x01});
id(mock_uart).inject_to_rx_buffer(reply);
}
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"
@@ -0,0 +1,79 @@
"""Integration test for LD2420 reports after the last config exit command."""
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
EXIT_SENT = re.compile(r"Config exit sent (\d+)")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("mode", "expected_sends"),
[
pytest.param(0, 3, id="silent"),
pytest.param(1, 3, id="nack"),
pytest.param(2, 3, id="short"),
pytest.param(3, 1, id="ack"),
],
)
async def test_uart_mock_ld2420_config_exit(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
mode: int,
expected_sends: int,
) -> None:
"""Test both energy reports arrive after the last config exit, and a failed exit is still sent 3 times."""
exit_sends: list[int] = []
def line_callback(line: str) -> None:
if match := EXIT_SENT.search(line):
exit_sends.append(int(match[1]))
collector = SensorStateCollector(sensor_names=["moving_distance"])
yaml_config = yaml_config.replace("EXIT_REPLY_MODE", str(mode))
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_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
resumed = collector.add_waiter(
lambda: len(collector.sensor_states["moving_distance"]) >= 2
)
try:
await asyncio.wait_for(resumed, timeout=5.0)
except TimeoutError:
pytest.fail(
"Energy reports did not resume after the last config exit.\n"
f" exit sends: {exit_sends}\n"
f" sensor_states: {collector.sensor_states}"
)
# The sensor skips repeated values, so each distinct distance is one report
assert collector.sensor_states["moving_distance"] == pytest.approx(
[100.0, 101.0]
)
# A failed exit is resent and then given up; it is never treated as answered
assert exit_sends == list(range(1, expected_sends + 1))