Accept advertisements from alternate scanners when a scanner stops scanning (#82448)

This commit is contained in:
J. Nick Koston 2022-11-21 20:23:07 -06:00 committed by GitHub
parent dfed57ed4d
commit a7caa038be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 275 additions and 68 deletions

View File

@ -51,6 +51,7 @@ from .api import (
async_rediscover_address, async_rediscover_address,
async_register_callback, async_register_callback,
async_register_scanner, async_register_scanner,
async_scanner_by_source,
async_scanner_count, async_scanner_count,
async_track_unavailable, async_track_unavailable,
) )
@ -86,6 +87,7 @@ __all__ = [
"async_register_callback", "async_register_callback",
"async_register_scanner", "async_register_scanner",
"async_track_unavailable", "async_track_unavailable",
"async_scanner_by_source",
"async_scanner_count", "async_scanner_count",
"BaseHaScanner", "BaseHaScanner",
"BaseHaRemoteScanner", "BaseHaRemoteScanner",

View File

@ -44,6 +44,19 @@ def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper:
return HaBleakScannerWrapper() return HaBleakScannerWrapper()
@hass_callback
def async_scanner_by_source(hass: HomeAssistant, source: str) -> BaseHaScanner | None:
"""Return a scanner for a given source.
This method is only intended to be used by integrations that implement
a bluetooth client and need to interact with a scanner directly.
It is not intended to be used by integrations that need to interact
with a device.
"""
return _get_manager(hass).async_scanner_by_source(source)
@hass_callback @hass_callback
def async_scanner_count(hass: HomeAssistant, connectable: bool = True) -> int: def async_scanner_count(hass: HomeAssistant, connectable: bool = True) -> int:
"""Return the number of scanners currently in use.""" """Return the number of scanners currently in use."""

View File

@ -2,7 +2,8 @@
from __future__ import annotations from __future__ import annotations
from abc import abstractmethod from abc import abstractmethod
from collections.abc import Callable from collections.abc import Callable, Generator
from contextlib import contextmanager
import datetime import datetime
from datetime import timedelta from datetime import timedelta
from typing import Any, Final from typing import Any, Final
@ -10,6 +11,7 @@ from typing import Any, Final
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import NO_RSSI_VALUE from bleak_retry_connector import NO_RSSI_VALUE
from bluetooth_adapters import adapter_human_name
from home_assistant_bluetooth import BluetoothServiceInfoBleak from home_assistant_bluetooth import BluetoothServiceInfoBleak
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
@ -28,10 +30,26 @@ MONOTONIC_TIME: Final = monotonic_time_coarse
class BaseHaScanner: class BaseHaScanner:
"""Base class for Ha Scanners.""" """Base class for Ha Scanners."""
def __init__(self, hass: HomeAssistant, source: str) -> None: __slots__ = ("hass", "source", "_connecting", "name", "scanning")
def __init__(self, hass: HomeAssistant, source: str, adapter: str) -> None:
"""Initialize the scanner.""" """Initialize the scanner."""
self.hass = hass self.hass = hass
self.source = source self.source = source
self._connecting = 0
self.name = adapter_human_name(adapter, source) if adapter != source else source
self.scanning = True
@contextmanager
def connecting(self) -> Generator[None, None, None]:
"""Context manager to track connecting state."""
self._connecting += 1
self.scanning = not self._connecting
try:
yield
finally:
self._connecting -= 1
self.scanning = not self._connecting
@property @property
@abstractmethod @abstractmethod
@ -62,16 +80,27 @@ class BaseHaScanner:
class BaseHaRemoteScanner(BaseHaScanner): class BaseHaRemoteScanner(BaseHaScanner):
"""Base class for a Home Assistant remote BLE scanner.""" """Base class for a Home Assistant remote BLE scanner."""
__slots__ = (
"_new_info_callback",
"_discovered_device_advertisement_datas",
"_discovered_device_timestamps",
"_connector",
"_connectable",
"_details",
"_expire_seconds",
)
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
scanner_id: str, scanner_id: str,
name: str,
new_info_callback: Callable[[BluetoothServiceInfoBleak], None], new_info_callback: Callable[[BluetoothServiceInfoBleak], None],
connector: HaBluetoothConnector, connector: HaBluetoothConnector,
connectable: bool, connectable: bool,
) -> None: ) -> None:
"""Initialize the scanner.""" """Initialize the scanner."""
super().__init__(hass, scanner_id) super().__init__(hass, scanner_id, name)
self._new_info_callback = new_info_callback self._new_info_callback = new_info_callback
self._discovered_device_advertisement_datas: dict[ self._discovered_device_advertisement_datas: dict[
str, tuple[BLEDevice, AdvertisementData] str, tuple[BLEDevice, AdvertisementData]

View File

@ -126,7 +126,7 @@ class BluetoothManager:
self._non_connectable_scanners: list[BaseHaScanner] = [] self._non_connectable_scanners: list[BaseHaScanner] = []
self._connectable_scanners: list[BaseHaScanner] = [] self._connectable_scanners: list[BaseHaScanner] = []
self._adapters: dict[str, AdapterDetails] = {} self._adapters: dict[str, AdapterDetails] = {}
self._sources: set[str] = set() self._sources: dict[str, BaseHaScanner] = {}
self._bluetooth_adapters = bluetooth_adapters self._bluetooth_adapters = bluetooth_adapters
@property @property
@ -169,6 +169,11 @@ class BluetoothManager:
return adapter return adapter
return None return None
@hass_callback
def async_scanner_by_source(self, source: str) -> BaseHaScanner | None:
"""Return the scanner for a source."""
return self._sources.get(source)
async def async_get_bluetooth_adapters( async def async_get_bluetooth_adapters(
self, cached: bool = True self, cached: bool = True
) -> dict[str, AdapterDetails]: ) -> dict[str, AdapterDetails]:
@ -302,6 +307,7 @@ class BluetoothManager:
self, self,
old: BluetoothServiceInfoBleak, old: BluetoothServiceInfoBleak,
new: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak,
debug: bool,
) -> bool: ) -> bool:
"""Prefer previous advertisement from a different source if it is better.""" """Prefer previous advertisement from a different source if it is better."""
if new.time - old.time > ( if new.time - old.time > (
@ -310,34 +316,32 @@ class BluetoothManager:
) )
): ):
# If the old advertisement is stale, any new advertisement is preferred # If the old advertisement is stale, any new advertisement is preferred
_LOGGER.debug( if debug:
"%s (%s): Switching from %s[%s] to %s[%s] (time elapsed:%s > stale seconds:%s)", _LOGGER.debug(
new.name, "%s (%s): Switching from %s to %s (time elapsed:%s > stale seconds:%s)",
new.address, new.name,
old.source, new.address,
old.connectable, self._async_describe_source(old),
new.source, self._async_describe_source(new),
new.connectable, new.time - old.time,
new.time - old.time, stale_seconds,
stale_seconds, )
)
return False return False
if (new.rssi or NO_RSSI_VALUE) - RSSI_SWITCH_THRESHOLD > ( if (new.rssi or NO_RSSI_VALUE) - RSSI_SWITCH_THRESHOLD > (
old.rssi or NO_RSSI_VALUE old.rssi or NO_RSSI_VALUE
): ):
# If new advertisement is RSSI_SWITCH_THRESHOLD more, the new one is preferred # If new advertisement is RSSI_SWITCH_THRESHOLD more, the new one is preferred
_LOGGER.debug( if debug:
"%s (%s): Switching from %s[%s] to %s[%s] (new rssi:%s - threshold:%s > old rssi:%s)", _LOGGER.debug(
new.name, "%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s > old rssi:%s)",
new.address, new.name,
old.source, new.address,
old.connectable, self._async_describe_source(old),
new.source, self._async_describe_source(new),
new.connectable, new.rssi,
new.rssi, RSSI_SWITCH_THRESHOLD,
RSSI_SWITCH_THRESHOLD, old.rssi,
old.rssi, )
)
return False return False
return True return True
@ -367,6 +371,7 @@ class BluetoothManager:
connectable_history = self._connectable_history connectable_history = self._connectable_history
source = service_info.source source = service_info.source
debug = _LOGGER.isEnabledFor(logging.DEBUG)
# This logic is complex due to the many combinations of scanners that are supported. # This logic is complex due to the many combinations of scanners that are supported.
# #
# We need to handle multiple connectable and non-connectable scanners # We need to handle multiple connectable and non-connectable scanners
@ -384,9 +389,10 @@ class BluetoothManager:
if ( if (
(old_service_info := all_history.get(address)) (old_service_info := all_history.get(address))
and source != old_service_info.source and source != old_service_info.source
and old_service_info.source in self._sources and (scanner := self._sources.get(old_service_info.source))
and scanner.scanning
and self._prefer_previous_adv_from_different_source( and self._prefer_previous_adv_from_different_source(
old_service_info, service_info old_service_info, service_info, debug
) )
): ):
# If we are rejecting the new advertisement and the device is connectable # If we are rejecting the new advertisement and the device is connectable
@ -404,9 +410,14 @@ class BluetoothManager:
# the old connectable advertisement # the old connectable advertisement
or ( or (
source != old_connectable_service_info.source source != old_connectable_service_info.source
and old_connectable_service_info.source in self._sources and (
connectable_scanner := self._sources.get(
old_connectable_service_info.source
)
)
and connectable_scanner.scanning
and self._prefer_previous_adv_from_different_source( and self._prefer_previous_adv_from_different_source(
old_connectable_service_info, service_info old_connectable_service_info, service_info, debug
) )
) )
): ):
@ -461,15 +472,14 @@ class BluetoothManager:
) )
matched_domains = self._integration_matcher.match_domains(service_info) matched_domains = self._integration_matcher.match_domains(service_info)
_LOGGER.debug( if debug:
"%s: %s %s connectable: %s match: %s rssi: %s", _LOGGER.debug(
source, "%s: %s %s match: %s",
address, self._async_describe_source(service_info),
advertisement_data, address,
connectable, advertisement_data,
matched_domains, matched_domains,
advertisement_data.rssi, )
)
if is_connectable_by_any_source: if is_connectable_by_any_source:
# Bleak callbacks must get a connectable device # Bleak callbacks must get a connectable device
@ -491,6 +501,17 @@ class BluetoothManager:
service_info, service_info,
) )
@hass_callback
def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str:
"""Describe a source."""
if scanner := self._sources.get(service_info.source):
description = scanner.name
else:
description = service_info.source
if service_info.connectable:
description += " [connectable]"
return description
@hass_callback @hass_callback
def async_track_unavailable( def async_track_unavailable(
self, self,
@ -611,15 +632,17 @@ class BluetoothManager:
self, scanner: BaseHaScanner, connectable: bool self, scanner: BaseHaScanner, connectable: bool
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register a new scanner.""" """Register a new scanner."""
_LOGGER.debug("Registering scanner %s", scanner.name)
scanners = self._get_scanners_by_type(connectable) scanners = self._get_scanners_by_type(connectable)
def _unregister_scanner() -> None: def _unregister_scanner() -> None:
_LOGGER.debug("Unregistering scanner %s", scanner.name)
self._advertisement_tracker.async_remove_source(scanner.source) self._advertisement_tracker.async_remove_source(scanner.source)
scanners.remove(scanner) scanners.remove(scanner)
self._sources.remove(scanner.source) del self._sources[scanner.source]
scanners.append(scanner) scanners.append(scanner)
self._sources.add(scanner.source) self._sources[scanner.source] = scanner
return _unregister_scanner return _unregister_scanner
@hass_callback @hass_callback

View File

@ -16,7 +16,7 @@ from bleak.backends.bluezdbus.advertisement_monitor import OrPattern
from bleak.backends.bluezdbus.scanner import BlueZScannerArgs from bleak.backends.bluezdbus.scanner import BlueZScannerArgs
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback
from bluetooth_adapters import DEFAULT_ADDRESS, adapter_human_name from bluetooth_adapters import DEFAULT_ADDRESS
from dbus_fast import InvalidMessageError from dbus_fast import InvalidMessageError
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
@ -130,7 +130,7 @@ class HaScanner(BaseHaScanner):
) -> None: ) -> None:
"""Init bluetooth discovery.""" """Init bluetooth discovery."""
source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL
super().__init__(hass, source) super().__init__(hass, source, adapter)
self.mode = mode self.mode = mode
self.adapter = adapter self.adapter = adapter
self._start_stop_lock = asyncio.Lock() self._start_stop_lock = asyncio.Lock()
@ -138,7 +138,7 @@ class HaScanner(BaseHaScanner):
self._last_detection = 0.0 self._last_detection = 0.0
self._start_time = 0.0 self._start_time = 0.0
self._new_info_callback = new_info_callback self._new_info_callback = new_info_callback
self.name = adapter_human_name(adapter, address) self.scanning = False
@property @property
def discovered_devices(self) -> list[BLEDevice]: def discovered_devices(self) -> list[BLEDevice]:
@ -312,6 +312,7 @@ class HaScanner(BaseHaScanner):
# Everything is fine, break out of the loop # Everything is fine, break out of the loop
break break
self.scanning = True
self._async_setup_scanner_watchdog() self._async_setup_scanner_watchdog()
@hass_callback @hass_callback
@ -385,6 +386,7 @@ class HaScanner(BaseHaScanner):
async def _async_stop_scanner(self) -> None: async def _async_stop_scanner(self) -> None:
"""Stop bluetooth discovery under the lock.""" """Stop bluetooth discovery under the lock."""
self.scanning = False
_LOGGER.debug("%s: Stopping bluetooth discovery", self.name) _LOGGER.debug("%s: Stopping bluetooth discovery", self.name)
try: try:
await self.scanner.stop() # type: ignore[no-untyped-call] await self.scanner.stop() # type: ignore[no-untyped-call]

View File

@ -67,7 +67,9 @@ async def async_connect_scanner(
source=source, source=source,
can_connect=_async_can_connect_factory(entry_data, source), can_connect=_async_can_connect_factory(entry_data, source),
) )
scanner = ESPHomeScanner(hass, source, new_info_callback, connector, connectable) scanner = ESPHomeScanner(
hass, source, entry.title, new_info_callback, connector, connectable
)
unload_callbacks = [ unload_callbacks = [
async_register_scanner(hass, scanner, connectable), async_register_scanner(hass, scanner, connectable),
scanner.async_setup(), scanner.async_setup(),

View File

@ -21,6 +21,7 @@ from bleak.backends.device import BLEDevice
from bleak.backends.service import BleakGATTServiceCollection from bleak.backends.service import BleakGATTServiceCollection
from bleak.exc import BleakError from bleak.exc import BleakError
from homeassistant.components.bluetooth import async_scanner_by_source
from homeassistant.core import CALLBACK_TYPE from homeassistant.core import CALLBACK_TYPE
from ..domain_data import DomainData from ..domain_data import DomainData
@ -119,11 +120,12 @@ class ESPHomeClient(BaseBleakClient):
"""Initialize the ESPHomeClient.""" """Initialize the ESPHomeClient."""
assert isinstance(address_or_ble_device, BLEDevice) assert isinstance(address_or_ble_device, BLEDevice)
super().__init__(address_or_ble_device, *args, **kwargs) super().__init__(address_or_ble_device, *args, **kwargs)
self._hass = kwargs["hass"]
self._ble_device = address_or_ble_device self._ble_device = address_or_ble_device
self._address_as_int = mac_to_int(self._ble_device.address) self._address_as_int = mac_to_int(self._ble_device.address)
assert self._ble_device.details is not None assert self._ble_device.details is not None
self._source = self._ble_device.details["source"] self._source = self._ble_device.details["source"]
self.domain_data = DomainData.get(kwargs["hass"]) self.domain_data = DomainData.get(self._hass)
config_entry = self.domain_data.get_by_unique_id(self._source) config_entry = self.domain_data.get_by_unique_id(self._source)
self.entry_data = self.domain_data.get_entry_data(config_entry) self.entry_data = self.domain_data.get_entry_data(config_entry)
self._client = self.entry_data.client self._client = self.entry_data.client
@ -257,12 +259,15 @@ class ESPHomeClient(BaseBleakClient):
connected_future.set_result(connected) connected_future.set_result(connected)
timeout = kwargs.get("timeout", self._timeout) timeout = kwargs.get("timeout", self._timeout)
self._cancel_connection_state = await self._client.bluetooth_device_connect( if not (scanner := async_scanner_by_source(self._hass, self._source)):
self._address_as_int, raise BleakError("Scanner disappeared for {self._source}")
_on_bluetooth_connection_state, with scanner.connecting():
timeout=timeout, self._cancel_connection_state = await self._client.bluetooth_device_connect(
) self._address_as_int,
await connected_future _on_bluetooth_connection_state,
timeout=timeout,
)
await connected_future
await self.get_services(dangerous_use_bleak_cache=dangerous_use_bleak_cache) await self.get_services(dangerous_use_bleak_cache=dangerous_use_bleak_cache)
self._disconnected_event = asyncio.Event() self._disconnected_event = asyncio.Event()
return True return True

View File

@ -34,6 +34,7 @@ async def async_connect_scanner(
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Connect scanner.""" """Connect scanner."""
device = coordinator.device device = coordinator.device
entry = coordinator.entry
source = format_mac(coordinator.mac).upper() source = format_mac(coordinator.mac).upper()
new_info_callback = async_get_advertisement_callback(hass) new_info_callback = async_get_advertisement_callback(hass)
connector = HaBluetoothConnector( connector = HaBluetoothConnector(
@ -42,7 +43,9 @@ async def async_connect_scanner(
source=source, source=source,
can_connect=lambda: False, can_connect=lambda: False,
) )
scanner = ShellyBLEScanner(hass, source, new_info_callback, connector, False) scanner = ShellyBLEScanner(
hass, source, entry.title, new_info_callback, connector, False
)
unload_callbacks = [ unload_callbacks = [
async_register_scanner(hass, scanner, False), async_register_scanner(hass, scanner, False),
scanner.async_setup(), scanner.async_setup(),

View File

@ -314,7 +314,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c
"""Return a list of discovered devices.""" """Return a list of discovered devices."""
return {} return {}
scanner = FakeScanner(hass, "new") scanner = FakeScanner(hass, "new", "fake_adapter")
cancel_scanner = async_register_scanner(hass, scanner, False) cancel_scanner = async_register_scanner(hass, scanner, False)
@callback @callback

View File

@ -0,0 +1,16 @@
"""Tests for the Bluetooth integration API."""
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BaseHaScanner, async_scanner_by_source
async def test_scanner_by_source(hass, enable_bluetooth):
"""Test we can get a scanner by source."""
hci2_scanner = BaseHaScanner(hass, "hci2", "hci2")
cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True)
assert async_scanner_by_source(hass, "hci2") is hci2_scanner
cancel_hci2()
assert async_scanner_by_source(hass, "hci2") is None

View File

@ -20,7 +20,7 @@ from . import MockBleakClient, _get_manager, generate_advertisement_data
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
async def test_remote_scanner(hass): async def test_remote_scanner(hass, enable_bluetooth):
"""Test the remote scanner base class merges advertisement_data.""" """Test the remote scanner base class merges advertisement_data."""
manager = _get_manager() manager = _get_manager()
@ -70,7 +70,7 @@ async def test_remote_scanner(hass):
connector = ( connector = (
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
) )
scanner = FakeScanner(hass, "esp32", new_info_callback, connector, True) scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, True)
scanner.async_setup() scanner.async_setup()
cancel = manager.async_register_scanner(scanner, True) cancel = manager.async_register_scanner(scanner, True)
@ -104,7 +104,7 @@ async def test_remote_scanner(hass):
cancel() cancel()
async def test_remote_scanner_expires_connectable(hass): async def test_remote_scanner_expires_connectable(hass, enable_bluetooth):
"""Test the remote scanner expires stale connectable data.""" """Test the remote scanner expires stale connectable data."""
manager = _get_manager() manager = _get_manager()
@ -140,7 +140,7 @@ async def test_remote_scanner_expires_connectable(hass):
connector = ( connector = (
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
) )
scanner = FakeScanner(hass, "esp32", new_info_callback, connector, True) scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, True)
scanner.async_setup() scanner.async_setup()
cancel = manager.async_register_scanner(scanner, True) cancel = manager.async_register_scanner(scanner, True)
@ -174,7 +174,7 @@ async def test_remote_scanner_expires_connectable(hass):
cancel() cancel()
async def test_remote_scanner_expires_non_connectable(hass): async def test_remote_scanner_expires_non_connectable(hass, enable_bluetooth):
"""Test the remote scanner expires stale non connectable data.""" """Test the remote scanner expires stale non connectable data."""
manager = _get_manager() manager = _get_manager()
@ -210,7 +210,7 @@ async def test_remote_scanner_expires_non_connectable(hass):
connector = ( connector = (
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
) )
scanner = FakeScanner(hass, "esp32", new_info_callback, connector, False) scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False)
scanner.async_setup() scanner.async_setup()
cancel = manager.async_register_scanner(scanner, True) cancel = manager.async_register_scanner(scanner, True)
@ -265,3 +265,61 @@ async def test_remote_scanner_expires_non_connectable(hass):
assert len(scanner.discovered_devices_and_advertisement_data) == 0 assert len(scanner.discovered_devices_and_advertisement_data) == 0
cancel() cancel()
async def test_base_scanner_connecting_behavior(hass, enable_bluetooth):
"""Test that the default behavior is to mark the scanner as not scanning when connecting."""
manager = _get_manager()
switchbot_device = BLEDevice(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
class FakeScanner(BaseHaRemoteScanner):
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,
)
new_info_callback = manager.scanner_adv_received
connector = (
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
)
scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False)
scanner.async_setup()
cancel = manager.async_register_scanner(scanner, True)
with scanner.connecting():
assert scanner.scanning is False
# We should still accept new advertisements while connecting
# since advertisements are delivered asynchronously and
# we don't want to miss any even when we are willing to
# accept advertisements from another scanner in the brief window
# between when we start connecting and when we stop scanning
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 1
assert len(scanner.discovered_devices_and_advertisement_data) == 1
assert devices[0].name == "wohand"
cancel()

View File

@ -2649,7 +2649,7 @@ async def test_getting_the_scanner_returns_the_wrapped_instance(hass, enable_blu
async def test_scanner_count_connectable(hass, enable_bluetooth): async def test_scanner_count_connectable(hass, enable_bluetooth):
"""Test getting the connectable scanner count.""" """Test getting the connectable scanner count."""
scanner = BaseHaScanner(hass, "any") scanner = BaseHaScanner(hass, "any", "any")
cancel = bluetooth.async_register_scanner(hass, scanner, False) cancel = bluetooth.async_register_scanner(hass, scanner, False)
assert bluetooth.async_scanner_count(hass, connectable=True) == 1 assert bluetooth.async_scanner_count(hass, connectable=True) == 1
cancel() cancel()
@ -2657,7 +2657,7 @@ async def test_scanner_count_connectable(hass, enable_bluetooth):
async def test_scanner_count(hass, enable_bluetooth): async def test_scanner_count(hass, enable_bluetooth):
"""Test getting the connectable and non-connectable scanner count.""" """Test getting the connectable and non-connectable scanner count."""
scanner = BaseHaScanner(hass, "any") scanner = BaseHaScanner(hass, "any", "any")
cancel = bluetooth.async_register_scanner(hass, scanner, False) cancel = bluetooth.async_register_scanner(hass, scanner, False)
assert bluetooth.async_scanner_count(hass, connectable=False) == 2 assert bluetooth.async_scanner_count(hass, connectable=False) == 2
cancel() cancel()

View File

@ -26,7 +26,8 @@ from . import (
@pytest.fixture @pytest.fixture
def register_hci0_scanner(hass: HomeAssistant) -> None: def register_hci0_scanner(hass: HomeAssistant) -> None:
"""Register an hci0 scanner.""" """Register an hci0 scanner."""
cancel = bluetooth.async_register_scanner(hass, BaseHaScanner(hass, "hci0"), True) hci0_scanner = BaseHaScanner(hass, "hci0", "hci0")
cancel = bluetooth.async_register_scanner(hass, hci0_scanner, True)
yield yield
cancel() cancel()
@ -34,7 +35,8 @@ def register_hci0_scanner(hass: HomeAssistant) -> None:
@pytest.fixture @pytest.fixture
def register_hci1_scanner(hass: HomeAssistant) -> None: def register_hci1_scanner(hass: HomeAssistant) -> None:
"""Register an hci1 scanner.""" """Register an hci1 scanner."""
cancel = bluetooth.async_register_scanner(hass, BaseHaScanner(hass, "hci1"), True) hci1_scanner = BaseHaScanner(hass, "hci1", "hci1")
cancel = bluetooth.async_register_scanner(hass, hci1_scanner, True)
yield yield
cancel() cancel()
@ -416,7 +418,7 @@ async def test_switching_adapters_when_one_goes_away(
): ):
"""Test switching adapters when one goes away.""" """Test switching adapters when one goes away."""
cancel_hci2 = bluetooth.async_register_scanner( cancel_hci2 = bluetooth.async_register_scanner(
hass, BaseHaScanner(hass, "hci2"), True hass, BaseHaScanner(hass, "hci2", "hci2"), True
) )
address = "44:44:33:11:23:45" address = "44:44:33:11:23:45"
@ -460,3 +462,55 @@ async def test_switching_adapters_when_one_goes_away(
bluetooth.async_ble_device_from_address(hass, address) bluetooth.async_ble_device_from_address(hass, address)
is switchbot_device_poor_signal is switchbot_device_poor_signal
) )
async def test_switching_adapters_when_one_stop_scanning(
hass, enable_bluetooth, register_hci0_scanner
):
"""Test switching adapters when stops scanning."""
hci2_scanner = BaseHaScanner(hass, "hci2", "hci2")
cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True)
address = "44:44:33:11:23:45"
switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci2"
)
assert (
bluetooth.async_ble_device_from_address(hass, address)
is switchbot_device_good_signal
)
switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
)
# We want to prefer the good signal when we have options
assert (
bluetooth.async_ble_device_from_address(hass, address)
is switchbot_device_good_signal
)
hci2_scanner.scanning = False
inject_advertisement_with_source(
hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
)
# Now that hci2 has stopped scanning, we should prefer the poor signal
# since poor signal is better than no signal
assert (
bluetooth.async_ble_device_from_address(hass, address)
is switchbot_device_poor_signal
)
cancel_hci2()

View File

@ -200,7 +200,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
return switchbot_proxy_device_has_connection_slot return switchbot_proxy_device_has_connection_slot
return None return None
scanner = FakeScanner(hass, "esp32") scanner = FakeScanner(hass, "esp32", "esp32")
cancel = manager.async_register_scanner(scanner, True) cancel = manager.async_register_scanner(scanner, True)
assert manager.async_discovered_devices(True) == [ assert manager.async_discovered_devices(True) == [
switchbot_proxy_device_no_connection_slot switchbot_proxy_device_no_connection_slot
@ -306,7 +306,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
return switchbot_proxy_device_has_connection_slot return switchbot_proxy_device_has_connection_slot
return None return None
scanner = FakeScanner(hass, "esp32") scanner = FakeScanner(hass, "esp32", "esp32")
cancel = manager.async_register_scanner(scanner, True) cancel = manager.async_register_scanner(scanner, True)
assert manager.async_discovered_devices(True) == [ assert manager.async_discovered_devices(True) == [
switchbot_proxy_device_no_connection_slot switchbot_proxy_device_no_connection_slot