diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index e81f117a1b3..b5f3292fbc2 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -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; } diff --git a/tests/integration/fixtures/uart_mock_ld2420_config_exit.yaml b/tests/integration/fixtures/uart_mock_ld2420_config_exit.yaml new file mode 100644 index 00000000000..dfea7b23ad8 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2420_config_exit.yaml @@ -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 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 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 reply = {0xFD, 0xFC, 0xFB, 0xFA, static_cast(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" diff --git a/tests/integration/test_uart_mock_ld2420_config_exit.py b/tests/integration/test_uart_mock_ld2420_config_exit.py new file mode 100644 index 00000000000..28b4f202d72 --- /dev/null +++ b/tests/integration/test_uart_mock_ld2420_config_exit.py @@ -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))