Home Assistant Unofficial Reference 2024.12.1
modbus.py
Go to the documentation of this file.
1 """Support for Modbus."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections import namedtuple
7 from collections.abc import Callable
8 import logging
9 from typing import Any
10 
11 from pymodbus.client import (
12  AsyncModbusSerialClient,
13  AsyncModbusTcpClient,
14  AsyncModbusUdpClient,
15 )
16 from pymodbus.exceptions import ModbusException
17 from pymodbus.pdu import ModbusResponse
18 from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer
19 import voluptuous as vol
20 
21 from homeassistant.const import (
22  ATTR_STATE,
23  CONF_DELAY,
24  CONF_HOST,
25  CONF_METHOD,
26  CONF_NAME,
27  CONF_PORT,
28  CONF_TIMEOUT,
29  CONF_TYPE,
30  EVENT_HOMEASSISTANT_STOP,
31 )
32 from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
34 from homeassistant.helpers.discovery import async_load_platform
35 from homeassistant.helpers.dispatcher import async_dispatcher_send
36 from homeassistant.helpers.event import async_call_later
37 from homeassistant.helpers.reload import async_setup_reload_service
38 from homeassistant.helpers.typing import ConfigType
39 
40 from .const import (
41  ATTR_ADDRESS,
42  ATTR_HUB,
43  ATTR_SLAVE,
44  ATTR_UNIT,
45  ATTR_VALUE,
46  CALL_TYPE_COIL,
47  CALL_TYPE_DISCRETE,
48  CALL_TYPE_REGISTER_HOLDING,
49  CALL_TYPE_REGISTER_INPUT,
50  CALL_TYPE_WRITE_COIL,
51  CALL_TYPE_WRITE_COILS,
52  CALL_TYPE_WRITE_REGISTER,
53  CALL_TYPE_WRITE_REGISTERS,
54  CONF_BAUDRATE,
55  CONF_BYTESIZE,
56  CONF_MSG_WAIT,
57  CONF_PARITY,
58  CONF_STOPBITS,
59  DEFAULT_HUB,
60  MODBUS_DOMAIN as DOMAIN,
61  PLATFORMS,
62  RTUOVERTCP,
63  SERIAL,
64  SERVICE_STOP,
65  SERVICE_WRITE_COIL,
66  SERVICE_WRITE_REGISTER,
67  SIGNAL_STOP_ENTITY,
68  TCP,
69  UDP,
70 )
71 from .validators import check_config
72 
73 _LOGGER = logging.getLogger(__name__)
74 
75 
76 ConfEntry = namedtuple("ConfEntry", "call_type attr func_name") # noqa: PYI024
77 RunEntry = namedtuple("RunEntry", "attr func") # noqa: PYI024
78 PB_CALL = [
79  ConfEntry(
80  CALL_TYPE_COIL,
81  "bits",
82  "read_coils",
83  ),
84  ConfEntry(
85  CALL_TYPE_DISCRETE,
86  "bits",
87  "read_discrete_inputs",
88  ),
89  ConfEntry(
90  CALL_TYPE_REGISTER_HOLDING,
91  "registers",
92  "read_holding_registers",
93  ),
94  ConfEntry(
95  CALL_TYPE_REGISTER_INPUT,
96  "registers",
97  "read_input_registers",
98  ),
99  ConfEntry(
100  CALL_TYPE_WRITE_COIL,
101  "value",
102  "write_coil",
103  ),
104  ConfEntry(
105  CALL_TYPE_WRITE_COILS,
106  "count",
107  "write_coils",
108  ),
109  ConfEntry(
110  CALL_TYPE_WRITE_REGISTER,
111  "value",
112  "write_register",
113  ),
114  ConfEntry(
115  CALL_TYPE_WRITE_REGISTERS,
116  "count",
117  "write_registers",
118  ),
119 ]
120 
121 
123  hass: HomeAssistant,
124  config: ConfigType,
125 ) -> bool:
126  """Set up Modbus component."""
127 
128  await async_setup_reload_service(hass, DOMAIN, [DOMAIN])
129 
130  if config[DOMAIN]:
131  config[DOMAIN] = check_config(hass, config[DOMAIN])
132  if not config[DOMAIN]:
133  return False
134  if DOMAIN in hass.data and config[DOMAIN] == []:
135  hubs = hass.data[DOMAIN]
136  for name in hubs:
137  if not await hubs[name].async_setup():
138  return False
139  hub_collect = hass.data[DOMAIN]
140  else:
141  hass.data[DOMAIN] = hub_collect = {}
142 
143  for conf_hub in config[DOMAIN]:
144  my_hub = ModbusHub(hass, conf_hub)
145  hub_collect[conf_hub[CONF_NAME]] = my_hub
146 
147  # modbus needs to be activated before components are loaded
148  # to avoid a racing problem
149  if not await my_hub.async_setup():
150  return False
151 
152  # load platforms
153  for component, conf_key in PLATFORMS:
154  if conf_key in conf_hub:
155  hass.async_create_task(
156  async_load_platform(hass, component, DOMAIN, conf_hub, config)
157  )
158 
159  async def async_stop_modbus(event: Event) -> None:
160  """Stop Modbus service."""
161  for client in hub_collect.values():
162  await client.async_close()
163 
164  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_modbus)
165 
166  async def async_write_register(service: ServiceCall) -> None:
167  """Write Modbus registers."""
168  slave = 0
169  if ATTR_UNIT in service.data:
170  slave = int(float(service.data[ATTR_UNIT]))
171 
172  if ATTR_SLAVE in service.data:
173  slave = int(float(service.data[ATTR_SLAVE]))
174  address = int(float(service.data[ATTR_ADDRESS]))
175  value = service.data[ATTR_VALUE]
176  hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)]
177  if isinstance(value, list):
178  await hub.async_pb_call(
179  slave,
180  address,
181  [int(float(i)) for i in value],
182  CALL_TYPE_WRITE_REGISTERS,
183  )
184  else:
185  await hub.async_pb_call(
186  slave, address, int(float(value)), CALL_TYPE_WRITE_REGISTER
187  )
188 
189  async def async_write_coil(service: ServiceCall) -> None:
190  """Write Modbus coil."""
191  slave = 0
192  if ATTR_UNIT in service.data:
193  slave = int(float(service.data[ATTR_UNIT]))
194  if ATTR_SLAVE in service.data:
195  slave = int(float(service.data[ATTR_SLAVE]))
196  address = service.data[ATTR_ADDRESS]
197  state = service.data[ATTR_STATE]
198  hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)]
199  if isinstance(state, list):
200  await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COILS)
201  else:
202  await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COIL)
203 
204  for x_write in (
205  (SERVICE_WRITE_REGISTER, async_write_register, ATTR_VALUE, cv.positive_int),
206  (SERVICE_WRITE_COIL, async_write_coil, ATTR_STATE, cv.boolean),
207  ):
208  hass.services.async_register(
209  DOMAIN,
210  x_write[0],
211  x_write[1],
212  schema=vol.Schema(
213  {
214  vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string,
215  vol.Exclusive(ATTR_SLAVE, "unit"): cv.positive_int,
216  vol.Exclusive(ATTR_UNIT, "unit"): cv.positive_int,
217  vol.Required(ATTR_ADDRESS): cv.positive_int,
218  vol.Required(x_write[2]): vol.Any(
219  cv.positive_int, vol.All(cv.ensure_list, [x_write[3]])
220  ),
221  }
222  ),
223  )
224 
225  async def async_stop_hub(service: ServiceCall) -> None:
226  """Stop Modbus hub."""
227  async_dispatcher_send(hass, SIGNAL_STOP_ENTITY)
228  hub = hub_collect[service.data[ATTR_HUB]]
229  await hub.async_close()
230 
231  hass.services.async_register(
232  DOMAIN,
233  SERVICE_STOP,
234  async_stop_hub,
235  schema=vol.Schema({vol.Required(ATTR_HUB): cv.string}),
236  )
237  return True
238 
239 
240 class ModbusHub:
241  """Thread safe wrapper class for pymodbus."""
242 
243  def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None:
244  """Initialize the Modbus hub."""
245 
246  # generic configuration
247  self._client_client: (
248  AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None
249  ) = None
250  self._async_cancel_listener_async_cancel_listener: Callable[[], None] | None = None
251  self._in_error_in_error = False
252  self._lock_lock = asyncio.Lock()
253  self.hasshass = hass
254  self.namename = client_config[CONF_NAME]
255  self._config_type_config_type = client_config[CONF_TYPE]
256  self._config_delay_config_delay = client_config[CONF_DELAY]
257  self._pb_request: dict[str, RunEntry] = {}
258  self._pb_class_pb_class = {
259  SERIAL: AsyncModbusSerialClient,
260  TCP: AsyncModbusTcpClient,
261  UDP: AsyncModbusUdpClient,
262  RTUOVERTCP: AsyncModbusTcpClient,
263  }
264  self._pb_params_pb_params = {
265  "port": client_config[CONF_PORT],
266  "timeout": client_config[CONF_TIMEOUT],
267  "retries": 3,
268  "retry_on_empty": True,
269  }
270  if self._config_type_config_type == SERIAL:
271  # serial configuration
272  if client_config[CONF_METHOD] == "ascii":
273  self._pb_params_pb_params["framer"] = ModbusAsciiFramer
274  else:
275  self._pb_params_pb_params["framer"] = ModbusRtuFramer
276  self._pb_params_pb_params.update(
277  {
278  "baudrate": client_config[CONF_BAUDRATE],
279  "stopbits": client_config[CONF_STOPBITS],
280  "bytesize": client_config[CONF_BYTESIZE],
281  "parity": client_config[CONF_PARITY],
282  }
283  )
284  else:
285  # network configuration
286  self._pb_params_pb_params["host"] = client_config[CONF_HOST]
287  if self._config_type_config_type == RTUOVERTCP:
288  self._pb_params_pb_params["framer"] = ModbusRtuFramer
289  else:
290  self._pb_params_pb_params["framer"] = ModbusSocketFramer
291 
292  if CONF_MSG_WAIT in client_config:
293  self._msg_wait_msg_wait = client_config[CONF_MSG_WAIT] / 1000
294  elif self._config_type_config_type == SERIAL:
295  self._msg_wait_msg_wait = 30 / 1000
296  else:
297  self._msg_wait_msg_wait = 0
298 
299  def _log_error(self, text: str, error_state: bool = True) -> None:
300  log_text = f"Pymodbus: {self.name}: {text}"
301  if self._in_error_in_error:
302  _LOGGER.debug(log_text)
303  else:
304  _LOGGER.error(log_text)
305  self._in_error_in_error = error_state
306 
307  async def async_pb_connect(self) -> None:
308  """Connect to device, async."""
309  async with self._lock_lock:
310  try:
311  await self._client_client.connect() # type: ignore[union-attr]
312  except ModbusException as exception_error:
313  err = f"{self.name} connect failed, retry in pymodbus ({exception_error!s})"
314  self._log_error_log_error(err, error_state=False)
315  return
316  message = f"modbus {self.name} communication open"
317  _LOGGER.info(message)
318 
319  async def async_setup(self) -> bool:
320  """Set up pymodbus client."""
321  try:
322  self._client_client = self._pb_class_pb_class[self._config_type_config_type](**self._pb_params_pb_params)
323  except ModbusException as exception_error:
324  self._log_error_log_error(str(exception_error), error_state=False)
325  return False
326 
327  for entry in PB_CALL:
328  func = getattr(self._client_client, entry.func_name)
329  self._pb_request[entry.call_type] = RunEntry(entry.attr, func)
330 
331  self.hasshass.async_create_background_task(
332  self.async_pb_connectasync_pb_connect(), "modbus-connect"
333  )
334 
335  # Start counting down to allow modbus requests.
336  if self._config_delay_config_delay:
337  self._async_cancel_listener_async_cancel_listener = async_call_later(
338  self.hasshass, self._config_delay_config_delay, self.async_end_delayasync_end_delay
339  )
340  return True
341 
342  @callback
343  def async_end_delay(self, args: Any) -> None:
344  """End startup delay."""
345  self._async_cancel_listener_async_cancel_listener = None
346  self._config_delay_config_delay = 0
347 
348  async def async_restart(self) -> None:
349  """Reconnect client."""
350  if self._client_client:
351  await self.async_closeasync_close()
352 
353  await self.async_setupasync_setup()
354 
355  async def async_close(self) -> None:
356  """Disconnect client."""
357  if self._async_cancel_listener_async_cancel_listener:
358  self._async_cancel_listener_async_cancel_listener()
359  self._async_cancel_listener_async_cancel_listener = None
360  async with self._lock_lock:
361  if self._client_client:
362  try:
363  self._client_client.close()
364  except ModbusException as exception_error:
365  self._log_error_log_error(str(exception_error))
366  del self._client_client
367  self._client_client = None
368  message = f"modbus {self.name} communication closed"
369  _LOGGER.info(message)
370 
371  async def low_level_pb_call(
372  self, slave: int | None, address: int, value: int | list[int], use_call: str
373  ) -> ModbusResponse | None:
374  """Call sync. pymodbus."""
375  kwargs = {"slave": slave} if slave else {}
376  entry = self._pb_request[use_call]
377  try:
378  result: ModbusResponse = await entry.func(address, value, **kwargs)
379  except ModbusException as exception_error:
380  error = f"Error: device: {slave} address: {address} -> {exception_error!s}"
381  self._log_error_log_error(error)
382  return None
383  if not result:
384  error = (
385  f"Error: device: {slave} address: {address} -> pymodbus returned None"
386  )
387  self._log_error_log_error(error)
388  return None
389  if not hasattr(result, entry.attr):
390  error = f"Error: device: {slave} address: {address} -> {result!s}"
391  self._log_error_log_error(error)
392  return None
393  if result.isError():
394  error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True"
395  self._log_error_log_error(error)
396  return None
397  self._in_error_in_error = False
398  return result
399 
400  async def async_pb_call(
401  self,
402  unit: int | None,
403  address: int,
404  value: int | list[int],
405  use_call: str,
406  ) -> ModbusResponse | None:
407  """Convert async to sync pymodbus call."""
408  if self._config_delay_config_delay:
409  return None
410  async with self._lock_lock:
411  if not self._client_client:
412  return None
413  result = await self.low_level_pb_calllow_level_pb_call(unit, address, value, use_call)
414  if self._msg_wait_msg_wait:
415  # small delay until next request/response
416  await asyncio.sleep(self._msg_wait_msg_wait)
417  return result
ModbusResponse|None low_level_pb_call(self, int|None slave, int address, int|list[int] value, str use_call)
Definition: modbus.py:373
None _log_error(self, str text, bool error_state=True)
Definition: modbus.py:299
ModbusResponse|None async_pb_call(self, int|None unit, int address, int|list[int] value, str use_call)
Definition: modbus.py:406
None __init__(self, HomeAssistant hass, dict[str, Any] client_config)
Definition: modbus.py:243
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
bool async_modbus_setup(HomeAssistant hass, ConfigType config)
Definition: modbus.py:125
dict check_config(HomeAssistant hass, dict config)
Definition: validators.py:359
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:450
None async_load_platform(core.HomeAssistant hass, Platform|str component, str platform, DiscoveryInfoType|None discovered, ConfigType hass_config)
Definition: discovery.py:152
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597
None async_setup_reload_service(HomeAssistant hass, str domain, Iterable[str] platforms)
Definition: reload.py:191