mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Switch to a different local Bluetooth adapter when one runs out of connection slots (#84331)
This commit is contained in:
parent
f39f3b612a
commit
070aa714a0
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
):
|
):
|
||||||
|
@ -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",
|
||||||
|
298
tests/components/bluetooth/test_wrappers.py
Normal file
298
tests/components/bluetooth/test_wrappers.py
Normal 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()
|
Loading…
x
Reference in New Issue
Block a user