diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index d0a69bfe37951f1e7f0e370a8f9d5066c47ce196..add7dad1a1fb6d0ac7f17552b99be8ec8c20a7c5 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -58,9 +58,10 @@ from .api import ( 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 @@ __all__ = [ "async_track_unavailable", "async_scanner_by_source", "async_scanner_count", + "async_scanner_devices_by_address", "BaseHaScanner", "BaseHaRemoteScanner", "BluetoothCallbackMatcher", @@ -107,6 +109,7 @@ __all__ = [ "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 cd6b4ac959b85d9fdfeb1c40ab22afc69ce1b4a1..6c232e2a42cfca351b004a66ab681c41992c0d26 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -13,7 +13,7 @@ from home_assistant_bluetooth import BluetoothServiceInfoBleak 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 @@ -93,6 +93,14 @@ def async_ble_device_from_address( return _get_manager(hass).async_ble_device_from_address(address, connectable) +@hass_callback +def async_scanner_devices_by_address( + hass: HomeAssistant, address: str, connectable: bool = True +) -> list[BluetoothScannerDevice]: + """Return all discovered BluetoothScannerDevice for an address.""" + return _get_manager(hass).async_scanner_devices_by_address(address, connectable) + + @hass_callback def async_address_present( hass: HomeAssistant, address: str, connectable: bool = True diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 8868b0a0883487a65cdd84475a538b7d2f4e8337..d9fcc750ed410dff977fb7a7860d8dcb855d81ba 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -4,6 +4,7 @@ from __future__ import annotations 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 @@ MONOTONIC_TIME: Final = monotonic_time_coarse _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 c863299d2064265d9199143216dcedc2530d968e..91d658cdf5872dca79bac1c43a45423d19533f93 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -29,7 +29,7 @@ from homeassistant.helpers.event import async_track_time_interval 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,18 +217,22 @@ class BluetoothManager: 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_): - devices_and_adv_data = scanner.discovered_devices_and_advertisement_data - if device_adv_data := devices_and_adv_data.get(address): - results.append((scanner, *device_adv_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, *device_adv) + for scanner in scanners + if ( + device_adv := scanner.discovered_devices_and_advertisement_data.get( + address + ) + ) + ] @hass_callback def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 4a1be63903fc2a9ecbaee4f4df596ae069f26e5d..6b463423c73dd2b8a845a8571b1e91d2552cacdd 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -12,11 +12,7 @@ from typing import TYPE_CHECKING, Any, Final 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.core import CALLBACK_TYPE, callback as hass_callback 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__) @@ -149,9 +145,7 @@ class HaBleakScannerWrapper(BaseBleakScanner): 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: @@ -168,9 +162,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) @@ -300,14 +293,10 @@ class HaBleakClientWrapper(BleakClient): that has a free connection slot. """ address = self.__address - scanner_device_advertisement_datas = manager.async_get_scanner_discovered_devices_and_advertisement_data_by_address( # noqa: E501 - 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, ) @@ -315,31 +304,28 @@ class HaBleakClientWrapper(BleakClient): # 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 acb09c22ba73c87380b3677fecead2cc68aaed23..c875710d8e585e367ad436a76a86a50949a73ce5 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()