diff --git a/homeassistant/components/ibeacon/const.py b/homeassistant/components/ibeacon/const.py index 9b7a5a81dd3..7d1ab15da0a 100644 --- a/homeassistant/components/ibeacon/const.py +++ b/homeassistant/components/ibeacon/const.py @@ -27,4 +27,9 @@ UPDATE_INTERVAL = timedelta(seconds=60) # we will add it to the ignore list since its garbage data. MAX_IDS = 10 +# If a device broadcasts this many major minors for the same uuid +# we will add it to the ignore list since its garbage data. +MAX_IDS_PER_UUID = 50 + CONF_IGNORE_ADDRESSES = "ignore_addresses" +CONF_IGNORE_UUIDS = "ignore_uuids" diff --git a/homeassistant/components/ibeacon/coordinator.py b/homeassistant/components/ibeacon/coordinator.py index 546b40c0c1b..2260624558e 100644 --- a/homeassistant/components/ibeacon/coordinator.py +++ b/homeassistant/components/ibeacon/coordinator.py @@ -23,8 +23,10 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( CONF_IGNORE_ADDRESSES, + CONF_IGNORE_UUIDS, DOMAIN, MAX_IDS, + MAX_IDS_PER_UUID, SIGNAL_IBEACON_DEVICE_NEW, SIGNAL_IBEACON_DEVICE_SEEN, SIGNAL_IBEACON_DEVICE_UNAVAILABLE, @@ -115,6 +117,9 @@ class IBeaconCoordinator: self._ignore_addresses: set[str] = set( entry.data.get(CONF_IGNORE_ADDRESSES, []) ) + # iBeacon devices that do not follow the spec + # and broadcast custom data in the major and minor fields + self._ignore_uuids: set[str] = set(entry.data.get(CONF_IGNORE_UUIDS, [])) # iBeacons with fixed MAC addresses self._last_ibeacon_advertisement_by_unique_id: dict[ @@ -131,6 +136,9 @@ class IBeaconCoordinator: self._last_seen_by_group_id: dict[str, bluetooth.BluetoothServiceInfoBleak] = {} self._unavailable_group_ids: set[str] = set() + # iBeacons with random MAC addresses, fixed UUID, random major/minor + self._major_minor_by_uuid: dict[str, set[tuple[int, int]]] = {} + @callback def _async_handle_unavailable( self, service_info: bluetooth.BluetoothServiceInfoBleak @@ -146,6 +154,25 @@ class IBeaconCoordinator: """Cancel unavailable tracking for an address.""" self._unavailable_trackers.pop(address)() + @callback + def _async_ignore_uuid(self, uuid: str) -> None: + """Ignore an UUID that does not follow the spec and any entities created by it.""" + self._ignore_uuids.add(uuid) + major_minor_by_uuid = self._major_minor_by_uuid.pop(uuid) + unique_ids_to_purge = set() + for major, minor in major_minor_by_uuid: + group_id = f"{uuid}_{major}_{minor}" + if unique_ids := self._unique_ids_by_group_id.pop(group_id, None): + unique_ids_to_purge.update(unique_ids) + for address in self._addresses_by_group_id.pop(group_id, []): + self._async_cancel_unavailable_tracker(address) + self._unique_ids_by_address.pop(address) + self._group_ids_by_address.pop(address) + self._async_purge_untrackable_entities(unique_ids_to_purge) + entry_data = self._entry.data + new_data = entry_data | {CONF_IGNORE_UUIDS: list(self._ignore_uuids)} + self.hass.config_entries.async_update_entry(self._entry, data=new_data) + @callback def _async_ignore_address(self, address: str) -> None: """Ignore an address that does not follow the spec and any entities created by it.""" @@ -203,7 +230,20 @@ class IBeaconCoordinator: return if not (ibeacon_advertisement := parse(service_info)): return - group_id = f"{ibeacon_advertisement.uuid}_{ibeacon_advertisement.major}_{ibeacon_advertisement.minor}" + + uuid_str = str(ibeacon_advertisement.uuid) + if uuid_str in self._ignore_uuids: + return + + major = ibeacon_advertisement.major + minor = ibeacon_advertisement.minor + major_minor_by_uuid = self._major_minor_by_uuid.setdefault(uuid_str, set()) + if len(major_minor_by_uuid) + 1 > MAX_IDS_PER_UUID: + self._async_ignore_uuid(uuid_str) + return + + major_minor_by_uuid.add((major, minor)) + group_id = f"{uuid_str}_{major}_{minor}" if group_id in self._group_ids_random_macs: self._async_update_ibeacon_with_random_mac( diff --git a/homeassistant/components/ibeacon/manifest.json b/homeassistant/components/ibeacon/manifest.json index 9cecb399281..7b4110a7fe4 100644 --- a/homeassistant/components/ibeacon/manifest.json +++ b/homeassistant/components/ibeacon/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/ibeacon", "dependencies": ["bluetooth"], "bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [2, 21] }], - "requirements": ["ibeacon_ble==0.7.1"], + "requirements": ["ibeacon_ble==0.7.2"], "codeowners": ["@bdraco"], "iot_class": "local_push", "loggers": ["bleak"], diff --git a/requirements_all.txt b/requirements_all.txt index 8e0d5571a26..8be2c2295b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -901,7 +901,7 @@ iammeter==0.1.7 iaqualink==0.4.1 # homeassistant.components.ibeacon -ibeacon_ble==0.7.1 +ibeacon_ble==0.7.2 # homeassistant.components.watson_tts ibm-watson==5.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7bb29b9dea..693cdf53d49 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -672,7 +672,7 @@ hyperion-py==0.7.5 iaqualink==0.4.1 # homeassistant.components.ibeacon -ibeacon_ble==0.7.1 +ibeacon_ble==0.7.2 # homeassistant.components.ping icmplib==3.0 diff --git a/tests/components/ibeacon/test_coordinator.py b/tests/components/ibeacon/test_coordinator.py index cb7e0bdefc8..5ea19914ee4 100644 --- a/tests/components/ibeacon/test_coordinator.py +++ b/tests/components/ibeacon/test_coordinator.py @@ -127,3 +127,71 @@ async def test_ignore_default_name(hass): ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == before_entity_count + + +async def test_rotating_major_minor_and_mac(hass): + """Test the different uuid, major, minor from many addresses removes all associated entities.""" + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + + before_entity_count = len(hass.states.async_entity_ids("device_tracker")) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + for i in range(100): + service_info = BluetoothServiceInfo( + name="BlueCharm_177999", + address=f"AA:BB:CC:DD:EE:{i:02X}", + rssi=-63, + service_data={}, + manufacturer_data={ + 76: b"\x02\x15BlueCharmBeacons" + + bytearray([i]) + + b"\xfe" + + bytearray([i]) + + b"U\xc5" + }, + service_uuids=[], + source="local", + ) + inject_bluetooth_service_info(hass, service_info) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids("device_tracker")) == before_entity_count + + +async def test_rotating_major_minor_and_mac_no_name(hass): + """Test no-name devices with different uuid, major, minor from many addresses removes all associated entities.""" + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + + before_entity_count = len(hass.states.async_entity_ids("device_tracker")) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + for i in range(51): + service_info = BluetoothServiceInfo( + name=f"AA:BB:CC:DD:EE:{i:02X}", + address=f"AA:BB:CC:DD:EE:{i:02X}", + rssi=-63, + service_data={}, + manufacturer_data={ + 76: b"\x02\x15BlueCharmBeacons" + + bytearray([i]) + + b"\xfe" + + bytearray([i]) + + b"U\xc5" + }, + service_uuids=[], + source="local", + ) + inject_bluetooth_service_info(hass, service_info) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids("device_tracker")) == before_entity_count