[ld2420] Add integration tests with mock UART (#14471)

This commit is contained in:
J. Nick Koston
2026-03-07 13:22:50 -10:00
committed by GitHub
parent 545395a6f0
commit 888f3d804b
4 changed files with 605 additions and 4 deletions

View File

@@ -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<uint8_t> buf(data, data + len);
this->tx_hook_(buf);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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']}"
)