mirror of
https://github.com/esphome/esphome.git
synced 2026-05-26 11:17:00 +08:00
[modbus] Add integration tests for server and server via controller (#14845)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
@@ -346,3 +346,109 @@ class SensorStateCollector:
|
||||
else:
|
||||
self._waiters.append((condition, future))
|
||||
return future
|
||||
|
||||
|
||||
class SensorTracker:
|
||||
"""Data-driven sensor state tracker with expected-value futures.
|
||||
|
||||
Tracks sensor state updates and resolves futures when sensors report
|
||||
specific expected values. Eliminates per-sensor future boilerplate.
|
||||
|
||||
Usage::
|
||||
|
||||
tracker = SensorTracker(["reg_u_word", "reg_s_word"])
|
||||
futures = tracker.expect_all({"reg_u_word": 99, "reg_s_word": -99})
|
||||
# ... subscribe_states with tracker.on_state, start scenario ...
|
||||
await tracker.await_all(futures)
|
||||
"""
|
||||
|
||||
def __init__(self, sensor_names: list[str]) -> None:
|
||||
self.sensor_states: dict[str, list[float]] = {name: [] for name in sensor_names}
|
||||
self.key_to_sensor: dict[int, str] = {}
|
||||
self._expectations: dict[str, list[tuple[object, asyncio.Future]]] = {}
|
||||
|
||||
_ANY = object() # Sentinel: match any value
|
||||
|
||||
def expect(self, name: str, value: object) -> asyncio.Future:
|
||||
"""Register an expected value for *name* and return a future for it."""
|
||||
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
||||
self._expectations.setdefault(name, []).append((value, future))
|
||||
return future
|
||||
|
||||
def expect_any(self, name: str) -> asyncio.Future:
|
||||
"""Register a future that resolves on *any* state update for *name*."""
|
||||
return self.expect(name, self._ANY)
|
||||
|
||||
def expect_all(self, expected: dict[str, object]) -> dict[str, asyncio.Future]:
|
||||
"""Call ``expect`` for every entry and return a dict of futures."""
|
||||
return {name: self.expect(name, value) for name, value in expected.items()}
|
||||
|
||||
def on_state(self, state: EntityState) -> None:
|
||||
"""State callback suitable for ``subscribe_states``."""
|
||||
if not isinstance(state, SensorState) or state.missing_state:
|
||||
return
|
||||
sensor_name = self.key_to_sensor.get(state.key)
|
||||
if not sensor_name or sensor_name not in self.sensor_states:
|
||||
return
|
||||
self.sensor_states[sensor_name].append(state.state)
|
||||
for expected_value, future in self._expectations.get(sensor_name, []):
|
||||
if not future.done() and (
|
||||
expected_value is self._ANY or state.state == expected_value
|
||||
):
|
||||
future.set_result(True)
|
||||
break
|
||||
|
||||
async def await_change(
|
||||
self, future: asyncio.Future, name: str, timeout: float = 2.0
|
||||
) -> None:
|
||||
"""Wait for a sensor future to resolve; fail the test on timeout."""
|
||||
try:
|
||||
await asyncio.wait_for(future, timeout=timeout)
|
||||
except TimeoutError:
|
||||
import pytest
|
||||
|
||||
pytest.fail(
|
||||
f"Timeout waiting for {name} change. Received sensor states:\n"
|
||||
f" {name}: {self.sensor_states[name]}\n"
|
||||
)
|
||||
|
||||
async def await_must_not_change(
|
||||
self, future: asyncio.Future, name: str, timeout: float = 2.0
|
||||
) -> None:
|
||||
"""Assert a sensor future does NOT resolve within the timeout."""
|
||||
try:
|
||||
await asyncio.wait_for(future, timeout=timeout)
|
||||
except TimeoutError:
|
||||
return # Expected
|
||||
import pytest
|
||||
|
||||
pytest.fail(
|
||||
f"{name} change should not have been triggered, but was. "
|
||||
f"Received sensor states:\n {name}: {self.sensor_states[name]}\n"
|
||||
)
|
||||
|
||||
async def await_all(
|
||||
self, futures: dict[str, asyncio.Future], timeout: float = 2.0
|
||||
) -> None:
|
||||
"""Await every future in *futures*, failing with per-sensor diagnostics."""
|
||||
for name, future in futures.items():
|
||||
await self.await_change(future, name, timeout=timeout)
|
||||
|
||||
async def setup_and_start_scenario(self, client) -> list:
|
||||
"""Wire up subscriptions, wait for initial states, press Start Scenario."""
|
||||
entities, _ = await client.list_entities_services()
|
||||
self.key_to_sensor.update(
|
||||
build_key_to_entity_mapping(entities, list(self.sensor_states.keys()))
|
||||
)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(self.on_state))
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
import pytest
|
||||
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
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)
|
||||
return entities
|
||||
|
||||
Reference in New Issue
Block a user