Try the next best adapter after a BLE connection fails (#84512)

* Try the next best adapter after a BLE connection fails

* add cover

* tweak

* tweak

* Update homeassistant/components/bluetooth/wrappers.py

* bump

* small tweak

* tweak logic
pull/84521/head
J. Nick Koston 2022-12-23 15:48:47 -10:00 committed by GitHub
parent 5872b72f80
commit 8c70e5aaad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 171 additions and 40 deletions

View File

@ -217,20 +217,19 @@ class BluetoothManager:
uninstall_multiple_bleak_catcher()
@hass_callback
def async_get_discovered_devices_and_advertisement_data_by_address(
def async_get_scanner_discovered_devices_and_advertisement_data_by_address(
self, address: str, connectable: bool
) -> list[tuple[BLEDevice, AdvertisementData]]:
"""Get devices and advertisement_data by address."""
) -> list[tuple[BaseHaScanner, BLEDevice, AdvertisementData]]:
"""Get scanner, devices, and advertisement_data by address."""
types_ = (True,) if connectable else (True, False)
return [
device_advertisement_data
for device_advertisement_data in (
scanner.discovered_devices_and_advertisement_data.get(address)
for type_ in types_
for scanner in self._get_scanners_by_type(type_)
)
if device_advertisement_data is not None
]
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
@hass_callback
def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]:

View File

@ -7,7 +7,7 @@
"quality_scale": "internal",
"requirements": [
"bleak==0.19.2",
"bleak-retry-connector==2.12.1",
"bleak-retry-connector==2.13.0",
"bluetooth-adapters==0.15.2",
"bluetooth-auto-recovery==1.0.3",
"bluetooth-data-tools==0.3.1",

View File

@ -5,13 +5,18 @@ import asyncio
from collections.abc import Callable
import contextlib
from dataclasses import dataclass
from functools import partial
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 AdvertisementDataCallback, BaseBleakScanner
from bleak.backends.scanner import (
AdvertisementData,
AdvertisementDataCallback,
BaseBleakScanner,
)
from bleak_retry_connector import (
NO_RSSI_VALUE,
ble_device_description,
@ -23,6 +28,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
FILTER_UUIDS: Final = "UUIDs"
_LOGGER = logging.getLogger(__name__)
@ -37,6 +43,7 @@ class _HaWrappedBleakBackend:
"""Wrap bleak backend to make it usable by Home Assistant."""
device: BLEDevice
scanner: BaseHaScanner
client: type[BaseBleakClient]
source: str | None
@ -140,6 +147,33 @@ class HaBleakScannerWrapper(BaseBleakScanner):
asyncio.get_running_loop().call_soon_threadsafe(self._detection_cancel)
def _rssi_sorter_with_connection_failure_penalty(
scanner_device_advertisement_data: tuple[
BaseHaScanner, BLEDevice, AdvertisementData
],
connection_failure_count: dict[BaseHaScanner, int],
rssi_diff: int,
) -> float:
"""Get a sorted list of scanner, device, advertisement data adjusting for previous connection failures.
When a connection fails, we want to try the next best adapter so we
apply a penalty to the RSSI value to make it less likely to be chosen
for every previous connection failure.
We use the 51% of the RSSI difference between the first and second
best adapter as the penalty. This ensures we will always try the
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):
if connect_failures > 1 and not rssi_diff:
rssi_diff = 1
return base_rssi - (rssi_diff * connect_failures * 0.51)
return base_rssi
class HaBleakClientWrapper(BleakClient):
"""Wrap the BleakClient to ensure it does not shutdown our scanner.
@ -171,6 +205,7 @@ class HaBleakClientWrapper(BleakClient):
self.__address = address_or_ble_device
self.__disconnected_callback = disconnected_callback
self.__timeout = timeout
self.__connect_failures: dict[BaseHaScanner, int] = {}
self._backend: BaseBleakClient | None = None # type: ignore[assignment]
@property
@ -197,12 +232,13 @@ class HaBleakClientWrapper(BleakClient):
async def connect(self, **kwargs: Any) -> bool:
"""Connect to the specified GATT server."""
assert models.MANAGER is not None
wrapped_backend = self._async_get_best_available_backend_and_device()
manager = models.MANAGER
wrapped_backend = self._async_get_best_available_backend_and_device(manager)
self._backend = wrapped_backend.client(
wrapped_backend.device,
disconnected_callback=self.__disconnected_callback,
timeout=self.__timeout,
hass=models.MANAGER.hass,
hass=manager.hass,
)
if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG):
# Only lookup the description if we are going to log it
@ -215,8 +251,12 @@ class HaBleakClientWrapper(BleakClient):
finally:
# If we failed to connect and its a local adapter (no source)
# we release the connection slot
if not connected and not wrapped_backend.source:
models.MANAGER.async_release_connection_slot(wrapped_backend.device)
if not connected:
self.__connect_failures[wrapped_backend.scanner] = (
self.__connect_failures.get(wrapped_backend.scanner, 0) + 1
)
if not wrapped_backend.source:
manager.async_release_connection_slot(wrapped_backend.device)
if debug_logging:
_LOGGER.debug("%s: Connected (last rssi: %s)", description, rssi)
@ -224,7 +264,7 @@ class HaBleakClientWrapper(BleakClient):
@hass_callback
def _async_get_backend_for_ble_device(
self, manager: BluetoothManager, ble_device: BLEDevice
self, manager: BluetoothManager, scanner: BaseHaScanner, ble_device: BLEDevice
) -> _HaWrappedBleakBackend | None:
"""Get the backend for a BLEDevice."""
if not (source := device_source(ble_device)):
@ -233,41 +273,68 @@ class HaBleakClientWrapper(BleakClient):
if not manager.async_allocate_connection_slot(ble_device):
return None
cls = get_platform_client_backend_type()
return _HaWrappedBleakBackend(ble_device, cls, source)
return _HaWrappedBleakBackend(ble_device, scanner, cls, source)
# Make sure the backend can connect to the device
# as some backends have connection limits
if (
not (scanner := manager.async_scanner_by_source(source))
or not scanner.connector
or not scanner.connector.can_connect()
):
if not scanner.connector or not scanner.connector.can_connect():
return None
return _HaWrappedBleakBackend(ble_device, scanner.connector.client, source)
return _HaWrappedBleakBackend(
ble_device, scanner, scanner.connector.client, source
)
@hass_callback
def _async_get_best_available_backend_and_device(
self,
self, manager: BluetoothManager
) -> _HaWrappedBleakBackend:
"""Get a best available backend and device for the given address.
This method will return the backend with the best rssi
that has a free connection slot.
"""
assert models.MANAGER is not None
address = self.__address
device_advertisement_datas = models.MANAGER.async_get_discovered_devices_and_advertisement_data_by_address(
scanner_device_advertisement_datas = manager.async_get_scanner_discovered_devices_and_advertisement_data_by_address(
address, True
)
for device_advertisement_data in sorted(
device_advertisement_datas,
key=lambda device_advertisement_data: device_advertisement_data[1].rssi
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,
reverse=True,
)
# If we have connection failures we adjust the rssi sorting
# 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
):
# 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
)
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,
key=adjusted_rssi_sorter,
reverse=True,
)
for (scanner, ble_device, _) in sorted_scanner_device_advertisement_datas:
if backend := self._async_get_backend_for_ble_device(
models.MANAGER, device_advertisement_data[0]
manager, scanner, ble_device
):
return backend

View File

@ -10,7 +10,7 @@ atomicwrites-homeassistant==1.4.1
attrs==22.1.0
awesomeversion==22.9.0
bcrypt==3.1.7
bleak-retry-connector==2.12.1
bleak-retry-connector==2.13.0
bleak==0.19.2
bluetooth-adapters==0.15.2
bluetooth-auto-recovery==1.0.3

View File

@ -428,7 +428,7 @@ bimmer_connected==0.10.4
bizkaibus==0.1.1
# homeassistant.components.bluetooth
bleak-retry-connector==2.12.1
bleak-retry-connector==2.13.0
# homeassistant.components.bluetooth
bleak==0.19.2

View File

@ -352,7 +352,7 @@ bellows==0.34.5
bimmer_connected==0.10.4
# homeassistant.components.bluetooth
bleak-retry-connector==2.12.1
bleak-retry-connector==2.13.0
# homeassistant.components.bluetooth
bleak==0.19.2

View File

@ -43,6 +43,10 @@ class FakeScanner(BaseHaRemoteScanner):
)
self._details: dict[str, str | HaBluetoothConnector] = {}
def __repr__(self) -> str:
"""Return the representation."""
return f"FakeScanner({self.name})"
def inject_advertisement(
self, device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
@ -65,6 +69,7 @@ class BaseFakeBleakClient:
def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs):
"""Initialize the fake bleak client."""
self._device_path = "/dev/test"
self._device = address_or_ble_device
self._address = address_or_ble_device.address
async def disconnect(self, *args, **kwargs):
@ -100,7 +105,7 @@ class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient):
def _generate_ble_device_and_adv_data(
interface: str, mac: str
interface: str, mac: str, rssi: int
) -> tuple[BLEDevice, AdvertisementData]:
"""Generate a BLE device with adv data."""
return (
@ -110,7 +115,7 @@ def _generate_ble_device_and_adv_data(
delegate="",
details={"path": f"/org/bluez/{interface}/dev_{mac}"},
),
generate_advertisement_data(),
generate_advertisement_data(rssi=rssi),
)
@ -158,13 +163,13 @@ def _generate_scanners_with_fake_devices(hass):
hci0_device_advs = {}
for i in range(10):
device, adv_data = _generate_ble_device_and_adv_data(
"hci0", f"00:00:00:00:00:{i:02x}"
"hci0", f"00:00:00:00:00:{i:02x}", rssi=-60
)
hci0_device_advs[device.address] = (device, adv_data)
hci1_device_advs = {}
for i in range(10):
device, adv_data = _generate_ble_device_and_adv_data(
"hci1", f"00:00:00:00:00:{i:02x}"
"hci1", f"00:00:00:00:00:{i:02x}", rssi=-80
)
hci1_device_advs[device.address] = (device, adv_data)
@ -296,3 +301,63 @@ async def test_release_slot_on_connect_exception(
cancel_hci0()
cancel_hci1()
async def test_we_switch_adapters_on_failure(
hass,
two_adapters,
enable_bluetooth,
install_bleak_catcher,
):
"""Ensure we try the next best adapter after a failure."""
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices(
hass
)
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
class FakeBleakClientFailsHCI0Only(BaseFakeBleakClient):
"""Fake bleak client that fails to connect."""
async def connect(self, *args, **kwargs):
"""Connect."""
if "/hci0/" in self._device.details["path"]:
return False
return True
with patch(
"homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsHCI0Only,
):
assert await client.connect() is False
with patch(
"homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsHCI0Only,
):
assert await client.connect() is False
# After two tries we should switch to hci1
with patch(
"homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsHCI0Only,
):
assert await client.connect() is True
# ..and we remember that hci1 works as long as the client doesn't change
with patch(
"homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsHCI0Only,
):
assert await client.connect() is True
# If we replace the client, we should try hci0 again
client = bleak.BleakClient(ble_device)
with patch(
"homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsHCI0Only,
):
assert await client.connect() is False
cancel_hci0()
cancel_hci1()