Home Assistant Unofficial Reference 2024.12.1
coordinator.py
Go to the documentation of this file.
1 """Central manager for tracking devices with random but resolvable MAC addresses."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 import logging
7 from typing import cast
8 
9 from bluetooth_data_tools import get_cipher_for_irk, resolve_private_address
10 from cryptography.hazmat.primitives.ciphers import Cipher
11 
12 from homeassistant.components import bluetooth
13 from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher
14 from homeassistant.core import HomeAssistant
15 
16 from .const import DOMAIN
17 
18 _LOGGER = logging.getLogger(__name__)
19 
20 type UnavailableCallback = Callable[[bluetooth.BluetoothServiceInfoBleak], None]
21 type Cancellable = Callable[[], None]
22 
23 
25  hass: HomeAssistant, irk: bytes
26 ) -> bluetooth.BluetoothServiceInfoBleak | None:
27  """Find a BluetoothServiceInfoBleak for the irk.
28 
29  This iterates over all currently visible mac addresses and checks them against `irk`.
30  It returns the newest.
31  """
32 
33  # This can't use existing data collected by the coordinator - its called when
34  # the coordinator doesn't know about the IRK, so doesn't optimise this lookup.
35 
36  cur: bluetooth.BluetoothServiceInfoBleak | None = None
37  cipher = get_cipher_for_irk(irk)
38 
39  for service_info in bluetooth.async_discovered_service_info(hass, False):
40  if resolve_private_address(cipher, service_info.address):
41  if not cur or cur.time < service_info.time:
42  cur = service_info
43 
44  return cur
45 
46 
48  """Monitor private bluetooth devices and correlate them with known IRK.
49 
50  This class should not be instanced directly - use `async_get_coordinator` to get an instance.
51 
52  There is a single shared coordinator for all instances of this integration. This is to avoid
53  unnecessary hashing (AES) operations as much as possible.
54  """
55 
56  def __init__(self, hass: HomeAssistant) -> None:
57  """Initialize the manager."""
58  self.hasshass = hass
59 
60  self._irks: dict[bytes, Cipher] = {}
61  self._unavailable_callbacks: dict[bytes, list[UnavailableCallback]] = {}
62  self._service_info_callbacks: dict[
63  bytes, list[bluetooth.BluetoothCallback]
64  ] = {}
65 
66  self._mac_to_irk: dict[str, bytes] = {}
67  self._irk_to_mac: dict[bytes, str] = {}
68 
69  # These MAC addresses have been compared to the IRK list
70  # They are unknown, so we can ignore them.
71  self._ignored: dict[str, Cancellable] = {}
72 
73  self._unavailability_trackers: dict[bytes, Cancellable] = {}
74  self._listener_cancel_listener_cancel: Cancellable | None = None
75 
76  def _async_ensure_started(self) -> None:
77  if not self._listener_cancel_listener_cancel:
78  self._listener_cancel_listener_cancel = bluetooth.async_register_callback(
79  self.hasshass,
80  self._async_track_service_info_async_track_service_info,
81  BluetoothCallbackMatcher(connectable=False),
82  bluetooth.BluetoothScanningMode.ACTIVE,
83  )
84 
85  def _async_ensure_stopped(self) -> None:
86  if self._listener_cancel_listener_cancel:
87  self._listener_cancel_listener_cancel()
88  self._listener_cancel_listener_cancel = None
89 
90  for cancel in self._ignored.values():
91  cancel()
92  self._ignored.clear()
93 
95  self, service_info: bluetooth.BluetoothServiceInfoBleak
96  ) -> None:
97  # This should be called when the current MAC address associated with an IRK goes away.
98  if resolved := self._mac_to_irk.get(service_info.address):
99  if callbacks := self._unavailable_callbacks.get(resolved):
100  for cb in callbacks:
101  cb(service_info)
102  return
103 
104  def _async_irk_resolved_to_mac(self, irk: bytes, mac: str) -> None:
105  if previous_mac := self._irk_to_mac.get(irk):
106  previous_interval = bluetooth.async_get_learned_advertising_interval(
107  self.hasshass, previous_mac
108  ) or bluetooth.async_get_fallback_availability_interval(
109  self.hasshass, previous_mac
110  )
111  if previous_interval:
112  bluetooth.async_set_fallback_availability_interval(
113  self.hasshass, mac, previous_interval
114  )
115 
116  self._mac_to_irk.pop(previous_mac, None)
117 
118  self._mac_to_irk[mac] = irk
119  self._irk_to_mac[irk] = mac
120 
121  # Stop ignoring this MAC
122  self._ignored.pop(mac, None)
123 
124  # Ignore availability events for the previous address
125  if cancel := self._unavailability_trackers.pop(irk, None):
126  cancel()
127 
128  # Track available for new address
129  self._unavailability_trackers[irk] = bluetooth.async_track_unavailable(
130  self.hasshass, self._async_track_unavailable_async_track_unavailable, mac, False
131  )
132 
134  self,
135  service_info: bluetooth.BluetoothServiceInfoBleak,
136  change: bluetooth.BluetoothChange,
137  ) -> None:
138  mac = service_info.address
139 
140  if mac in self._ignored:
141  return
142 
143  if resolved := self._mac_to_irk.get(mac):
144  if callbacks := self._service_info_callbacks.get(resolved):
145  for cb in callbacks:
146  cb(service_info, change)
147  return
148 
149  for irk, cipher in self._irks.items():
150  if resolve_private_address(cipher, service_info.address):
151  self._async_irk_resolved_to_mac_async_irk_resolved_to_mac(irk, mac)
152  if callbacks := self._service_info_callbacks.get(irk):
153  for cb in callbacks:
154  cb(service_info, change)
155  return
156 
157  def _unignore(service_info: bluetooth.BluetoothServiceInfoBleak) -> None:
158  self._ignored.pop(service_info.address, None)
159 
160  self._ignored[mac] = bluetooth.async_track_unavailable(
161  self.hasshass, _unignore, mac, False
162  )
163 
164  def _async_maybe_learn_irk(self, irk: bytes) -> None:
165  """Add irk to list of irks that we can use to resolve RPAs."""
166  if irk not in self._irks:
167  if service_info := async_last_service_info(self.hasshass, irk):
168  self._async_irk_resolved_to_mac_async_irk_resolved_to_mac(irk, service_info.address)
169  self._irks[irk] = get_cipher_for_irk(irk)
170 
171  def _async_maybe_forget_irk(self, irk: bytes) -> None:
172  """If no downstream caller is tracking this irk, lets forget it."""
173  if irk in self._service_info_callbacks or irk in self._unavailable_callbacks:
174  return
175 
176  # Ignore availability events for this irk as no
177  # one is listening.
178  if cancel := self._unavailability_trackers.pop(irk, None):
179  cancel()
180 
181  del self._irks[irk]
182 
183  if mac := self._irk_to_mac.pop(irk, None):
184  self._mac_to_irk.pop(mac, None)
185 
186  if not self._mac_to_irk:
187  self._async_ensure_stopped_async_ensure_stopped()
188 
190  self, callback: bluetooth.BluetoothCallback, irk: bytes
191  ) -> Cancellable:
192  """Receive a callback when a new advertisement is received for an irk.
193 
194  Returns a callback that can be used to cancel the registration.
195  """
196  self._async_ensure_started_async_ensure_started()
197  self._async_maybe_learn_irk_async_maybe_learn_irk(irk)
198 
199  callbacks = self._service_info_callbacks.setdefault(irk, [])
200  callbacks.append(callback)
201 
202  def _unsubscribe() -> None:
203  callbacks.remove(callback)
204  if not callbacks:
205  self._service_info_callbacks.pop(irk, None)
206  self._async_maybe_forget_irk_async_maybe_forget_irk(irk)
207 
208  return _unsubscribe
209 
211  self,
212  callback: UnavailableCallback,
213  irk: bytes,
214  ) -> Cancellable:
215  """Register to receive a callback when an irk is unavailable.
216 
217  Returns a callback that can be used to cancel the registration.
218  """
219  self._async_ensure_started_async_ensure_started()
220  self._async_maybe_learn_irk_async_maybe_learn_irk(irk)
221 
222  callbacks = self._unavailable_callbacks.setdefault(irk, [])
223  callbacks.append(callback)
224 
225  def _unsubscribe() -> None:
226  callbacks.remove(callback)
227  if not callbacks:
228  self._unavailable_callbacks.pop(irk, None)
229 
230  self._async_maybe_forget_irk_async_maybe_forget_irk(irk)
231 
232  return _unsubscribe
233 
234 
235 def async_get_coordinator(hass: HomeAssistant) -> PrivateDevicesCoordinator:
236  """Create or return an existing PrivateDeviceManager.
237 
238  There should only be one per HomeAssistant instance. Associating private
239  mac addresses with an IRK involves AES operations. We don't want to
240  duplicate that work.
241  """
242  if existing := hass.data.get(DOMAIN):
243  return cast(PrivateDevicesCoordinator, existing)
244 
245  pdm = hass.data[DOMAIN] = PrivateDevicesCoordinator(hass)
246 
247  return pdm
None _async_track_service_info(self, bluetooth.BluetoothServiceInfoBleak service_info, bluetooth.BluetoothChange change)
Definition: coordinator.py:137
Cancellable async_track_service_info(self, bluetooth.BluetoothCallback callback, bytes irk)
Definition: coordinator.py:191
Cancellable async_track_unavailable(self, UnavailableCallback callback, bytes irk)
Definition: coordinator.py:214
None _async_track_unavailable(self, bluetooth.BluetoothServiceInfoBleak service_info)
Definition: coordinator.py:96
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
PrivateDevicesCoordinator async_get_coordinator(HomeAssistant hass)
Definition: coordinator.py:235
bluetooth.BluetoothServiceInfoBleak|None async_last_service_info(HomeAssistant hass, bytes irk)
Definition: coordinator.py:26