mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Include YAML hubs in the Modbus connections list (#182024)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6fa4ed1d9f
commit
363f35da4e
@@ -16,7 +16,6 @@ from homeassistant.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_NAME,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_SLAVE,
|
||||
CONF_STRUCTURE,
|
||||
CONF_UNIQUE_ID,
|
||||
STATE_OFF,
|
||||
@@ -40,7 +39,6 @@ from .const import (
|
||||
CALL_TYPE_X_COILS,
|
||||
CALL_TYPE_X_REGISTER_HOLDINGS,
|
||||
CONF_DATA_TYPE,
|
||||
CONF_DEVICE_ADDRESS,
|
||||
CONF_INPUT_TYPE,
|
||||
CONF_MAX_VALUE,
|
||||
CONF_MIN_VALUE,
|
||||
@@ -63,7 +61,7 @@ from .const import (
|
||||
SIGNAL_STOP_ENTITY,
|
||||
DataType,
|
||||
)
|
||||
from .modbus import ModbusHub
|
||||
from .modbus import ModbusHub, entity_unit_id
|
||||
|
||||
|
||||
class ModbusBaseEntity(Entity):
|
||||
@@ -80,10 +78,7 @@ class ModbusBaseEntity(Entity):
|
||||
"""Initialize the Modbus binary sensor."""
|
||||
|
||||
self._hub = hub
|
||||
if (conf_slave := entry.get(CONF_SLAVE)) is not None:
|
||||
self._device_address = conf_slave
|
||||
else:
|
||||
self._device_address = entry.get(CONF_DEVICE_ADDRESS, 1)
|
||||
self._device_address = entity_unit_id(entry)
|
||||
self._address = int(entry[CONF_ADDRESS])
|
||||
self._input_type = entry[CONF_INPUT_TYPE]
|
||||
self._scan_interval = int(entry[CONF_SCAN_INTERVAL])
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.const import (
|
||||
CONF_METHOD,
|
||||
CONF_NAME,
|
||||
CONF_PORT,
|
||||
CONF_SLAVE,
|
||||
CONF_TIMEOUT,
|
||||
CONF_TYPE,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
@@ -27,6 +28,7 @@ from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .connection import ModbusEndpoint
|
||||
from .const import (
|
||||
CALL_TYPE_COIL,
|
||||
CALL_TYPE_DISCRETE,
|
||||
@@ -38,6 +40,7 @@ from .const import (
|
||||
CALL_TYPE_WRITE_REGISTERS,
|
||||
CONF_BAUDRATE,
|
||||
CONF_BYTESIZE,
|
||||
CONF_DEVICE_ADDRESS,
|
||||
CONF_MSG_WAIT,
|
||||
CONF_PARITY,
|
||||
CONF_STOPBITS,
|
||||
@@ -109,6 +112,13 @@ PB_CALL = [
|
||||
]
|
||||
|
||||
|
||||
def entity_unit_id(entity_config: dict[str, Any]) -> int:
|
||||
"""Return the unit an entity config addresses, defaulting to 1."""
|
||||
if (conf_slave := entity_config.get(CONF_SLAVE)) is not None:
|
||||
return int(conf_slave)
|
||||
return int(entity_config.get(CONF_DEVICE_ADDRESS, 1))
|
||||
|
||||
|
||||
async def async_modbus_setup(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -198,8 +208,11 @@ class ModbusHub:
|
||||
"timeout": client_config[CONF_TIMEOUT],
|
||||
"retries": 3,
|
||||
}
|
||||
# The endpoint is keyed like `ModbusParams.endpoint`, so that a hub and
|
||||
# a shared connection to one device can be told apart from two devices
|
||||
if self._config_type == SERIAL:
|
||||
# serial configuration
|
||||
self.endpoint: ModbusEndpoint = ("serial", client_config[CONF_PORT])
|
||||
if client_config[CONF_METHOD] == "ascii":
|
||||
self._pb_params["framer"] = FramerType.ASCII
|
||||
else:
|
||||
@@ -215,6 +228,11 @@ class ModbusHub:
|
||||
else:
|
||||
# network configuration
|
||||
self._pb_params["host"] = client_config[CONF_HOST]
|
||||
self.endpoint = (
|
||||
"udp" if self._config_type == UDP else "tcp",
|
||||
client_config[CONF_HOST].lower(),
|
||||
client_config[CONF_PORT],
|
||||
)
|
||||
if self._config_type == RTUOVERTCP:
|
||||
self._pb_params["framer"] = FramerType.RTU
|
||||
else:
|
||||
@@ -227,6 +245,19 @@ class ModbusHub:
|
||||
else:
|
||||
self._msg_wait = 0
|
||||
|
||||
self.units = sorted(
|
||||
{
|
||||
entity_unit_id(entity_config)
|
||||
for _, conf_key in PLATFORMS
|
||||
for entity_config in client_config.get(conf_key, [])
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Return whether the client currently holds a link to the device."""
|
||||
return self._client is not None and self._client.connected
|
||||
|
||||
def _log_error(self, text: str) -> None:
|
||||
if text == self._last_log_error:
|
||||
return
|
||||
|
||||
@@ -121,9 +121,12 @@ async def _async_reload_config(call: ServiceCall) -> None:
|
||||
reload_config = await async_integration_yaml_config(hass, DOMAIN)
|
||||
if not reload_config:
|
||||
LOGGER.debug("Modbus not present anymore")
|
||||
hubs.clear()
|
||||
return
|
||||
LOGGER.debug("Modbus reloading")
|
||||
await async_modbus_setup(hass, reload_config)
|
||||
# Setup replaces the hubs only once it has new ones to replace them with
|
||||
if not await async_modbus_setup(hass, reload_config):
|
||||
hubs.clear()
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
@@ -8,9 +8,13 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .connection import async_get_connection_info
|
||||
from .const import DATA_MODBUS_HUBS
|
||||
|
||||
TYPE_LIST_CONNECTIONS: Final = "modbus/connections/list"
|
||||
|
||||
SOURCE_CONFIG_ENTRY: Final = "config_entry"
|
||||
SOURCE_YAML: Final = "yaml"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup(hass: HomeAssistant) -> None:
|
||||
@@ -26,7 +30,13 @@ def websocket_list_connections(
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""List the connections, and which config entries hold units on each."""
|
||||
"""List the connections held over config entries, then those from YAML.
|
||||
|
||||
The unit ids of a connection are keyed by config entry id, or by hub name
|
||||
for one from YAML. A YAML hub is a link of its own rather than a hold on a
|
||||
shared connection, so it is listed separately even when it addresses a
|
||||
device a config entry also talks to.
|
||||
"""
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
@@ -34,9 +44,19 @@ def websocket_list_connections(
|
||||
{
|
||||
"endpoint": list(info.endpoint),
|
||||
"connected": info.connected,
|
||||
"source": SOURCE_CONFIG_ENTRY,
|
||||
"units": info.units,
|
||||
}
|
||||
for info in async_get_connection_info(hass)
|
||||
]
|
||||
+ [
|
||||
{
|
||||
"endpoint": list(hub.endpoint),
|
||||
"connected": hub.connected,
|
||||
"source": SOURCE_YAML,
|
||||
"units": {name: hub.units},
|
||||
}
|
||||
for name, hub in hass.data.get(DATA_MODBUS_HUBS, {}).items()
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
modbus:
|
||||
type: "tcp"
|
||||
host: "testHost"
|
||||
port: 5001
|
||||
name: "testModbus"
|
||||
@@ -1,20 +1,25 @@
|
||||
"""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,
|
||||
@@ -23,6 +28,35 @@ 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."""
|
||||
@@ -76,6 +110,7 @@ async def test_list_connections(
|
||||
{
|
||||
"endpoint": ["tcp", "device.local", 502],
|
||||
"connected": False,
|
||||
"source": "config_entry",
|
||||
"units": {first.entry_id: [1], second.entry_id: [2]},
|
||||
}
|
||||
]
|
||||
@@ -104,6 +139,7 @@ async def test_a_connection_that_is_up_reports_itself_connected(
|
||||
{
|
||||
"endpoint": ["tcp", "device.local", 502],
|
||||
"connected": True,
|
||||
"source": "config_entry",
|
||||
"units": {entry.entry_id: [1]},
|
||||
}
|
||||
]
|
||||
@@ -185,3 +221,157 @@ async def test_one_entry_holding_two_units(
|
||||
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": []}
|
||||
|
||||
Reference in New Issue
Block a user