diff --git a/homeassistant/components/ibeacon/coordinator.py b/homeassistant/components/ibeacon/coordinator.py index bab0a5b5655..d38f935cb77 100644 --- a/homeassistant/components/ibeacon/coordinator.py +++ b/homeassistant/components/ibeacon/coordinator.py @@ -354,7 +354,25 @@ class IBeaconCoordinator: for group_id in self._group_ids_random_macs if group_id not in self._unavailable_group_ids and (service_info := self._last_seen_by_group_id.get(group_id)) - and now - service_info.time > UNAVAILABLE_TIMEOUT + and ( + # We will not be callbacks for iBeacons with random macs + # that rotate infrequently since their advertisement data is + # does not change as the bluetooth.async_register_callback API + # suppresses callbacks for duplicate advertisements to avoid + # exposing integrations to the firehose of bluetooth advertisements. + # + # To solve this we need to ask for the latest service info for + # the address we last saw to get the latest timestamp. + # + # If there is no last service info for the address we know that + # the device is no longer advertising. + not ( + latest_service_info := bluetooth.async_last_service_info( + self.hass, service_info.address, connectable=False + ) + ) + or now - latest_service_info.time > UNAVAILABLE_TIMEOUT + ) ] for group_id in gone_unavailable: self._unavailable_group_ids.add(group_id) diff --git a/tests/components/ibeacon/test_device_tracker.py b/tests/components/ibeacon/test_device_tracker.py index b16144d7d90..2f86ccb9042 100644 --- a/tests/components/ibeacon/test_device_tracker.py +++ b/tests/components/ibeacon/test_device_tracker.py @@ -7,8 +7,17 @@ from unittest.mock import patch import pytest +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_ble_device_from_address, + async_last_service_info, +) from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS -from homeassistant.components.ibeacon.const import DOMAIN, UNAVAILABLE_TIMEOUT +from homeassistant.components.ibeacon.const import ( + DOMAIN, + UNAVAILABLE_TIMEOUT, + UPDATE_INTERVAL, +) from homeassistant.const import ( ATTR_FRIENDLY_NAME, STATE_HOME, @@ -27,6 +36,7 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, + inject_bluetooth_service_info_bleak, patch_all_discovered_devices, ) @@ -130,3 +140,108 @@ async def test_device_tracker_random_address(hass): tracker_attributes = tracker.attributes assert tracker.state == STATE_HOME assert tracker_attributes[ATTR_FRIENDLY_NAME] == "RandomAddress_1234" + + +async def test_device_tracker_random_address_infrequent_changes(hass): + """Test creating and updating device_tracker with a random mac that only changes once per day.""" + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + start_time = time.monotonic() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + for i in range(20): + inject_bluetooth_service_info( + hass, + replace( + BEACON_RANDOM_ADDRESS_SERVICE_INFO, address=f"AA:BB:CC:DD:EE:{i:02X}" + ), + ) + await hass.async_block_till_done() + + tracker = hass.states.get("device_tracker.randomaddress_1234") + tracker_attributes = tracker.attributes + assert tracker.state == STATE_HOME + assert tracker_attributes[ATTR_FRIENDLY_NAME] == "RandomAddress_1234" + + await hass.async_block_till_done() + with patch_all_discovered_devices([]), patch( + "homeassistant.components.ibeacon.coordinator.MONOTONIC_TIME", + return_value=start_time + UNAVAILABLE_TIMEOUT + 1, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TIMEOUT) + ) + await hass.async_block_till_done() + + tracker = hass.states.get("device_tracker.randomaddress_1234") + assert tracker.state == STATE_NOT_HOME + + inject_bluetooth_service_info( + hass, replace(BEACON_RANDOM_ADDRESS_SERVICE_INFO, address="AA:BB:CC:DD:EE:14") + ) + await hass.async_block_till_done() + + tracker = hass.states.get("device_tracker.randomaddress_1234") + tracker_attributes = tracker.attributes + assert tracker.state == STATE_HOME + assert tracker_attributes[ATTR_FRIENDLY_NAME] == "RandomAddress_1234" + + inject_bluetooth_service_info( + hass, replace(BEACON_RANDOM_ADDRESS_SERVICE_INFO, address="AA:BB:CC:DD:EE:14") + ) + device = async_ble_device_from_address(hass, "AA:BB:CC:DD:EE:14", False) + + with patch_all_discovered_devices([device]), patch( + "homeassistant.components.ibeacon.coordinator.MONOTONIC_TIME", + return_value=start_time + UPDATE_INTERVAL.total_seconds() + 1, + ): + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + tracker = hass.states.get("device_tracker.randomaddress_1234") + tracker_attributes = tracker.attributes + assert tracker.state == STATE_HOME + assert tracker_attributes[ATTR_FRIENDLY_NAME] == "RandomAddress_1234" + + one_day_future = start_time + 86400 + previous_service_info = async_last_service_info( + hass, "AA:BB:CC:DD:EE:14", connectable=False + ) + inject_bluetooth_service_info_bleak( + hass, + BluetoothServiceInfoBleak( + name="RandomAddress_1234", + address="AA:BB:CC:DD:EE:14", + rssi=-63, + service_data={}, + manufacturer_data={76: b"\x02\x15RandCharmBeacons\x0e\xfe\x13U\xc5"}, + service_uuids=[], + source="local", + time=one_day_future, + connectable=False, + device=device, + advertisement=previous_service_info.advertisement, + ), + ) + device = async_ble_device_from_address(hass, "AA:BB:CC:DD:EE:14", False) + assert ( + async_last_service_info(hass, "AA:BB:CC:DD:EE:14", connectable=False).time + == one_day_future + ) + + with patch_all_discovered_devices([device]), patch( + "homeassistant.components.ibeacon.coordinator.MONOTONIC_TIME", + return_value=start_time + UNAVAILABLE_TIMEOUT + 1, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TIMEOUT + 1) + ) + await hass.async_block_till_done() + + tracker = hass.states.get("device_tracker.randomaddress_1234") + tracker_attributes = tracker.attributes + assert tracker.state == STATE_HOME + assert tracker_attributes[ATTR_FRIENDLY_NAME] == "RandomAddress_1234"