Compare commits

...

1 Commits

Author SHA1 Message Date
Claude 1887a94ca0 Migrate modbus to modbus-connection abstraction
Route all Modbus I/O through the backend-neutral modbus-connection
library instead of calling pymodbus directly. ModbusHub now wraps its
pymodbus client in a PymodbusConnection and dispatches reads/writes via
per-unit ModbusUnit handles, replacing the hand-rolled PB_CALL dispatch
table and the manual PDU error inspection (the library raises a neutral
ModbusError hierarchy instead).

Reads now return a small ModbusResult adapter exposing bits/registers so
the entity platforms stay unchanged. The connection lifecycle (client
construction, connect retry, native pymodbus reconnect) is preserved, so
all transports (tcp, udp, rtuovertcp, serial) keep working. As the
integration does not group reads, the library's ComponentGroup pooling
is intentionally not used in this initial migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANBzVnWwCzD87VjyTvJRFv
2026-06-27 03:46:30 +00:00
6 changed files with 92 additions and 130 deletions
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/modbus",
"iot_class": "local_polling",
"loggers": ["pymodbus"],
"requirements": ["pymodbus==3.13.1"]
"requirements": ["modbus-connection[pymodbus]==3.1.0", "pymodbus==3.13.1"]
}
+56 -99
View File
@@ -1,9 +1,11 @@
"""Support for Modbus."""
import asyncio
from collections import namedtuple
from typing import Any
from dataclasses import dataclass
from typing import Any, cast
from modbus_connection import ModbusConnection, ModbusError
from modbus_connection.pymodbus import PymodbusConnection
from pymodbus.client import (
AsyncModbusSerialClient,
AsyncModbusTcpClient,
@@ -11,7 +13,6 @@ from pymodbus.client import (
)
from pymodbus.exceptions import ModbusException
from pymodbus.framer import FramerType
from pymodbus.pdu import ModbusPDU
import voluptuous as vol
from homeassistant.const import (
@@ -53,7 +54,6 @@ from .const import (
CONF_PARITY,
CONF_STOPBITS,
DEFAULT_HUB,
DEVICE_ID,
DOMAIN,
PLATFORMS,
RTUOVERTCP,
@@ -71,58 +71,18 @@ DATA_MODBUS_HUBS: HassKey[dict[str, ModbusHub]] = HassKey(DOMAIN)
PRIMARY_RECONNECT_DELAY = 60
ConfEntry = namedtuple("ConfEntry", "call_type attr func_name value_attr_name") # noqa: PYI024
RunEntry = namedtuple("RunEntry", "attr func value_attr_name") # noqa: PYI024
PB_CALL = [
ConfEntry(
CALL_TYPE_COIL,
"bits",
"read_coils",
"count",
),
ConfEntry(
CALL_TYPE_DISCRETE,
"bits",
"read_discrete_inputs",
"count",
),
ConfEntry(
CALL_TYPE_REGISTER_HOLDING,
"registers",
"read_holding_registers",
"count",
),
ConfEntry(
CALL_TYPE_REGISTER_INPUT,
"registers",
"read_input_registers",
"count",
),
ConfEntry(
CALL_TYPE_WRITE_COIL,
"bits",
"write_coil",
"value",
),
ConfEntry(
CALL_TYPE_WRITE_COILS,
"count",
"write_coils",
"values",
),
ConfEntry(
CALL_TYPE_WRITE_REGISTER,
"registers",
"write_register",
"value",
),
ConfEntry(
CALL_TYPE_WRITE_REGISTERS,
"count",
"write_registers",
"values",
),
]
@dataclass
class ModbusResult:
"""Result of a Modbus read or write call.
Reads populate exactly one of ``bits`` (coils/discrete inputs) or
``registers`` (holding/input registers); writes leave both unset and the
non-``None`` instance simply signals success.
"""
bits: list[bool] | None = None
registers: list[int] | None = None
async def async_modbus_setup(
@@ -242,7 +202,7 @@ async def async_modbus_setup(
class ModbusHub:
"""Thread safe wrapper class for pymodbus."""
"""Thread safe wrapper class for modbus_connection."""
def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None:
"""Initialize the Modbus hub."""
@@ -251,13 +211,13 @@ class ModbusHub:
self._client: (
AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None
) = None
self._connection: ModbusConnection | None = None
self._lock = asyncio.Lock()
self.event_connected = asyncio.Event()
self.hass = hass
self.name = client_config[CONF_NAME]
self._config_type = client_config[CONF_TYPE]
self.config_delay = client_config[CONF_DELAY]
self._pb_request: dict[str, RunEntry] = {}
self._connect_task: asyncio.Task
self._last_log_error: str = ""
self._pb_class = {
@@ -337,12 +297,7 @@ class ModbusHub:
except ModbusException as exception_error:
self._log_error(str(exception_error))
return False
for entry in PB_CALL:
func = getattr(self._client, entry.func_name)
self._pb_request[entry.call_type] = RunEntry(
entry.attr, func, entry.value_attr_name
)
self._connection = PymodbusConnection(self._client)
self._connect_task = self.hass.async_create_background_task(
self.async_pb_connect(), "modbus-connect"
@@ -368,46 +323,48 @@ class ModbusHub:
except ModbusException as exception_error:
self._log_error(str(exception_error))
self._client = None
self._connection = None
_LOGGER.info(f"modbus {self.name} communication closed")
async def low_level_pb_call(
self, slave: int | None, address: int, value: int | list[int], use_call: str
) -> ModbusPDU | None:
"""Call sync. pymodbus."""
kwargs: dict[str, Any] = (
{DEVICE_ID: slave} if slave is not None else {DEVICE_ID: 1}
)
entry = self._pb_request[use_call]
if use_call in {"write_registers", "write_coils"}:
if not isinstance(value, list):
value = [value]
kwargs[entry.value_attr_name] = value
) -> ModbusResult | None:
"""Call modbus_connection, mapping errors onto a None result."""
unit = self._connection.for_unit(slave if slave is not None else 1) # type: ignore[union-attr]
try:
result: ModbusPDU = await entry.func(address, **kwargs)
except ModbusException as exception_error:
if use_call == CALL_TYPE_COIL:
return ModbusResult(
bits=await unit.read_coils(address, cast(int, value))
)
if use_call == CALL_TYPE_DISCRETE:
return ModbusResult(
bits=await unit.read_discrete_inputs(address, cast(int, value))
)
if use_call == CALL_TYPE_REGISTER_HOLDING:
return ModbusResult(
registers=await unit.read_holding_registers(
address, cast(int, value)
)
)
if use_call == CALL_TYPE_REGISTER_INPUT:
return ModbusResult(
registers=await unit.read_input_registers(address, cast(int, value))
)
if use_call == CALL_TYPE_WRITE_COIL:
await unit.write_coil(address, bool(value))
elif use_call == CALL_TYPE_WRITE_COILS:
values = value if isinstance(value, list) else [value]
await unit.write_coils(address, [bool(v) for v in values])
elif use_call == CALL_TYPE_WRITE_REGISTER:
await unit.write_register(address, cast(int, value))
elif use_call == CALL_TYPE_WRITE_REGISTERS:
values = value if isinstance(value, list) else [value]
await unit.write_registers(address, values)
except ModbusError as exception_error:
error = f"Error: device: {slave} address: {address} -> {exception_error!s}"
self._log_error(error)
return None
if not result:
error = (
f"Error: device: {slave} address: {address} -> pymodbus returned None"
)
self._log_error(error)
return None
if not hasattr(result, entry.attr):
error = f"Error: device: {slave} address: {address} -> {result!s}"
self._log_error(error)
return None
if result.isError():
error = (
f"Error: device: {slave} address: {address}"
" -> pymodbus returned isError True"
)
self._log_error(error)
return None
return result
return ModbusResult()
async def async_pb_call(
self,
@@ -415,9 +372,9 @@ class ModbusHub:
address: int,
value: int | list[int],
use_call: str,
) -> ModbusPDU | None:
"""Convert async to sync pymodbus call."""
if not self._client:
) -> ModbusResult | None:
"""Serialize a single Modbus request/response cycle."""
if not self._connection:
return None
async with self._lock:
result = await self.low_level_pb_call(unit, address, value, use_call)
+3
View File
@@ -1585,6 +1585,9 @@ mitsubishi-comfort==0.3.2
# homeassistant.components.moat
moat-ble==0.1.1
# homeassistant.components.modbus
modbus-connection[pymodbus]==3.1.0
# homeassistant.components.moehlenhoff_alpha2
moehlenhoff-alpha2==1.4.0
+10 -5
View File
@@ -1,5 +1,6 @@
"""The tests for the Modbus climate component."""
from pymodbus.exceptions import ModbusException
import pytest
from homeassistant.components.climate import (
@@ -473,7 +474,7 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None:
)
await hass.async_block_till_done()
mock_modbus.write_register.assert_called_with(11, value=0xAA, device_id=10)
mock_modbus.write_register.assert_called_with(11, 0xAA, device_id=10)
await hass.services.async_call(
CLIMATE_DOMAIN,
@@ -483,7 +484,7 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None:
)
await hass.async_block_till_done()
mock_modbus.write_register.assert_called_with(11, value=0xFF, device_id=10)
mock_modbus.write_register.assert_called_with(11, 0xFF, device_id=10)
@pytest.mark.parametrize(
@@ -512,7 +513,7 @@ async def test_hvac_onoff_coil(hass: HomeAssistant, mock_modbus) -> None:
)
await hass.async_block_till_done()
mock_modbus.write_coil.assert_called_with(11, value=1, device_id=10)
mock_modbus.write_coil.assert_called_with(11, True, device_id=10)
await hass.services.async_call(
CLIMATE_DOMAIN,
@@ -522,7 +523,7 @@ async def test_hvac_onoff_coil(hass: HomeAssistant, mock_modbus) -> None:
)
await hass.async_block_till_done()
mock_modbus.write_coil.assert_called_with(11, value=0, device_id=10)
mock_modbus.write_coil.assert_called_with(11, False, device_id=10)
@pytest.mark.parametrize(
@@ -786,7 +787,11 @@ async def test_hvac_onoff_coil_update(
) -> None:
"""Test climate update based on On/Off coil values."""
mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words)
mock_modbus_ha.read_coils.return_value = ReadResult(coil_value)
if coil_value is None:
# A device that does not answer the coil read leaves the entity unavailable
mock_modbus_ha.read_coils.side_effect = ModbusException("no response")
else:
mock_modbus_ha.read_coils.return_value = ReadResult(coil_value)
await hass.services.async_call(
HOMEASSISTANT_DOMAIN,
+21 -24
View File
@@ -15,6 +15,7 @@ It uses binary_sensors/sensors to do black box testing of the read calls.
from datetime import timedelta
import logging
from typing import Any
from unittest import mock
from freezegun.api import FrozenDateTimeFactory
@@ -907,6 +908,14 @@ DATA = "data"
SERVICE = "service"
def _set_pymodbus_return(mock_func: mock.AsyncMock, result: Any) -> None:
"""Model a pymodbus call result: raise exceptions, return PDUs/values."""
if isinstance(result, Exception):
mock_func.side_effect = result
else:
mock_func.return_value = result
@pytest.mark.parametrize(
"do_config",
[
@@ -989,13 +998,6 @@ async def test_pb_service_write(
CALL_TYPE_WRITE_REGISTERS: mock_modbus_with_pymodbus.write_registers,
}
value_arg_name = {
CALL_TYPE_WRITE_COIL: "value",
CALL_TYPE_WRITE_COILS: "values",
CALL_TYPE_WRITE_REGISTER: "value",
CALL_TYPE_WRITE_REGISTERS: "values",
}
data = {
ATTR_HUB: TEST_MODBUS_NAME,
do_slave: 17,
@@ -1005,14 +1007,14 @@ async def test_pb_service_write(
mock_modbus_with_pymodbus.reset_mock()
caplog.clear()
caplog.set_level(logging.DEBUG)
func_name[do_write[FUNC]].return_value = do_return[VALUE]
_set_pymodbus_return(func_name[do_write[FUNC]], do_return[VALUE])
await hass.services.async_call(DOMAIN, do_write[SERVICE], data, blocking=True)
assert func_name[do_write[FUNC]].called
assert func_name[do_write[FUNC]].call_args.args == (data[ATTR_ADDRESS],)
assert func_name[do_write[FUNC]].call_args.kwargs == {
DEVICE_ID: 17,
value_arg_name[do_write[FUNC]]: data[do_write[DATA]],
}
assert func_name[do_write[FUNC]].call_args.args == (
data[ATTR_ADDRESS],
data[do_write[DATA]],
)
assert func_name[do_write[FUNC]].call_args.kwargs == {DEVICE_ID: 17}
if do_return[DATA]:
assert any(message.startswith("Pymodbus:") for message in caplog.messages)
@@ -1541,11 +1543,6 @@ async def test_pb_service_write_no_slave(
CALL_TYPE_WRITE_REGISTER: mock_modbus_with_pymodbus.write_register,
}
value_arg_name = {
CALL_TYPE_WRITE_COIL: "value",
CALL_TYPE_WRITE_REGISTER: "value",
}
data = {
ATTR_HUB: TEST_MODBUS_NAME,
ATTR_ADDRESS: 16,
@@ -1554,14 +1551,14 @@ async def test_pb_service_write_no_slave(
mock_modbus_with_pymodbus.reset_mock()
caplog.clear()
caplog.set_level(logging.DEBUG)
func_name[do_write[FUNC]].return_value = do_return[VALUE]
_set_pymodbus_return(func_name[do_write[FUNC]], do_return[VALUE])
await hass.services.async_call(DOMAIN, do_write[SERVICE], data, blocking=True)
assert func_name[do_write[FUNC]].called
assert func_name[do_write[FUNC]].call_args.args == (data[ATTR_ADDRESS],)
assert func_name[do_write[FUNC]].call_args.kwargs == {
DEVICE_ID: 1,
value_arg_name[do_write[FUNC]]: data[do_write[DATA]],
}
assert func_name[do_write[FUNC]].call_args.args == (
data[ATTR_ADDRESS],
data[do_write[DATA]],
)
assert func_name[do_write[FUNC]].call_args.kwargs == {DEVICE_ID: 1}
if do_return[DATA]:
assert any(message.startswith("Pymodbus:") for message in caplog.messages)
+1 -1
View File
@@ -415,7 +415,7 @@ async def test_color_temp_brightness_light(
calls = mock_modbus_ha.write_register.call_args_list
for expected_register, expected_value in expected_calls:
assert any(
call.args[0] == expected_register and call.kwargs["value"] == expected_value
call.args[0] == expected_register and call.args[1] == expected_value
for call in calls
), (
f"Expected register {expected_register} with value"