Home Assistant Unofficial Reference 2024.12.1
helpers.py
Go to the documentation of this file.
1 """Sorting helpers for ISY device classifications."""
2 
3 from __future__ import annotations
4 
5 from typing import cast
6 
7 from pyisy.constants import (
8  BACKLIGHT_SUPPORT,
9  CMD_BACKLIGHT,
10  ISY_VALUE_UNKNOWN,
11  PROP_BUSY,
12  PROP_COMMS_ERROR,
13  PROP_ON_LEVEL,
14  PROP_RAMP_RATE,
15  PROP_STATUS,
16  PROTO_GROUP,
17  PROTO_INSTEON,
18  PROTO_PROGRAM,
19  PROTO_ZWAVE,
20  TAG_ENABLED,
21  TAG_FOLDER,
22  UOM_INDEX,
23 )
24 from pyisy.nodes import Group, Node, Nodes
25 from pyisy.programs import Programs
26 
27 from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, Platform
28 from homeassistant.helpers.device_registry import DeviceInfo
29 
30 from .const import (
31  _LOGGER,
32  DEFAULT_PROGRAM_STRING,
33  DOMAIN,
34  FILTER_INSTEON_TYPE,
35  FILTER_NODE_DEF_ID,
36  FILTER_STATES,
37  FILTER_UOM,
38  FILTER_ZWAVE_CAT,
39  ISY_GROUP_PLATFORM,
40  KEY_ACTIONS,
41  KEY_STATUS,
42  NODE_AUX_FILTERS,
43  NODE_FILTERS,
44  NODE_PLATFORMS,
45  PROGRAM_PLATFORMS,
46  SUBNODE_CLIMATE_COOL,
47  SUBNODE_CLIMATE_HEAT,
48  SUBNODE_EZIO2X4_SENSORS,
49  SUBNODE_FANLINC_LIGHT,
50  SUBNODE_IOLINC_RELAY,
51  TYPE_CATEGORY_SENSOR_ACTUATORS,
52  TYPE_EZIO2X4,
53  UOM_DOUBLE_TEMP,
54  UOM_ISYV4_DEGREES,
55 )
56 from .models import IsyData
57 
58 BINARY_SENSOR_UOMS = ["2", "78"]
59 BINARY_SENSOR_ISY_STATES = ["on", "off"]
60 ROOT_AUX_CONTROLS = {
61  PROP_ON_LEVEL,
62  PROP_RAMP_RATE,
63 }
64 SKIP_AUX_PROPS = {PROP_BUSY, PROP_COMMS_ERROR, PROP_STATUS, *ROOT_AUX_CONTROLS}
65 
66 
68  isy_data: IsyData, node: Group | Node, single_platform: Platform | None = None
69 ) -> bool:
70  """Check if the node matches the node_def_id for any platforms.
71 
72  This is only present on the 5.0 ISY firmware, and is the most reliable
73  way to determine a device's type.
74  """
75  if not hasattr(node, "node_def_id") or node.node_def_id is None:
76  # Node doesn't have a node_def (pre 5.0 firmware most likely)
77  return False
78 
79  node_def_id = node.node_def_id
80 
81  platforms = NODE_PLATFORMS if not single_platform else [single_platform]
82  for platform in platforms:
83  if node_def_id in NODE_FILTERS[platform][FILTER_NODE_DEF_ID]:
84  isy_data.nodes[platform].append(node)
85  return True
86 
87  return False
88 
89 
91  isy_data: IsyData, node: Group | Node, single_platform: Platform | None = None
92 ) -> bool:
93  """Check if the node matches the Insteon type for any platforms.
94 
95  This is for (presumably) every version of the ISY firmware, but only
96  works for Insteon device. "Node Server" (v5+) and Z-Wave and others will
97  not have a type.
98  """
99  if node.protocol != PROTO_INSTEON:
100  return False
101  if not hasattr(node, "type") or node.type is None:
102  # Node doesn't have a type (non-Insteon device most likely)
103  return False
104 
105  device_type = node.type
106  platforms = NODE_PLATFORMS if not single_platform else [single_platform]
107  for platform in platforms:
108  if any(
109  device_type.startswith(t)
110  for t in set(NODE_FILTERS[platform][FILTER_INSTEON_TYPE])
111  ):
112  # Hacky special-cases for certain devices with different platforms
113  # included as subnodes. Note that special-cases are not necessary
114  # on ISY 5.x firmware as it uses the superior NodeDefs method
115  subnode_id = int(node.address.split(" ")[-1], 16)
116 
117  # FanLinc, which has a light module as one of its nodes.
118  if platform == Platform.FAN and subnode_id == SUBNODE_FANLINC_LIGHT:
119  isy_data.nodes[Platform.LIGHT].append(node)
120  return True
121 
122  # Thermostats, which has a "Heat" and "Cool" sub-node on address 2 and 3
123  if platform == Platform.CLIMATE and subnode_id in (
124  SUBNODE_CLIMATE_COOL,
125  SUBNODE_CLIMATE_HEAT,
126  ):
127  isy_data.nodes[Platform.BINARY_SENSOR].append(node)
128  return True
129 
130  # IOLincs which have a sensor and relay on 2 different nodes
131  if (
132  platform == Platform.BINARY_SENSOR
133  and device_type.startswith(TYPE_CATEGORY_SENSOR_ACTUATORS)
134  and subnode_id == SUBNODE_IOLINC_RELAY
135  ):
136  isy_data.nodes[Platform.SWITCH].append(node)
137  return True
138 
139  # Smartenit EZIO2X4
140  if (
141  platform == Platform.SWITCH
142  and device_type.startswith(TYPE_EZIO2X4)
143  and subnode_id in SUBNODE_EZIO2X4_SENSORS
144  ):
145  isy_data.nodes[Platform.BINARY_SENSOR].append(node)
146  return True
147 
148  isy_data.nodes[platform].append(node)
149  return True
150 
151  return False
152 
153 
155  isy_data: IsyData, node: Group | Node, single_platform: Platform | None = None
156 ) -> bool:
157  """Check if the node matches the ISY Z-Wave Category for any platforms.
158 
159  This is for (presumably) every version of the ISY firmware, but only
160  works for Z-Wave Devices with the devtype.cat property.
161  """
162  if node.protocol != PROTO_ZWAVE:
163  return False
164 
165  if not hasattr(node, "zwave_props") or node.zwave_props is None:
166  # Node doesn't have a device type category (non-Z-Wave device)
167  return False
168 
169  device_type = node.zwave_props.category
170  platforms = NODE_PLATFORMS if not single_platform else [single_platform]
171  for platform in platforms:
172  if any(
173  device_type.startswith(t)
174  for t in set(NODE_FILTERS[platform][FILTER_ZWAVE_CAT])
175  ):
176  isy_data.nodes[platform].append(node)
177  return True
178 
179  return False
180 
181 
183  isy_data: IsyData,
184  node: Group | Node,
185  single_platform: Platform | None = None,
186  uom_list: list[str] | None = None,
187 ) -> bool:
188  """Check if a node's uom matches any of the platforms uom filter.
189 
190  This is used for versions of the ISY firmware that report uoms as a single
191  ID. We can often infer what type of device it is by that ID.
192  """
193  if not hasattr(node, "uom") or node.uom in (None, ""):
194  # Node doesn't have a uom (Scenes for example)
195  return False
196 
197  # Backwards compatibility for ISYv4 Firmware:
198  node_uom = node.uom
199  if isinstance(node.uom, list):
200  node_uom = node.uom[0]
201 
202  if uom_list and single_platform:
203  if node_uom in uom_list:
204  isy_data.nodes[single_platform].append(node)
205  return True
206  return False
207 
208  platforms = NODE_PLATFORMS if not single_platform else [single_platform]
209  for platform in platforms:
210  if node_uom in NODE_FILTERS[platform][FILTER_UOM]:
211  isy_data.nodes[platform].append(node)
212  return True
213 
214  return False
215 
216 
218  isy_data: IsyData,
219  node: Group | Node,
220  single_platform: Platform | None = None,
221  states_list: list[str] | None = None,
222 ) -> bool:
223  """Check if a list of uoms matches two possible filters.
224 
225  This is for versions of the ISY firmware that report uoms as a list of all
226  possible "human readable" states. This filter passes if all of the possible
227  states fit inside the given filter.
228  """
229  if not hasattr(node, "uom") or node.uom in (None, ""):
230  # Node doesn't have a uom (Scenes for example)
231  return False
232 
233  # This only works for ISYv4 Firmware where uom is a list of states:
234  if not isinstance(node.uom, list):
235  return False
236 
237  node_uom = set(map(str.lower, node.uom))
238 
239  if states_list and single_platform:
240  if node_uom == set(states_list):
241  isy_data.nodes[single_platform].append(node)
242  return True
243  return False
244 
245  platforms = NODE_PLATFORMS if not single_platform else [single_platform]
246  for platform in platforms:
247  if node_uom == set(NODE_FILTERS[platform][FILTER_STATES]):
248  isy_data.nodes[platform].append(node)
249  return True
250 
251  return False
252 
253 
254 def _is_sensor_a_binary_sensor(isy_data: IsyData, node: Group | Node) -> bool:
255  """Determine if the given sensor node should be a binary_sensor."""
256  if _check_for_node_def(isy_data, node, single_platform=Platform.BINARY_SENSOR):
257  return True
258  if _check_for_insteon_type(isy_data, node, single_platform=Platform.BINARY_SENSOR):
259  return True
260 
261  # For the next two checks, we're providing our own set of uoms that
262  # represent on/off devices. This is because we can only depend on these
263  # checks in the context of already knowing that this is definitely a
264  # sensor device.
266  isy_data,
267  node,
268  single_platform=Platform.BINARY_SENSOR,
269  uom_list=BINARY_SENSOR_UOMS,
270  ):
271  return True
273  isy_data,
274  node,
275  single_platform=Platform.BINARY_SENSOR,
276  states_list=BINARY_SENSOR_ISY_STATES,
277  ):
278  return True
279 
280  return False
281 
282 
283 def _add_backlight_if_supported(isy_data: IsyData, node: Node) -> None:
284  """Check if a node supports setting a backlight and add entity."""
285  if not getattr(node, "is_backlight_supported", False):
286  return
287  if BACKLIGHT_SUPPORT[node.node_def_id] == UOM_INDEX:
288  isy_data.aux_properties[Platform.SELECT].append((node, CMD_BACKLIGHT))
289  return
290  isy_data.aux_properties[Platform.NUMBER].append((node, CMD_BACKLIGHT))
291 
292 
293 def _generate_device_info(node: Node) -> DeviceInfo:
294  """Generate the device info for a root node device."""
295  isy = node.isy
296  device_info = DeviceInfo(
297  identifiers={(DOMAIN, f"{isy.uuid}_{node.address}")},
298  manufacturer=node.protocol.title(),
299  name=node.name,
300  via_device=(DOMAIN, isy.uuid),
301  configuration_url=isy.conn.url,
302  suggested_area=node.folder,
303  )
304 
305  # ISYv5 Device Types can provide model and manufacturer
306  model: str = str(node.address).rpartition(" ")[0] or node.address
307  if node.node_def_id is not None:
308  model += f": {node.node_def_id}"
309 
310  # Numerical Device Type
311  if node.type is not None:
312  model += f" ({node.type})"
313 
314  # Get extra information for Z-Wave Devices
315  if (
316  node.protocol == PROTO_ZWAVE
317  and node.zwave_props
318  and node.zwave_props.mfr_id != "0"
319  ):
320  device_info[ATTR_MANUFACTURER] = (
321  f"Z-Wave MfrID:{int(node.zwave_props.mfr_id):#0{6}x}"
322  )
323  model += (
324  f"Type:{int(node.zwave_props.prod_type_id):#0{6}x} "
325  f"Product:{int(node.zwave_props.product_id):#0{6}x}"
326  )
327  device_info[ATTR_MODEL] = model
328 
329  return device_info
330 
331 
333  isy_data: IsyData, nodes: Nodes, ignore_identifier: str, sensor_identifier: str
334 ) -> None:
335  """Sort the nodes to their proper platforms."""
336  for path, node in nodes:
337  ignored = ignore_identifier in path or ignore_identifier in node.name
338  if ignored:
339  # Don't import this node as a device at all
340  continue
341 
342  if hasattr(node, "parent_node") and node.parent_node is None:
343  # This is a physical device / parent node
344  isy_data.devices[node.address] = _generate_device_info(node)
345  isy_data.root_nodes[Platform.BUTTON].append(node)
346  # Any parent node can have communication errors:
347  isy_data.aux_properties[Platform.SENSOR].append((node, PROP_COMMS_ERROR))
348  # Add Ramp Rate and On Levels for Dimmable Load devices
349  if getattr(node, "is_dimmable", False):
350  aux_controls = ROOT_AUX_CONTROLS.intersection(node.aux_properties)
351  for control in aux_controls:
352  platform = NODE_AUX_FILTERS[control]
353  isy_data.aux_properties[platform].append((node, control))
354  if hasattr(node, TAG_ENABLED):
355  isy_data.aux_properties[Platform.SWITCH].append((node, TAG_ENABLED))
356  _add_backlight_if_supported(isy_data, node)
357 
358  if node.protocol == PROTO_GROUP:
359  isy_data.nodes[ISY_GROUP_PLATFORM].append(node)
360  continue
361 
362  if node.protocol == PROTO_INSTEON:
363  for control in node.aux_properties:
364  if control in SKIP_AUX_PROPS:
365  continue
366  isy_data.aux_properties[Platform.SENSOR].append((node, control))
367 
368  if sensor_identifier in path or sensor_identifier in node.name:
369  # User has specified to treat this as a sensor. First we need to
370  # determine if it should be a binary_sensor.
371  if _is_sensor_a_binary_sensor(isy_data, node):
372  continue
373  isy_data.nodes[Platform.SENSOR].append(node)
374  continue
375 
376  # We have a bunch of different methods for determining the device type,
377  # each of which works with different ISY firmware versions or device
378  # family. The order here is important, from most reliable to least.
379  if _check_for_node_def(isy_data, node):
380  continue
381  if _check_for_insteon_type(isy_data, node):
382  continue
383  if _check_for_zwave_cat(isy_data, node):
384  continue
385  if _check_for_uom_id(isy_data, node):
386  continue
387  if _check_for_states_in_uom(isy_data, node):
388  continue
389 
390  # Fallback as as sensor, e.g. for un-sortable items like NodeServer nodes.
391  isy_data.nodes[Platform.SENSOR].append(node)
392 
393 
394 def _categorize_programs(isy_data: IsyData, programs: Programs) -> None:
395  """Categorize the ISY programs."""
396  for platform in PROGRAM_PLATFORMS:
397  folder = programs.get_by_name(f"{DEFAULT_PROGRAM_STRING}{platform}")
398  if not folder:
399  continue
400 
401  for dtype, _, node_id in folder.children:
402  if dtype != TAG_FOLDER:
403  continue
404  entity_folder = folder[node_id]
405 
406  actions = None
407  status = entity_folder.get_by_name(KEY_STATUS)
408  if not status or status.protocol != PROTO_PROGRAM:
409  _LOGGER.warning(
410  "Program %s entity '%s' not loaded, invalid/missing status program",
411  platform,
412  entity_folder.name,
413  )
414  continue
415 
416  if platform != Platform.BINARY_SENSOR:
417  actions = entity_folder.get_by_name(KEY_ACTIONS)
418  if not actions or actions.protocol != PROTO_PROGRAM:
419  _LOGGER.warning(
420  (
421  "Program %s entity '%s' not loaded, invalid/missing actions"
422  " program"
423  ),
424  platform,
425  entity_folder.name,
426  )
427  continue
428 
429  entity = (entity_folder.name, status, actions)
430  isy_data.programs[platform].append(entity)
431 
432 
434  value: float | None,
435  uom: str | None,
436  precision: int | str,
437  fallback_precision: int | None = None,
438 ) -> float | int | None:
439  """Fix ISY Reported Values.
440 
441  ISY provides float values as an integer and precision component.
442  Correct by shifting the decimal place left by the value of precision.
443  (e.g. value=2345, prec="2" == 23.45)
444 
445  Insteon Thermostats report temperature in 0.5-deg precision as an int
446  by sending a value of 2 times the Temp. Correct by dividing by 2 here.
447  """
448  if value is None or value == ISY_VALUE_UNKNOWN:
449  return None
450  if uom in (UOM_DOUBLE_TEMP, UOM_ISYV4_DEGREES):
451  return round(float(value) / 2.0, 1)
452  if precision not in ("0", 0):
453  return cast(float, round(float(value) / 10 ** int(precision), int(precision)))
454  if fallback_precision:
455  return round(float(value), fallback_precision)
456  return value
None _categorize_programs(IsyData isy_data, Programs programs)
Definition: helpers.py:394
bool _check_for_uom_id(IsyData isy_data, Group|Node node, Platform|None single_platform=None, list[str]|None uom_list=None)
Definition: helpers.py:187
bool _check_for_insteon_type(IsyData isy_data, Group|Node node, Platform|None single_platform=None)
Definition: helpers.py:92
bool _check_for_states_in_uom(IsyData isy_data, Group|Node node, Platform|None single_platform=None, list[str]|None states_list=None)
Definition: helpers.py:222
bool _is_sensor_a_binary_sensor(IsyData isy_data, Group|Node node)
Definition: helpers.py:254
float|int|None convert_isy_value_to_hass(float|None value, str|None uom, int|str precision, int|None fallback_precision=None)
Definition: helpers.py:438
bool _check_for_zwave_cat(IsyData isy_data, Group|Node node, Platform|None single_platform=None)
Definition: helpers.py:156
bool _check_for_node_def(IsyData isy_data, Group|Node node, Platform|None single_platform=None)
Definition: helpers.py:69
None _categorize_nodes(IsyData isy_data, Nodes nodes, str ignore_identifier, str sensor_identifier)
Definition: helpers.py:334
DeviceInfo _generate_device_info(Node node)
Definition: helpers.py:293
None _add_backlight_if_supported(IsyData isy_data, Node node)
Definition: helpers.py:283