Files
esphome/tests/integration/test_uart_mock_ufm01.py
T
86192d89f4 [ufm01] Improve stale recovery, add diagnostics and clear flow action (#18899)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 09:29:56 -10:00

400 lines
16 KiB
Python

"""Integration tests for the UFM-01 flow meter with a simulated meter on the mock UART.
All tests share uart_mock_ufm01.yaml, whose on_tx lambda answers like a UFM-01: it ACKs commands,
answers sensor data and software version reads, and streams active frames each second after
SET_ACTIVE_MODE. The "Silent" switch stops it answering, "Pause Stream" stops the stream while it still
answers, and "Resume Stream" starts the stream again.
test_uart_mock_ufm01_startup:
Reset, software version read and active mode go out in order, the active frames publish flow,
temperature and accumulated flow, and the meter ID and software version publish once.
test_uart_mock_ufm01_stale_recovery:
A stopped stream marks flow and temperature unknown after 5 s and switches to passive polling,
whose answers publish; a resumed stream switches back to the active stream and polling stops.
test_uart_mock_ufm01_escalation:
Eight unanswered passive polls send a reset and run the startup sequence again; values publish
once the meter answers, and the diagnostics are not published again.
test_uart_mock_ufm01_clear_action:
The clear command is sent once the UART is idle while the automation continues at once; the
meter's ACK, or its absence, is only logged, and a second request joins the pending one.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import math
import re
from aioesphomeapi import (
APIClient,
ButtonInfo,
EntityInfo,
EntityState,
SensorState,
SwitchInfo,
TextSensorState,
UserService,
)
import pytest
from .state_utils import InitialStateHelper, StateWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
# Commands as the uart mock logs them: FE FE 11, command, argument, checksum, 16
RESET_DEVICE = "FE:FE:11:5D:CB:28:16"
SET_ACTIVE_MODE = "FE:FE:11:5C:00:5C:16"
SET_PASSIVE_MODE = "FE:FE:11:5C:01:5D:16"
READ_SENSOR_DATA = "FE:FE:11:5B:0F:6A:16"
GET_SOFTWARE_VERSION = "FE:FE:11:5E:62:C0:16"
CLEAR_ACCUMULATED_FLOW = "FE:FE:11:5A:FD:57:16"
# Values the fixture's frames encode
ACTIVE_FLOW = 1.2345
ACTIVE_TEMPERATURE = 23.5
ACTIVE_ACCUMULATED = 12.345
PASSIVE_FLOW = 0.567
PASSIVE_TEMPERATURE = 19.25
PASSIVE_ACCUMULATED = 25.678
METER_ID = "2307140001"
SOFTWARE_VERSION = "23380315"
ACTIVE_STARTED = "UFM-01 active stream started"
ACTIVE_RESUMED = "UFM-01 active stream resumed"
STALE = "Active stream stale, switching to passive polling"
ESCALATED = "Passive poll failed repeatedly, re-initializing UFM-01"
CLEAR_ACKED = "Clear accumulated flow acknowledged"
CLEAR_NOT_ACKED = "Clear accumulated flow not acknowledged"
# Startup wait, reset ACK, post-reset wait, then the first frame within the 1 s stream interval
STARTUP_TIMEOUT = 8.0
ACTIVE_STALE_S = 5.0
ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
class _Log:
"""Record TX frames and ufm01 log lines with their arrival time, and wait for lines."""
def __init__(self) -> None:
self.events: list[tuple[float, str]] = []
self._awaited: list[tuple[str, asyncio.Future[float]]] = []
def on_line(self, line: str) -> None:
line = ANSI_ESCAPE.sub("", line)
if "uart_mock" in line and "TX " in line and "bytes: " in line:
text = line.split("bytes: ", 1)[1].strip()
elif "[ufm01" in line:
text = line
else:
return
now = asyncio.get_running_loop().time()
self.events.append((now, text))
for wanted, future in self._awaited:
if wanted in text and not future.done():
future.set_result(now)
def next(self, text: str) -> asyncio.Future[float]:
"""Return a future for the time of the next event containing ``text``."""
future: asyncio.Future[float] = asyncio.get_running_loop().create_future()
self._awaited.append((text, future))
return future
@property
def tx(self) -> list[str]:
return [text for _, text in self.events if text.startswith("FE:")]
def index(self, text: str, start: int = 0) -> int:
return next(
i
for i, (_, event) in enumerate(self.events)
if i >= start and text in event
)
def tx_between(self, start: int, end: int) -> list[str]:
return [text for _, text in self.events[start:end] if text.startswith("FE:")]
class _Meter:
"""Entities and state history of the running fixture."""
def __init__(
self, client: APIClient, entities: list[EntityInfo], services: list[UserService]
) -> None:
self.client = client
self.services = services
self.by_id = {entity.object_id: entity for entity in entities}
self.waiter = StateWaiter()
self.states: dict[str, list[float | str]] = {name: [] for name in self.by_id}
self._names = {entity.key: entity.object_id for entity in entities}
self.helper = InitialStateHelper(entities)
def on_state(self, state: EntityState) -> None:
if (
isinstance(state, (SensorState, TextSensorState))
and not state.missing_state
):
self.states[self._names[state.key]].append(state.state)
self.waiter.on_state(state)
def expect(
self, name: str, check: Callable[[float | str], bool], timeout: float
) -> Awaitable[EntityState]:
key = self.by_id[name].key
return self.waiter.expect(
lambda s: s.key == key and not s.missing_state and check(s.state),
timeout=timeout,
label=name,
)
def press(self, name: str) -> None:
entity = self.by_id[name]
assert isinstance(entity, ButtonInfo)
self.client.button_command(entity.key)
def set_silent(self, silent: bool) -> None:
entity = self.by_id["silent"]
assert isinstance(entity, SwitchInfo)
self.client.switch_command(entity.key, silent)
def all_values(self, name: str) -> list[float | str]:
"""Return every value ``name`` held, including one already set at connect time."""
initial = self.helper.initial_states[self.by_id[name].key]
return ([] if initial.missing_state else [initial.state]) + self.states[name]
async def _connect(client: APIClient) -> _Meter:
entities, services = await client.list_entities_services()
meter = _Meter(client, entities, services)
client.subscribe_states(meter.helper.on_state_wrapper(meter.on_state))
try:
await meter.helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
return meter
def _is_nan(value: float | str) -> bool:
return isinstance(value, float) and math.isnan(value)
def _approx(expected: float) -> Callable[[float | str], bool]:
return lambda value: value == pytest.approx(expected, abs=1e-4)
def _active_values(meter: _Meter, timeout: float) -> list[Awaitable[EntityState]]:
"""Arm waits for the next flow, temperature and accumulated flow from an active frame."""
return [
meter.expect("flow", _approx(ACTIVE_FLOW), timeout),
meter.expect("temperature", _approx(ACTIVE_TEMPERATURE), timeout),
meter.expect("accumulated_flow", _approx(ACTIVE_ACCUMULATED), timeout),
]
async def _gather(meter: _Meter, waits: list[Awaitable[EntityState]]) -> None:
try:
await asyncio.gather(*waits)
except TimeoutError as err:
pytest.fail(f"{err}; states: {meter.states}")
async def _wait_for(log: _Log, future: asyncio.Future[float], timeout: float) -> float:
try:
return await asyncio.wait_for(future, timeout)
except TimeoutError:
pytest.fail(f"Timed out; log: {log.events}")
@pytest.mark.shared_yaml("uart_mock_ufm01")
@pytest.mark.asyncio
async def test_uart_mock_ufm01_startup(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Startup reaches the active stream, publishes the decoded values and the diagnostics once."""
log = _Log()
started = log.next(ACTIVE_STARTED)
async with (
run_compiled(yaml_config, line_callback=log.on_line),
api_client_connected() as client,
):
meter = await _connect(client)
values = _active_values(meter, STARTUP_TIMEOUT)
await _wait_for(log, started, STARTUP_TIMEOUT)
await _gather(meter, values)
# Two more stream frames
for _ in range(2):
await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0)
assert log.tx == [RESET_DEVICE, GET_SOFTWARE_VERSION, SET_ACTIVE_MODE]
assert meter.all_values("meter_id") == [METER_ID]
assert meter.all_values("software_version") == [SOFTWARE_VERSION]
@pytest.mark.shared_yaml("uart_mock_ufm01")
@pytest.mark.asyncio
async def test_uart_mock_ufm01_stale_recovery(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A stopped stream falls back to passive polling and a resumed stream ends it."""
log = _Log()
started = log.next(ACTIVE_STARTED)
async with (
run_compiled(yaml_config, line_callback=log.on_line),
api_client_connected() as client,
):
meter = await _connect(client)
await _wait_for(log, started, STARTUP_TIMEOUT)
await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0)
stale = log.next(STALE)
flow_unknown = meter.expect("flow", _is_nan, ACTIVE_STALE_S + 2)
temperature_unknown = meter.expect("temperature", _is_nan, ACTIVE_STALE_S + 2)
polled = [
meter.expect("flow", _approx(PASSIVE_FLOW), ACTIVE_STALE_S + 4),
meter.expect(
"temperature", _approx(PASSIVE_TEMPERATURE), ACTIVE_STALE_S + 4
),
meter.expect(
"accumulated_flow", _approx(PASSIVE_ACCUMULATED), ACTIVE_STALE_S + 4
),
]
meter.press("pause_stream")
paused_at = asyncio.get_running_loop().time()
stale_at = await _wait_for(log, stale, ACTIVE_STALE_S + 2)
# The last frame came up to 1 s before the pause
assert stale_at - paused_at >= ACTIVE_STALE_S - 1.5
await _gather(meter, [flow_unknown, temperature_unknown])
await _gather(meter, polled)
stale_index = log.index(STALE)
assert log.tx_between(stale_index, len(log.events))[:2] == [
SET_PASSIVE_MODE,
READ_SENSOR_DATA,
]
# Unknown first, then the passive answers
assert _is_nan(meter.states["flow"][-2])
assert _is_nan(meter.states["temperature"][-2])
resumed = log.next(ACTIVE_RESUMED)
meter.press("resume_stream")
values = _active_values(meter, 3.0)
await _wait_for(log, resumed, 3.0)
await _gather(meter, values)
# Two more stream frames without any poll in between
for _ in range(2):
await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0)
assert READ_SENSOR_DATA not in log.tx_between(
log.index(ACTIVE_RESUMED), len(log.events)
)
@pytest.mark.shared_yaml("uart_mock_ufm01")
@pytest.mark.asyncio
async def test_uart_mock_ufm01_escalation(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Eight unanswered passive polls reset the meter and rerun the startup sequence."""
log = _Log()
started = log.next(ACTIVE_STARTED)
async with (
run_compiled(yaml_config, line_callback=log.on_line),
api_client_connected() as client,
):
meter = await _connect(client)
await _wait_for(log, started, STARTUP_TIMEOUT)
await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0)
escalated = log.next(ESCALATED)
meter.set_silent(True)
# Stale after 5 s, then eight polls that each time out after 1 s
flow_count = len(meter.states["flow"])
await _wait_for(log, escalated, ACTIVE_STALE_S + 8 + 4)
restarted = log.next(ACTIVE_STARTED)
values = _active_values(meter, STARTUP_TIMEOUT)
meter.set_silent(False)
await _wait_for(log, restarted, STARTUP_TIMEOUT)
await _gather(meter, values)
stale_index = log.index(STALE)
escalated_index = log.index(ESCALATED, stale_index)
assert (
log.tx_between(stale_index, escalated_index)
== [SET_PASSIVE_MODE] + [READ_SENSOR_DATA] * 8
)
# A reset (resent once if the meter was still silent), then active mode; the software
# version is already known, so it is not read again
after = log.tx_between(
escalated_index, log.index(ACTIVE_STARTED, escalated_index)
)
assert after in (
[RESET_DEVICE, SET_ACTIVE_MODE],
[RESET_DEVICE, RESET_DEVICE, SET_ACTIVE_MODE],
), after
# Unknown when the stream went stale and again on the re-init, nothing in between;
# a frame already in flight when the meter went silent may come first
flow = meter.states["flow"][flow_count:]
first_unknown = next(i for i, value in enumerate(flow) if _is_nan(value))
assert _is_nan(flow[first_unknown + 1]), flow
assert flow[first_unknown + 2] == pytest.approx(ACTIVE_FLOW, abs=1e-4), flow
assert meter.all_values("meter_id") == [METER_ID]
assert meter.all_values("software_version") == [SOFTWARE_VERSION]
@pytest.mark.shared_yaml("uart_mock_ufm01")
@pytest.mark.asyncio
async def test_uart_mock_ufm01_clear_action(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The clear command is sent once the UART is idle; the automation continues at once."""
log = _Log()
started = log.next(ACTIVE_STARTED)
acked = log.next(CLEAR_ACKED)
async with (
run_compiled(yaml_config, line_callback=log.on_line),
api_client_connected() as client,
):
meter = await _connect(client)
# A second entity listing would re-send the initial states and duplicate the first completion
(clear,) = meter.services
# Normally still inside the startup wait; a slow runner may already be streaming
queued_during_startup = not started.done()
completed = meter.expect("clear_completions", _approx(1), STARTUP_TIMEOUT)
zeroed = meter.expect("accumulated_flow", _approx(0.0), STARTUP_TIMEOUT + 2)
await client.execute_service(clear, {})
await _wait_for(log, acked, STARTUP_TIMEOUT)
await _gather(meter, [completed, zeroed])
# Queued during startup, sent only once the active stream started
if queued_during_startup:
assert CLEAR_ACCUMULATED_FLOW not in log.tx_between(
0, log.index(ACTIVE_STARTED)
)
assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 1
assert log.index(CLEAR_ACCUMULATED_FLOW) < log.index(CLEAR_ACKED)
# Two requests in a row share one command; without an answer it is reported after the 500 ms ACK timeout
sent = log.next(CLEAR_ACCUMULATED_FLOW)
not_acked = log.next(CLEAR_NOT_ACKED)
completed = meter.expect("clear_completions", _approx(3), 3.0)
meter.set_silent(True)
await asyncio.sleep(0.2)
await client.execute_service(clear, {})
await client.execute_service(clear, {})
sent_at = await _wait_for(log, sent, 2.0)
not_acked_at = await _wait_for(log, not_acked, 2.0)
await _gather(meter, [completed])
assert not_acked_at - sent_at >= 0.4
assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 2
assert meter.states["clear_completions"] == [1.0, 2.0, 3.0]