[ld2420] Address fourth review round

Sync new_config with current_config on the give-up path and gate the
config writing actions on an explicit config read complete flag so a
partial read can never be written to the module's NVM, raise the retry
listen window above the module boot silence, treat replies shorter
than the expected data length as silence, apply the parser mode only
after the mode write is acknowledged, propagate write errors in the
apply and factory reset actions before adopting the new config or
clearing the warning, log drain failures and unbuildable startup
frames, watch the runtime marked as failed log string in the tests,
give the first enable command in the retry fixture genuine silence,
and alternate the warm restart stream values so state deduplication
cannot starve a late subscriber.
This commit is contained in:
J. Nick Koston
2026-08-16 21:59:17 -05:00
parent 713b3b2bc9
commit 6a0e5ffce8
5 changed files with 118 additions and 47 deletions
@@ -42,9 +42,15 @@ uart_mock:
0xF8, 0xF7, 0xF6, 0xF5,
]
# The config mode enable command is answered from the on_tx hook below so
# that the first attempt can be ignored; it must not have a responder here.
# The config mode enable command is matched by an empty responder so the
# catch-all cannot answer it (responders match on the TX suffix and every
# command ends with the frame footer); the on_tx hook below acks it from
# the second attempt on, so the first attempt is genuine silence.
responses:
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x02, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx: []
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
@@ -26,7 +26,12 @@ uart_mock:
baud_rate: 115200
auto_start: true
# Module streams a valid energy frame (presence=1, distance=100) continuously
# Module streams valid energy frames continuously. Two alternating frames
# are used (presence=1/distance=100 and presence=0/distance=75) so states
# keep changing: with a constant frame the API deduplicates the repeated
# identical states, and a client that subscribes after the first publish
# would swallow the only transition as the initial state and never see an
# update.
periodic_rx:
- interval: 250ms
data:
@@ -41,6 +46,19 @@ uart_mock:
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
- interval: 1050ms
data:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x23, 0x00,
0x00,
0x4B, 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,
]
responses:
# Version response: returns "v2.0.0" → 200 >= 154 → energy mode
+22 -7
View File
@@ -287,6 +287,7 @@ async def _run_listen_first_test(
api_client_connected: APIClientConnectedFactory,
*,
post_setup_distance: float | None = None,
strict_first: bool = True,
) -> None:
"""Shared body for the listen-first startup tests.
@@ -294,7 +295,9 @@ async def _run_listen_first_test(
(real hardware locks up until power cycled if it does), that the setup
handshake completes, and that sensor data publishes. When
post_setup_distance is given, additionally waits for that value to prove
streaming still works after the handshake.
streaming still works after the handshake. strict_first asserts on the
first collected state; pass False for fixtures whose stream alternates
values, where the first collected state depends on subscribe timing.
"""
loop = asyncio.get_running_loop()
@@ -314,6 +317,7 @@ async def _run_listen_first_test(
setup_complete.set_result(True)
if (
"marked FAILED" in line
or "was marked as failed" in line
or "Communication failed" in line
or "No data received from the module" in line
):
@@ -358,8 +362,12 @@ async def _run_listen_first_test(
),
)
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
assert collector.binary_states["has_target"][0] is True
if strict_first:
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
assert collector.binary_states["has_target"][0] is True
else:
assert pytest.approx(100.0) in collector.sensor_states["moving_distance"]
assert True in collector.binary_states["has_target"]
if post_setup_received is not None:
await _wait_or_fail(
@@ -389,7 +397,9 @@ async def test_uart_mock_ld2420_warm_restart(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Module streams from boot; component must listen first, then set up."""
await _run_listen_first_test(yaml_config, run_compiled, api_client_connected)
await _run_listen_first_test(
yaml_config, run_compiled, api_client_connected, strict_first=False
)
@pytest.mark.asyncio
@@ -414,7 +424,12 @@ async def test_uart_mock_ld2420_cmd_retry(
watcher = _LogWatcher()
resend_seen = watcher.watch("No reply to startup command")
setup_complete = watcher.watch(SETUP_COMPLETE_LOG)
watcher.collect("marked FAILED", "Communication failed", "Module setup attempt")
watcher.collect(
"marked FAILED",
"was marked as failed",
"Communication failed",
"Module setup attempt",
)
collector = SensorStateCollector(
sensor_names=["moving_distance"],
@@ -472,7 +487,7 @@ async def test_uart_mock_ld2420_give_up(
parser_alive_after_give_up = watcher.watch(
"Max command length exceeded", after=give_up_seen
)
watcher.collect("marked FAILED")
watcher.collect("marked FAILED", "was marked as failed")
collector = SensorStateCollector(
sensor_names=["moving_distance"],
@@ -535,7 +550,7 @@ async def test_uart_mock_ld2420_restart_button(
after=restart_seen,
until=module_frame_after_restart,
)
watcher.collect("marked FAILED", "Communication failed")
watcher.collect("marked FAILED", "was marked as failed", "Communication failed")
async with (
run_compiled(yaml_config, line_callback=watcher),