Switch to a different local Bluetooth adapter when one runs out of connection slots (#84331)

This commit is contained in:
J. Nick Koston 2022-12-23 08:58:33 -10:00 committed by GitHub
parent f39f3b612a
commit 070aa714a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 395 additions and 29 deletions

View File

@ -7,12 +7,15 @@ import platform
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from bleak_retry_connector import BleakSlotManager
from bluetooth_adapters import ( from bluetooth_adapters import (
ADAPTER_ADDRESS, ADAPTER_ADDRESS,
ADAPTER_CONNECTION_SLOTS,
ADAPTER_HW_VERSION, ADAPTER_HW_VERSION,
ADAPTER_MANUFACTURER, ADAPTER_MANUFACTURER,
ADAPTER_SW_VERSION, ADAPTER_SW_VERSION,
DEFAULT_ADDRESS, DEFAULT_ADDRESS,
DEFAULT_CONNECTION_SLOTS,
AdapterDetails, AdapterDetails,
adapter_human_name, adapter_human_name,
adapter_model, adapter_model,
@ -165,8 +168,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
bluetooth_adapters = get_adapters() bluetooth_adapters = get_adapters()
bluetooth_storage = BluetoothStorage(hass) bluetooth_storage = BluetoothStorage(hass)
await bluetooth_storage.async_setup() await bluetooth_storage.async_setup()
slot_manager = BleakSlotManager()
await slot_manager.async_setup()
manager = BluetoothManager( manager = BluetoothManager(
hass, integration_matcher, bluetooth_adapters, bluetooth_storage hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager
) )
await manager.async_setup() await manager.async_setup()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop)
@ -270,7 +275,7 @@ async def async_discover_adapters(
async def async_update_device( async def async_update_device(
hass: HomeAssistant, entry: ConfigEntry, adapter: str hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails
) -> None: ) -> None:
"""Update device registry entry. """Update device registry entry.
@ -279,11 +284,7 @@ async def async_update_device(
update the device with the new location so they can update the device with the new location so they can
figure out where the adapter is. figure out where the adapter is.
""" """
manager: BluetoothManager = hass.data[DATA_MANAGER] dr.async_get(hass).async_get_or_create(
adapters = await manager.async_get_bluetooth_adapters()
details = adapters[adapter]
registry = dr.async_get(manager.hass)
registry.async_get_or_create(
config_entry_id=entry.entry_id, config_entry_id=entry.entry_id,
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]), name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])}, connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
@ -307,6 +308,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
passive = entry.options.get(CONF_PASSIVE) passive = entry.options.get(CONF_PASSIVE)
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
new_info_callback = async_get_advertisement_callback(hass) new_info_callback = async_get_advertisement_callback(hass)
manager: BluetoothManager = hass.data[DATA_MANAGER]
scanner = HaScanner(hass, mode, adapter, address, new_info_callback) scanner = HaScanner(hass, mode, adapter, address, new_info_callback)
try: try:
scanner.async_setup() scanner.async_setup()
@ -318,8 +320,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await scanner.async_start() await scanner.async_start()
except ScannerStartError as err: except ScannerStartError as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
entry.async_on_unload(async_register_scanner(hass, scanner, True)) adapters = await manager.async_get_bluetooth_adapters()
await async_update_device(hass, entry, adapter) details = adapters[adapter]
slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS
entry.async_on_unload(async_register_scanner(hass, scanner, True, slots))
await async_update_device(hass, entry, adapter, details)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner
entry.async_on_unload(entry.add_update_listener(async_update_listener)) entry.async_on_unload(entry.add_update_listener(async_update_listener))
return True return True

View File

@ -172,10 +172,15 @@ def async_rediscover_address(hass: HomeAssistant, address: str) -> None:
@hass_callback @hass_callback
def async_register_scanner( def async_register_scanner(
hass: HomeAssistant, scanner: BaseHaScanner, connectable: bool hass: HomeAssistant,
scanner: BaseHaScanner,
connectable: bool,
connection_slots: int | None = None,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register a BleakScanner.""" """Register a BleakScanner."""
return _get_manager(hass).async_register_scanner(scanner, connectable) return _get_manager(hass).async_register_scanner(
scanner, connectable, connection_slots
)
@hass_callback @hass_callback

View File

@ -44,6 +44,7 @@ class BaseHaScanner(ABC):
__slots__ = ( __slots__ = (
"hass", "hass",
"adapter",
"connectable", "connectable",
"source", "source",
"connector", "connector",
@ -68,6 +69,7 @@ class BaseHaScanner(ABC):
self.source = source self.source = source
self.connector = connector self.connector = connector
self._connecting = 0 self._connecting = 0
self.adapter = adapter
self.name = adapter_human_name(adapter, source) if adapter != source else source self.name = adapter_human_name(adapter, source) if adapter != source else source
self.scanning = True self.scanning = True
self._last_detection = 0.0 self._last_detection = 0.0

View File

@ -9,7 +9,7 @@ import logging
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
from bleak.backends.scanner import AdvertisementDataCallback from bleak.backends.scanner import AdvertisementDataCallback
from bleak_retry_connector import NO_RSSI_VALUE, RSSI_SWITCH_THRESHOLD from bleak_retry_connector import NO_RSSI_VALUE, RSSI_SWITCH_THRESHOLD, BleakSlotManager
from bluetooth_adapters import ( from bluetooth_adapters import (
ADAPTER_ADDRESS, ADAPTER_ADDRESS,
ADAPTER_PASSIVE_SCAN, ADAPTER_PASSIVE_SCAN,
@ -104,6 +104,7 @@ class BluetoothManager:
integration_matcher: IntegrationMatcher, integration_matcher: IntegrationMatcher,
bluetooth_adapters: BluetoothAdapters, bluetooth_adapters: BluetoothAdapters,
storage: BluetoothStorage, storage: BluetoothStorage,
slot_manager: BleakSlotManager,
) -> None: ) -> None:
"""Init bluetooth manager.""" """Init bluetooth manager."""
self.hass = hass self.hass = hass
@ -131,6 +132,7 @@ class BluetoothManager:
self._sources: dict[str, BaseHaScanner] = {} self._sources: dict[str, BaseHaScanner] = {}
self._bluetooth_adapters = bluetooth_adapters self._bluetooth_adapters = bluetooth_adapters
self.storage = storage self.storage = storage
self.slot_manager = slot_manager
@property @property
def supports_passive_scan(self) -> bool: def supports_passive_scan(self) -> bool:
@ -155,6 +157,7 @@ class BluetoothManager:
) )
return { return {
"adapters": self._adapters, "adapters": self._adapters,
"slot_manager": self.slot_manager.diagnostics(),
"scanners": scanner_diagnostics, "scanners": scanner_diagnostics,
"connectable_history": [ "connectable_history": [
service_info.as_dict() service_info.as_dict()
@ -642,7 +645,10 @@ class BluetoothManager:
return self._connectable_history if connectable else self._all_history return self._connectable_history if connectable else self._all_history
def async_register_scanner( def async_register_scanner(
self, scanner: BaseHaScanner, connectable: bool self,
scanner: BaseHaScanner,
connectable: bool,
connection_slots: int | None = None,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register a new scanner.""" """Register a new scanner."""
_LOGGER.debug("Registering scanner %s", scanner.name) _LOGGER.debug("Registering scanner %s", scanner.name)
@ -653,9 +659,13 @@ class BluetoothManager:
self._advertisement_tracker.async_remove_source(scanner.source) self._advertisement_tracker.async_remove_source(scanner.source)
scanners.remove(scanner) scanners.remove(scanner)
del self._sources[scanner.source] del self._sources[scanner.source]
if connection_slots:
self.slot_manager.remove_adapter(scanner.adapter)
scanners.append(scanner) scanners.append(scanner)
self._sources[scanner.source] = scanner self._sources[scanner.source] = scanner
if connection_slots:
self.slot_manager.register_adapter(scanner.adapter, connection_slots)
return _unregister_scanner return _unregister_scanner
@hass_callback @hass_callback
@ -679,3 +689,13 @@ class BluetoothManager:
) )
return _remove_callback return _remove_callback
@hass_callback
def async_release_connection_slot(self, device: BLEDevice) -> None:
"""Release a connection slot."""
self.slot_manager.release_slot(device)
@hass_callback
def async_allocate_connection_slot(self, device: BLEDevice) -> bool:
"""Allocate a connection slot."""
return self.slot_manager.allocate_slot(device)

View File

@ -7,8 +7,8 @@
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"bleak==0.19.2", "bleak==0.19.2",
"bleak-retry-connector==2.10.2", "bleak-retry-connector==2.12.1",
"bluetooth-adapters==0.14.1", "bluetooth-adapters==0.15.2",
"bluetooth-auto-recovery==1.0.3", "bluetooth-auto-recovery==1.0.3",
"bluetooth-data-tools==0.3.1", "bluetooth-data-tools==0.3.1",
"dbus-fast==1.82.0" "dbus-fast==1.82.0"

View File

@ -132,7 +132,6 @@ class HaScanner(BaseHaScanner):
super().__init__(hass, source, adapter) super().__init__(hass, source, adapter)
self.connectable = True self.connectable = True
self.mode = mode self.mode = mode
self.adapter = adapter
self._start_stop_lock = asyncio.Lock() self._start_stop_lock = asyncio.Lock()
self._new_info_callback = new_info_callback self._new_info_callback = new_info_callback
self.scanning = False self.scanning = False

View File

@ -12,7 +12,12 @@ from bleak import BleakClient, BleakError
from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementDataCallback, BaseBleakScanner from bleak.backends.scanner import AdvertisementDataCallback, BaseBleakScanner
from bleak_retry_connector import NO_RSSI_VALUE, ble_device_description, clear_cache from bleak_retry_connector import (
NO_RSSI_VALUE,
ble_device_description,
clear_cache,
device_source,
)
from homeassistant.core import CALLBACK_TYPE, callback as hass_callback from homeassistant.core import CALLBACK_TYPE, callback as hass_callback
from homeassistant.helpers.frame import report from homeassistant.helpers.frame import report
@ -33,6 +38,7 @@ class _HaWrappedBleakBackend:
device: BLEDevice device: BLEDevice
client: type[BaseBleakClient] client: type[BaseBleakClient]
source: str | None
class HaBleakScannerWrapper(BaseBleakScanner): class HaBleakScannerWrapper(BaseBleakScanner):
@ -203,7 +209,15 @@ class HaBleakClientWrapper(BleakClient):
description = ble_device_description(wrapped_backend.device) description = ble_device_description(wrapped_backend.device)
rssi = wrapped_backend.device.rssi rssi = wrapped_backend.device.rssi
_LOGGER.debug("%s: Connecting (last rssi: %s)", description, rssi) _LOGGER.debug("%s: Connecting (last rssi: %s)", description, rssi)
connected = await super().connect(**kwargs) connected = None
try:
connected = await super().connect(**kwargs)
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 debug_logging: if debug_logging:
_LOGGER.debug("%s: Connected (last rssi: %s)", description, rssi) _LOGGER.debug("%s: Connected (last rssi: %s)", description, rssi)
return connected return connected
@ -213,14 +227,14 @@ class HaBleakClientWrapper(BleakClient):
self, manager: BluetoothManager, ble_device: BLEDevice self, manager: BluetoothManager, ble_device: BLEDevice
) -> _HaWrappedBleakBackend | None: ) -> _HaWrappedBleakBackend | None:
"""Get the backend for a BLEDevice.""" """Get the backend for a BLEDevice."""
details = ble_device.details if not (source := device_source(ble_device)):
if not isinstance(details, dict) or "source" not in details:
# If client is not defined in details # If client is not defined in details
# its the client for this platform # its the client for this platform
if not manager.async_allocate_connection_slot(ble_device):
return None
cls = get_platform_client_backend_type() cls = get_platform_client_backend_type()
return _HaWrappedBleakBackend(ble_device, cls) return _HaWrappedBleakBackend(ble_device, cls, source)
source: str = details["source"]
# Make sure the backend can connect to the device # Make sure the backend can connect to the device
# as some backends have connection limits # as some backends have connection limits
if ( if (
@ -230,7 +244,7 @@ class HaBleakClientWrapper(BleakClient):
): ):
return None return None
return _HaWrappedBleakBackend(ble_device, scanner.connector.client) return _HaWrappedBleakBackend(ble_device, scanner.connector.client, source)
@hass_callback @hass_callback
def _async_get_best_available_backend_and_device( def _async_get_best_available_backend_and_device(

View File

@ -10,9 +10,9 @@ atomicwrites-homeassistant==1.4.1
attrs==22.1.0 attrs==22.1.0
awesomeversion==22.9.0 awesomeversion==22.9.0
bcrypt==3.1.7 bcrypt==3.1.7
bleak-retry-connector==2.10.2 bleak-retry-connector==2.12.1
bleak==0.19.2 bleak==0.19.2
bluetooth-adapters==0.14.1 bluetooth-adapters==0.15.2
bluetooth-auto-recovery==1.0.3 bluetooth-auto-recovery==1.0.3
bluetooth-data-tools==0.3.1 bluetooth-data-tools==0.3.1
certifi>=2021.5.30 certifi>=2021.5.30

View File

@ -428,7 +428,7 @@ bimmer_connected==0.10.4
bizkaibus==0.1.1 bizkaibus==0.1.1
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bleak-retry-connector==2.10.2 bleak-retry-connector==2.12.1
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bleak==0.19.2 bleak==0.19.2
@ -453,7 +453,7 @@ bluemaestro-ble==0.2.0
# bluepy==1.3.0 # bluepy==1.3.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-adapters==0.14.1 bluetooth-adapters==0.15.2
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-auto-recovery==1.0.3 bluetooth-auto-recovery==1.0.3

View File

@ -352,7 +352,7 @@ bellows==0.34.5
bimmer_connected==0.10.4 bimmer_connected==0.10.4
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bleak-retry-connector==2.10.2 bleak-retry-connector==2.12.1
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bleak==0.19.2 bleak==0.19.2
@ -367,7 +367,7 @@ blinkpy==0.19.2
bluemaestro-ble==0.2.0 bluemaestro-ble==0.2.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-adapters==0.14.1 bluetooth-adapters==0.15.2
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-auto-recovery==1.0.3 bluetooth-auto-recovery==1.0.3

View File

@ -140,6 +140,7 @@ def two_adapters_fixture():
"product": "Bluetooth Adapter 5.0", "product": "Bluetooth Adapter 5.0",
"product_id": "aa01", "product_id": "aa01",
"vendor_id": "cc01", "vendor_id": "cc01",
"connection_slots": 1,
}, },
"hci1": { "hci1": {
"address": "00:00:00:00:00:02", "address": "00:00:00:00:00:02",
@ -150,6 +151,7 @@ def two_adapters_fixture():
"product": "Bluetooth Adapter 5.0", "product": "Bluetooth Adapter 5.0",
"product_id": "aa01", "product_id": "aa01",
"vendor_id": "cc01", "vendor_id": "cc01",
"connection_slots": 2,
}, },
}, },
): ):

View File

@ -86,6 +86,7 @@ async def test_diagnostics(
"product": "Bluetooth Adapter 5.0", "product": "Bluetooth Adapter 5.0",
"product_id": "aa01", "product_id": "aa01",
"vendor_id": "cc01", "vendor_id": "cc01",
"connection_slots": 1,
}, },
"hci1": { "hci1": {
"address": "00:00:00:00:00:02", "address": "00:00:00:00:00:02",
@ -96,6 +97,7 @@ async def test_diagnostics(
"product": "Bluetooth Adapter 5.0", "product": "Bluetooth Adapter 5.0",
"product_id": "aa01", "product_id": "aa01",
"vendor_id": "cc01", "vendor_id": "cc01",
"connection_slots": 2,
}, },
}, },
"dbus": { "dbus": {
@ -115,6 +117,11 @@ async def test_diagnostics(
} }
}, },
"manager": { "manager": {
"slot_manager": {
"adapter_slots": {"hci0": 5, "hci1": 2},
"allocations_by_adapter": {"hci0": [], "hci1": []},
"manager": False,
},
"adapters": { "adapters": {
"hci0": { "hci0": {
"address": "00:00:00:00:00:01", "address": "00:00:00:00:00:01",
@ -125,6 +132,7 @@ async def test_diagnostics(
"product": "Bluetooth Adapter 5.0", "product": "Bluetooth Adapter 5.0",
"product_id": "aa01", "product_id": "aa01",
"vendor_id": "cc01", "vendor_id": "cc01",
"connection_slots": 1,
}, },
"hci1": { "hci1": {
"address": "00:00:00:00:00:02", "address": "00:00:00:00:00:02",
@ -135,6 +143,7 @@ async def test_diagnostics(
"product": "Bluetooth Adapter 5.0", "product": "Bluetooth Adapter 5.0",
"product_id": "aa01", "product_id": "aa01",
"vendor_id": "cc01", "vendor_id": "cc01",
"connection_slots": 2,
}, },
}, },
"advertisement_tracker": { "advertisement_tracker": {
@ -274,6 +283,7 @@ async def test_diagnostics_macos(
inject_advertisement(hass, switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1)
assert diag == { assert diag == {
"adapters": { "adapters": {
"Core Bluetooth": { "Core Bluetooth": {
@ -287,6 +297,11 @@ async def test_diagnostics_macos(
} }
}, },
"manager": { "manager": {
"slot_manager": {
"adapter_slots": {"Core Bluetooth": 5},
"allocations_by_adapter": {"Core Bluetooth": []},
"manager": False,
},
"adapters": { "adapters": {
"Core Bluetooth": { "Core Bluetooth": {
"address": "00:00:00:00:00:00", "address": "00:00:00:00:00:00",
@ -457,6 +472,7 @@ async def test_diagnostics_remote_adapter(
inject_advertisement(hass, switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1)
assert diag == { assert diag == {
"adapters": { "adapters": {
"hci0": { "hci0": {
@ -472,6 +488,11 @@ async def test_diagnostics_remote_adapter(
}, },
"dbus": {}, "dbus": {},
"manager": { "manager": {
"slot_manager": {
"adapter_slots": {"hci0": 5},
"allocations_by_adapter": {"hci0": []},
"manager": False,
},
"adapters": { "adapters": {
"hci0": { "hci0": {
"address": "00:00:00:00:00:01", "address": "00:00:00:00:00:01",

View File

@ -0,0 +1,298 @@
"""Tests for the Bluetooth integration."""
from collections.abc import Callable
from typing import Union
from unittest.mock import patch
import bleak
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
import pytest
from homeassistant.components.bluetooth import (
BaseHaRemoteScanner,
BluetoothServiceInfoBleak,
HaBluetoothConnector,
async_get_advertisement_callback,
)
from homeassistant.components.bluetooth.usage import (
install_multiple_bleak_catcher,
uninstall_multiple_bleak_catcher,
)
from homeassistant.core import HomeAssistant
from . import _get_manager, generate_advertisement_data
class FakeScanner(BaseHaRemoteScanner):
"""Fake scanner."""
def __init__(
self,
hass: HomeAssistant,
scanner_id: str,
name: str,
new_info_callback: Callable[[BluetoothServiceInfoBleak], None],
connector: None,
connectable: bool,
) -> None:
"""Initialize the scanner."""
super().__init__(
hass, scanner_id, name, new_info_callback, connector, connectable
)
self._details: dict[str, str | HaBluetoothConnector] = {}
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,
device.details | {"scanner_specific_data": "test"},
)
class BaseFakeBleakClient:
"""Base class for fake bleak clients."""
def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs):
"""Initialize the fake bleak client."""
self._device_path = "/dev/test"
self._address = address_or_ble_device.address
async def disconnect(self, *args, **kwargs):
"""Disconnect.""" ""
async def get_services(self, *args, **kwargs):
"""Get services."""
return []
class FakeBleakClient(BaseFakeBleakClient):
"""Fake bleak client."""
async def connect(self, *args, **kwargs):
"""Connect."""
return True
class FakeBleakClientFailsToConnect(BaseFakeBleakClient):
"""Fake bleak client that fails to connect."""
async def connect(self, *args, **kwargs):
"""Connect."""
return False
class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient):
"""Fake bleak client that raises on connect."""
async def connect(self, *args, **kwargs):
"""Connect."""
raise Exception("Test exception")
def _generate_ble_device_and_adv_data(
interface: str, mac: str
) -> tuple[BLEDevice, AdvertisementData]:
"""Generate a BLE device with adv data."""
return (
BLEDevice(
mac,
"any",
delegate="",
details={"path": f"/org/bluez/{interface}/dev_{mac}"},
),
generate_advertisement_data(),
)
@pytest.fixture(name="install_bleak_catcher")
def install_bleak_catcher_fixture():
"""Fixture that installs the bleak catcher."""
install_multiple_bleak_catcher()
yield
uninstall_multiple_bleak_catcher()
@pytest.fixture(name="mock_platform_client")
def mock_platform_client_fixture():
"""Fixture that mocks the platform client."""
with patch(
"homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClient,
):
yield
@pytest.fixture(name="mock_platform_client_that_fails_to_connect")
def mock_platform_client_that_fails_to_connect_fixture():
"""Fixture that mocks the platform client that fails to connect."""
with patch(
"homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsToConnect,
):
yield
@pytest.fixture(name="mock_platform_client_that_raises_on_connect")
def mock_platform_client_that_raises_on_connect_fixture():
"""Fixture that mocks the platform client that fails to connect."""
with patch(
"homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientRaisesOnConnect,
):
yield
def _generate_scanners_with_fake_devices(hass):
"""Generate scanners with fake devices."""
manager = _get_manager()
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_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_device_advs[device.address] = (device, adv_data)
new_info_callback = async_get_advertisement_callback(hass)
scanner_hci0 = FakeScanner(
hass, "00:00:00:00:00:01", "hci0", new_info_callback, None, True
)
scanner_hci1 = FakeScanner(
hass, "00:00:00:00:00:02", "hci1", new_info_callback, None, True
)
for (device, adv_data) in hci0_device_advs.values():
scanner_hci0.inject_advertisement(device, adv_data)
for (device, adv_data) in hci1_device_advs.values():
scanner_hci1.inject_advertisement(device, adv_data)
cancel_hci0 = manager.async_register_scanner(scanner_hci0, True, 2)
cancel_hci1 = manager.async_register_scanner(scanner_hci1, True, 1)
return hci0_device_advs, cancel_hci0, cancel_hci1
async def test_test_switch_adapters_when_out_of_slots(
hass, two_adapters, enable_bluetooth, install_bleak_catcher, mock_platform_client
):
"""Ensure we try another scanner when one runs out of slots."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices(
hass
)
# hci0 has 2 slots, hci1 has 1 slot
with patch.object(
manager.slot_manager, "release_slot"
) as release_slot_mock, patch.object(
manager.slot_manager, "allocate_slot", return_value=True
) as allocate_slot_mock:
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
assert await client.connect() is True
assert allocate_slot_mock.call_count == 1
assert release_slot_mock.call_count == 0
# All adapters are out of slots
with patch.object(
manager.slot_manager, "release_slot"
) as release_slot_mock, patch.object(
manager.slot_manager, "allocate_slot", return_value=False
) as allocate_slot_mock:
ble_device = hci0_device_advs["00:00:00:00:00:02"][0]
client = bleak.BleakClient(ble_device)
with pytest.raises(bleak.exc.BleakError):
await client.connect()
assert allocate_slot_mock.call_count == 2
assert release_slot_mock.call_count == 0
# When hci0 runs out of slots, we should try hci1
def _allocate_slot_mock(ble_device: BLEDevice):
if "hci1" in ble_device.details["path"]:
return True
return False
with patch.object(
manager.slot_manager, "release_slot"
) as release_slot_mock, patch.object(
manager.slot_manager, "allocate_slot", _allocate_slot_mock
) as allocate_slot_mock:
ble_device = hci0_device_advs["00:00:00:00:00:03"][0]
client = bleak.BleakClient(ble_device)
await client.connect() is True
assert release_slot_mock.call_count == 0
cancel_hci0()
cancel_hci1()
async def test_release_slot_on_connect_failure(
hass,
two_adapters,
enable_bluetooth,
install_bleak_catcher,
mock_platform_client_that_fails_to_connect,
):
"""Ensure the slot gets released on connection failure."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices(
hass
)
# hci0 has 2 slots, hci1 has 1 slot
with patch.object(
manager.slot_manager, "release_slot"
) as release_slot_mock, patch.object(
manager.slot_manager, "allocate_slot", return_value=True
) as allocate_slot_mock:
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
assert await client.connect() is False
assert allocate_slot_mock.call_count == 1
assert release_slot_mock.call_count == 1
cancel_hci0()
cancel_hci1()
async def test_release_slot_on_connect_exception(
hass,
two_adapters,
enable_bluetooth,
install_bleak_catcher,
mock_platform_client_that_raises_on_connect,
):
"""Ensure the slot gets released on connection exception."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices(
hass
)
# hci0 has 2 slots, hci1 has 1 slot
with patch.object(
manager.slot_manager, "release_slot"
) as release_slot_mock, patch.object(
manager.slot_manager, "allocate_slot", return_value=True
) as allocate_slot_mock:
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
with pytest.raises(Exception):
assert await client.connect() is False
assert allocate_slot_mock.call_count == 1
assert release_slot_mock.call_count == 1
cancel_hci0()
cancel_hci1()