From e15f2e050e7afadbb19d32973104e4e2f5a172ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Oct 2022 03:21:30 -0500 Subject: [PATCH] Update ibeacon-ble to 1.0.1 (#80785) --- homeassistant/components/ibeacon/__init__.py | 2 +- homeassistant/components/ibeacon/const.py | 13 ++++ .../components/ibeacon/coordinator.py | 63 ++++++++++++------ .../components/ibeacon/manifest.json | 2 +- homeassistant/components/ibeacon/sensor.py | 10 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ibeacon/__init__.py | 16 ++++- tests/components/ibeacon/test_coordinator.py | 65 +++++++++++++++++-- 9 files changed, 144 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/ibeacon/__init__.py b/homeassistant/components/ibeacon/__init__.py index 2c191211910..02a19ef6332 100644 --- a/homeassistant/components/ibeacon/__init__.py +++ b/homeassistant/components/ibeacon/__init__.py @@ -13,7 +13,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Bluetooth LE Tracker from a config entry.""" coordinator = hass.data[DOMAIN] = IBeaconCoordinator(hass, entry, async_get(hass)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - coordinator.async_start() + await coordinator.async_start() return True diff --git a/homeassistant/components/ibeacon/const.py b/homeassistant/components/ibeacon/const.py index 7d1ab15da0a..19b3a6f6599 100644 --- a/homeassistant/components/ibeacon/const.py +++ b/homeassistant/components/ibeacon/const.py @@ -2,6 +2,9 @@ from datetime import timedelta +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.const import Platform DOMAIN = "ibeacon" @@ -31,5 +34,15 @@ MAX_IDS = 10 # we will add it to the ignore list since its garbage data. MAX_IDS_PER_UUID = 50 +# Number of times a beacon must be seen before it is added to the system +# This is to prevent devices that are just passing by from being added +# to the system. +MIN_SEEN_TRANSIENT_NEW = ( + round( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS / UPDATE_INTERVAL.total_seconds() + ) + + 1 +) + 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 9979cdf4fa8..33b33c56ed0 100644 --- a/homeassistant/components/ibeacon/coordinator.py +++ b/homeassistant/components/ibeacon/coordinator.py @@ -9,8 +9,7 @@ from ibeacon_ble import ( IBEACON_FIRST_BYTE, IBEACON_SECOND_BYTE, iBeaconAdvertisement, - is_ibeacon_service_info, - parse, + iBeaconParser, ) from homeassistant.components import bluetooth @@ -27,6 +26,7 @@ from .const import ( DOMAIN, MAX_IDS, MAX_IDS_PER_UUID, + MIN_SEEN_TRANSIENT_NEW, SIGNAL_IBEACON_DEVICE_NEW, SIGNAL_IBEACON_DEVICE_SEEN, SIGNAL_IBEACON_DEVICE_UNAVAILABLE, @@ -111,6 +111,7 @@ class IBeaconCoordinator: self.hass = hass self._entry = entry self._dev_reg = registry + self._ibeacon_parser = iBeaconParser() # iBeacon devices that do not follow the spec # and broadcast custom data in the major and minor fields @@ -125,6 +126,7 @@ class IBeaconCoordinator: self._last_ibeacon_advertisement_by_unique_id: dict[ str, iBeaconAdvertisement ] = {} + self._transient_seen_count: dict[str, int] = {} self._group_ids_by_address: dict[str, set[str]] = {} self._unique_ids_by_address: dict[str, set[str]] = {} self._unique_ids_by_group_id: dict[str, set[str]] = {} @@ -161,6 +163,7 @@ class IBeaconCoordinator: def _async_cancel_unavailable_tracker(self, address: str) -> None: """Cancel unavailable tracking for an address.""" self._unavailable_trackers.pop(address)() + self._transient_seen_count.pop(address, None) @callback def _async_ignore_uuid(self, uuid: str) -> None: @@ -236,7 +239,7 @@ class IBeaconCoordinator: """Update from a bluetooth callback.""" if service_info.address in self._ignore_addresses: return - if not (ibeacon_advertisement := parse(service_info)): + if not (ibeacon_advertisement := self._ibeacon_parser.parse(service_info)): return uuid_str = str(ibeacon_advertisement.uuid) @@ -297,12 +300,21 @@ class IBeaconCoordinator: or service_info.device.name.replace("-", ":") == service_info.device.address ): return + previously_tracked = address in self._unique_ids_by_address self._last_ibeacon_advertisement_by_unique_id[unique_id] = ibeacon_advertisement self._async_track_ibeacon_with_unique_address(address, group_id, unique_id) if address not in self._unavailable_trackers: self._unavailable_trackers[address] = bluetooth.async_track_unavailable( self.hass, self._async_handle_unavailable, address ) + + if not previously_tracked and new and ibeacon_advertisement.transient: + # Do not create a new tracker right away for transient devices + # If they keep advertising, we will create entities for them + # once _async_update_rssi_and_transients has seen them enough times + self._transient_seen_count[address] = 1 + return + # Some manufacturers violate the spec and flood us with random # data (sometimes its temperature data). # @@ -349,23 +361,42 @@ class IBeaconCoordinator: async_dispatcher_send(self.hass, signal_unavailable(group_id)) @callback - def _async_update_rssi(self) -> None: + def _async_update_rssi_and_transients(self) -> None: """Check to see if the rssi has changed and update any devices. We don't callback on RSSI changes so we need to check them here and send them over the dispatcher periodically to ensure the distance calculation is update. + + If the transient flag is set we also need to check to see + if the device is still transmitting and increment the counter """ for ( unique_id, ibeacon_advertisement, ) in self._last_ibeacon_advertisement_by_unique_id.items(): address = unique_id.split("_")[-1] - if ( - service_info := bluetooth.async_last_service_info( - self.hass, address, connectable=False - ) - ) and service_info.rssi != ibeacon_advertisement.rssi: + service_info = bluetooth.async_last_service_info( + self.hass, address, connectable=False + ) + if not service_info: + continue + + if address in self._transient_seen_count: + self._transient_seen_count[address] += 1 + if self._transient_seen_count[address] == MIN_SEEN_TRANSIENT_NEW: + self._transient_seen_count.pop(address) + _async_dispatch_update( + self.hass, + unique_id, + service_info, + ibeacon_advertisement, + True, + True, + ) + continue + + if service_info.rssi != ibeacon_advertisement.rssi: ibeacon_advertisement.update_rssi(service_info.rssi) async_dispatcher_send( self.hass, @@ -377,7 +408,7 @@ class IBeaconCoordinator: def _async_update(self, _now: datetime) -> None: """Update the Coordinator.""" self._async_check_unavailable_groups_with_random_macs() - self._async_update_rssi() + self._async_update_rssi_and_transients() @callback def _async_restore_from_registry(self) -> None: @@ -403,9 +434,9 @@ class IBeaconCoordinator: group_id = f"{uuid}_{major}_{minor}" self._group_ids_random_macs.add(group_id) - @callback - def async_start(self) -> None: + async def async_start(self) -> None: """Start the Coordinator.""" + await self._ibeacon_parser.async_setup() self._async_restore_from_registry() entry = self._entry entry.async_on_unload( @@ -421,14 +452,6 @@ class IBeaconCoordinator: ) ) entry.async_on_unload(self._async_stop) - # Replay any that are already there. - for service_info in bluetooth.async_discovered_service_info( - self.hass, connectable=False - ): - if is_ibeacon_service_info(service_info): - self._async_update_ibeacon( - service_info, bluetooth.BluetoothChange.ADVERTISEMENT - ) entry.async_on_unload( async_track_time_interval(self.hass, self._async_update, UPDATE_INTERVAL) ) diff --git a/homeassistant/components/ibeacon/manifest.json b/homeassistant/components/ibeacon/manifest.json index 3df2b9e000d..ade53491a4c 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.4"], + "requirements": ["ibeacon_ble==1.0.1"], "codeowners": ["@bdraco"], "iot_class": "local_push", "loggers": ["bleak"], diff --git a/homeassistant/components/ibeacon/sensor.py b/homeassistant/components/ibeacon/sensor.py index 4684efdf142..32c17957b60 100644 --- a/homeassistant/components/ibeacon/sensor.py +++ b/homeassistant/components/ibeacon/sensor.py @@ -27,7 +27,7 @@ from .entity import IBeaconEntity class IBeaconRequiredKeysMixin: """Mixin for required keys.""" - value_fn: Callable[[iBeaconAdvertisement], int | None] + value_fn: Callable[[iBeaconAdvertisement], str | int | None] @dataclass @@ -63,6 +63,12 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, ), + IBeaconSensorEntityDescription( + key="vendor", + name="Vendor", + entity_registry_enabled_default=False, + value_fn=lambda ibeacon_advertisement: ibeacon_advertisement.vendor, + ), ) @@ -132,6 +138,6 @@ class IBeaconSensorEntity(IBeaconEntity, SensorEntity): self.async_write_ha_state() @property - def native_value(self) -> int | None: + def native_value(self) -> str | int | None: """Return the state of the sensor.""" return self.entity_description.value_fn(self._ibeacon_advertisement) diff --git a/requirements_all.txt b/requirements_all.txt index 5efb7623eb2..78da40a9eea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -901,7 +901,7 @@ iammeter==0.1.7 iaqualink==0.5.0 # homeassistant.components.ibeacon -ibeacon_ble==0.7.4 +ibeacon_ble==1.0.1 # homeassistant.components.watson_tts ibm-watson==5.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b74b354936b..bfd614cec5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -672,7 +672,7 @@ hyperion-py==0.7.5 iaqualink==0.5.0 # homeassistant.components.ibeacon -ibeacon_ble==0.7.4 +ibeacon_ble==1.0.1 # homeassistant.components.ping icmplib==3.0 diff --git a/tests/components/ibeacon/__init__.py b/tests/components/ibeacon/__init__.py index f10bc65ed33..56d5eb78467 100644 --- a/tests/components/ibeacon/__init__.py +++ b/tests/components/ibeacon/__init__.py @@ -58,7 +58,21 @@ BEACON_RANDOM_ADDRESS_SERVICE_INFO = BluetoothServiceInfo( service_uuids=[], source="local", ) - +TESLA_TRANSIENT = BluetoothServiceInfo( + address="CC:CC:CC:CC:CC:CC", + rssi=-60, + name="S6da7c9389bd5452cC", + manufacturer_data={ + 76: b"\x02\x15t'\x8b\xda\xb6DE \x8f\x0cr\x0e\xaf\x05\x995\x00\x00[$\xc5" + }, + service_data={}, + service_uuids=[], + source="hci0", +) +TESLA_TRANSIENT_BLE_DEVICE = BLEDevice( + address="CC:CC:CC:CC:CC:CC", + name="S6da7c9389bd5452cC", +) FEASY_BEACON_BLE_DEVICE = BLEDevice( address="AA:BB:CC:DD:EE:FF", diff --git a/tests/components/ibeacon/test_coordinator.py b/tests/components/ibeacon/test_coordinator.py index 5ea19914ee4..25ce7154a37 100644 --- a/tests/components/ibeacon/test_coordinator.py +++ b/tests/components/ibeacon/test_coordinator.py @@ -2,16 +2,27 @@ from dataclasses import replace +from datetime import timedelta import pytest -from homeassistant.components.ibeacon.const import DOMAIN +from homeassistant.components.ibeacon.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.const import STATE_HOME from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from homeassistant.util import dt as dt_util -from . import BLUECHARM_BEACON_SERVICE_INFO, BLUECHARM_BEACON_SERVICE_INFO_DBUS +from . import ( + BLUECHARM_BEACON_SERVICE_INFO, + BLUECHARM_BEACON_SERVICE_INFO_DBUS, + TESLA_TRANSIENT, + TESLA_TRANSIENT_BLE_DEVICE, +) -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) @pytest.fixture(autouse=True) @@ -195,3 +206,49 @@ async def test_rotating_major_minor_and_mac_no_name(hass): await hass.async_block_till_done() assert len(hass.states.async_entity_ids("device_tracker")) == before_entity_count + + +async def test_ignore_transient_devices_unless_we_see_them_a_few_times(hass): + """Test we ignore transient devices unless we see them a few times.""" + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + before_entity_count = len(hass.states.async_entity_ids()) + inject_bluetooth_service_info( + hass, + TESLA_TRANSIENT, + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == before_entity_count + + with patch_all_discovered_devices([TESLA_TRANSIENT_BLE_DEVICE]): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=UPDATE_INTERVAL.total_seconds() * 2), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == before_entity_count + + for i in range(3, 17): + with patch_all_discovered_devices([TESLA_TRANSIENT_BLE_DEVICE]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=UPDATE_INTERVAL.total_seconds() * 2 * i), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) > before_entity_count + + assert hass.states.get("device_tracker.s6da7c9389bd5452cc_cccc").state == STATE_HOME + + await hass.config_entries.async_reload(entry.entry_id) + + await hass.async_block_till_done() + assert hass.states.get("device_tracker.s6da7c9389bd5452cc_cccc").state == STATE_HOME