Home Assistant Unofficial Reference 2024.12.1
helpers.py
Go to the documentation of this file.
1 """Helper functions for Z-Wave JS integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from dataclasses import astuple, dataclass
7 import logging
8 from typing import Any, cast
9 
10 import voluptuous as vol
11 from zwave_js_server.client import Client as ZwaveClient
12 from zwave_js_server.const import (
13  LOG_LEVEL_MAP,
14  CommandClass,
15  ConfigurationValueType,
16  LogLevel,
17 )
18 from zwave_js_server.model.controller import Controller
19 from zwave_js_server.model.driver import Driver
20 from zwave_js_server.model.log_config import LogConfig
21 from zwave_js_server.model.node import Node as ZwaveNode
22 from zwave_js_server.model.value import (
23  ConfigurationValue,
24  Value as ZwaveValue,
25  ValueDataType,
26  get_value_id_str,
27 )
28 
29 from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
30 from homeassistant.config_entries import ConfigEntry, ConfigEntryState
31 from homeassistant.const import (
32  ATTR_AREA_ID,
33  ATTR_DEVICE_ID,
34  ATTR_ENTITY_ID,
35  CONF_TYPE,
36  __version__ as HA_VERSION,
37 )
38 from homeassistant.core import HomeAssistant, callback
39 from homeassistant.exceptions import HomeAssistantError
40 from homeassistant.helpers import device_registry as dr, entity_registry as er
41 from homeassistant.helpers.device_registry import DeviceInfo
42 from homeassistant.helpers.group import expand_entity_ids
43 from homeassistant.helpers.typing import ConfigType, VolSchemaType
44 
45 from .const import (
46  ATTR_COMMAND_CLASS,
47  ATTR_ENDPOINT,
48  ATTR_PROPERTY,
49  ATTR_PROPERTY_KEY,
50  DATA_CLIENT,
51  DATA_OLD_SERVER_LOG_LEVEL,
52  DOMAIN,
53  LIB_LOGGER,
54  LOGGER,
55 )
56 
57 
58 @dataclass
60  """Class to represent a value ID."""
61 
62  property_: str | int
63  command_class: int
64  endpoint: int | None = None
65  property_key: str | int | None = None
66 
67 
68 @dataclass
70  """Class to allow matching a Z-Wave Value."""
71 
72  property_: str | int | None = None
73  command_class: int | None = None
74  endpoint: int | None = None
75  property_key: str | int | None = None
76 
77  def __post_init__(self) -> None:
78  """Post initialization check."""
79  if all(val is None for val in astuple(self)):
80  raise ValueError("At least one of the fields must be set.")
81 
82 
84  matcher: ZwaveValueMatcher, value_data: ValueDataType
85 ) -> bool:
86  """Return whether value matches matcher."""
87  command_class = None
88  if "commandClass" in value_data:
89  command_class = CommandClass(value_data["commandClass"])
90  zwave_value_id = ZwaveValueMatcher(
91  property_=value_data.get("property"),
92  command_class=command_class,
93  endpoint=value_data.get("endpoint"),
94  property_key=value_data.get("propertyKey"),
95  )
96  return all(
97  redacted_field_val is None or redacted_field_val == zwave_value_field_val
98  for redacted_field_val, zwave_value_field_val in zip(
99  astuple(matcher), astuple(zwave_value_id), strict=False
100  )
101  )
102 
103 
104 def get_value_id_from_unique_id(unique_id: str) -> str | None:
105  """Get the value ID and optional state key from a unique ID.
106 
107  Raises ValueError
108  """
109  split_unique_id = unique_id.split(".")
110  # If the unique ID contains a `-` in its second part, the unique ID contains
111  # a value ID and we can return it.
112  if "-" in (value_id := split_unique_id[1]):
113  return value_id
114  return None
115 
116 
117 def get_state_key_from_unique_id(unique_id: str) -> int | None:
118  """Get the state key from a unique ID."""
119  # If the unique ID has more than two parts, it's a special unique ID. If the last
120  # part of the unique ID is an int, then it's a state key and we return it.
121  if len(split_unique_id := unique_id.split(".")) > 2:
122  try:
123  return int(split_unique_id[-1])
124  except ValueError:
125  pass
126  return None
127 
128 
129 def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None:
130  """Return the value of a ZwaveValue."""
131  return value.value if value else None
132 
133 
134 async def async_enable_statistics(driver: Driver) -> None:
135  """Enable statistics on the driver."""
136  await driver.async_enable_statistics("Home Assistant", HA_VERSION)
137 
138 
140  hass: HomeAssistant, entry: ConfigEntry, driver: Driver
141 ) -> None:
142  """Enable logging of zwave-js-server in the lib."""
143  # If lib log level is set to debug, we want to enable server logging. First we
144  # check if server log level is less verbose than library logging, and if so, set it
145  # to debug to match library logging. We will store the old server log level in
146  # hass.data so we can reset it later
147  if (
148  not driver
149  or not driver.client.connected
150  or driver.client.server_logging_enabled
151  ):
152  return
153 
154  LOGGER.info("Enabling zwave-js-server logging")
155  if (curr_server_log_level := driver.log_config.level) and (
156  LOG_LEVEL_MAP[curr_server_log_level]
157  ) > (lib_log_level := LIB_LOGGER.getEffectiveLevel()):
158  entry_data = entry.runtime_data
159  LOGGER.warning(
160  (
161  "Server logging is set to %s and is currently less verbose "
162  "than library logging, setting server log level to %s to match"
163  ),
164  curr_server_log_level,
165  logging.getLevelName(lib_log_level),
166  )
167  entry_data[DATA_OLD_SERVER_LOG_LEVEL] = curr_server_log_level
168  await driver.async_update_log_config(LogConfig(level=LogLevel.DEBUG))
169  await driver.client.enable_server_logging()
170  LOGGER.info("Zwave-js-server logging is enabled")
171 
172 
174  hass: HomeAssistant, entry: ConfigEntry, driver: Driver
175 ) -> None:
176  """Disable logging of zwave-js-server in the lib if still connected to server."""
177  if (
178  not driver
179  or not driver.client.connected
180  or not driver.client.server_logging_enabled
181  ):
182  return
183  LOGGER.info("Disabling zwave_js server logging")
184  if (
185  DATA_OLD_SERVER_LOG_LEVEL in entry.runtime_data
186  and (old_server_log_level := entry.runtime_data.pop(DATA_OLD_SERVER_LOG_LEVEL))
187  != driver.log_config.level
188  ):
189  LOGGER.info(
190  (
191  "Server logging is currently set to %s as a result of server logging "
192  "being enabled. It is now being reset to %s"
193  ),
194  driver.log_config.level,
195  old_server_log_level,
196  )
197  await driver.async_update_log_config(LogConfig(level=old_server_log_level))
198  await driver.client.disable_server_logging()
199  LOGGER.info("Zwave-js-server logging is enabled")
200 
201 
202 def get_valueless_base_unique_id(driver: Driver, node: ZwaveNode) -> str:
203  """Return the base unique ID for an entity that is not based on a value."""
204  return f"{driver.controller.home_id}.{node.node_id}"
205 
206 
207 def get_unique_id(driver: Driver, value_id: str) -> str:
208  """Get unique ID from client and value ID."""
209  return f"{driver.controller.home_id}.{value_id}"
210 
211 
212 def get_device_id(driver: Driver, node: ZwaveNode) -> tuple[str, str]:
213  """Get device registry identifier for Z-Wave node."""
214  return (DOMAIN, f"{driver.controller.home_id}-{node.node_id}")
215 
216 
217 def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None:
218  """Get extended device registry identifier for Z-Wave node."""
219  if None in (node.manufacturer_id, node.product_type, node.product_id):
220  return None
221 
222  domain, dev_id = get_device_id(driver, node)
223  return (
224  domain,
225  f"{dev_id}-{node.manufacturer_id}:{node.product_type}:{node.product_id}",
226  )
227 
228 
230  device_entry: dr.DeviceEntry,
231 ) -> tuple[str, int] | None:
232  """Get home ID and node ID for Z-Wave device registry entry.
233 
234  Returns (home_id, node_id) or None if not found.
235  """
236  device_id = next(
237  (
238  identifier[1]
239  for identifier in device_entry.identifiers
240  if identifier[0] == DOMAIN
241  ),
242  None,
243  )
244  if device_id is None:
245  return None
246  id_ = device_id.split("-")
247  return (id_[0], int(id_[1]))
248 
249 
250 @callback
252  hass: HomeAssistant, device_id: str, dev_reg: dr.DeviceRegistry | None = None
253 ) -> ZwaveNode:
254  """Get node from a device ID.
255 
256  Raises ValueError if device is invalid or node can't be found.
257  """
258  if not dev_reg:
259  dev_reg = dr.async_get(hass)
260 
261  if not (device_entry := dev_reg.async_get(device_id)):
262  raise ValueError(f"Device ID {device_id} is not valid")
263 
264  # Use device config entry ID's to validate that this is a valid zwave_js device
265  # and to get the client
266  config_entry_ids = device_entry.config_entries
267  entry = next(
268  (
269  entry
270  for entry in hass.config_entries.async_entries(DOMAIN)
271  if entry.entry_id in config_entry_ids
272  ),
273  None,
274  )
275  if entry and entry.state != ConfigEntryState.LOADED:
276  raise ValueError(f"Device {device_id} config entry is not loaded")
277  if entry is None:
278  raise ValueError(
279  f"Device {device_id} is not from an existing zwave_js config entry"
280  )
281 
282  client: ZwaveClient = entry.runtime_data[DATA_CLIENT]
283  driver = client.driver
284 
285  if driver is None:
286  raise ValueError("Driver is not ready.")
287 
288  # Get node ID from device identifier, perform some validation, and then get the
289  # node
290  identifiers = get_home_and_node_id_from_device_entry(device_entry)
291 
292  node_id = identifiers[1] if identifiers else None
293 
294  if node_id is None or node_id not in driver.controller.nodes:
295  raise ValueError(f"Node for device {device_id} can't be found")
296 
297  return driver.controller.nodes[node_id]
298 
299 
300 @callback
302  hass: HomeAssistant,
303  entity_id: str,
304  ent_reg: er.EntityRegistry | None = None,
305  dev_reg: dr.DeviceRegistry | None = None,
306 ) -> ZwaveNode:
307  """Get node from an entity ID.
308 
309  Raises ValueError if entity is invalid.
310  """
311  if not ent_reg:
312  ent_reg = er.async_get(hass)
313  entity_entry = ent_reg.async_get(entity_id)
314 
315  if entity_entry is None or entity_entry.platform != DOMAIN:
316  raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity")
317 
318  # Assert for mypy, safe because we know that zwave_js entities are always
319  # tied to a device
320  assert entity_entry.device_id
321  return async_get_node_from_device_id(hass, entity_entry.device_id, dev_reg)
322 
323 
324 @callback
326  hass: HomeAssistant,
327  area_id: str,
328  ent_reg: er.EntityRegistry | None = None,
329  dev_reg: dr.DeviceRegistry | None = None,
330 ) -> set[ZwaveNode]:
331  """Get nodes for all Z-Wave JS devices and entities that are in an area."""
332  nodes: set[ZwaveNode] = set()
333  if ent_reg is None:
334  ent_reg = er.async_get(hass)
335  if dev_reg is None:
336  dev_reg = dr.async_get(hass)
337  # Add devices for all entities in an area that are Z-Wave JS entities
338  nodes.update(
339  {
340  async_get_node_from_device_id(hass, entity.device_id, dev_reg)
341  for entity in er.async_entries_for_area(ent_reg, area_id)
342  if entity.platform == DOMAIN and entity.device_id is not None
343  }
344  )
345  # Add devices in an area that are Z-Wave JS devices
346  nodes.update(
347  async_get_node_from_device_id(hass, device.id, dev_reg)
348  for device in dr.async_entries_for_area(dev_reg, area_id)
349  if any(
350  cast(
351  ConfigEntry,
352  hass.config_entries.async_get_entry(config_entry_id),
353  ).domain
354  == DOMAIN
355  for config_entry_id in device.config_entries
356  )
357  )
358 
359  return nodes
360 
361 
362 @callback
364  hass: HomeAssistant,
365  val: dict[str, Any],
366  ent_reg: er.EntityRegistry | None = None,
367  dev_reg: dr.DeviceRegistry | None = None,
368  logger: logging.Logger = LOGGER,
369 ) -> set[ZwaveNode]:
370  """Get nodes for all targets.
371 
372  Supports entity_id with group expansion, area_id, and device_id.
373  """
374  nodes: set[ZwaveNode] = set()
375  # Convert all entity IDs to nodes
376  for entity_id in expand_entity_ids(hass, val.get(ATTR_ENTITY_ID, [])):
377  try:
378  nodes.add(async_get_node_from_entity_id(hass, entity_id, ent_reg, dev_reg))
379  except ValueError as err:
380  logger.warning(err.args[0])
381 
382  # Convert all area IDs to nodes
383  for area_id in val.get(ATTR_AREA_ID, []):
384  nodes.update(async_get_nodes_from_area_id(hass, area_id, ent_reg, dev_reg))
385 
386  # Convert all device IDs to nodes
387  for device_id in val.get(ATTR_DEVICE_ID, []):
388  try:
389  nodes.add(async_get_node_from_device_id(hass, device_id, dev_reg))
390  except ValueError as err:
391  logger.warning(err.args[0])
392 
393  return nodes
394 
395 
396 def get_zwave_value_from_config(node: ZwaveNode, config: ConfigType) -> ZwaveValue:
397  """Get a Z-Wave JS Value from a config."""
398  endpoint = None
399  if config.get(ATTR_ENDPOINT):
400  endpoint = config[ATTR_ENDPOINT]
401  property_key = None
402  if config.get(ATTR_PROPERTY_KEY):
403  property_key = config[ATTR_PROPERTY_KEY]
404  value_id = get_value_id_str(
405  node,
406  config[ATTR_COMMAND_CLASS],
407  config[ATTR_PROPERTY],
408  endpoint,
409  property_key,
410  )
411  if value_id not in node.values:
412  raise vol.Invalid(f"Value {value_id} can't be found on node {node}")
413  return node.values[value_id]
414 
415 
416 def _zwave_js_config_entry(hass: HomeAssistant, device: dr.DeviceEntry) -> str | None:
417  """Find zwave_js config entry from a device."""
418  for entry_id in device.config_entries:
419  entry = hass.config_entries.async_get_entry(entry_id)
420  if entry and entry.domain == DOMAIN:
421  return entry_id
422  return None
423 
424 
425 @callback
427  hass: HomeAssistant,
428  device_id: str,
429  ent_reg: er.EntityRegistry | None = None,
430  dev_reg: dr.DeviceRegistry | None = None,
431 ) -> str | None:
432  """Get the node status sensor entity ID for a given Z-Wave JS device."""
433  if not ent_reg:
434  ent_reg = er.async_get(hass)
435  if not dev_reg:
436  dev_reg = dr.async_get(hass)
437  if not (device := dev_reg.async_get(device_id)):
438  raise HomeAssistantError("Invalid Device ID provided")
439 
440  if not (entry_id := _zwave_js_config_entry(hass, device)):
441  return None
442 
443  entry = hass.config_entries.async_get_entry(entry_id)
444  assert entry
445  client = entry.runtime_data[DATA_CLIENT]
446  node = async_get_node_from_device_id(hass, device_id, dev_reg)
447  return ent_reg.async_get_entity_id(
448  SENSOR_DOMAIN,
449  DOMAIN,
450  f"{client.driver.controller.home_id}.{node.node_id}.node_status",
451  )
452 
453 
454 def remove_keys_with_empty_values(config: ConfigType) -> ConfigType:
455  """Remove keys from config where the value is an empty string or None."""
456  return {key: value for key, value in config.items() if value not in ("", None)}
457 
458 
460  schema_map: dict[str, vol.Schema],
461 ) -> Callable[[ConfigType], ConfigType]:
462  """Check type specific schema against config."""
463 
464  def _check_type_schema(config: ConfigType) -> ConfigType:
465  """Check type specific schema against config."""
466  return cast(ConfigType, schema_map[str(config[CONF_TYPE])](config))
467 
468  return _check_type_schema
469 
470 
472  input_dict: dict[str, Any], output_dict: dict[str, Any], params: list[str]
473 ) -> None:
474  """Copy available params from input into output."""
475  output_dict.update(
476  {param: input_dict[param] for param in params if param in input_dict}
477  )
478 
479 
481  value: ZwaveValue,
482 ) -> VolSchemaType | vol.Coerce | vol.In | None:
483  """Return device automation schema for a config entry."""
484  if isinstance(value, ConfigurationValue):
485  min_ = value.metadata.min
486  max_ = value.metadata.max
487  if value.configuration_value_type in (
488  ConfigurationValueType.RANGE,
489  ConfigurationValueType.MANUAL_ENTRY,
490  ):
491  return vol.All(vol.Coerce(int), vol.Range(min=min_, max=max_))
492 
493  if value.configuration_value_type == ConfigurationValueType.BOOLEAN:
494  return vol.Coerce(bool)
495 
496  if value.configuration_value_type == ConfigurationValueType.ENUMERATED:
497  return vol.In({int(k): v for k, v in value.metadata.states.items()})
498 
499  return None
500 
501  if value.metadata.states:
502  return vol.In({int(k): v for k, v in value.metadata.states.items()})
503 
504  return vol.All(
505  vol.Coerce(int),
506  vol.Range(min=value.metadata.min, max=value.metadata.max),
507  )
508 
509 
510 def get_device_info(driver: Driver, node: ZwaveNode) -> DeviceInfo:
511  """Get DeviceInfo for node."""
512  return DeviceInfo(
513  identifiers={get_device_id(driver, node)},
514  sw_version=node.firmware_version,
515  name=node.name or node.device_config.description or f"Node {node.node_id}",
516  model=node.device_config.label,
517  manufacturer=node.device_config.manufacturer,
518  suggested_area=node.location if node.location else None,
519  )
520 
521 
523  hass: HomeAssistant, config_entry: ConfigEntry, controller: Controller
524 ) -> str:
525  """Return the network identifier string for persistent notifications."""
526  home_id = str(controller.home_id)
527  if len(hass.config_entries.async_entries(DOMAIN)) > 1:
528  if str(home_id) != config_entry.title:
529  return f"`{config_entry.title}`, with the home ID `{home_id}`,"
530  return f"with the home ID `{home_id}`"
531  return ""
tuple[str, str] get_device_id(Driver driver, ZwaveNode node)
Definition: helpers.py:212
set[ZwaveNode] async_get_nodes_from_area_id(HomeAssistant hass, str area_id, er.EntityRegistry|None ent_reg=None, dr.DeviceRegistry|None dev_reg=None)
Definition: helpers.py:330
tuple[str, int]|None get_home_and_node_id_from_device_entry(dr.DeviceEntry device_entry)
Definition: helpers.py:231
int|None get_state_key_from_unique_id(str unique_id)
Definition: helpers.py:117
tuple[str, str]|None get_device_id_ext(Driver driver, ZwaveNode node)
Definition: helpers.py:217
str get_valueless_base_unique_id(Driver driver, ZwaveNode node)
Definition: helpers.py:202
str|None get_value_id_from_unique_id(str unique_id)
Definition: helpers.py:104
ConfigType remove_keys_with_empty_values(ConfigType config)
Definition: helpers.py:454
None async_enable_statistics(Driver driver)
Definition: helpers.py:134
str|None async_get_node_status_sensor_entity_id(HomeAssistant hass, str device_id, er.EntityRegistry|None ent_reg=None, dr.DeviceRegistry|None dev_reg=None)
Definition: helpers.py:431
ZwaveNode async_get_node_from_entity_id(HomeAssistant hass, str entity_id, er.EntityRegistry|None ent_reg=None, dr.DeviceRegistry|None dev_reg=None)
Definition: helpers.py:306
bool value_matches_matcher(ZwaveValueMatcher matcher, ValueDataType value_data)
Definition: helpers.py:85
None copy_available_params(dict[str, Any] input_dict, dict[str, Any] output_dict, list[str] params)
Definition: helpers.py:473
Callable[[ConfigType], ConfigType] check_type_schema_map(dict[str, vol.Schema] schema_map)
Definition: helpers.py:461
DeviceInfo get_device_info(Driver driver, ZwaveNode node)
Definition: helpers.py:510
str get_unique_id(Driver driver, str value_id)
Definition: helpers.py:207
None async_enable_server_logging_if_needed(HomeAssistant hass, ConfigEntry entry, Driver driver)
Definition: helpers.py:141
set[ZwaveNode] async_get_nodes_from_targets(HomeAssistant hass, dict[str, Any] val, er.EntityRegistry|None ent_reg=None, dr.DeviceRegistry|None dev_reg=None, logging.Logger logger=LOGGER)
Definition: helpers.py:369
str get_network_identifier_for_notification(HomeAssistant hass, ConfigEntry config_entry, Controller controller)
Definition: helpers.py:524
Any|None get_value_of_zwave_value(ZwaveValue|None value)
Definition: helpers.py:129
None async_disable_server_logging_if_needed(HomeAssistant hass, ConfigEntry entry, Driver driver)
Definition: helpers.py:175
VolSchemaType|vol.Coerce|vol.In|None get_value_state_schema(ZwaveValue value)
Definition: helpers.py:482
ZwaveValue get_zwave_value_from_config(ZwaveNode node, ConfigType config)
Definition: helpers.py:396
ZwaveNode async_get_node_from_device_id(HomeAssistant hass, str device_id, dr.DeviceRegistry|None dev_reg=None)
Definition: helpers.py:253
str|None _zwave_js_config_entry(HomeAssistant hass, dr.DeviceEntry device)
Definition: helpers.py:416