From 1b144c0e4dd683e3b47668a89da5eb6da4ae5e08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Sep 2022 15:09:28 -1000 Subject: [PATCH] Update to bleak 0.18.0 (#79008) --- .../components/bluetooth/__init__.py | 4 + homeassistant/components/bluetooth/const.py | 3 + homeassistant/components/bluetooth/manager.py | 3 +- .../components/bluetooth/manifest.json | 6 +- homeassistant/components/bluetooth/models.py | 149 ++++++++++-- homeassistant/components/bluetooth/usage.py | 2 +- homeassistant/package_constraints.txt | 6 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/bluetooth/conftest.py | 2 +- tests/components/bluetooth/test_models.py | 212 ++++++++++++++++++ tests/components/bluetooth/test_usage.py | 4 - 12 files changed, 371 insertions(+), 32 deletions(-) create mode 100644 tests/components/bluetooth/test_models.py diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 24eab5c9a5c..2afb638b230 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -52,6 +52,7 @@ from .models import ( BluetoothServiceInfo, BluetoothServiceInfoBleak, HaBleakScannerWrapper, + HaBluetoothConnector, ProcessAdvertisementCallback, ) from .scanner import HaScanner, ScannerStartError, create_bleak_scanner @@ -66,9 +67,11 @@ __all__ = [ "async_ble_device_from_address", "async_discovered_service_info", "async_get_scanner", + "async_last_service_info", "async_process_advertisements", "async_rediscover_address", "async_register_callback", + "async_register_scanner", "async_track_unavailable", "async_scanner_count", "BaseHaScanner", @@ -76,6 +79,7 @@ __all__ = [ "BluetoothServiceInfoBleak", "BluetoothScanningMode", "BluetoothCallback", + "HaBluetoothConnector", "SOURCE_LOCAL", ] diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index 891e6d8be82..4d4a096bb66 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -66,3 +66,6 @@ ADAPTER_ADDRESS: Final = "address" ADAPTER_SW_VERSION: Final = "sw_version" ADAPTER_HW_VERSION: Final = "hw_version" ADAPTER_PASSIVE_SCAN: Final = "passive_scan" + + +NO_RSSI_VALUE: Final = -1000 diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 06eb71b5a71..a7d1e141b3d 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -24,6 +24,7 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( ADAPTER_ADDRESS, ADAPTER_PASSIVE_SCAN, + NO_RSSI_VALUE, STALE_ADVERTISEMENT_SECONDS, UNAVAILABLE_TRACK_SECONDS, AdapterDetails, @@ -65,7 +66,6 @@ APPLE_START_BYTES_WANTED: Final = { } RSSI_SWITCH_THRESHOLD = 6 -NO_RSSI_VALUE = -1000 _LOGGER = logging.getLogger(__name__) @@ -340,6 +340,7 @@ class BluetoothManager: service_info.manufacturer_data != old_service_info.manufacturer_data or service_info.service_data != old_service_info.service_data or service_info.service_uuids != old_service_info.service_uuids + or service_info.name != old_service_info.name ): return diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 7a7cfbae007..dc4e1f05656 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -6,11 +6,11 @@ "after_dependencies": ["hassio"], "quality_scale": "internal", "requirements": [ - "bleak==0.17.0", - "bleak-retry-connector==1.17.1", + "bleak==0.18.0", + "bleak-retry-connector==1.17.3", "bluetooth-adapters==0.5.1", "bluetooth-auto-recovery==0.3.3", - "dbus-fast==1.5.1" + "dbus-fast==1.7.0" ], "codeowners": ["@bdraco"], "config_flow": true, diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 6c70633f597..100d9f69c03 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -11,17 +11,21 @@ import logging 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_retry_connector import freshen_ble_device -from homeassistant.core import CALLBACK_TYPE +from homeassistant.core import CALLBACK_TYPE, callback as hass_callback from homeassistant.helpers.frame import report from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from .const import NO_RSSI_VALUE + if TYPE_CHECKING: from .manager import BluetoothManager @@ -62,6 +66,23 @@ BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] +@dataclass +class HaBluetoothConnector: + """Data for how to connect a BLEDevice from a given scanner.""" + + client: type[BaseBleakClient] + source: str + can_connect: Callable[[], bool] + + +@dataclass +class _HaWrappedBleakBackend: + """Wrap bleak backend to make it usable by Home Assistant.""" + + device: BLEDevice + client: type[BaseBleakClient] + + class BaseHaScanner: """Base class for Ha Scanners.""" @@ -109,6 +130,12 @@ class HaBleakScannerWrapper(BaseBleakScanner): detection_callback=detection_callback, service_uuids=service_uuids or [] ) + @classmethod + async def discover(cls, timeout: float = 5.0, **kwargs: Any) -> list[BLEDevice]: + """Discover devices.""" + assert MANAGER is not None + return list(MANAGER.async_discovered_devices(True)) + async def stop(self, *args: Any, **kwargs: Any) -> None: """Stop scanning for devices.""" @@ -189,20 +216,116 @@ class HaBleakClientWrapper(BleakClient): when an integration does this. """ - def __init__( - self, address_or_ble_device: str | BLEDevice, *args: Any, **kwargs: Any + def __init__( # pylint: disable=super-init-not-called, keyword-arg-before-vararg + self, + address_or_ble_device: str | BLEDevice, + disconnected_callback: Callable[[BleakClient], None] | None = None, + *args: Any, + timeout: float = 10.0, + **kwargs: Any, ) -> None: """Initialize the BleakClient.""" if isinstance(address_or_ble_device, BLEDevice): - super().__init__(address_or_ble_device, *args, **kwargs) - return - report( - "attempted to call BleakClient with an address instead of a BLEDevice", - exclude_integrations={"bluetooth"}, - error_if_core=False, - ) + self.__address = address_or_ble_device.address + else: + report( + "attempted to call BleakClient with an address instead of a BLEDevice", + exclude_integrations={"bluetooth"}, + error_if_core=False, + ) + self.__address = address_or_ble_device + self.__disconnected_callback = disconnected_callback + self.__timeout = timeout + self._backend: BaseBleakClient | None = None # type: ignore[assignment] + + @property + def is_connected(self) -> bool: + """Return True if the client is connected to a device.""" + return self._backend is not None and self._backend.is_connected + + def set_disconnected_callback( + self, + callback: Callable[[BleakClient], None] | None, + **kwargs: Any, + ) -> None: + """Set the disconnect callback.""" + self.__disconnected_callback = callback + if self._backend: + self._backend.set_disconnected_callback(callback, **kwargs) # type: ignore[arg-type] + + async def connect(self, **kwargs: Any) -> bool: + """Connect to the specified GATT server.""" + if not self._backend: + wrapped_backend = self._async_get_backend() + self._backend = wrapped_backend.client( + await freshen_ble_device(wrapped_backend.device) + or wrapped_backend.device, + disconnected_callback=self.__disconnected_callback, + timeout=self.__timeout, + ) + return await super().connect(**kwargs) + + @hass_callback + def _async_get_backend_for_ble_device( + self, ble_device: BLEDevice + ) -> _HaWrappedBleakBackend | None: + """Get the backend for a BLEDevice.""" + details = ble_device.details + if not isinstance(details, dict) or "connector" not in details: + # If client is not defined in details + # its the client for this platform + cls = get_platform_client_backend_type() + return _HaWrappedBleakBackend(ble_device, cls) + + connector: HaBluetoothConnector = details["connector"] + # Make sure the backend can connect to the device + # as some backends have connection limits + if not connector.can_connect(): + return None + + return _HaWrappedBleakBackend(ble_device, connector.client) + + @hass_callback + def _async_get_backend(self) -> _HaWrappedBleakBackend: + """Get the bleak backend for the given address.""" assert MANAGER is not None - ble_device = MANAGER.async_ble_device_from_address(address_or_ble_device, True) + address = self.__address + ble_device = MANAGER.async_ble_device_from_address(address, True) if ble_device is None: - raise BleakError(f"No device found for address {address_or_ble_device}") - super().__init__(ble_device, *args, **kwargs) + raise BleakError(f"No device found for address {address}") + + if backend := self._async_get_backend_for_ble_device(ble_device): + return backend + + # + # The preferred backend cannot currently connect the device + # because it is likely out of connection slots. + # + # We need to try all backends to find one that can + # connect to the device. + # + # Currently we have to search all the discovered devices + # because the bleak API does not allow us to get the + # details for a specific device. + # + for ble_device in sorted( + ( + ble_device + for ble_device in MANAGER.async_all_discovered_devices(True) + if ble_device.address == address + ), + key=lambda ble_device: ble_device.rssi or NO_RSSI_VALUE, + reverse=True, + ): + if backend := self._async_get_backend_for_ble_device(ble_device): + return backend + + raise BleakError( + f"No backend with an available connection slot that can reach address {address} was found" + ) + + async def disconnect(self) -> bool: + """Disconnect from the device.""" + if self._backend is None: + return True + return await self._backend.disconnect() diff --git a/homeassistant/components/bluetooth/usage.py b/homeassistant/components/bluetooth/usage.py index d282ca7415b..23388c84302 100644 --- a/homeassistant/components/bluetooth/usage.py +++ b/homeassistant/components/bluetooth/usage.py @@ -13,7 +13,7 @@ ORIGINAL_BLEAK_CLIENT = bleak.BleakClient def install_multiple_bleak_catcher() -> None: """Wrap the bleak classes to return the shared instance if multiple instances are detected.""" bleak.BleakScanner = HaBleakScannerWrapper # type: ignore[misc, assignment] - bleak.BleakClient = HaBleakClientWrapper # type: ignore[misc] + bleak.BleakClient = HaBleakClientWrapper # type: ignore[misc, assignment] def uninstall_multiple_bleak_catcher() -> None: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 48eb026d30b..dcf8f415c3b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,14 +10,14 @@ atomicwrites-homeassistant==1.4.1 attrs==21.2.0 awesomeversion==22.9.0 bcrypt==3.1.7 -bleak-retry-connector==1.17.1 -bleak==0.17.0 +bleak-retry-connector==1.17.3 +bleak==0.18.0 bluetooth-adapters==0.5.1 bluetooth-auto-recovery==0.3.3 certifi>=2021.5.30 ciso8601==2.2.0 cryptography==37.0.4 -dbus-fast==1.5.1 +dbus-fast==1.7.0 fnvhash==0.1.0 hass-nabucasa==0.55.0 home-assistant-bluetooth==1.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6b05a4d21f8..f3cb09602d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -410,10 +410,10 @@ bimmer_connected==0.10.4 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==1.17.1 +bleak-retry-connector==1.17.3 # homeassistant.components.bluetooth -bleak==0.17.0 +bleak==0.18.0 # homeassistant.components.blebox blebox_uniapi==2.0.2 @@ -540,7 +540,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.5.1 +dbus-fast==1.7.0 # homeassistant.components.debugpy debugpy==1.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 679756d3400..881de4a5650 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -331,10 +331,10 @@ bellows==0.33.1 bimmer_connected==0.10.4 # homeassistant.components.bluetooth -bleak-retry-connector==1.17.1 +bleak-retry-connector==1.17.3 # homeassistant.components.bluetooth -bleak==0.17.0 +bleak==0.18.0 # homeassistant.components.blebox blebox_uniapi==2.0.2 @@ -417,7 +417,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.5.1 +dbus-fast==1.7.0 # homeassistant.components.debugpy debugpy==1.6.3 diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 4c78f063780..3d29b4cbbe1 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -56,7 +56,7 @@ def bluez_dbus_mock(): @pytest.fixture(name="macos_adapter") def macos_adapter(): """Fixture that mocks the macos adapter.""" - with patch( + with patch("bleak.get_platform_scanner_backend_type"), patch( "homeassistant.components.bluetooth.platform.system", return_value="Darwin" ), patch( "homeassistant.components.bluetooth.scanner.platform.system", diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py new file mode 100644 index 00000000000..2321a64e1e3 --- /dev/null +++ b/tests/components/bluetooth/test_models.py @@ -0,0 +1,212 @@ +"""Tests for the Bluetooth integration models.""" + +from unittest.mock import patch + +import bleak +from bleak import BleakClient, BleakError +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +import pytest + +from homeassistant.components.bluetooth.models import ( + BaseHaScanner, + HaBleakClientWrapper, + HaBleakScannerWrapper, + HaBluetoothConnector, +) + +from . import _get_manager, inject_advertisement, inject_advertisement_with_source + + +class MockBleakClient(BleakClient): + """Mock bleak client.""" + + def __init__(self, *args, **kwargs): + """Mock init.""" + super().__init__(*args, **kwargs) + self._device_path = "/dev/test" + + @property + def is_connected(self) -> bool: + """Mock connected.""" + return True + + async def connect(self, *args, **kwargs): + """Mock connect.""" + return True + + async def disconnect(self, *args, **kwargs): + """Mock disconnect.""" + pass + + async def get_services(self, *args, **kwargs): + """Mock get_services.""" + return [] + + +async def test_wrapped_bleak_scanner(hass, enable_bluetooth): + """Test wrapped bleak scanner dispatches calls as expected.""" + scanner = HaBleakScannerWrapper() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = AdvertisementData( + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + ) + inject_advertisement(hass, switchbot_device, switchbot_adv) + assert scanner.discovered_devices == [switchbot_device] + assert await scanner.discover() == [switchbot_device] + + +async def test_wrapped_bleak_client_raises_device_missing(hass, enable_bluetooth): + """Test wrapped bleak client dispatches calls as expected.""" + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + client = HaBleakClientWrapper(switchbot_device) + assert client.is_connected is False + with pytest.raises(bleak.BleakError): + await client.connect() + assert client.is_connected is False + await client.disconnect() + + +async def test_wrapped_bleak_client_set_disconnected_callback_before_connected( + hass, enable_bluetooth +): + """Test wrapped bleak client can set a disconnected callback before connected.""" + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + client = HaBleakClientWrapper(switchbot_device) + client.set_disconnected_callback(lambda client: None) + + +async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( + hass, enable_bluetooth, one_adapter +): + """Test wrapped bleak client can set a disconnected callback after connected.""" + switchbot_device = BLEDevice( + "44:44:33:11:23:45", "wohand", {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"} + ) + switchbot_adv = AdvertisementData( + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + ) + inject_advertisement(hass, switchbot_device, switchbot_adv) + client = HaBleakClientWrapper(switchbot_device) + with patch( + "bleak.backends.bluezdbus.client.BleakClientBlueZDBus.connect" + ) as connect: + await client.connect() + assert len(connect.mock_calls) == 1 + assert client._backend is not None + client.set_disconnected_callback(lambda client: None) + await client.disconnect() + + +async def test_ble_device_with_proxy_client_out_of_connections( + hass, enable_bluetooth, one_adapter +): + """Test we switch to the next available proxy when one runs out of connections.""" + manager = _get_manager() + + switchbot_proxy_device_no_connection_slot = BLEDevice( + "44:44:33:11:23:45", + "wohand", + { + "connector": HaBluetoothConnector( + MockBleakClient, "mock_bleak_client", lambda: False + ), + "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", + }, + rssi=-30, + ) + switchbot_adv = AdvertisementData( + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + ) + + inject_advertisement_with_source( + hass, switchbot_proxy_device_no_connection_slot, switchbot_adv, "esp32" + ) + + assert manager.async_discovered_devices(True) == [ + switchbot_proxy_device_no_connection_slot + ] + + client = HaBleakClientWrapper(switchbot_proxy_device_no_connection_slot) + with patch( + "bleak.backends.bluezdbus.client.BleakClientBlueZDBus.connect" + ), pytest.raises(BleakError): + await client.connect() + assert client.is_connected is False + client.set_disconnected_callback(lambda client: None) + await client.disconnect() + + +async def test_ble_device_with_proxy_client_out_of_connections_uses_best_available( + hass, enable_bluetooth, one_adapter +): + """Test we switch to the next available proxy when one runs out of connections.""" + manager = _get_manager() + + switchbot_proxy_device_no_connection_slot = BLEDevice( + "44:44:33:11:23:45", + "wohand", + { + "connector": HaBluetoothConnector( + MockBleakClient, "mock_bleak_client", lambda: False + ), + "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", + }, + rssi=-30, + ) + switchbot_proxy_device_has_connection_slot = BLEDevice( + "44:44:33:11:23:45", + "wohand", + { + "connector": HaBluetoothConnector( + MockBleakClient, "mock_bleak_client", lambda: True + ), + "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", + }, + rssi=-40, + ) + switchbot_device = BLEDevice( + "44:44:33:11:23:45", + "wohand", + {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"}, + rssi=-100, + ) + switchbot_adv = AdvertisementData( + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + ) + + inject_advertisement_with_source( + hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01" + ) + inject_advertisement_with_source( + hass, + switchbot_proxy_device_has_connection_slot, + switchbot_adv, + "esp32_has_connection_slot", + ) + inject_advertisement_with_source( + hass, + switchbot_proxy_device_no_connection_slot, + switchbot_adv, + "esp32_no_connection_slot", + ) + + class FakeScanner(BaseHaScanner): + @property + def discovered_devices(self) -> list[BLEDevice]: + """Return a list of discovered devices.""" + return [switchbot_proxy_device_has_connection_slot] + + scanner = FakeScanner() + cancel = manager.async_register_scanner(scanner, True) + assert manager.async_discovered_devices(True) == [ + switchbot_proxy_device_no_connection_slot + ] + + client = HaBleakClientWrapper(switchbot_proxy_device_no_connection_slot) + with patch("bleak.backends.bluezdbus.client.BleakClientBlueZDBus.connect"): + await client.connect() + assert client.is_connected is True + client.set_disconnected_callback(lambda client: None) + await client.disconnect() + cancel() diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index 3e35547d2f2..2c6bacfd4cb 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -5,7 +5,6 @@ from unittest.mock import patch import bleak from bleak.backends.device import BLEDevice -import pytest from homeassistant.components.bluetooth.models import ( HaBleakClientWrapper, @@ -57,9 +56,6 @@ async def test_bleak_client_reports_with_address(hass, enable_bluetooth, caplog) """Test we report when we pass an address to BleakClient.""" install_multiple_bleak_catcher() - with pytest.raises(bleak.BleakError): - instance = bleak.BleakClient("00:00:00:00:00:00") - with patch.object( _get_manager(), "async_ble_device_from_address",