[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:
Bonne Eggleston
2026-04-03 13:24:02 -07:00
committed by GitHub
parent f8f65c1a7b
commit c6bb1fe141
10 changed files with 1136 additions and 229 deletions
+106
View File
@@ -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