diff --git a/homeassistant/components/modbus/connection.py b/homeassistant/components/modbus/connection.py index 84a87d6b3eca..f78f9e34c334 100644 --- a/homeassistant/components/modbus/connection.py +++ b/homeassistant/components/modbus/connection.py @@ -34,6 +34,27 @@ DATA_MODBUS_CONNECTIONS: HassKey[dict[ModbusEndpoint, _SharedConnection]] = Hass ) +def _canonical(params: ModbusParams) -> ModbusParams: + """Return the params in the form the link is actually built from. + + RTU and ASCII frame a serial line, so `ModbusTcpParams` carrying one builds + the serial link over a `socket://` device that `ModbusSerialParams` names + directly. Both spellings of that link have to reach the same connection. + """ + if not isinstance(params, ModbusTcpParams) or params.framer not in ("rtu", "ascii"): + return params + # An IPv6 literal is bracketed, or its own colons read as the port separator. + host = f"[{params.host}]" if ":" in params.host else params.host + return ModbusSerialParams( + device=f"socket://{host}:{params.port}", + framer=params.framer, + # The line speed modbus-connection gives a socket-carried serial framing. + # It is fast enough to land on tmodbus's 1.75 ms inter-frame gap floor, + # which any value at or above 19200 baud also gets. + baudrate=115200, + ) + + @dataclass class _SharedConnection: """A connection and the units held on it.""" @@ -77,6 +98,7 @@ def _async_acquire( Raises `HomeAssistantError` if the device is already in use over different link settings, which cannot both be honoured on one connection. """ + params = _canonical(params) endpoint = params.endpoint connections = hass.data.setdefault(DATA_MODBUS_CONNECTIONS, {}) if (shared := connections.get(endpoint)) is None: diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 3ece80593cff..4e4915dc82c6 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -7,7 +7,7 @@ "loggers": ["pymodbus"], "requirements": [ "pymodbus==3.13.1", - "modbus-connection[tmodbus]==4.11.1", + "modbus-connection[tmodbus]==4.12.0", "tmodbus==0.6.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index c7d42576de91..ad0b7df77f48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1641,7 +1641,7 @@ mitsubishi-comfort==0.5.2 moat-ble==0.1.1 # homeassistant.components.modbus -modbus-connection[tmodbus]==4.11.1 +modbus-connection[tmodbus]==4.12.0 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 diff --git a/tests/components/modbus/test_connection.py b/tests/components/modbus/test_connection.py index fb95d1cc1864..d89649a4b6e9 100644 --- a/tests/components/modbus/test_connection.py +++ b/tests/components/modbus/test_connection.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator from unittest.mock import AsyncMock, patch -from modbus_connection import ModbusSerialParams, ModbusTcpParams +from modbus_connection import ModbusSerialParams, ModbusTcpParams, ModbusTlsParams from modbus_connection.tmodbus import ModbusConnection import pytest @@ -100,6 +100,63 @@ async def test_the_same_device_reached_by_a_different_name_still_shares( assert len(hass.data[DATA_MODBUS_CONNECTIONS]) == 1 +@pytest.mark.parametrize( + "device", + [ + pytest.param("socket://1.2.3.4:502", id="ipv4"), + pytest.param("socket://[fe80::1]:502", id="ipv6"), + ], +) +async def test_a_serial_framing_over_a_socket_shares_the_serial_link( + hass: HomeAssistant, consumer: ConsumerFactory, device: str +) -> None: + """A serial framing on a TCP link is the serial link the socket device names. + + Both spellings build one link at one line speed, so splitting them would put + a second connection on a gateway that can only answer one. + """ + entry = consumer() + await hass.config_entries.async_setup(entry.entry_id) + host = device.removeprefix("socket://").rpartition(":")[0].strip("[]") + + async_get_unit( + hass, entry, ModbusSerialParams(device=device, framer="rtu", baudrate=115200), 1 + ) + with pytest.deprecated_call(): + async_get_unit( + hass, entry, ModbusTcpParams(host=host, port=502, framer="rtu"), 2 + ) + + assert len(hass.data[DATA_MODBUS_CONNECTIONS]) == 1 + + +async def test_a_serial_framing_over_a_socket_keeps_the_line_speed_asked_for( + hass: HomeAssistant, consumer: ConsumerFactory +) -> None: + """The framing alone says nothing about the line speed, so it cannot assume one. + + A slower line needs a longer gap between frames, which the connection built + for the faster one does not leave. + """ + entry = consumer() + await hass.config_entries.async_setup(entry.entry_id) + + async_get_unit( + hass, + entry, + ModbusSerialParams(device="socket://1.2.3.4:502", framer="rtu", baudrate=9600), + 1, + ) + + with ( + pytest.deprecated_call(), + pytest.raises(HomeAssistantError, match="different link settings"), + ): + async_get_unit( + hass, entry, ModbusTcpParams(host="1.2.3.4", port=502, framer="rtu"), 2 + ) + + async def test_one_device_cannot_be_used_with_two_link_settings( hass: HomeAssistant, consumer: ConsumerFactory ) -> None: @@ -114,9 +171,7 @@ async def test_one_device_cannot_be_used_with_two_link_settings( async_get_unit(hass, entry, ModbusTcpParams(host="1.2.3.4", port=502), 1) with pytest.raises(HomeAssistantError, match="different link settings"): - async_get_unit( - hass, entry, ModbusTcpParams(host="1.2.3.4", port=502, framer="rtu"), 2 - ) + async_get_unit(hass, entry, ModbusTlsParams(host="1.2.3.4", port=502), 2) async def test_the_last_consumer_closes_the_connection( @@ -251,7 +306,7 @@ async def test_a_temporary_unit_cannot_clash_with_held_link_settings( with pytest.raises(HomeAssistantError, match="different link settings"): async with async_get_temporary_unit( - hass, ModbusTcpParams(host="1.2.3.4", port=502, framer="rtu"), 2 + hass, ModbusTlsParams(host="1.2.3.4", port=502), 2 ): pass diff --git a/tests/components/stiebel_eltron/test_config_flow.py b/tests/components/stiebel_eltron/test_config_flow.py index 5653da2c0ff9..df3d748cec12 100644 --- a/tests/components/stiebel_eltron/test_config_flow.py +++ b/tests/components/stiebel_eltron/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from modbus_connection import ModbusTcpParams +from modbus_connection import ModbusTlsParams from pystiebeleltron import ControllerModel, StiebelEltronModbusError import pytest @@ -79,9 +79,7 @@ async def test_form_conflicting_link_settings(hass: HomeAssistant) -> None: async_get_unit( hass, other_entry, - ModbusTcpParams( - host=USER_INPUT[CONF_HOST], port=USER_INPUT[CONF_PORT], framer="rtu" - ), + ModbusTlsParams(host=USER_INPUT[CONF_HOST], port=USER_INPUT[CONF_PORT]), UNIT_ID, ) diff --git a/tests/components/stiebel_eltron/test_init.py b/tests/components/stiebel_eltron/test_init.py index dec8f65e7b31..fb6a4cb2e13c 100644 --- a/tests/components/stiebel_eltron/test_init.py +++ b/tests/components/stiebel_eltron/test_init.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from modbus_connection import ModbusError, ModbusTcpParams +from modbus_connection import ModbusError, ModbusTcpParams, ModbusTlsParams from modbus_connection.mock import MockModbusConnection from pystiebeleltron import StiebelEltronModbusError import pytest @@ -82,7 +82,7 @@ async def test_async_setup_entry_conflicting_link_settings( async_get_unit( hass, other_entry, - ModbusTcpParams(host="1.1.1.1", port=502, framer="rtu"), + ModbusTlsParams(host="1.1.1.1", port=502), UNIT_ID, ) mock_config_entry.add_to_hass(hass)