Update to bleak 0.18.0 (#79008)

This commit is contained in:
J. Nick Koston 2022-09-23 15:09:28 -10:00 committed by GitHub
parent 02731efc4c
commit 1b144c0e4d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 371 additions and 32 deletions

View file

@ -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",
]

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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()

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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()

View file

@ -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",