Improve performance of Bluetooth device fallback (#79078)

This commit is contained in:
J. Nick Koston 2022-09-26 03:12:08 -10:00 committed by GitHub
parent a8b8b245d1
commit 75f6f9b5e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 155 additions and 24 deletions

View file

@ -235,6 +235,23 @@ class BluetoothManager:
self._cancel_unavailable_tracking.clear() self._cancel_unavailable_tracking.clear()
uninstall_multiple_bleak_catcher() uninstall_multiple_bleak_catcher()
async def async_get_devices_by_address(
self, address: str, connectable: bool
) -> list[BLEDevice]:
"""Get devices by address."""
types_ = (True,) if connectable else (True, False)
return [
device
for device in await asyncio.gather(
*(
scanner.async_get_device_by_address(address)
for type_ in types_
for scanner in self._get_scanners_by_type(type_)
)
)
if device is not None
]
@hass_callback @hass_callback
def async_all_discovered_devices(self, connectable: bool) -> Iterable[BLEDevice]: def async_all_discovered_devices(self, connectable: bool) -> Iterable[BLEDevice]:
"""Return all of discovered devices from all the scanners including duplicates.""" """Return all of discovered devices from all the scanners including duplicates."""

View file

@ -6,9 +6,9 @@
"after_dependencies": ["hassio"], "after_dependencies": ["hassio"],
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"bleak==0.18.0", "bleak==0.18.1",
"bleak-retry-connector==2.0.2", "bleak-retry-connector==2.1.0",
"bluetooth-adapters==0.5.1", "bluetooth-adapters==0.5.2",
"bluetooth-auto-recovery==0.3.3", "bluetooth-auto-recovery==0.3.3",
"dbus-fast==1.14.0" "dbus-fast==1.14.0"
], ],

View file

@ -91,6 +91,10 @@ class BaseHaScanner:
def discovered_devices(self) -> list[BLEDevice]: def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices.""" """Return a list of discovered devices."""
@abstractmethod
async def async_get_device_by_address(self, address: str) -> BLEDevice | None:
"""Get a device by address."""
async def async_diagnostics(self) -> dict[str, Any]: async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner.""" """Return diagnostic information about the scanner."""
return { return {
@ -256,7 +260,9 @@ class HaBleakClientWrapper(BleakClient):
async def connect(self, **kwargs: Any) -> bool: async def connect(self, **kwargs: Any) -> bool:
"""Connect to the specified GATT server.""" """Connect to the specified GATT server."""
if not self._backend: if not self._backend:
wrapped_backend = self._async_get_backend() wrapped_backend = (
self._async_get_backend() or await self._async_get_fallback_backend()
)
self._backend = wrapped_backend.client( self._backend = wrapped_backend.client(
await freshen_ble_device(wrapped_backend.device) await freshen_ble_device(wrapped_backend.device)
or wrapped_backend.device, or wrapped_backend.device,
@ -286,7 +292,7 @@ class HaBleakClientWrapper(BleakClient):
return _HaWrappedBleakBackend(ble_device, connector.client) return _HaWrappedBleakBackend(ble_device, connector.client)
@hass_callback @hass_callback
def _async_get_backend(self) -> _HaWrappedBleakBackend: def _async_get_backend(self) -> _HaWrappedBleakBackend | None:
"""Get the bleak backend for the given address.""" """Get the bleak backend for the given address."""
assert MANAGER is not None assert MANAGER is not None
address = self.__address address = self.__address
@ -297,6 +303,10 @@ class HaBleakClientWrapper(BleakClient):
if backend := self._async_get_backend_for_ble_device(ble_device): if backend := self._async_get_backend_for_ble_device(ble_device):
return backend return backend
return None
async def _async_get_fallback_backend(self) -> _HaWrappedBleakBackend:
"""Get a fallback backend for the given address."""
# #
# The preferred backend cannot currently connect the device # The preferred backend cannot currently connect the device
# because it is likely out of connection slots. # because it is likely out of connection slots.
@ -304,16 +314,11 @@ class HaBleakClientWrapper(BleakClient):
# We need to try all backends to find one that can # We need to try all backends to find one that can
# connect to the device. # connect to the device.
# #
# Currently we have to search all the discovered devices assert MANAGER is not None
# because the bleak API does not allow us to get the address = self.__address
# details for a specific device. devices = await MANAGER.async_get_devices_by_address(address, True)
#
for ble_device in sorted( for ble_device in sorted(
( devices,
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, key=lambda ble_device: ble_device.rssi or NO_RSSI_VALUE,
reverse=True, reverse=True,
): ):

View file

@ -17,6 +17,7 @@ from bleak.backends.bluezdbus.advertisement_monitor import OrPattern
from bleak.backends.bluezdbus.scanner import BlueZScannerArgs from bleak.backends.bluezdbus.scanner import BlueZScannerArgs
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import get_device_by_adapter
from dbus_fast import InvalidMessageError from dbus_fast import InvalidMessageError
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
@ -140,6 +141,16 @@ class HaScanner(BaseHaScanner):
"""Return a list of discovered devices.""" """Return a list of discovered devices."""
return self.scanner.discovered_devices return self.scanner.discovered_devices
async def async_get_device_by_address(self, address: str) -> BLEDevice | None:
"""Get a device by address."""
if platform.system() == "Linux":
return await get_device_by_adapter(address, self.adapter)
# We don't have a fast version of this for MacOS yet
return next(
(device for device in self.discovered_devices if device.address == address),
None,
)
async def async_diagnostics(self) -> dict[str, Any]: async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner.""" """Return diagnostic information about the scanner."""
base_diag = await super().async_diagnostics() base_diag = await super().async_diagnostics()

View file

@ -13,7 +13,7 @@ ORIGINAL_BLEAK_CLIENT = bleak.BleakClient
def install_multiple_bleak_catcher() -> None: def install_multiple_bleak_catcher() -> None:
"""Wrap the bleak classes to return the shared instance if multiple instances are detected.""" """Wrap the bleak classes to return the shared instance if multiple instances are detected."""
bleak.BleakScanner = HaBleakScannerWrapper # type: ignore[misc, assignment] bleak.BleakScanner = HaBleakScannerWrapper # type: ignore[misc, assignment]
bleak.BleakClient = HaBleakClientWrapper # type: ignore[misc, assignment] bleak.BleakClient = HaBleakClientWrapper # type: ignore[misc]
def uninstall_multiple_bleak_catcher() -> None: def uninstall_multiple_bleak_catcher() -> None:

View file

@ -1,4 +1,5 @@
"""Bluetooth scanner for esphome.""" """Bluetooth scanner for esphome."""
from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import datetime import datetime
@ -77,6 +78,10 @@ class ESPHomeScannner(BaseHaScanner):
"""Return a list of discovered devices.""" """Return a list of discovered devices."""
return list(self._discovered_devices.values()) return list(self._discovered_devices.values())
async def async_get_device_by_address(self, address: str) -> BLEDevice | None:
"""Get a device by address."""
return self._discovered_devices.get(address)
@callback @callback
def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None: def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None:
"""Call the registered callback.""" """Call the registered callback."""

View file

@ -10,9 +10,9 @@ atomicwrites-homeassistant==1.4.1
attrs==21.2.0 attrs==21.2.0
awesomeversion==22.9.0 awesomeversion==22.9.0
bcrypt==3.1.7 bcrypt==3.1.7
bleak-retry-connector==2.0.2 bleak-retry-connector==2.1.0
bleak==0.18.0 bleak==0.18.1
bluetooth-adapters==0.5.1 bluetooth-adapters==0.5.2
bluetooth-auto-recovery==0.3.3 bluetooth-auto-recovery==0.3.3
certifi>=2021.5.30 certifi>=2021.5.30
ciso8601==2.2.0 ciso8601==2.2.0

View file

@ -410,10 +410,10 @@ bimmer_connected==0.10.4
bizkaibus==0.1.1 bizkaibus==0.1.1
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bleak-retry-connector==2.0.2 bleak-retry-connector==2.1.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bleak==0.18.0 bleak==0.18.1
# homeassistant.components.blebox # homeassistant.components.blebox
blebox_uniapi==2.0.2 blebox_uniapi==2.0.2
@ -435,7 +435,7 @@ bluemaestro-ble==0.2.0
# bluepy==1.3.0 # bluepy==1.3.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-adapters==0.5.1 bluetooth-adapters==0.5.2
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-auto-recovery==0.3.3 bluetooth-auto-recovery==0.3.3

View file

@ -331,10 +331,10 @@ bellows==0.33.1
bimmer_connected==0.10.4 bimmer_connected==0.10.4
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bleak-retry-connector==2.0.2 bleak-retry-connector==2.1.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bleak==0.18.0 bleak==0.18.1
# homeassistant.components.blebox # homeassistant.components.blebox
blebox_uniapi==2.0.2 blebox_uniapi==2.0.2
@ -346,7 +346,7 @@ blinkpy==0.19.2
bluemaestro-ble==0.2.0 bluemaestro-ble==0.2.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-adapters==0.5.1 bluetooth-adapters==0.5.2
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-auto-recovery==0.3.3 bluetooth-auto-recovery==0.3.3

View file

@ -1,4 +1,5 @@
"""Tests for the Bluetooth integration models.""" """Tests for the Bluetooth integration models."""
from __future__ import annotations
from unittest.mock import patch from unittest.mock import patch
@ -197,6 +198,12 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
"""Return a list of discovered devices.""" """Return a list of discovered devices."""
return [switchbot_proxy_device_has_connection_slot] return [switchbot_proxy_device_has_connection_slot]
async def async_get_device_by_address(self, address: str) -> BLEDevice | None:
"""Return a list of discovered devices."""
if address == switchbot_proxy_device_has_connection_slot.address:
return switchbot_proxy_device_has_connection_slot
return None
scanner = FakeScanner() scanner = FakeScanner()
cancel = manager.async_register_scanner(scanner, True) cancel = manager.async_register_scanner(scanner, True)
assert manager.async_discovered_devices(True) == [ assert manager.async_discovered_devices(True) == [
@ -210,3 +217,89 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
client.set_disconnected_callback(lambda client: None) client.set_disconnected_callback(lambda client: None)
await client.disconnect() await client.disconnect()
cancel() cancel()
async def test_ble_device_with_proxy_client_out_of_connections_uses_best_available_macos(
hass, enable_bluetooth, macos_adapter
):
"""Test we switch to the next available proxy when one runs out of connections on MacOS."""
manager = _get_manager()
switchbot_proxy_device_no_connection_slot = BLEDevice(
"44:44:33:11:23:45",
"wohand_no_connection_slot",
{
"connector": HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
),
"path": "/org/bluez/hci0/dev_44_44_33_11_23_45",
},
rssi=-30,
)
switchbot_proxy_device_no_connection_slot.metadata["delegate"] = 0
switchbot_proxy_device_has_connection_slot = BLEDevice(
"44:44:33:11:23:45",
"wohand_has_connection_slot",
{
"connector": HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: True
),
"path": "/org/bluez/hci0/dev_44_44_33_11_23_45",
},
rssi=-40,
)
switchbot_proxy_device_has_connection_slot.metadata["delegate"] = 0
switchbot_device = BLEDevice(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device.metadata["delegate"] = 0
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]
async def async_get_device_by_address(self, address: str) -> BLEDevice | None:
"""Return a list of discovered devices."""
if address == switchbot_proxy_device_has_connection_slot.address:
return switchbot_proxy_device_has_connection_slot
return None
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.get_platform_client_backend_type"):
await client.connect()
assert client.is_connected is True
client.set_disconnected_callback(lambda client: None)
await client.disconnect()
cancel()