Home Assistant Unofficial Reference 2024.12.1
util.py
Go to the documentation of this file.
1 """Collection of useful functions for the HomeKit component."""
2 
3 from __future__ import annotations
4 
5 import io
6 import ipaddress
7 import logging
8 import os
9 import re
10 import secrets
11 import socket
12 from typing import Any, cast
13 
14 from pyhap.accessory import Accessory
15 import pyqrcode
16 import voluptuous as vol
17 
18 from homeassistant.components import (
19  binary_sensor,
20  media_player,
21  persistent_notification,
22  sensor,
23 )
24 from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
25 from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
26 from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
28  DOMAIN as MEDIA_PLAYER_DOMAIN,
29  MediaPlayerDeviceClass,
30  MediaPlayerEntityFeature,
31 )
32 from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN, RemoteEntityFeature
33 from homeassistant.const import (
34  ATTR_CODE,
35  ATTR_DEVICE_CLASS,
36  ATTR_SUPPORTED_FEATURES,
37  CONF_NAME,
38  CONF_PORT,
39  CONF_TYPE,
40  UnitOfTemperature,
41 )
42 from homeassistant.core import (
43  Event,
44  EventStateChangedData,
45  HomeAssistant,
46  State,
47  callback,
48  split_entity_id,
49 )
51 from homeassistant.helpers.storage import STORAGE_DIR
52 from homeassistant.util.unit_conversion import TemperatureConverter
53 
54 from .const import (
55  AUDIO_CODEC_COPY,
56  AUDIO_CODEC_OPUS,
57  CONF_AUDIO_CODEC,
58  CONF_AUDIO_MAP,
59  CONF_AUDIO_PACKET_SIZE,
60  CONF_FEATURE,
61  CONF_FEATURE_LIST,
62  CONF_LINKED_BATTERY_CHARGING_SENSOR,
63  CONF_LINKED_BATTERY_SENSOR,
64  CONF_LINKED_DOORBELL_SENSOR,
65  CONF_LINKED_HUMIDITY_SENSOR,
66  CONF_LINKED_MOTION_SENSOR,
67  CONF_LINKED_OBSTRUCTION_SENSOR,
68  CONF_LOW_BATTERY_THRESHOLD,
69  CONF_MAX_FPS,
70  CONF_MAX_HEIGHT,
71  CONF_MAX_WIDTH,
72  CONF_STREAM_ADDRESS,
73  CONF_STREAM_COUNT,
74  CONF_STREAM_SOURCE,
75  CONF_SUPPORT_AUDIO,
76  CONF_THRESHOLD_CO,
77  CONF_THRESHOLD_CO2,
78  CONF_VIDEO_CODEC,
79  CONF_VIDEO_MAP,
80  CONF_VIDEO_PACKET_SIZE,
81  DEFAULT_AUDIO_CODEC,
82  DEFAULT_AUDIO_MAP,
83  DEFAULT_AUDIO_PACKET_SIZE,
84  DEFAULT_LOW_BATTERY_THRESHOLD,
85  DEFAULT_MAX_FPS,
86  DEFAULT_MAX_HEIGHT,
87  DEFAULT_MAX_WIDTH,
88  DEFAULT_STREAM_COUNT,
89  DEFAULT_SUPPORT_AUDIO,
90  DEFAULT_VIDEO_CODEC,
91  DEFAULT_VIDEO_MAP,
92  DEFAULT_VIDEO_PACKET_SIZE,
93  DOMAIN,
94  FEATURE_ON_OFF,
95  FEATURE_PLAY_PAUSE,
96  FEATURE_PLAY_STOP,
97  FEATURE_TOGGLE_MUTE,
98  MAX_NAME_LENGTH,
99  TYPE_FAUCET,
100  TYPE_OUTLET,
101  TYPE_SHOWER,
102  TYPE_SPRINKLER,
103  TYPE_SWITCH,
104  TYPE_VALVE,
105  VIDEO_CODEC_COPY,
106  VIDEO_CODEC_H264_OMX,
107  VIDEO_CODEC_H264_V4L2M2M,
108  VIDEO_CODEC_LIBX264,
109 )
110 from .models import HomeKitConfigEntry
111 
112 _LOGGER = logging.getLogger(__name__)
113 
114 
115 NUMBERS_ONLY_RE = re.compile(r"[^\d.]+")
116 VERSION_RE = re.compile(r"([0-9]+)(\.[0-9]+)?(\.[0-9]+)?")
117 INVALID_END_CHARS = "-_ "
118 MAX_VERSION_PART = 2**32 - 1
119 
120 
121 MAX_PORT = 65535
122 VALID_VIDEO_CODECS = [
123  VIDEO_CODEC_LIBX264,
124  VIDEO_CODEC_H264_OMX,
125  VIDEO_CODEC_H264_V4L2M2M,
126  AUDIO_CODEC_COPY,
127 ]
128 VALID_AUDIO_CODECS = [AUDIO_CODEC_OPUS, VIDEO_CODEC_COPY]
129 
130 BASIC_INFO_SCHEMA = vol.Schema(
131  {
132  vol.Optional(CONF_NAME): cv.string,
133  vol.Optional(CONF_LINKED_BATTERY_SENSOR): cv.entity_domain(sensor.DOMAIN),
134  vol.Optional(CONF_LINKED_BATTERY_CHARGING_SENSOR): cv.entity_domain(
135  binary_sensor.DOMAIN
136  ),
137  vol.Optional(
138  CONF_LOW_BATTERY_THRESHOLD, default=DEFAULT_LOW_BATTERY_THRESHOLD
139  ): cv.positive_int,
140  }
141 )
142 
143 FEATURE_SCHEMA = BASIC_INFO_SCHEMA.extend(
144  {vol.Optional(CONF_FEATURE_LIST, default=None): cv.ensure_list}
145 )
146 
147 CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend(
148  {
149  vol.Optional(CONF_STREAM_ADDRESS): vol.All(ipaddress.ip_address, cv.string),
150  vol.Optional(CONF_STREAM_SOURCE): cv.string,
151  vol.Optional(CONF_AUDIO_CODEC, default=DEFAULT_AUDIO_CODEC): vol.In(
152  VALID_AUDIO_CODECS
153  ),
154  vol.Optional(CONF_SUPPORT_AUDIO, default=DEFAULT_SUPPORT_AUDIO): cv.boolean,
155  vol.Optional(CONF_MAX_WIDTH, default=DEFAULT_MAX_WIDTH): cv.positive_int,
156  vol.Optional(CONF_MAX_HEIGHT, default=DEFAULT_MAX_HEIGHT): cv.positive_int,
157  vol.Optional(CONF_MAX_FPS, default=DEFAULT_MAX_FPS): cv.positive_int,
158  vol.Optional(CONF_AUDIO_MAP, default=DEFAULT_AUDIO_MAP): cv.string,
159  vol.Optional(CONF_VIDEO_MAP, default=DEFAULT_VIDEO_MAP): cv.string,
160  vol.Optional(CONF_STREAM_COUNT, default=DEFAULT_STREAM_COUNT): vol.All(
161  vol.Coerce(int), vol.Range(min=1, max=10)
162  ),
163  vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In(
164  VALID_VIDEO_CODECS
165  ),
166  vol.Optional(
167  CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE
168  ): cv.positive_int,
169  vol.Optional(
170  CONF_VIDEO_PACKET_SIZE, default=DEFAULT_VIDEO_PACKET_SIZE
171  ): cv.positive_int,
172  vol.Optional(CONF_LINKED_MOTION_SENSOR): cv.entity_domain(
173  [binary_sensor.DOMAIN, EVENT_DOMAIN]
174  ),
175  vol.Optional(CONF_LINKED_DOORBELL_SENSOR): cv.entity_domain(
176  [binary_sensor.DOMAIN, EVENT_DOMAIN]
177  ),
178  }
179 )
180 
181 HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend(
182  {vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)}
183 )
184 
185 
186 COVER_SCHEMA = BASIC_INFO_SCHEMA.extend(
187  {
188  vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain(
189  binary_sensor.DOMAIN
190  )
191  }
192 )
193 
194 CODE_SCHEMA = BASIC_INFO_SCHEMA.extend(
195  {vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)}
196 )
197 
198 MEDIA_PLAYER_SCHEMA = vol.Schema(
199  {
200  vol.Required(CONF_FEATURE): vol.All(
201  cv.string,
202  vol.In(
203  (
204  FEATURE_ON_OFF,
205  FEATURE_PLAY_PAUSE,
206  FEATURE_PLAY_STOP,
207  FEATURE_TOGGLE_MUTE,
208  )
209  ),
210  )
211  }
212 )
213 
214 SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend(
215  {
216  vol.Optional(CONF_TYPE, default=TYPE_SWITCH): vol.All(
217  cv.string,
218  vol.In(
219  (
220  TYPE_FAUCET,
221  TYPE_OUTLET,
222  TYPE_SHOWER,
223  TYPE_SPRINKLER,
224  TYPE_SWITCH,
225  TYPE_VALVE,
226  )
227  ),
228  )
229  }
230 )
231 
232 SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend(
233  {
234  vol.Optional(CONF_THRESHOLD_CO): vol.Any(None, cv.positive_int),
235  vol.Optional(CONF_THRESHOLD_CO2): vol.Any(None, cv.positive_int),
236  }
237 )
238 
239 
240 HOMEKIT_CHAR_TRANSLATIONS = {
241  0: " ", # nul
242  10: " ", # nl
243  13: " ", # cr
244  33: "-", # !
245  34: " ", # "
246  36: "-", # $
247  37: "-", # %
248  40: "-", # (
249  41: "-", # )
250  42: "-", # *
251  43: "-", # +
252  47: "-", # /
253  58: "-", # :
254  59: "-", # ;
255  60: "-", # <
256  61: "-", # =
257  62: "-", # >
258  63: "-", # ?
259  64: "-", # @
260  91: "-", # [
261  92: "-", # \
262  93: "-", # ]
263  94: "-", # ^
264  95: " ", # _
265  96: "-", # `
266  123: "-", # {
267  124: "-", # |
268  125: "-", # }
269  126: "-", # ~
270  127: "-", # del
271 }
272 
273 
274 def validate_entity_config(values: dict) -> dict[str, dict]:
275  """Validate config entry for CONF_ENTITY."""
276  if not isinstance(values, dict):
277  raise vol.Invalid("expected a dictionary")
278 
279  entities = {}
280  for entity_id, config in values.items():
281  entity = cv.entity_id(entity_id)
282  domain, _ = split_entity_id(entity)
283 
284  if not isinstance(config, dict):
285  raise vol.Invalid(f"The configuration for {entity} must be a dictionary.")
286 
287  if domain in ("alarm_control_panel", "lock"):
288  config = CODE_SCHEMA(config)
289 
290  elif domain == media_player.const.DOMAIN:
291  config = FEATURE_SCHEMA(config)
292  feature_list = {}
293  for feature in config[CONF_FEATURE_LIST]:
294  params = MEDIA_PLAYER_SCHEMA(feature)
295  key = params.pop(CONF_FEATURE)
296  if key in feature_list:
297  raise vol.Invalid(f"A feature can be added only once for {entity}")
298  feature_list[key] = params
299  config[CONF_FEATURE_LIST] = feature_list
300 
301  elif domain == "camera":
302  config = CAMERA_SCHEMA(config)
303 
304  elif domain == "switch":
305  config = SWITCH_TYPE_SCHEMA(config)
306 
307  elif domain == "humidifier":
308  config = HUMIDIFIER_SCHEMA(config)
309 
310  elif domain == "cover":
311  config = COVER_SCHEMA(config)
312 
313  elif domain == "sensor":
314  config = SENSOR_SCHEMA(config)
315 
316  else:
317  config = BASIC_INFO_SCHEMA(config)
318 
319  entities[entity] = config
320  return entities
321 
322 
323 def get_media_player_features(state: State) -> list[str]:
324  """Determine features for media players."""
325  features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
326 
327  supported_modes = []
328  if features & (
329  MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF
330  ):
331  supported_modes.append(FEATURE_ON_OFF)
332  if features & (MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE):
333  supported_modes.append(FEATURE_PLAY_PAUSE)
334  if features & (MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP):
335  supported_modes.append(FEATURE_PLAY_STOP)
336  if features & MediaPlayerEntityFeature.VOLUME_MUTE:
337  supported_modes.append(FEATURE_TOGGLE_MUTE)
338  return supported_modes
339 
340 
341 def validate_media_player_features(state: State, feature_list: str) -> bool:
342  """Validate features for media players."""
343  if not (supported_modes := get_media_player_features(state)):
344  _LOGGER.error("%s does not support any media_player features", state.entity_id)
345  return False
346 
347  if not feature_list:
348  # Auto detected
349  return True
350 
351  error_list = [feature for feature in feature_list if feature not in supported_modes]
352 
353  if error_list:
354  _LOGGER.error(
355  "%s does not support media_player features: %s", state.entity_id, error_list
356  )
357  return False
358  return True
359 
360 
361 def async_show_setup_message(
362  hass: HomeAssistant, entry_id: str, bridge_name: str, pincode: bytes, uri: str
363 ) -> None:
364  """Display persistent notification with setup information."""
365  pin = pincode.decode()
366  _LOGGER.info("Pincode: %s", pin)
367 
368  buffer = io.BytesIO()
369  url = pyqrcode.create(uri)
370  url.svg(buffer, scale=5, module_color="#000", background="#FFF")
371  pairing_secret = secrets.token_hex(32)
372 
373  entry = cast(HomeKitConfigEntry, hass.config_entries.async_get_entry(entry_id))
374  entry_data = entry.runtime_data
375 
376  entry_data.pairing_qr = buffer.getvalue()
377  entry_data.pairing_qr_secret = pairing_secret
378 
379  message = (
380  f"To set up {bridge_name} in the Home App, "
381  "scan the QR code or enter the following code:\n"
382  f"### {pin}\n"
383  f"![image](/api/homekit/pairingqr?{entry_id}-{pairing_secret})"
384  )
385  persistent_notification.async_create(hass, message, "HomeKit Pairing", entry_id)
386 
387 
388 def async_dismiss_setup_message(hass: HomeAssistant, entry_id: str) -> None:
389  """Dismiss persistent notification and remove QR code."""
390  persistent_notification.async_dismiss(hass, entry_id)
391 
392 
393 def convert_to_float(state: Any) -> float | None:
394  """Return float of state, catch errors."""
395  try:
396  return float(state)
397  except (ValueError, TypeError):
398  return None
399 
400 
401 def coerce_int(state: str) -> int:
402  """Return int."""
403  try:
404  return int(state)
405  except (ValueError, TypeError):
406  return 0
407 
408 
409 def cleanup_name_for_homekit(name: str | None) -> str:
410  """Ensure the name of the device will not crash homekit."""
411  #
412  # This is not a security measure.
413  #
414  # UNICODE_EMOJI is also not allowed but that
415  # likely isn't a problem
416  if name is None:
417  return "None" # None crashes apple watches
418  return (
419  name.translate(HOMEKIT_CHAR_TRANSLATIONS)
420  .lstrip(INVALID_END_CHARS)[:MAX_NAME_LENGTH]
421  .rstrip(INVALID_END_CHARS)
422  )
423 
424 
425 def temperature_to_homekit(temperature: float, unit: str) -> float:
426  """Convert temperature to Celsius for HomeKit."""
427  return TemperatureConverter.convert(temperature, unit, UnitOfTemperature.CELSIUS)
428 
429 
430 def temperature_to_states(temperature: float, unit: str) -> float:
431  """Convert temperature back from Celsius to Home Assistant unit."""
432  return TemperatureConverter.convert(temperature, UnitOfTemperature.CELSIUS, unit)
433 
434 
435 def density_to_air_quality(density: float) -> int:
436  """Map PM2.5 µg/m3 density to HomeKit AirQuality level."""
437  if density <= 9: # US AQI 0-50 (HomeKit: Excellent)
438  return 1
439  if density <= 35.4: # US AQI 51-100 (HomeKit: Good)
440  return 2
441  if density <= 55.4: # US AQI 101-150 (HomeKit: Fair)
442  return 3
443  if density <= 125.4: # US AQI 151-200 (HomeKit: Inferior)
444  return 4
445  return 5 # US AQI 201+ (HomeKit: Poor)
446 
447 
448 def density_to_air_quality_pm10(density: float) -> int:
449  """Map PM10 µg/m3 density to HomeKit AirQuality level."""
450  if density <= 54: # US AQI 0-50 (HomeKit: Excellent)
451  return 1
452  if density <= 154: # US AQI 51-100 (HomeKit: Good)
453  return 2
454  if density <= 254: # US AQI 101-150 (HomeKit: Fair)
455  return 3
456  if density <= 354: # US AQI 151-200 (HomeKit: Inferior)
457  return 4
458  return 5 # US AQI 201+ (HomeKit: Poor)
459 
460 
461 def density_to_air_quality_nitrogen_dioxide(density: float) -> int:
462  """Map nitrogen dioxide µg/m3 to HomeKit AirQuality level."""
463  if density <= 30:
464  return 1
465  if density <= 60:
466  return 2
467  if density <= 80:
468  return 3
469  if density <= 90:
470  return 4
471  return 5
472 
473 
474 def density_to_air_quality_voc(density: float) -> int:
475  """Map VOCs µg/m3 to HomeKit AirQuality level.
476 
477  The VOC mappings use the IAQ guidelines for Europe released by the WHO (World Health Organization).
478  Referenced from Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf
479  https://github.com/paulvha/svm30/blob/master/extras/Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf
480  """
481  if density <= 250: # WHO IAQ 1 (HomeKit: Excellent)
482  return 1
483  if density <= 500: # WHO IAQ 2 (HomeKit: Good)
484  return 2
485  if density <= 1000: # WHO IAQ 3 (HomeKit: Fair)
486  return 3
487  if density <= 3000: # WHO IAQ 4 (HomeKit: Inferior)
488  return 4
489  return 5 # WHOA IAQ 5 (HomeKit: Poor)
490 
491 
492 def get_persist_filename_for_entry_id(entry_id: str) -> str:
493  """Determine the filename of the homekit state file."""
494  return f"{DOMAIN}.{entry_id}.state"
495 
496 
497 def get_aid_storage_filename_for_entry_id(entry_id: str) -> str:
498  """Determine the filename of homekit aid storage file."""
499  return f"{DOMAIN}.{entry_id}.aids"
500 
501 
502 def get_iid_storage_filename_for_entry_id(entry_id: str) -> str:
503  """Determine the filename of homekit iid storage file."""
504  return f"{DOMAIN}.{entry_id}.iids"
505 
506 
507 def get_persist_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> str:
508  """Determine the path to the homekit state file."""
509  return hass.config.path(STORAGE_DIR, get_persist_filename_for_entry_id(entry_id))
510 
511 
512 def get_aid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> str:
513  """Determine the path to the homekit aid storage file."""
514  return hass.config.path(
515  STORAGE_DIR, get_aid_storage_filename_for_entry_id(entry_id)
516  )
517 
518 
519 def get_iid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> str:
520  """Determine the path to the homekit iid storage file."""
521  return hass.config.path(
522  STORAGE_DIR, get_iid_storage_filename_for_entry_id(entry_id)
523  )
524 
525 
526 def _format_version_part(version_part: str) -> str:
527  return str(max(0, min(MAX_VERSION_PART, coerce_int(version_part))))
528 
529 
530 def format_version(version: str) -> str | None:
531  """Extract the version string in a format homekit can consume."""
532  split_ver = str(version).replace("-", ".").replace(" ", ".")
533  num_only = NUMBERS_ONLY_RE.sub("", split_ver)
534  if (match := VERSION_RE.search(num_only)) is None:
535  return None
536  value = ".".join(map(_format_version_part, match.group(0).split(".")))
537  return None if _is_zero_but_true(value) else value
538 
539 
540 def _is_zero_but_true(value: Any) -> bool:
541  """Zero but true values can crash apple watches."""
542  return convert_to_float(value) == 0
543 
544 
545 def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str) -> None:
546  """Remove the state files from disk."""
547  for path in (
548  get_persist_fullpath_for_entry_id(hass, entry_id),
549  get_aid_storage_fullpath_for_entry_id(hass, entry_id),
550  get_iid_storage_fullpath_for_entry_id(hass, entry_id),
551  ):
552  if os.path.exists(path):
553  os.unlink(path)
554 
555 
556 def _get_test_socket() -> socket.socket:
557  """Create a socket to test binding ports."""
558  test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
559  test_socket.setblocking(False)
560  test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
561  return test_socket
562 
563 
564 @callback
565 def async_port_is_available(port: int) -> bool:
566  """Check to see if a port is available."""
567  try:
568  _get_test_socket().bind(("", port))
569  except OSError:
570  return False
571  return True
572 
573 
574 @callback
575 def async_find_next_available_port(hass: HomeAssistant, start_port: int) -> int:
576  """Find the next available port not assigned to a config entry."""
577  exclude_ports = {
578  entry.data[CONF_PORT]
579  for entry in hass.config_entries.async_entries(DOMAIN)
580  if CONF_PORT in entry.data
581  }
582  return _async_find_next_available_port(start_port, exclude_ports)
583 
584 
585 @callback
586 def _async_find_next_available_port(start_port: int, exclude_ports: set) -> int:
587  """Find the next available port starting with the given port."""
588  test_socket = _get_test_socket()
589  for port in range(start_port, MAX_PORT + 1):
590  if port in exclude_ports:
591  continue
592  try:
593  test_socket.bind(("", port))
594  except OSError:
595  if port == MAX_PORT:
596  raise
597  continue
598  else:
599  return port
600  raise RuntimeError("unreachable")
601 
602 
603 def pid_is_alive(pid: int) -> bool:
604  """Check to see if a process is alive."""
605  try:
606  os.kill(pid, 0)
607  except OSError:
608  return False
609  return True
610 
611 
612 def accessory_friendly_name(hass_name: str, accessory: Accessory) -> str:
613  """Return the combined name for the accessory.
614 
615  The mDNS name and the Home Assistant config entry
616  name are usually different which means they need to
617  see both to identify the accessory.
618  """
619  accessory_mdns_name = cast(str, accessory.display_name)
620  if hass_name.casefold().startswith(accessory_mdns_name.casefold()):
621  return hass_name
622  if accessory_mdns_name.casefold().startswith(hass_name.casefold()):
623  return accessory_mdns_name
624  return f"{hass_name} ({accessory_mdns_name})"
625 
626 
627 def state_needs_accessory_mode(state: State) -> bool:
628  """Return if the entity represented by the state must be paired in accessory mode."""
629  if state.domain in (CAMERA_DOMAIN, LOCK_DOMAIN):
630  return True
631 
632  return (
633  state.domain == MEDIA_PLAYER_DOMAIN
634  and state.attributes.get(ATTR_DEVICE_CLASS)
635  in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER)
636  or state.domain == REMOTE_DOMAIN
637  and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
638  & RemoteEntityFeature.ACTIVITY
639  )
640 
641 
642 def state_changed_event_is_same_state(event: Event[EventStateChangedData]) -> bool:
643  """Check if a state changed event is the same state."""
644  event_data = event.data
645  old_state = event_data["old_state"]
646  new_state = event_data["new_state"]
647  return bool(new_state and old_state and new_state.state == old_state.state)
int _async_find_next_available_port(AddressTupleVXType source)
Definition: __init__.py:764
tuple[str, str] split_entity_id(str entity_id)
Definition: core.py:214