Home Assistant Unofficial Reference 2024.12.1
entity.py
Go to the documentation of this file.
1 """Base implementation for all modbus platforms."""
2 
3 from __future__ import annotations
4 
5 from abc import abstractmethod
6 from collections.abc import Callable
7 from datetime import datetime, timedelta
8 import logging
9 import struct
10 from typing import Any, cast
11 
12 from homeassistant.const import (
13  CONF_ADDRESS,
14  CONF_COMMAND_OFF,
15  CONF_COMMAND_ON,
16  CONF_COUNT,
17  CONF_DELAY,
18  CONF_DEVICE_CLASS,
19  CONF_NAME,
20  CONF_OFFSET,
21  CONF_SCAN_INTERVAL,
22  CONF_SLAVE,
23  CONF_STRUCTURE,
24  CONF_UNIQUE_ID,
25  STATE_OFF,
26  STATE_ON,
27 )
28 from homeassistant.core import HomeAssistant, callback
29 from homeassistant.helpers.dispatcher import async_dispatcher_connect
30 from homeassistant.helpers.entity import Entity, ToggleEntity
31 from homeassistant.helpers.event import async_call_later, async_track_time_interval
32 from homeassistant.helpers.restore_state import RestoreEntity
33 
34 from .const import (
35  CALL_TYPE_COIL,
36  CALL_TYPE_DISCRETE,
37  CALL_TYPE_REGISTER_HOLDING,
38  CALL_TYPE_REGISTER_INPUT,
39  CALL_TYPE_WRITE_COIL,
40  CALL_TYPE_WRITE_COILS,
41  CALL_TYPE_WRITE_REGISTER,
42  CALL_TYPE_WRITE_REGISTERS,
43  CALL_TYPE_X_COILS,
44  CALL_TYPE_X_REGISTER_HOLDINGS,
45  CONF_DATA_TYPE,
46  CONF_DEVICE_ADDRESS,
47  CONF_INPUT_TYPE,
48  CONF_MAX_VALUE,
49  CONF_MIN_VALUE,
50  CONF_NAN_VALUE,
51  CONF_PRECISION,
52  CONF_SCALE,
53  CONF_SLAVE_COUNT,
54  CONF_STATE_OFF,
55  CONF_STATE_ON,
56  CONF_SWAP,
57  CONF_SWAP_BYTE,
58  CONF_SWAP_WORD,
59  CONF_SWAP_WORD_BYTE,
60  CONF_VERIFY,
61  CONF_VIRTUAL_COUNT,
62  CONF_WRITE_TYPE,
63  CONF_ZERO_SUPPRESS,
64  SIGNAL_START_ENTITY,
65  SIGNAL_STOP_ENTITY,
66  DataType,
67 )
68 from .modbus import ModbusHub
69 
70 _LOGGER = logging.getLogger(__name__)
71 
72 
74  """Base for readonly platforms."""
75 
76  def __init__(
77  self, hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any]
78  ) -> None:
79  """Initialize the Modbus binary sensor."""
80 
81  self._hub_hub = hub
82  self._slave_slave = entry.get(CONF_SLAVE) or entry.get(CONF_DEVICE_ADDRESS, 0)
83  self._address_address = int(entry[CONF_ADDRESS])
84  self._input_type_input_type = entry[CONF_INPUT_TYPE]
85  self._value: str | None = None
86  self._scan_interval_scan_interval = int(entry[CONF_SCAN_INTERVAL])
87  self._call_active_call_active = False
88  self._cancel_timer_cancel_timer: Callable[[], None] | None = None
89  self._cancel_call_cancel_call: Callable[[], None] | None = None
90 
91  self._attr_unique_id_attr_unique_id = entry.get(CONF_UNIQUE_ID)
92  self._attr_name_attr_name = entry[CONF_NAME]
93  self._attr_should_poll_attr_should_poll = False
94  self._attr_device_class_attr_device_class = entry.get(CONF_DEVICE_CLASS)
95  self._attr_available_attr_available = True
96  self._attr_unit_of_measurement_attr_unit_of_measurement = None
97 
98  def get_optional_numeric_config(config_name: str) -> int | float | None:
99  if (val := entry.get(config_name)) is None:
100  return None
101  assert isinstance(
102  val, (float, int)
103  ), f"Expected float or int but {config_name} was {type(val)}"
104  return val
105 
106  self._min_value_min_value = get_optional_numeric_config(CONF_MIN_VALUE)
107  self._max_value_max_value = get_optional_numeric_config(CONF_MAX_VALUE)
108  self._nan_value_nan_value = entry.get(CONF_NAN_VALUE)
109  self._zero_suppress_zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS)
110 
111  @abstractmethod
112  async def async_update(self, now: datetime | None = None) -> None:
113  """Virtual function to be overwritten."""
114 
115  @callback
116  def async_run(self) -> None:
117  """Remote start entity."""
118  self.async_holdasync_hold(update=False)
119  self._cancel_call_cancel_call = async_call_later(
120  self.hasshass, timedelta(milliseconds=100), self.async_updateasync_update
121  )
122  if self._scan_interval_scan_interval > 0:
124  self.hasshass, self.async_updateasync_update, timedelta(seconds=self._scan_interval_scan_interval)
125  )
126  self._attr_available_attr_available = True
127  self.async_write_ha_stateasync_write_ha_state()
128 
129  @callback
130  def async_hold(self, update: bool = True) -> None:
131  """Remote stop entity."""
132  if self._cancel_call_cancel_call:
133  self._cancel_call_cancel_call()
134  self._cancel_call_cancel_call = None
135  if self._cancel_timer_cancel_timer:
136  self._cancel_timer_cancel_timer()
137  self._cancel_timer_cancel_timer = None
138  if update:
139  self._attr_available_attr_available = False
140  self.async_write_ha_stateasync_write_ha_state()
141 
142  async def async_base_added_to_hass(self) -> None:
143  """Handle entity which will be added."""
144  self.async_runasync_run()
145  self.async_on_removeasync_on_remove(
146  async_dispatcher_connect(self.hasshass, SIGNAL_STOP_ENTITY, self.async_holdasync_hold)
147  )
148  self.async_on_removeasync_on_remove(
149  async_dispatcher_connect(self.hasshass, SIGNAL_START_ENTITY, self.async_runasync_run)
150  )
151 
152 
154  """Base class representing a sensor/climate."""
155 
156  def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None:
157  """Initialize the switch."""
158  super().__init__(hass, hub, config)
159  self._swap_swap = config[CONF_SWAP]
160  self._data_type_data_type = config[CONF_DATA_TYPE]
161  self._structure: str = config[CONF_STRUCTURE]
162  self._scale_scale = config[CONF_SCALE]
163  self._offset_offset = config[CONF_OFFSET]
164  self._slave_count_slave_count = config.get(CONF_SLAVE_COUNT) or config.get(
165  CONF_VIRTUAL_COUNT, 0
166  )
167  self._slave_size_slave_size = self._count_count = config[CONF_COUNT]
168  self._value_is_int_value_is_int: bool = self._data_type_data_type in (
169  DataType.INT16,
170  DataType.INT32,
171  DataType.INT64,
172  DataType.UINT16,
173  DataType.UINT32,
174  DataType.UINT64,
175  )
176  if not self._value_is_int_value_is_int:
177  self._precision_precision = config.get(CONF_PRECISION, 2)
178  else:
179  self._precision_precision = config.get(CONF_PRECISION, 0)
180  if self._precision_precision > 0 or self._scale_scale != int(self._scale_scale):
181  self._value_is_int_value_is_int = False
182 
183  def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]:
184  """Do swap as needed."""
185  if slave_count:
186  swapped = []
187  for i in range(self._slave_count_slave_count + 1):
188  inx = i * self._slave_size_slave_size
189  inx2 = inx + self._slave_size_slave_size
190  swapped.extend(self._swap_registers_swap_registers(registers[inx:inx2], 0))
191  return swapped
192  if self._swap_swap in (CONF_SWAP_BYTE, CONF_SWAP_WORD_BYTE):
193  # convert [12][34] --> [21][43]
194  for i, register in enumerate(registers):
195  registers[i] = int.from_bytes(
196  register.to_bytes(2, byteorder="little"),
197  byteorder="big",
198  signed=False,
199  )
200  if self._swap_swap in (CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE):
201  # convert [12][34] ==> [34][12]
202  registers.reverse()
203  return registers
204 
205  def __process_raw_value(self, entry: float | str | bytes) -> str | None:
206  """Process value from sensor with NaN handling, scaling, offset, min/max etc."""
207  if self._nan_value_nan_value and entry in (self._nan_value_nan_value, -self._nan_value_nan_value):
208  return None
209  if isinstance(entry, bytes):
210  return entry.decode()
211  if entry != entry: # noqa: PLR0124
212  # NaN float detection replace with None
213  return None
214  val: float | int = self._scale_scale * entry + self._offset_offset
215  if self._min_value_min_value is not None and val < self._min_value_min_value:
216  val = self._min_value_min_value
217  if self._max_value_max_value is not None and val > self._max_value_max_value:
218  val = self._max_value_max_value
219  if self._zero_suppress_zero_suppress is not None and abs(val) <= self._zero_suppress_zero_suppress:
220  return "0"
221  if self._precision_precision == 0:
222  return str(round(val))
223  return f"{float(val):.{self._precision}f}"
224 
225  def unpack_structure_result(self, registers: list[int]) -> str | None:
226  """Convert registers to proper result."""
227 
228  if self._swap_swap:
229  registers = self._swap_registers_swap_registers(registers, self._slave_count_slave_count)
230  byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers])
231  if self._data_type_data_type == DataType.STRING:
232  return byte_string.decode()
233  if byte_string == b"nan\x00":
234  return None
235 
236  try:
237  val = struct.unpack(self._structure, byte_string)
238  except struct.error as err:
239  recv_size = len(registers) * 2
240  msg = f"Received {recv_size} bytes, unpack error {err}"
241  _LOGGER.error(msg)
242  return None
243  if len(val) > 1:
244  # Apply scale, precision, limits to floats and ints
245  v_result = []
246  for entry in val:
247  v_temp = self.__process_raw_value__process_raw_value(entry)
248  if v_temp is None:
249  v_result.append("0")
250  else:
251  v_result.append(str(v_temp))
252  return ",".join(map(str, v_result))
253 
254  # Apply scale, precision, limits to floats and ints
255  return self.__process_raw_value__process_raw_value(val[0])
256 
257 
259  """Base class representing a Modbus switch."""
260 
261  def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None:
262  """Initialize the switch."""
263  config[CONF_INPUT_TYPE] = ""
264  super().__init__(hass, hub, config)
265  self._attr_is_on_attr_is_on = False
266  convert = {
267  CALL_TYPE_REGISTER_HOLDING: (
268  CALL_TYPE_REGISTER_HOLDING,
269  CALL_TYPE_WRITE_REGISTER,
270  ),
271  CALL_TYPE_DISCRETE: (
272  CALL_TYPE_DISCRETE,
273  None,
274  ),
275  CALL_TYPE_REGISTER_INPUT: (
276  CALL_TYPE_REGISTER_INPUT,
277  None,
278  ),
279  CALL_TYPE_COIL: (CALL_TYPE_COIL, CALL_TYPE_WRITE_COIL),
280  CALL_TYPE_X_COILS: (CALL_TYPE_COIL, CALL_TYPE_WRITE_COILS),
281  CALL_TYPE_X_REGISTER_HOLDINGS: (
282  CALL_TYPE_REGISTER_HOLDING,
283  CALL_TYPE_WRITE_REGISTERS,
284  ),
285  }
286  self._write_type_write_type = cast(str, convert[config[CONF_WRITE_TYPE]][1])
287  self.command_oncommand_on = config[CONF_COMMAND_ON]
288  self._command_off_command_off = config[CONF_COMMAND_OFF]
289  if CONF_VERIFY in config:
290  if config[CONF_VERIFY] is None:
291  config[CONF_VERIFY] = {}
292  self._verify_active_verify_active = True
293  self._verify_delay_verify_delay = config[CONF_VERIFY].get(CONF_DELAY, 0)
294  self._verify_address_verify_address = config[CONF_VERIFY].get(
295  CONF_ADDRESS, config[CONF_ADDRESS]
296  )
297  self._verify_type_verify_type = convert[
298  config[CONF_VERIFY].get(CONF_INPUT_TYPE, config[CONF_WRITE_TYPE])
299  ][0]
300  self._state_on_state_on = config[CONF_VERIFY].get(CONF_STATE_ON, [self.command_oncommand_on])
301  self._state_off_state_off = config[CONF_VERIFY].get(
302  CONF_STATE_OFF, [self._command_off_command_off]
303  )
304  else:
305  self._verify_active_verify_active = False
306 
307  async def async_added_to_hass(self) -> None:
308  """Handle entity which will be added."""
309  await self.async_base_added_to_hassasync_base_added_to_hass()
310  if state := await self.async_get_last_stateasync_get_last_state():
311  if state.state == STATE_ON:
312  self._attr_is_on_attr_is_on = True
313  elif state.state == STATE_OFF:
314  self._attr_is_on_attr_is_on = False
315 
316  async def async_turn(self, command: int) -> None:
317  """Evaluate switch result."""
318  result = await self._hub_hub.async_pb_call(
319  self._slave_slave, self._address_address, command, self._write_type_write_type
320  )
321  if result is None:
322  self._attr_available_attr_available_attr_available = False
323  self.async_write_ha_stateasync_write_ha_state()
324  return
325 
326  self._attr_available_attr_available_attr_available = True
327  if not self._verify_active_verify_active:
328  self._attr_is_on_attr_is_on = command == self.command_oncommand_on
329  self.async_write_ha_stateasync_write_ha_state()
330  return
331 
332  if self._verify_delay_verify_delay:
333  async_call_later(self.hasshass, self._verify_delay_verify_delay, self.async_updateasync_updateasync_update)
334  else:
335  await self.async_updateasync_updateasync_update()
336 
337  async def async_turn_off(self, **kwargs: Any) -> None:
338  """Set switch off."""
339  await self.async_turnasync_turn(self._command_off_command_off)
340 
341  async def async_update(self, now: datetime | None = None) -> None:
342  """Update the entity state."""
343  # remark "now" is a dummy parameter to avoid problems with
344  # async_track_time_interval
345  if not self._verify_active_verify_active:
346  self._attr_available_attr_available_attr_available = True
347  self.async_write_ha_stateasync_write_ha_state()
348  return
349 
350  # do not allow multiple active calls to the same platform
351  if self._call_active_call_active_call_active:
352  return
353  self._call_active_call_active_call_active = True
354  result = await self._hub_hub.async_pb_call(
355  self._slave_slave, self._verify_address_verify_address, 1, self._verify_type_verify_type
356  )
357  self._call_active_call_active_call_active = False
358  if result is None:
359  self._attr_available_attr_available_attr_available = False
360  self.async_write_ha_stateasync_write_ha_state()
361  return
362 
363  self._attr_available_attr_available_attr_available = True
364  if self._verify_type_verify_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE):
365  self._attr_is_on_attr_is_on = bool(result.bits[0] & 1)
366  else:
367  value = int(result.registers[0])
368  if value in self._state_on_state_on:
369  self._attr_is_on_attr_is_on = True
370  elif value in self._state_off_state_off:
371  self._attr_is_on_attr_is_on = False
372  elif value is not None:
373  _LOGGER.error(
374  (
375  "Unexpected response from modbus device slave %s register %s,"
376  " got 0x%2x"
377  ),
378  self._slave_slave,
379  self._verify_address_verify_address,
380  value,
381  )
382  self.async_write_ha_stateasync_write_ha_state()
None __init__(self, HomeAssistant hass, ModbusHub hub, dict[str, Any] entry)
Definition: entity.py:78
None async_update(self, datetime|None now=None)
Definition: entity.py:112
None async_hold(self, bool update=True)
Definition: entity.py:130
None __init__(self, HomeAssistant hass, ModbusHub hub, dict config)
Definition: entity.py:156
str|None unpack_structure_result(self, list[int] registers)
Definition: entity.py:225
list[int] _swap_registers(self, list[int] registers, int slave_count)
Definition: entity.py:183
str|None __process_raw_value(self, float|str|bytes entry)
Definition: entity.py:205
None async_update(self, datetime|None now=None)
Definition: entity.py:341
None __init__(self, HomeAssistant hass, ModbusHub hub, dict config)
Definition: entity.py:261
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
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
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679