mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
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>
400 lines
16 KiB
Python
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]
|