Home Assistant Unofficial Reference 2024.12.1
coordinator.py
Go to the documentation of this file.
1 """Tracking for iBeacon devices."""
2 
3 from __future__ import annotations
4 
5 from datetime import datetime
6 import logging
7 import time
8 
9 from ibeacon_ble import (
10  APPLE_MFR_ID,
11  IBEACON_FIRST_BYTE,
12  IBEACON_SECOND_BYTE,
13  iBeaconAdvertisement,
14  iBeaconParser,
15 )
16 
17 from homeassistant.components import bluetooth
18 from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher
19 from homeassistant.config_entries import ConfigEntry
20 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
21 from homeassistant.helpers.device_registry import DeviceRegistry
22 from homeassistant.helpers.dispatcher import async_dispatcher_send
23 from homeassistant.helpers.event import async_track_time_interval
24 
25 from .const import (
26  CONF_ALLOW_NAMELESS_UUIDS,
27  CONF_IGNORE_ADDRESSES,
28  CONF_IGNORE_UUIDS,
29  DOMAIN,
30  MAX_IDS,
31  MAX_IDS_PER_UUID,
32  MIN_SEEN_TRANSIENT_NEW,
33  SIGNAL_IBEACON_DEVICE_NEW,
34  SIGNAL_IBEACON_DEVICE_SEEN,
35  SIGNAL_IBEACON_DEVICE_UNAVAILABLE,
36  UNAVAILABLE_TIMEOUT,
37  UPDATE_INTERVAL,
38 )
39 
40 _LOGGER = logging.getLogger(__name__)
41 
42 MONOTONIC_TIME = time.monotonic
43 
44 
45 def signal_unavailable(unique_id: str) -> str:
46  """Signal for the unique_id going unavailable."""
47  return f"{SIGNAL_IBEACON_DEVICE_UNAVAILABLE}_{unique_id}"
48 
49 
50 def signal_seen(unique_id: str) -> str:
51  """Signal for the unique_id being seen."""
52  return f"{SIGNAL_IBEACON_DEVICE_SEEN}_{unique_id}"
53 
54 
55 def make_short_address(address: str) -> str:
56  """Convert a Bluetooth address to a short address."""
57  results = address.replace("-", ":").split(":")
58  return f"{results[-2].upper()}{results[-1].upper()}"[-4:]
59 
60 
61 @callback
63  service_info: bluetooth.BluetoothServiceInfoBleak,
64  ibeacon_advertisement: iBeaconAdvertisement,
65  unique_address: bool = False,
66 ) -> str:
67  """Return a name for the device."""
68  if service_info.address in (
69  service_info.name,
70  service_info.name.replace("-", ":"),
71  ):
72  base_name = f"{ibeacon_advertisement.uuid}_{ibeacon_advertisement.major}_{ibeacon_advertisement.minor}"
73  else:
74  base_name = service_info.name
75  if unique_address:
76  short_address = make_short_address(service_info.address)
77  if not base_name.upper().endswith(short_address):
78  return f"{base_name} {short_address}"
79  return base_name
80 
81 
82 @callback
84  hass: HomeAssistant,
85  device_id: str,
86  service_info: bluetooth.BluetoothServiceInfoBleak,
87  ibeacon_advertisement: iBeaconAdvertisement,
88  new: bool,
89  unique_address: bool,
90 ) -> None:
91  """Dispatch an update."""
92  if new:
94  hass,
95  SIGNAL_IBEACON_DEVICE_NEW,
96  device_id,
97  async_name(service_info, ibeacon_advertisement, unique_address),
98  ibeacon_advertisement,
99  )
100  return
101 
103  hass,
104  signal_seen(device_id),
105  ibeacon_advertisement,
106  )
107 
108 
110  """Set up the iBeacon Coordinator."""
111 
112  def __init__(
113  self, hass: HomeAssistant, entry: ConfigEntry, registry: DeviceRegistry
114  ) -> None:
115  """Initialize the Coordinator."""
116  self.hasshass = hass
117  self._entry_entry = entry
118  self._dev_reg_dev_reg = registry
119  self._ibeacon_parser_ibeacon_parser = iBeaconParser()
120 
121  # iBeacon devices that do not follow the spec
122  # and broadcast custom data in the major and minor fields
123  self._ignore_addresses: set[str] = set(
124  entry.data.get(CONF_IGNORE_ADDRESSES, [])
125  )
126  # iBeacon devices that do not follow the spec
127  # and broadcast custom data in the major and minor fields
128  self._ignore_uuids: set[str] = set(entry.data.get(CONF_IGNORE_UUIDS, []))
129 
130  # iBeacons with fixed MAC addresses
131  self._last_ibeacon_advertisement_by_unique_id: dict[
132  str, iBeaconAdvertisement
133  ] = {}
134  self._transient_seen_count: dict[str, int] = {}
135  self._group_ids_by_address: dict[str, set[str]] = {}
136  self._unique_ids_by_address: dict[str, set[str]] = {}
137  self._unique_ids_by_group_id: dict[str, set[str]] = {}
138  self._addresses_by_group_id: dict[str, set[str]] = {}
139  self._unavailable_trackers: dict[str, CALLBACK_TYPE] = {}
140 
141  # iBeacon with random MAC addresses
142  self._group_ids_random_macs: set[str] = set()
143  self._last_seen_by_group_id: dict[str, bluetooth.BluetoothServiceInfoBleak] = {}
144  self._unavailable_group_ids: set[str] = set()
145 
146  # iBeacons with random MAC addresses, fixed UUID, random major/minor
147  self._major_minor_by_uuid: dict[str, set[tuple[int, int]]] = {}
148 
149  # iBeacons from devices with no name
150  self._allow_nameless_uuids_allow_nameless_uuids = set(
151  entry.options.get(CONF_ALLOW_NAMELESS_UUIDS, [])
152  )
153  self._ignored_nameless_by_uuid: dict[str, set[str]] = {}
154 
155  self._entry_entry.async_on_unload(
156  self._entry_entry.add_update_listener(self.async_config_entry_updatedasync_config_entry_updated)
157  )
158 
159  @callback
160  def async_device_id_seen(self, device_id: str) -> bool:
161  """Return True if the device_id has been seen since boot."""
162  return bool(
163  device_id in self._last_ibeacon_advertisement_by_unique_id
164  or device_id in self._last_seen_by_group_id
165  )
166 
167  @callback
169  self, service_info: bluetooth.BluetoothServiceInfoBleak
170  ) -> None:
171  """Handle unavailable devices."""
172  address = service_info.address
173  self._async_cancel_unavailable_tracker_async_cancel_unavailable_tracker(address)
174  for unique_id in self._unique_ids_by_address[address]:
175  async_dispatcher_send(self.hasshass, signal_unavailable(unique_id))
176 
177  @callback
178  def _async_cancel_unavailable_tracker(self, address: str) -> None:
179  """Cancel unavailable tracking for an address."""
180  self._unavailable_trackers.pop(address)()
181  self._transient_seen_count.pop(address, None)
182 
183  @callback
184  def _async_ignore_uuid(self, uuid: str) -> None:
185  """Ignore an UUID that does not follow the spec and any entities created by it."""
186  self._ignore_uuids.add(uuid)
187  major_minor_by_uuid = self._major_minor_by_uuid.pop(uuid)
188  unique_ids_to_purge = set()
189  for major, minor in major_minor_by_uuid:
190  group_id = f"{uuid}_{major}_{minor}"
191  if unique_ids := self._unique_ids_by_group_id.pop(group_id, None):
192  unique_ids_to_purge.update(unique_ids)
193  for address in self._addresses_by_group_id.pop(group_id, []):
194  self._async_cancel_unavailable_tracker_async_cancel_unavailable_tracker(address)
195  self._unique_ids_by_address.pop(address)
196  self._group_ids_by_address.pop(address)
197  self._async_purge_untrackable_entities_async_purge_untrackable_entities(unique_ids_to_purge)
198  entry_data = self._entry_entry.data
199  new_data = entry_data | {CONF_IGNORE_UUIDS: list(self._ignore_uuids)}
200  self.hasshass.config_entries.async_update_entry(self._entry_entry, data=new_data)
201 
202  @callback
203  def _async_ignore_address(self, address: str) -> None:
204  """Ignore an address that does not follow the spec and any entities created by it."""
205  self._ignore_addresses.add(address)
206  self._async_cancel_unavailable_tracker_async_cancel_unavailable_tracker(address)
207  entry_data = self._entry_entry.data
208  new_data = entry_data | {CONF_IGNORE_ADDRESSES: list(self._ignore_addresses)}
209  self.hasshass.config_entries.async_update_entry(self._entry_entry, data=new_data)
210  self._async_purge_untrackable_entities_async_purge_untrackable_entities(self._unique_ids_by_address[address])
211  self._group_ids_by_address.pop(address)
212  self._unique_ids_by_address.pop(address)
213 
214  @callback
215  def _async_purge_untrackable_entities(self, unique_ids: set[str]) -> None:
216  """Remove entities that are no longer trackable."""
217  for unique_id in unique_ids:
218  if device := self._dev_reg_dev_reg.async_get_device(
219  identifiers={(DOMAIN, unique_id)}
220  ):
221  self._dev_reg_dev_reg.async_remove_device(device.id)
222  self._last_ibeacon_advertisement_by_unique_id.pop(unique_id, None)
223 
224  @callback
226  self,
227  group_id: str,
228  service_info: bluetooth.BluetoothServiceInfoBleak,
229  ibeacon_advertisement: iBeaconAdvertisement,
230  ) -> None:
231  """Switch to random mac tracking method when a group is using rotating mac addresses."""
232  self._group_ids_random_macs.add(group_id)
233  self._async_purge_untrackable_entities_async_purge_untrackable_entities(self._unique_ids_by_group_id[group_id])
234  self._unique_ids_by_group_id.pop(group_id)
235  self._addresses_by_group_id.pop(group_id)
236  self._async_update_ibeacon_with_random_mac_async_update_ibeacon_with_random_mac(
237  group_id, service_info, ibeacon_advertisement
238  )
239 
241  self, address: str, group_id: str, unique_id: str
242  ) -> None:
243  """Track an iBeacon with a unique address."""
244  self._unique_ids_by_address.setdefault(address, set()).add(unique_id)
245  self._group_ids_by_address.setdefault(address, set()).add(group_id)
246 
247  self._unique_ids_by_group_id.setdefault(group_id, set()).add(unique_id)
248  self._addresses_by_group_id.setdefault(group_id, set()).add(address)
249 
250  @callback
252  self,
253  service_info: bluetooth.BluetoothServiceInfoBleak,
254  change: bluetooth.BluetoothChange,
255  ) -> None:
256  """Update from a bluetooth callback."""
257  if service_info.address in self._ignore_addresses:
258  return
259  if not (ibeacon_advertisement := self._ibeacon_parser_ibeacon_parser.parse(service_info)):
260  return
261 
262  uuid_str = str(ibeacon_advertisement.uuid)
263  if uuid_str in self._ignore_uuids:
264  return
265 
266  _LOGGER.debug("update beacon %s", uuid_str)
267 
268  major = ibeacon_advertisement.major
269  minor = ibeacon_advertisement.minor
270  major_minor_by_uuid = self._major_minor_by_uuid.setdefault(uuid_str, set())
271  if len(major_minor_by_uuid) + 1 > MAX_IDS_PER_UUID:
272  self._async_ignore_uuid_async_ignore_uuid(uuid_str)
273  return
274 
275  major_minor_by_uuid.add((major, minor))
276  group_id = f"{uuid_str}_{major}_{minor}"
277 
278  if group_id in self._group_ids_random_macs:
279  self._async_update_ibeacon_with_random_mac_async_update_ibeacon_with_random_mac(
280  group_id, service_info, ibeacon_advertisement
281  )
282  return
283 
284  self._async_update_ibeacon_with_unique_address_async_update_ibeacon_with_unique_address(
285  group_id, service_info, ibeacon_advertisement
286  )
287 
288  @callback
290  self,
291  group_id: str,
292  service_info: bluetooth.BluetoothServiceInfoBleak,
293  ibeacon_advertisement: iBeaconAdvertisement,
294  ) -> None:
295  """Update iBeacons with random mac addresses."""
296  new = group_id not in self._last_seen_by_group_id
297  self._last_seen_by_group_id[group_id] = service_info
298  self._unavailable_group_ids.discard(group_id)
300  self.hasshass, group_id, service_info, ibeacon_advertisement, new, False
301  )
302 
303  @callback
305  self,
306  group_id: str,
307  service_info: bluetooth.BluetoothServiceInfoBleak,
308  ibeacon_advertisement: iBeaconAdvertisement,
309  ) -> None:
310  # Handle iBeacon with a fixed mac address
311  # and or detect if the iBeacon is using a rotating mac address
312  # and switch to random mac tracking method
313  address = service_info.address
314  unique_id = f"{group_id}_{address}"
315  new = unique_id not in self._last_ibeacon_advertisement_by_unique_id
316  uuid = str(ibeacon_advertisement.uuid)
317 
318  # Reject creating new trackers if the name is not set (unless the uuid is allowlisted).
319  if (
320  new
321  and uuid not in self._allow_nameless_uuids_allow_nameless_uuids
322  and (
323  service_info.device.name is None
324  or service_info.device.name.replace("-", ":")
325  == service_info.device.address
326  )
327  ):
328  # Store the ignored addresses, cause the uuid might be allowlisted later
329  self._ignored_nameless_by_uuid.setdefault(uuid, set()).add(address)
330 
331  _LOGGER.debug("ignoring new beacon %s due to empty device name", unique_id)
332  return
333 
334  previously_tracked = address in self._unique_ids_by_address
335  self._last_ibeacon_advertisement_by_unique_id[unique_id] = ibeacon_advertisement
336  self._async_track_ibeacon_with_unique_address_async_track_ibeacon_with_unique_address(address, group_id, unique_id)
337  if address not in self._unavailable_trackers:
338  self._unavailable_trackers[address] = bluetooth.async_track_unavailable(
339  self.hasshass, self._async_handle_unavailable_async_handle_unavailable, address
340  )
341 
342  if not previously_tracked and new and ibeacon_advertisement.transient:
343  # Do not create a new tracker right away for transient devices
344  # If they keep advertising, we will create entities for them
345  # once _async_update_rssi_and_transients has seen them enough times
346  self._transient_seen_count[address] = 1
347  return
348 
349  # Some manufacturers violate the spec and flood us with random
350  # data (sometimes its temperature data).
351  #
352  # Once we see more than MAX_IDS from the same
353  # address we remove all the trackers for that address and add the
354  # address to the ignore list since we know its garbage data.
355  if len(self._group_ids_by_address[address]) >= MAX_IDS:
356  self._async_ignore_address_async_ignore_address(address)
357  return
358 
359  # Once we see more than MAX_IDS from the same
360  # group_id we remove all the trackers for that group_id
361  # as it means the addresses are being rotated.
362  if len(self._addresses_by_group_id[group_id]) >= MAX_IDS:
363  self._async_convert_random_mac_tracking_async_convert_random_mac_tracking(
364  group_id, service_info, ibeacon_advertisement
365  )
366  return
367 
369  self.hasshass, unique_id, service_info, ibeacon_advertisement, new, True
370  )
371 
372  @callback
373  def _async_stop(self) -> None:
374  """Stop the Coordinator."""
375  for cancel in self._unavailable_trackers.values():
376  cancel()
377  self._unavailable_trackers.clear()
378 
379  @callback
381  """Check for random mac groups that have not been seen in a while and mark them as unavailable."""
382  now = MONOTONIC_TIME()
383  gone_unavailable = [
384  group_id
385  for group_id in self._group_ids_random_macs
386  if group_id not in self._unavailable_group_ids
387  and (service_info := self._last_seen_by_group_id.get(group_id))
388  and (
389  # We will not get callbacks for iBeacons with random macs
390  # that rotate infrequently since their advertisement data
391  # does not change as the bluetooth.async_register_callback API
392  # suppresses callbacks for duplicate advertisements to avoid
393  # exposing integrations to the firehose of bluetooth advertisements.
394  #
395  # To solve this we need to ask for the latest service info for
396  # the address we last saw to get the latest timestamp.
397  #
398  # If there is no last service info for the address we know that
399  # the device is no longer advertising.
400  not (
401  latest_service_info := bluetooth.async_last_service_info(
402  self.hasshass, service_info.address, connectable=False
403  )
404  )
405  or now - latest_service_info.time > UNAVAILABLE_TIMEOUT
406  )
407  ]
408  for group_id in gone_unavailable:
409  self._unavailable_group_ids.add(group_id)
410  async_dispatcher_send(self.hasshass, signal_unavailable(group_id))
411 
412  @callback
414  """Check to see if the rssi has changed and update any devices.
415 
416  We don't callback on RSSI changes so we need to check them
417  here and send them over the dispatcher periodically to
418  ensure the distance calculation is update.
419 
420  If the transient flag is set we also need to check to see
421  if the device is still transmitting and increment the counter
422  """
423  for (
424  unique_id,
425  ibeacon_advertisement,
426  ) in self._last_ibeacon_advertisement_by_unique_id.items():
427  address = unique_id.split("_")[-1]
428  service_info = bluetooth.async_last_service_info(
429  self.hasshass, address, connectable=False
430  )
431  if not service_info:
432  continue
433 
434  if address in self._transient_seen_count:
435  self._transient_seen_count[address] += 1
436  if self._transient_seen_count[address] == MIN_SEEN_TRANSIENT_NEW:
437  self._transient_seen_count.pop(address)
439  self.hasshass,
440  unique_id,
441  service_info,
442  ibeacon_advertisement,
443  True,
444  True,
445  )
446  continue
447 
448  if (
449  service_info.rssi != ibeacon_advertisement.rssi
450  or service_info.source != ibeacon_advertisement.source
451  ):
452  ibeacon_advertisement.source = service_info.source
453  ibeacon_advertisement.update_rssi(service_info.rssi)
455  self.hasshass,
456  signal_seen(unique_id),
457  ibeacon_advertisement,
458  )
459 
461  self, hass: HomeAssistant, config_entry: ConfigEntry
462  ) -> None:
463  """Restore ignored nameless beacons when the allowlist is updated."""
464 
465  self._allow_nameless_uuids_allow_nameless_uuids = set(
466  self._entry_entry.options.get(CONF_ALLOW_NAMELESS_UUIDS, [])
467  )
468 
469  for uuid in self._allow_nameless_uuids_allow_nameless_uuids:
470  for address in self._ignored_nameless_by_uuid.pop(uuid, set()):
471  _LOGGER.debug(
472  "restoring nameless iBeacon %s from address %s", uuid, address
473  )
474 
475  if not (
476  service_info := bluetooth.async_last_service_info(
477  self.hasshass, address, connectable=False
478  )
479  ):
480  continue # no longer available
481 
482  # the beacon was ignored, we need to re-process it from scratch
483  self._async_update_ibeacon_async_update_ibeacon(
484  service_info, bluetooth.BluetoothChange.ADVERTISEMENT
485  )
486 
487  @callback
488  def _async_update(self, _now: datetime) -> None:
489  """Update the Coordinator."""
490  self._async_check_unavailable_groups_with_random_macs_async_check_unavailable_groups_with_random_macs()
491  self._async_update_rssi_and_transients_async_update_rssi_and_transients()
492 
493  @callback
494  def _async_restore_from_registry(self) -> None:
495  """Restore the state of the Coordinator from the device registry."""
496  for device in self._dev_reg_dev_reg.devices.get_devices_for_config_entry_id(
497  self._entry_entry.entry_id
498  ):
499  if not (identifier := next(iter(device.identifiers), None)):
500  continue
501  unique_id = identifier[1]
502  # iBeacons with a fixed MAC address
503  if unique_id.count("_") == 3:
504  uuid, major, minor, address = unique_id.split("_")
505  group_id = f"{uuid}_{major}_{minor}"
506  self._async_track_ibeacon_with_unique_address_async_track_ibeacon_with_unique_address(
507  address, group_id, unique_id
508  )
509  # iBeacons with a random MAC address
510  elif unique_id.count("_") == 2:
511  uuid, major, minor = unique_id.split("_")
512  group_id = f"{uuid}_{major}_{minor}"
513  self._group_ids_random_macs.add(group_id)
514 
515  async def async_start(self) -> None:
516  """Start the Coordinator."""
517  await self._ibeacon_parser_ibeacon_parser.async_setup()
518  self._async_restore_from_registry_async_restore_from_registry()
519  entry = self._entry_entry
520  entry.async_on_unload(
521  bluetooth.async_register_callback(
522  self.hasshass,
523  self._async_update_ibeacon_async_update_ibeacon,
525  connectable=False,
526  manufacturer_id=APPLE_MFR_ID,
527  manufacturer_data_start=[IBEACON_FIRST_BYTE, IBEACON_SECOND_BYTE],
528  ), # We will take data from any source
529  bluetooth.BluetoothScanningMode.PASSIVE,
530  )
531  )
532  entry.async_on_unload(self._async_stop_async_stop)
533  entry.async_on_unload(
534  async_track_time_interval(self.hasshass, self._async_update_async_update, UPDATE_INTERVAL)
535  )
None _async_update_ibeacon(self, bluetooth.BluetoothServiceInfoBleak service_info, bluetooth.BluetoothChange change)
Definition: coordinator.py:255
None _async_track_ibeacon_with_unique_address(self, str address, str group_id, str unique_id)
Definition: coordinator.py:242
None _async_update_ibeacon_with_unique_address(self, str group_id, bluetooth.BluetoothServiceInfoBleak service_info, iBeaconAdvertisement ibeacon_advertisement)
Definition: coordinator.py:309
None async_config_entry_updated(self, HomeAssistant hass, ConfigEntry config_entry)
Definition: coordinator.py:462
None _async_convert_random_mac_tracking(self, str group_id, bluetooth.BluetoothServiceInfoBleak service_info, iBeaconAdvertisement ibeacon_advertisement)
Definition: coordinator.py:230
None _async_handle_unavailable(self, bluetooth.BluetoothServiceInfoBleak service_info)
Definition: coordinator.py:170
None _async_purge_untrackable_entities(self, set[str] unique_ids)
Definition: coordinator.py:215
None __init__(self, HomeAssistant hass, ConfigEntry entry, DeviceRegistry registry)
Definition: coordinator.py:114
None _async_update_ibeacon_with_random_mac(self, str group_id, bluetooth.BluetoothServiceInfoBleak service_info, iBeaconAdvertisement ibeacon_advertisement)
Definition: coordinator.py:294
bool add(self, _T matcher)
Definition: match.py:185
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
str async_name(bluetooth.BluetoothServiceInfoBleak service_info, iBeaconAdvertisement ibeacon_advertisement, bool unique_address=False)
Definition: coordinator.py:66
None _async_dispatch_update(HomeAssistant hass, str device_id, bluetooth.BluetoothServiceInfoBleak service_info, iBeaconAdvertisement ibeacon_advertisement, bool new, bool unique_address)
Definition: coordinator.py:90
None async_setup(self, list[Platform]|None pending_platforms=None)
Definition: coordinator.py:149
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
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