mirror of
https://github.com/home-assistant/core.git
synced 2026-09-16 21:48:38 +01:00
378 lines
12 KiB
Python
378 lines
12 KiB
Python
"""Test the Modbus websocket API."""
|
|
|
|
from collections.abc import Callable, Generator
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from modbus_connection import ModbusTcpParams
|
|
from modbus_connection.tmodbus import ModbusConnection
|
|
import pytest
|
|
|
|
from homeassistant import config as hass_config
|
|
from homeassistant.components.modbus import async_get_unit
|
|
from homeassistant.components.modbus.const import DATA_MODBUS_HUBS
|
|
from homeassistant.config_entries import ConfigFlow
|
|
from homeassistant.const import SERVICE_RELOAD
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.setup import async_setup_component
|
|
|
|
from tests.common import (
|
|
MockConfigEntry,
|
|
MockModule,
|
|
get_fixture_path,
|
|
mock_config_flow,
|
|
mock_integration,
|
|
mock_platform,
|
|
)
|
|
from tests.typing import WebSocketGenerator
|
|
|
|
type ConsumerFactory = Callable[[], MockConfigEntry]
|
|
|
|
YAML_HUB_NAME = "yaml_hub"
|
|
|
|
# The host is given in mixed case, as a shared connection folds the one it is
|
|
# keyed by to lower case
|
|
TCP_TRANSPORT = {"type": "tcp", "host": "Device.Local", "port": 502}
|
|
|
|
SERIAL_TRANSPORT = {
|
|
"type": "serial",
|
|
"port": "/dev/ttyUSB0",
|
|
"baudrate": 9600,
|
|
"bytesize": 8,
|
|
"method": "rtu",
|
|
"parity": "E",
|
|
"stopbits": 1,
|
|
}
|
|
|
|
|
|
def yaml_hub(transport: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return a hub config on *transport*, with sensors on three units."""
|
|
return {
|
|
"name": YAML_HUB_NAME,
|
|
"sensors": [
|
|
{"name": "on unit 3", "address": 10, "slave": 3},
|
|
{"name": "on unit 2", "address": 11, "device_address": 2},
|
|
{"name": "on the default unit", "address": 12},
|
|
],
|
|
**transport,
|
|
}
|
|
|
|
|
|
class MockFlow(ConfigFlow):
|
|
"""A config flow for the integration standing in for a consumer."""
|
|
|
|
|
|
@pytest.fixture(name="consumer")
|
|
def consumer_fixture(hass: HomeAssistant) -> Generator[ConsumerFactory]:
|
|
"""Return a factory for config entries that can be set up and unloaded."""
|
|
mock_integration(
|
|
hass,
|
|
MockModule(
|
|
"test",
|
|
async_setup_entry=AsyncMock(return_value=True),
|
|
async_unload_entry=AsyncMock(return_value=True),
|
|
),
|
|
)
|
|
mock_platform(hass, "test.config_flow")
|
|
|
|
def _consumer() -> MockConfigEntry:
|
|
entry = MockConfigEntry(domain="test")
|
|
entry.add_to_hass(hass)
|
|
return entry
|
|
|
|
with mock_config_flow("test", MockFlow):
|
|
yield _consumer
|
|
|
|
|
|
async def test_list_connections(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
consumer: ConsumerFactory,
|
|
) -> None:
|
|
"""Two entries on one device are one connection naming both."""
|
|
assert await async_setup_component(hass, "modbus", {})
|
|
|
|
first = consumer()
|
|
await hass.config_entries.async_setup(first.entry_id)
|
|
second = consumer()
|
|
await hass.config_entries.async_setup(second.entry_id)
|
|
|
|
params = ModbusTcpParams(host="device.local", port=502)
|
|
async_get_unit(hass, first, params, 1)
|
|
async_get_unit(hass, second, params, 2)
|
|
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({"type": "modbus/connections/list"})
|
|
result = (await client.receive_json())["result"]
|
|
|
|
assert result == {
|
|
"connections": [
|
|
{
|
|
"endpoint": ["tcp", "device.local", 502],
|
|
"connected": False,
|
|
"source": "config_entry",
|
|
"units": {first.entry_id: [1], second.entry_id: [2]},
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
async def test_a_connection_that_is_up_reports_itself_connected(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
consumer: ConsumerFactory,
|
|
) -> None:
|
|
"""The reported state follows the connection, rather than being fixed."""
|
|
assert await async_setup_component(hass, "modbus", {})
|
|
|
|
entry = consumer()
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
async_get_unit(hass, entry, ModbusTcpParams(host="device.local", port=502), 1)
|
|
|
|
client = await hass_ws_client(hass)
|
|
with patch.object(ModbusConnection, "connected", True):
|
|
await client.send_json_auto_id({"type": "modbus/connections/list"})
|
|
result = (await client.receive_json())["result"]
|
|
|
|
assert result == {
|
|
"connections": [
|
|
{
|
|
"endpoint": ["tcp", "device.local", 502],
|
|
"connected": True,
|
|
"source": "config_entry",
|
|
"units": {entry.entry_id: [1]},
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
async def test_listing_the_connections_requires_admin(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_read_only_access_token: str,
|
|
) -> None:
|
|
"""The endpoint names devices and config entries, so admins only."""
|
|
assert await async_setup_component(hass, "modbus", {})
|
|
|
|
client = await hass_ws_client(hass, hass_read_only_access_token)
|
|
await client.send_json_auto_id({"type": "modbus/connections/list"})
|
|
response = await client.receive_json()
|
|
|
|
assert not response["success"]
|
|
assert response["error"]["code"] == "unauthorized"
|
|
|
|
|
|
async def test_unloading_an_entry_drops_it_from_the_listing(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
consumer: ConsumerFactory,
|
|
) -> None:
|
|
"""The connection stays while somebody else holds a unit on it."""
|
|
assert await async_setup_component(hass, "modbus", {})
|
|
|
|
first = consumer()
|
|
await hass.config_entries.async_setup(first.entry_id)
|
|
second = consumer()
|
|
await hass.config_entries.async_setup(second.entry_id)
|
|
|
|
params = ModbusTcpParams(host="device.local", port=502)
|
|
async_get_unit(hass, first, params, 1)
|
|
async_get_unit(hass, second, params, 2)
|
|
|
|
await hass.config_entries.async_unload(first.entry_id)
|
|
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({"type": "modbus/connections/list"})
|
|
result = (await client.receive_json())["result"]
|
|
|
|
assert len(result["connections"]) == 1
|
|
assert result["connections"][0]["units"] == {second.entry_id: [2]}
|
|
|
|
|
|
async def test_no_connections_when_nobody_asked(
|
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
|
) -> None:
|
|
"""Nothing is opened until an integration asks for a unit."""
|
|
assert await async_setup_component(hass, "modbus", {})
|
|
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({"type": "modbus/connections/list"})
|
|
|
|
assert (await client.receive_json())["result"] == {"connections": []}
|
|
|
|
|
|
async def test_one_entry_holding_two_units(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
consumer: ConsumerFactory,
|
|
) -> None:
|
|
"""An entry with two devices on one link reports both units."""
|
|
assert await async_setup_component(hass, "modbus", {})
|
|
|
|
entry = consumer()
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
|
|
params = ModbusTcpParams(host="device.local", port=502)
|
|
async_get_unit(hass, entry, params, 1)
|
|
async_get_unit(hass, entry, params, 2)
|
|
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({"type": "modbus/connections/list"})
|
|
result = (await client.receive_json())["result"]
|
|
|
|
assert result["connections"][0]["units"] == {entry.entry_id: [1, 2]}
|
|
|
|
|
|
async def test_a_yaml_hub_is_listed_with_the_units_its_entities_address(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
mock_pymodbus: AsyncMock,
|
|
) -> None:
|
|
"""A hub is flagged as YAML and keyed by its name, having no config entry."""
|
|
mock_pymodbus.connected = True
|
|
assert await async_setup_component(
|
|
hass, "modbus", {"modbus": [yaml_hub(TCP_TRANSPORT)]}
|
|
)
|
|
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({"type": "modbus/connections/list"})
|
|
result = (await client.receive_json())["result"]
|
|
|
|
assert result == {
|
|
"connections": [
|
|
{
|
|
"endpoint": ["tcp", "device.local", 502],
|
|
"connected": True,
|
|
"source": "yaml",
|
|
"units": {YAML_HUB_NAME: [1, 2, 3]},
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
async def test_a_closed_yaml_hub_reports_itself_not_connected(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
mock_pymodbus: AsyncMock,
|
|
) -> None:
|
|
"""The stop action drops the client, which is no longer a link."""
|
|
mock_pymodbus.connected = True
|
|
assert await async_setup_component(
|
|
hass, "modbus", {"modbus": [yaml_hub(TCP_TRANSPORT)]}
|
|
)
|
|
await hass.data[DATA_MODBUS_HUBS][YAML_HUB_NAME].async_close()
|
|
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({"type": "modbus/connections/list"})
|
|
result = (await client.receive_json())["result"]
|
|
|
|
assert result["connections"][0]["connected"] is False
|
|
|
|
|
|
async def test_a_yaml_hub_is_listed_beside_a_connection_to_the_same_device(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
consumer: ConsumerFactory,
|
|
mock_pymodbus: AsyncMock,
|
|
) -> None:
|
|
"""A hub is a link of its own, so it is never folded into a shared one."""
|
|
mock_pymodbus.connected = False
|
|
assert await async_setup_component(
|
|
hass, "modbus", {"modbus": [yaml_hub(TCP_TRANSPORT)]}
|
|
)
|
|
|
|
entry = consumer()
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
async_get_unit(hass, entry, ModbusTcpParams(host="device.local", port=502), 7)
|
|
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({"type": "modbus/connections/list"})
|
|
result = (await client.receive_json())["result"]
|
|
|
|
assert result["connections"] == [
|
|
{
|
|
"endpoint": ["tcp", "device.local", 502],
|
|
"connected": False,
|
|
"source": "config_entry",
|
|
"units": {entry.entry_id: [7]},
|
|
},
|
|
{
|
|
"endpoint": ["tcp", "device.local", 502],
|
|
"connected": False,
|
|
"source": "yaml",
|
|
"units": {YAML_HUB_NAME: [1, 2, 3]},
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("transport", "endpoint"),
|
|
[
|
|
pytest.param(TCP_TRANSPORT, ["tcp", "device.local", 502], id="tcp"),
|
|
pytest.param(
|
|
{**TCP_TRANSPORT, "type": "rtuovertcp"},
|
|
["tcp", "device.local", 502],
|
|
id="rtuovertcp",
|
|
),
|
|
pytest.param(
|
|
{**TCP_TRANSPORT, "type": "udp"}, ["udp", "device.local", 502], id="udp"
|
|
),
|
|
pytest.param(SERIAL_TRANSPORT, ["serial", "/dev/ttyUSB0"], id="serial"),
|
|
],
|
|
)
|
|
async def test_the_endpoint_of_a_yaml_hub_follows_its_transport(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
mock_pymodbus: AsyncMock,
|
|
transport: dict[str, Any],
|
|
endpoint: list[str | int],
|
|
) -> None:
|
|
"""A hub is keyed by the device it addresses, as a shared connection is.
|
|
|
|
An RTU-over-TCP hub keys as TCP: the framing differs, the device does not.
|
|
"""
|
|
mock_pymodbus.connected = True
|
|
assert await async_setup_component(
|
|
hass, "modbus", {"modbus": [yaml_hub(transport)]}
|
|
)
|
|
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({"type": "modbus/connections/list"})
|
|
result = (await client.receive_json())["result"]
|
|
|
|
assert result["connections"][0]["endpoint"] == endpoint
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fixture",
|
|
[
|
|
pytest.param("configuration_empty.yaml", id="modbus gone from yaml"),
|
|
pytest.param("configuration_no_entities.yaml", id="hub without entities"),
|
|
],
|
|
)
|
|
async def test_a_yaml_hub_a_reload_leaves_behind_is_not_listed(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
mock_pymodbus: AsyncMock,
|
|
fixture: str,
|
|
) -> None:
|
|
"""A reload that sets no hub up again leaves no connection behind.
|
|
|
|
The reload closes the hubs before reading the new config, so one it does
|
|
not set up again is a link to a device nothing talks to.
|
|
"""
|
|
mock_pymodbus.connected = True
|
|
assert await async_setup_component(
|
|
hass, "modbus", {"modbus": [yaml_hub(TCP_TRANSPORT)]}
|
|
)
|
|
|
|
yaml_path = get_fixture_path(fixture, "modbus")
|
|
with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
|
|
await hass.services.async_call("modbus", SERVICE_RELOAD, blocking=True)
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({"type": "modbus/connections/list"})
|
|
|
|
assert (await client.receive_json())["result"] == {"connections": []}
|