From 62942ba85c2a8528ce2871a2c6262230d6010eec Mon Sep 17 00:00:00 2001 From: David Buezas Date: Sat, 10 Dec 2022 21:19:52 +0100 Subject: [PATCH 1/6] Expose async_get_discovered_devices_and_advertisement_data_by_address from the bluetooth api. --- homeassistant/components/bluetooth/api.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 582370ffbda92..9053d83cb86fa 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: from bleak.backends.device import BLEDevice + from bleak.backends.scanner import AdvertisementData def _get_manager(hass: HomeAssistant) -> BluetoothManager: @@ -93,6 +94,20 @@ def async_ble_device_from_address( return _get_manager(hass).async_ble_device_from_address(address, connectable) +@hass_callback +def async_get_discovered_devices_and_advertisement_data_by_address( + hass: HomeAssistant, address: str, connectable: bool = True +) -> list[tuple[BLEDevice, AdvertisementData]]: + """Return BLEDevice for an address if its present.""" + if DATA_MANAGER not in hass.data: + return [] + return _get_manager( + hass + ).async_get_discovered_devices_and_advertisement_data_by_address( + address, connectable + ) + + @hass_callback def async_address_present( hass: HomeAssistant, address: str, connectable: bool = True From 54b0e8e35efab4513869917648192ffb6eabb6cf Mon Sep 17 00:00:00 2001 From: David Buezas Date: Sat, 10 Dec 2022 21:53:10 +0100 Subject: [PATCH 2/6] Corrected function comment --- homeassistant/components/bluetooth/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 9053d83cb86fa..db453d24e16ef 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -98,7 +98,7 @@ def async_ble_device_from_address( def async_get_discovered_devices_and_advertisement_data_by_address( hass: HomeAssistant, address: str, connectable: bool = True ) -> list[tuple[BLEDevice, AdvertisementData]]: - """Return BLEDevice for an address if its present.""" + """Return all discovered [BLEDevice, AdvertisementData] tuples for an address.""" if DATA_MANAGER not in hass.data: return [] return _get_manager( From ebffd5b5ee14d31bbd62e17826623a1b7931e651 Mon Sep 17 00:00:00 2001 From: David Buezas Date: Fri, 16 Dec 2022 17:31:58 +0100 Subject: [PATCH 3/6] Remove test for existance of DATA_MANAGER in hass.data Co-authored-by: J. Nick Koston --- homeassistant/components/bluetooth/api.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index db453d24e16ef..07350469803b4 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -99,8 +99,6 @@ def async_get_discovered_devices_and_advertisement_data_by_address( hass: HomeAssistant, address: str, connectable: bool = True ) -> list[tuple[BLEDevice, AdvertisementData]]: """Return all discovered [BLEDevice, AdvertisementData] tuples for an address.""" - if DATA_MANAGER not in hass.data: - return [] return _get_manager( hass ).async_get_discovered_devices_and_advertisement_data_by_address( From 8e6622a4b0aa347eff76a692e7cceb240a303f0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Jan 2023 10:28:13 -1000 Subject: [PATCH 4/6] rename async_get_scanner_discovered_devices_and_advertisement_data_by_address -> async_scanner_devices_by_address and wrap up the result in a dataclass --- .../components/bluetooth/__init__.py | 5 +- homeassistant/components/bluetooth/api.py | 15 +-- .../components/bluetooth/base_scanner.py | 10 ++ homeassistant/components/bluetooth/manager.py | 27 ++-- homeassistant/components/bluetooth/models.py | 1 - .../components/bluetooth/wrappers.py | 47 +++---- tests/components/bluetooth/test_api.py | 125 +++++++++++++++++- 7 files changed, 172 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index d0a69bfe37951..add7dad1a1fb6 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -58,9 +58,10 @@ async_register_scanner, async_scanner_by_source, async_scanner_count, + async_scanner_devices_by_address, async_track_unavailable, ) -from .base_scanner import BaseHaRemoteScanner, BaseHaScanner +from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice from .const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_ADAPTER, @@ -99,6 +100,7 @@ "async_track_unavailable", "async_scanner_by_source", "async_scanner_count", + "async_scanner_devices_by_address", "BaseHaScanner", "BaseHaRemoteScanner", "BluetoothCallbackMatcher", @@ -107,6 +109,7 @@ "BluetoothServiceInfoBleak", "BluetoothScanningMode", "BluetoothCallback", + "BluetoothScannerDevice", "HaBluetoothConnector", "SOURCE_LOCAL", "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 151a3b93eec77..6c232e2a42cfc 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -13,7 +13,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback -from .base_scanner import BaseHaScanner +from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import DATA_MANAGER from .manager import BluetoothManager from .match import BluetoothCallbackMatcher @@ -27,7 +27,6 @@ if TYPE_CHECKING: from bleak.backends.device import BLEDevice - from bleak.backends.scanner import AdvertisementData def _get_manager(hass: HomeAssistant) -> BluetoothManager: @@ -95,15 +94,11 @@ def async_ble_device_from_address( @hass_callback -def async_get_discovered_devices_and_advertisement_data_by_address( +def async_scanner_devices_by_address( hass: HomeAssistant, address: str, connectable: bool = True -) -> list[tuple[BLEDevice, AdvertisementData]]: - """Return all discovered [BLEDevice, AdvertisementData] tuples for an address.""" - return _get_manager( - hass - ).async_get_discovered_devices_and_advertisement_data_by_address( - address, connectable - ) +) -> list[BluetoothScannerDevice]: + """Return all discovered BluetoothScannerDevice for an address.""" + return _get_manager(hass).async_scanner_devices_by_address(address, connectable) @hass_callback diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index b4c88260591a9..c5a21697b7ec2 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Generator from contextlib import contextmanager +from dataclasses import dataclass import datetime from datetime import timedelta import logging @@ -39,6 +40,15 @@ _LOGGER = logging.getLogger(__name__) +@dataclass +class BluetoothScannerDevice: + """Data for a bluetooth device from a given scanner.""" + + scanner: BaseHaScanner + ble_device: BLEDevice + advertisement: AdvertisementData + + class BaseHaScanner(ABC): """Base class for Ha Scanners.""" diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 748b685d866b3..913391e898910 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -29,7 +29,7 @@ from homeassistant.util.dt import monotonic_time_coarse from .advertisement_tracker import AdvertisementTracker -from .base_scanner import BaseHaScanner +from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, UNAVAILABLE_TRACK_SECONDS, @@ -217,19 +217,20 @@ def async_stop(self, event: Event) -> None: uninstall_multiple_bleak_catcher() @hass_callback - def async_get_scanner_discovered_devices_and_advertisement_data_by_address( + def async_scanner_devices_by_address( self, address: str, connectable: bool - ) -> list[tuple[BaseHaScanner, BLEDevice, AdvertisementData]]: - """Get scanner, devices, and advertisement_data by address.""" - types_ = (True,) if connectable else (True, False) - results: list[tuple[BaseHaScanner, BLEDevice, AdvertisementData]] = [] - for type_ in types_: - for scanner in self._get_scanners_by_type(type_): - if device_advertisement_data := scanner.discovered_devices_and_advertisement_data.get( - address - ): - results.append((scanner, *device_advertisement_data)) - return results + ) -> list[BluetoothScannerDevice]: + """Get BluetoothScannerDevice by address.""" + scanners = self._get_scanners_by_type(True) + if not connectable: + scanners.extend(self._get_scanners_by_type(False)) + return [ + BluetoothScannerDevice( + scanner, *scanner.discovered_devices_and_advertisement_data[address] + ) + for scanner in scanners + if address in scanner.discovered_devices_and_advertisement_data + ] @hass_callback def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 58067a467ce39..40ac86de6077c 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -12,7 +12,6 @@ from homeassistant.util.dt import monotonic_time_coarse if TYPE_CHECKING: - from .manager import BluetoothManager diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index a2c417ca38259..b8393b76421fa 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -12,11 +12,7 @@ from bleak import BleakClient, BleakError from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type from bleak.backends.device import BLEDevice -from bleak.backends.scanner import ( - AdvertisementData, - AdvertisementDataCallback, - BaseBleakScanner, -) +from bleak.backends.scanner import AdvertisementDataCallback, BaseBleakScanner from bleak_retry_connector import ( NO_RSSI_VALUE, ble_device_description, @@ -28,7 +24,7 @@ from homeassistant.helpers.frame import report from . import models -from .base_scanner import BaseHaScanner +from .base_scanner import BaseHaScanner, BluetoothScannerDevice FILTER_UUIDS: Final = "UUIDs" _LOGGER = logging.getLogger(__name__) @@ -148,9 +144,7 @@ def __del__(self) -> None: def _rssi_sorter_with_connection_failure_penalty( - scanner_device_advertisement_data: tuple[ - BaseHaScanner, BLEDevice, AdvertisementData - ], + device: BluetoothScannerDevice, connection_failure_count: dict[BaseHaScanner, int], rssi_diff: int, ) -> float: @@ -165,9 +159,8 @@ def _rssi_sorter_with_connection_failure_penalty( best adapter twice before moving on to the next best adapter since the first failure may be a transient service resolution issue. """ - scanner, _, advertisement_data = scanner_device_advertisement_data - base_rssi = advertisement_data.rssi or NO_RSSI_VALUE - if connect_failures := connection_failure_count.get(scanner): + base_rssi = device.advertisement.rssi or NO_RSSI_VALUE + if connect_failures := connection_failure_count.get(device.scanner): if connect_failures > 1 and not rssi_diff: rssi_diff = 1 return base_rssi - (rssi_diff * connect_failures * 0.51) @@ -294,15 +287,10 @@ def _async_get_best_available_backend_and_device( that has a free connection slot. """ address = self.__address - scanner_device_advertisement_datas = manager.async_get_scanner_discovered_devices_and_advertisement_data_by_address( - address, True - ) - sorted_scanner_device_advertisement_datas = sorted( - scanner_device_advertisement_datas, - key=lambda scanner_device_advertisement_data: scanner_device_advertisement_data[ - 2 - ].rssi - or NO_RSSI_VALUE, + devices = manager.async_scanner_devices_by_address(self.__address, True) + sorted_devices = sorted( + devices, + key=lambda device: device.advertisement.rssi or NO_RSSI_VALUE, reverse=True, ) @@ -310,31 +298,28 @@ def _async_get_best_available_backend_and_device( # to prefer the adapter/scanner with the less failures so # we don't keep trying to connect with an adapter # that is failing - if ( - self.__connect_failures - and len(sorted_scanner_device_advertisement_datas) > 1 - ): + if self.__connect_failures and len(sorted_devices) > 1: # We use the rssi diff between to the top two # to adjust the rssi sorter so that each failure # will reduce the rssi sorter by the diff amount rssi_diff = ( - sorted_scanner_device_advertisement_datas[0][2].rssi - - sorted_scanner_device_advertisement_datas[1][2].rssi + sorted_devices[0].advertisement.rssi + - sorted_devices[1].advertisement.rssi ) adjusted_rssi_sorter = partial( _rssi_sorter_with_connection_failure_penalty, connection_failure_count=self.__connect_failures, rssi_diff=rssi_diff, ) - sorted_scanner_device_advertisement_datas = sorted( - scanner_device_advertisement_datas, + sorted_devices = sorted( + devices, key=adjusted_rssi_sorter, reverse=True, ) - for (scanner, ble_device, _) in sorted_scanner_device_advertisement_datas: + for device in sorted_devices: if backend := self._async_get_backend_for_ble_device( - manager, scanner, ble_device + manager, device.scanner, device.ble_device ): return backend diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index acb09c22ba73c..c875710d8e585 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -1,10 +1,18 @@ """Tests for the Bluetooth integration API.""" +from bleak.backends.scanner import AdvertisementData, BLEDevice + from homeassistant.components import bluetooth -from homeassistant.components.bluetooth import async_scanner_by_source +from homeassistant.components.bluetooth import ( + BaseHaRemoteScanner, + BaseHaScanner, + HaBluetoothConnector, + async_scanner_by_source, + async_scanner_devices_by_address, +) -from . import FakeScanner +from . import FakeScanner, MockBleakClient, _get_manager, generate_advertisement_data async def test_scanner_by_source(hass, enable_bluetooth): @@ -16,3 +24,116 @@ async def test_scanner_by_source(hass, enable_bluetooth): assert async_scanner_by_source(hass, "hci2") is hci2_scanner cancel_hci2() assert async_scanner_by_source(hass, "hci2") is None + + +async def test_async_scanner_devices_by_address_connectable(hass, enable_bluetooth): + """Test getting scanner devices by address with connectable devices.""" + manager = _get_manager() + + class FakeInjectableScanner(BaseHaRemoteScanner): + def inject_advertisement( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + ) + + new_info_callback = manager.scanner_adv_received + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeInjectableScanner( + hass, "esp32", "esp32", new_info_callback, connector, False + ) + unsetup = scanner.async_setup() + cancel = manager.async_register_scanner(scanner, True) + switchbot_device = BLEDevice( + "44:44:33:11:23:45", + "wohand", + {}, + rssi=-100, + ) + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], + service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + scanner.inject_advertisement(switchbot_device, switchbot_device_adv) + assert async_scanner_devices_by_address( + hass, switchbot_device.address, connectable=True + ) == async_scanner_devices_by_address(hass, "44:44:33:11:23:45", connectable=False) + devices = async_scanner_devices_by_address( + hass, switchbot_device.address, connectable=False + ) + assert len(devices) == 1 + assert devices[0].scanner == scanner + assert devices[0].ble_device.name == switchbot_device.name + assert devices[0].advertisement.local_name == switchbot_device_adv.local_name + unsetup() + cancel() + + +async def test_async_scanner_devices_by_address_non_connectable(hass, enable_bluetooth): + """Test getting scanner devices by address with non-connectable devices.""" + manager = _get_manager() + switchbot_device = BLEDevice( + "44:44:33:11:23:45", + "wohand", + {}, + rssi=-100, + ) + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], + service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + + class FakeStaticScanner(BaseHaScanner): + @property + def discovered_devices(self) -> list[BLEDevice]: + """Return a list of discovered devices.""" + return [switchbot_device] + + @property + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: + """Return a list of discovered devices and their advertisement data.""" + return {switchbot_device.address: (switchbot_device, switchbot_device_adv)} + + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeStaticScanner(hass, "esp32", "esp32", connector) + cancel = manager.async_register_scanner(scanner, False) + + assert scanner.discovered_devices_and_advertisement_data == { + switchbot_device.address: (switchbot_device, switchbot_device_adv) + } + + assert ( + async_scanner_devices_by_address( + hass, switchbot_device.address, connectable=True + ) + == [] + ) + devices = async_scanner_devices_by_address( + hass, switchbot_device.address, connectable=False + ) + assert len(devices) == 1 + assert devices[0].scanner == scanner + assert devices[0].ble_device.name == switchbot_device.name + assert devices[0].advertisement.local_name == switchbot_device_adv.local_name + cancel() From 2ec6cdcb146ee55d2533498a80e946ed71e90062 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Jan 2023 10:43:49 -1000 Subject: [PATCH 5/6] Update homeassistant/components/bluetooth/models.py --- homeassistant/components/bluetooth/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 40ac86de6077c..58067a467ce39 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -12,6 +12,7 @@ from homeassistant.util.dt import monotonic_time_coarse if TYPE_CHECKING: + from .manager import BluetoothManager From a87f8eab59328d33e70584edd89f57aeab63d604 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Jan 2023 13:19:11 -1000 Subject: [PATCH 6/6] tweak --- homeassistant/components/bluetooth/manager.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 1b8dc6a3a0000..91d658cdf5872 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -225,11 +225,13 @@ def async_scanner_devices_by_address( if not connectable: scanners.extend(self._get_scanners_by_type(False)) return [ - BluetoothScannerDevice( - scanner, *scanner.discovered_devices_and_advertisement_data[address] - ) + BluetoothScannerDevice(scanner, *device_adv) for scanner in scanners - if address in scanner.discovered_devices_and_advertisement_data + if ( + device_adv := scanner.discovered_devices_and_advertisement_data.get( + address + ) + ) ] @hass_callback