1 """Tracking for iBeacon devices."""
3 from __future__
import annotations
5 from datetime
import datetime
9 from ibeacon_ble
import (
26 CONF_ALLOW_NAMELESS_UUIDS,
27 CONF_IGNORE_ADDRESSES,
32 MIN_SEEN_TRANSIENT_NEW,
33 SIGNAL_IBEACON_DEVICE_NEW,
34 SIGNAL_IBEACON_DEVICE_SEEN,
35 SIGNAL_IBEACON_DEVICE_UNAVAILABLE,
40 _LOGGER = logging.getLogger(__name__)
42 MONOTONIC_TIME = time.monotonic
46 """Signal for the unique_id going unavailable."""
47 return f
"{SIGNAL_IBEACON_DEVICE_UNAVAILABLE}_{unique_id}"
51 """Signal for the unique_id being seen."""
52 return f
"{SIGNAL_IBEACON_DEVICE_SEEN}_{unique_id}"
56 """Convert a Bluetooth address to a short address."""
57 results = address.replace(
"-",
":").split(
":")
58 return f
"{results[-2].upper()}{results[-1].upper()}"[-4:]
63 service_info: bluetooth.BluetoothServiceInfoBleak,
64 ibeacon_advertisement: iBeaconAdvertisement,
65 unique_address: bool =
False,
67 """Return a name for the device."""
68 if service_info.address
in (
70 service_info.name.replace(
"-",
":"),
72 base_name = f
"{ibeacon_advertisement.uuid}_{ibeacon_advertisement.major}_{ibeacon_advertisement.minor}"
74 base_name = service_info.name
77 if not base_name.upper().endswith(short_address):
78 return f
"{base_name} {short_address}"
86 service_info: bluetooth.BluetoothServiceInfoBleak,
87 ibeacon_advertisement: iBeaconAdvertisement,
91 """Dispatch an update."""
95 SIGNAL_IBEACON_DEVICE_NEW,
97 async_name(service_info, ibeacon_advertisement, unique_address),
98 ibeacon_advertisement,
105 ibeacon_advertisement,
110 """Set up the iBeacon Coordinator."""
113 self, hass: HomeAssistant, entry: ConfigEntry, registry: DeviceRegistry
115 """Initialize the Coordinator."""
123 self._ignore_addresses: set[str] = set(
124 entry.data.get(CONF_IGNORE_ADDRESSES, [])
128 self._ignore_uuids: set[str] = set(entry.data.get(CONF_IGNORE_UUIDS, []))
131 self._last_ibeacon_advertisement_by_unique_id: dict[
132 str, iBeaconAdvertisement
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] = {}
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()
147 self._major_minor_by_uuid: dict[str, set[tuple[int, int]]] = {}
151 entry.options.get(CONF_ALLOW_NAMELESS_UUIDS, [])
153 self._ignored_nameless_by_uuid: dict[str, set[str]] = {}
155 self.
_entry_entry.async_on_unload(
161 """Return True if the device_id has been seen since boot."""
163 device_id
in self._last_ibeacon_advertisement_by_unique_id
164 or device_id
in self._last_seen_by_group_id
169 self, service_info: bluetooth.BluetoothServiceInfoBleak
171 """Handle unavailable devices."""
172 address = service_info.address
174 for unique_id
in self._unique_ids_by_address[address]:
179 """Cancel unavailable tracking for an address."""
180 self._unavailable_trackers.pop(address)()
181 self._transient_seen_count.pop(address,
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, []):
195 self._unique_ids_by_address.pop(address)
196 self._group_ids_by_address.pop(address)
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)
204 """Ignore an address that does not follow the spec and any entities created by it."""
205 self._ignore_addresses.
add(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)
211 self._group_ids_by_address.pop(address)
212 self._unique_ids_by_address.pop(address)
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)}
221 self.
_dev_reg_dev_reg.async_remove_device(device.id)
222 self._last_ibeacon_advertisement_by_unique_id.pop(unique_id,
None)
228 service_info: bluetooth.BluetoothServiceInfoBleak,
229 ibeacon_advertisement: iBeaconAdvertisement,
231 """Switch to random mac tracking method when a group is using rotating mac addresses."""
232 self._group_ids_random_macs.
add(group_id)
234 self._unique_ids_by_group_id.pop(group_id)
235 self._addresses_by_group_id.pop(group_id)
237 group_id, service_info, ibeacon_advertisement
241 self, address: str, group_id: str, unique_id: str
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)
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)
253 service_info: bluetooth.BluetoothServiceInfoBleak,
254 change: bluetooth.BluetoothChange,
256 """Update from a bluetooth callback."""
257 if service_info.address
in self._ignore_addresses:
259 if not (ibeacon_advertisement := self.
_ibeacon_parser_ibeacon_parser.parse(service_info)):
262 uuid_str =
str(ibeacon_advertisement.uuid)
263 if uuid_str
in self._ignore_uuids:
266 _LOGGER.debug(
"update beacon %s", uuid_str)
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:
275 major_minor_by_uuid.add((major, minor))
276 group_id = f
"{uuid_str}_{major}_{minor}"
278 if group_id
in self._group_ids_random_macs:
280 group_id, service_info, ibeacon_advertisement
285 group_id, service_info, ibeacon_advertisement
292 service_info: bluetooth.BluetoothServiceInfoBleak,
293 ibeacon_advertisement: iBeaconAdvertisement,
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
307 service_info: bluetooth.BluetoothServiceInfoBleak,
308 ibeacon_advertisement: iBeaconAdvertisement,
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)
323 service_info.device.name
is None
324 or service_info.device.name.replace(
"-",
":")
325 == service_info.device.address
329 self._ignored_nameless_by_uuid.setdefault(uuid, set()).
add(address)
331 _LOGGER.debug(
"ignoring new beacon %s due to empty device name", unique_id)
334 previously_tracked = address
in self._unique_ids_by_address
335 self._last_ibeacon_advertisement_by_unique_id[unique_id] = ibeacon_advertisement
337 if address
not in self._unavailable_trackers:
338 self._unavailable_trackers[address] = bluetooth.async_track_unavailable(
342 if not previously_tracked
and new
and ibeacon_advertisement.transient:
346 self._transient_seen_count[address] = 1
355 if len(self._group_ids_by_address[address]) >= MAX_IDS:
362 if len(self._addresses_by_group_id[group_id]) >= MAX_IDS:
364 group_id, service_info, ibeacon_advertisement
369 self.
hasshass, unique_id, service_info, ibeacon_advertisement, new,
True
374 """Stop the Coordinator."""
375 for cancel
in self._unavailable_trackers.values():
377 self._unavailable_trackers.clear()
381 """Check for random mac groups that have not been seen in a while and mark them as unavailable."""
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))
401 latest_service_info := bluetooth.async_last_service_info(
402 self.
hasshass, service_info.address, connectable=
False
405 or now - latest_service_info.time > UNAVAILABLE_TIMEOUT
408 for group_id
in gone_unavailable:
409 self._unavailable_group_ids.
add(group_id)
414 """Check to see if the rssi has changed and update any devices.
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.
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
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
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)
442 ibeacon_advertisement,
449 service_info.rssi != ibeacon_advertisement.rssi
450 or service_info.source != ibeacon_advertisement.source
452 ibeacon_advertisement.source = service_info.source
453 ibeacon_advertisement.update_rssi(service_info.rssi)
457 ibeacon_advertisement,
461 self, hass: HomeAssistant, config_entry: ConfigEntry
463 """Restore ignored nameless beacons when the allowlist is updated."""
466 self.
_entry_entry.options.get(CONF_ALLOW_NAMELESS_UUIDS, [])
470 for address
in self._ignored_nameless_by_uuid.pop(uuid, set()):
472 "restoring nameless iBeacon %s from address %s", uuid, address
476 service_info := bluetooth.async_last_service_info(
477 self.
hasshass, address, connectable=
False
484 service_info, bluetooth.BluetoothChange.ADVERTISEMENT
489 """Update the Coordinator."""
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
499 if not (identifier := next(iter(device.identifiers),
None)):
501 unique_id = identifier[1]
503 if unique_id.count(
"_") == 3:
504 uuid, major, minor, address = unique_id.split(
"_")
505 group_id = f
"{uuid}_{major}_{minor}"
507 address, group_id, unique_id
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)
516 """Start the Coordinator."""
520 entry.async_on_unload(
521 bluetooth.async_register_callback(
526 manufacturer_id=APPLE_MFR_ID,
527 manufacturer_data_start=[IBEACON_FIRST_BYTE, IBEACON_SECOND_BYTE],
529 bluetooth.BluetoothScanningMode.PASSIVE,
533 entry.async_on_unload(
None _async_update_ibeacon(self, bluetooth.BluetoothServiceInfoBleak service_info, bluetooth.BluetoothChange change)
None _async_track_ibeacon_with_unique_address(self, str address, str group_id, str unique_id)
None _async_restore_from_registry(self)
None _async_update_ibeacon_with_unique_address(self, str group_id, bluetooth.BluetoothServiceInfoBleak service_info, iBeaconAdvertisement ibeacon_advertisement)
None async_config_entry_updated(self, HomeAssistant hass, ConfigEntry config_entry)
None _async_check_unavailable_groups_with_random_macs(self)
None _async_convert_random_mac_tracking(self, str group_id, bluetooth.BluetoothServiceInfoBleak service_info, iBeaconAdvertisement ibeacon_advertisement)
None _async_cancel_unavailable_tracker(self, str address)
None _async_ignore_address(self, str address)
None _async_update_rssi_and_transients(self)
None _async_handle_unavailable(self, bluetooth.BluetoothServiceInfoBleak service_info)
None _async_purge_untrackable_entities(self, set[str] unique_ids)
None _async_ignore_uuid(self, str uuid)
None __init__(self, HomeAssistant hass, ConfigEntry entry, DeviceRegistry registry)
bool async_device_id_seen(self, str device_id)
None _async_update(self, datetime _now)
None _async_update_ibeacon_with_random_mac(self, str group_id, bluetooth.BluetoothServiceInfoBleak service_info, iBeaconAdvertisement ibeacon_advertisement)
bool add(self, _T matcher)
web.Response get(self, web.Request request, str config_key)
str signal_unavailable(str unique_id)
str async_name(bluetooth.BluetoothServiceInfoBleak service_info, iBeaconAdvertisement ibeacon_advertisement, bool unique_address=False)
str make_short_address(str address)
None _async_dispatch_update(HomeAssistant hass, str device_id, bluetooth.BluetoothServiceInfoBleak service_info, iBeaconAdvertisement ibeacon_advertisement, bool new, bool unique_address)
str signal_seen(str unique_id)
None async_setup(self, list[Platform]|None pending_platforms=None)
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
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)