From 49998e97868eed44289136a89de94c139959617e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 11:35:50 -1000 Subject: [PATCH 1/5] feat: port bluetooth manager from HA --- src/habluetooth/__init__.py | 9 +- src/habluetooth/base_scanner.py | 10 + src/habluetooth/const.py | 3 + src/habluetooth/manager.py | 674 ++++++++++++++++++++++++++++++++ src/habluetooth/usage.py | 51 +++ src/habluetooth/wrappers.py | 385 ++++++++++++++++++ 6 files changed, 1131 insertions(+), 1 deletion(-) create mode 100644 src/habluetooth/manager.py create mode 100644 src/habluetooth/usage.py create mode 100644 src/habluetooth/wrappers.py diff --git a/src/habluetooth/__init__.py b/src/habluetooth/__init__.py index d37dceb..33030b3 100644 --- a/src/habluetooth/__init__.py +++ b/src/habluetooth/__init__.py @@ -4,17 +4,24 @@ TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker, ) -from .base_scanner import BaseHaRemoteScanner, BaseHaScanner +from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice from .const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, + UNAVAILABLE_TRACK_SECONDS, ) +from .manager import BluetoothManager, get_manager, set_manager from .models import HaBluetoothConnector from .scanner import BluetoothScanningMode, HaScanner, ScannerStartError __all__ = [ + "BluetoothManager", + "get_manager", + "set_manager", + "BluetoothScannerDevice", + "UNAVAILABLE_TRACK_SECONDS", "TRACKER_BUFFERING_WOBBLE_SECONDS", "AdvertisementTracker", "BluetoothScanningMode", diff --git a/src/habluetooth/base_scanner.py b/src/habluetooth/base_scanner.py index 155e47e..a8642b5 100644 --- a/src/habluetooth/base_scanner.py +++ b/src/habluetooth/base_scanner.py @@ -5,6 +5,7 @@ import logging from collections.abc import Callable, Generator from contextlib import contextmanager +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, final from bleak.backends.device import BLEDevice @@ -32,6 +33,15 @@ _str = str +@dataclass(slots=True) +class BluetoothScannerDevice: + """Data for a bluetooth device from a given scanner.""" + + scanner: BaseHaScanner + ble_device: BLEDevice + advertisement: AdvertisementData + + class BaseHaScanner: """Base class for high availability BLE scanners.""" diff --git a/src/habluetooth/const.py b/src/habluetooth/const.py index 694eaec..2499909 100644 --- a/src/habluetooth/const.py +++ b/src/habluetooth/const.py @@ -45,3 +45,6 @@ # How often to check if the scanner has reached # the SCANNER_WATCHDOG_TIMEOUT without seeing anything SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30) + + +UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py new file mode 100644 index 0000000..087bd6f --- /dev/null +++ b/src/habluetooth/manager.py @@ -0,0 +1,674 @@ +"""The bluetooth integration.""" +from __future__ import annotations + +import asyncio +import itertools +import logging +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING, Any, Final + +from bleak.backends.scanner import AdvertisementDataCallback +from bleak_retry_connector import NO_RSSI_VALUE, RSSI_SWITCH_THRESHOLD, BleakSlotManager +from bluetooth_adapters import ( + ADAPTER_ADDRESS, + ADAPTER_PASSIVE_SCAN, + AdapterDetails, + BluetoothAdapters, +) +from bluetooth_data_tools import monotonic_time_coarse +from home_assistant_bluetooth import BluetoothServiceInfoBleak + +from habluetooth import TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker + +from .base_scanner import BaseHaScanner, BluetoothScannerDevice +from .const import ( + CALLBACK_TYPE, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + UNAVAILABLE_TRACK_SECONDS, +) +from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher + +if TYPE_CHECKING: + from bleak.backends.device import BLEDevice + from bleak.backends.scanner import AdvertisementData + + +FILTER_UUIDS: Final = "UUIDs" + +APPLE_MFR_ID: Final = 76 +APPLE_IBEACON_START_BYTE: Final = 0x02 # iBeacon (tilt_ble) +APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller +APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker +APPLE_HOMEKIT_NOTIFY_START_BYTE: Final = 0x11 # homekit_controller +APPLE_START_BYTES_WANTED: Final = { + APPLE_IBEACON_START_BYTE, + APPLE_HOMEKIT_START_BYTE, + APPLE_HOMEKIT_NOTIFY_START_BYTE, + APPLE_DEVICE_ID_START_BYTE, +} + +MONOTONIC_TIME: Final = monotonic_time_coarse + +_LOGGER = logging.getLogger(__name__) + + +class CentralBluetoothManager: + """Central Bluetooth Manager.""" + + manager: BluetoothManager | None = None + + +def get_manager() -> BluetoothManager: + """Get the BluetoothManager.""" + if TYPE_CHECKING: + assert CentralBluetoothManager.manager is not None + return CentralBluetoothManager.manager + + +def set_manager(manager: BluetoothManager) -> None: + """Set the BluetoothManager.""" + CentralBluetoothManager.manager = manager + + +def _dispatch_bleak_callback( + callback: AdvertisementDataCallback | None, + filters: dict[str, set[str]], + device: BLEDevice, + advertisement_data: AdvertisementData, +) -> None: + """Dispatch the callback.""" + if not callback: + # Callback destroyed right before being called, ignore + return + + if (uuids := filters.get(FILTER_UUIDS)) and not uuids.intersection( + advertisement_data.service_uuids + ): + return + + try: + callback(device, advertisement_data) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in callback: %s", callback) + + +class BluetoothManager: + """Manage Bluetooth.""" + + __slots__ = ( + "_cancel_unavailable_tracking", + "_advertisement_tracker", + "_fallback_intervals", + "_intervals", + "_unavailable_callbacks", + "_connectable_unavailable_callbacks", + "_bleak_callbacks", + "_all_history", + "_connectable_history", + "_non_connectable_scanners", + "_connectable_scanners", + "_adapters", + "_sources", + "_bluetooth_adapters", + "slot_manager", + "_debug", + "shutdown", + "_loop", + ) + + def __init__( + self, + bluetooth_adapters: BluetoothAdapters, + slot_manager: BleakSlotManager, + ) -> None: + """Init bluetooth manager.""" + self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None + + self._advertisement_tracker = AdvertisementTracker() + self._fallback_intervals = self._advertisement_tracker.fallback_intervals + self._intervals = self._advertisement_tracker.intervals + + self._unavailable_callbacks: dict[ + str, list[Callable[[BluetoothServiceInfoBleak], None]] + ] = {} + self._connectable_unavailable_callbacks: dict[ + str, list[Callable[[BluetoothServiceInfoBleak], None]] + ] = {} + + self._bleak_callbacks: list[ + tuple[AdvertisementDataCallback, dict[str, set[str]]] + ] = [] + self._all_history: dict[str, BluetoothServiceInfoBleak] = {} + self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {} + self._non_connectable_scanners: list[BaseHaScanner] = [] + self._connectable_scanners: list[BaseHaScanner] = [] + self._adapters: dict[str, AdapterDetails] = {} + self._sources: dict[str, BaseHaScanner] = {} + self._bluetooth_adapters = bluetooth_adapters + self.slot_manager = slot_manager + self._debug = _LOGGER.isEnabledFor(logging.DEBUG) + self.shutdown = False + self._loop: asyncio.AbstractEventLoop | None = None + + @property + def supports_passive_scan(self) -> bool: + """Return if passive scan is supported.""" + return any(adapter[ADAPTER_PASSIVE_SCAN] for adapter in self._adapters.values()) + + def async_scanner_count(self, connectable: bool = True) -> int: + """Return the number of scanners.""" + if connectable: + return len(self._connectable_scanners) + return len(self._connectable_scanners) + len(self._non_connectable_scanners) + + async def async_diagnostics(self) -> dict[str, Any]: + """Diagnostics for the manager.""" + scanner_diagnostics = await asyncio.gather( + *[ + scanner.async_diagnostics() + for scanner in itertools.chain( + self._non_connectable_scanners, self._connectable_scanners + ) + ] + ) + return { + "adapters": self._adapters, + "slot_manager": self.slot_manager.diagnostics(), + "scanners": scanner_diagnostics, + "connectable_history": [ + service_info.as_dict() + for service_info in self._connectable_history.values() + ], + "all_history": [ + service_info.as_dict() for service_info in self._all_history.values() + ], + "advertisement_tracker": self._advertisement_tracker.async_diagnostics(), + } + + def _find_adapter_by_address(self, address: str) -> str | None: + for adapter, details in self._adapters.items(): + if details[ADAPTER_ADDRESS] == address: + return adapter + return None + + 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( + self, cached: bool = True + ) -> dict[str, AdapterDetails]: + """Get bluetooth adapters.""" + if not self._adapters or not cached: + if not cached: + await self._bluetooth_adapters.refresh() + self._adapters = self._bluetooth_adapters.adapters + return self._adapters + + async def async_get_adapter_from_address(self, address: str) -> str | None: + """Get adapter from address.""" + if adapter := self._find_adapter_by_address(address): + return adapter + await self._bluetooth_adapters.refresh() + self._adapters = self._bluetooth_adapters.adapters + return self._find_adapter_by_address(address) + + async def async_setup(self) -> None: + """Set up the bluetooth manager.""" + self._loop = asyncio.get_running_loop() + await self._bluetooth_adapters.refresh() + install_multiple_bleak_catcher() + self.async_setup_unavailable_tracking() + + def async_stop(self) -> None: + """Stop the Bluetooth integration at shutdown.""" + _LOGGER.debug("Stopping bluetooth manager") + self.shutdown = True + if self._cancel_unavailable_tracking: + self._cancel_unavailable_tracking.cancel() + self._cancel_unavailable_tracking = None + uninstall_multiple_bleak_catcher() + + def async_scanner_devices_by_address( + self, address: str, connectable: bool + ) -> list[BluetoothScannerDevice]: + """Get BluetoothScannerDevice by address.""" + if not connectable: + scanners: Iterable[BaseHaScanner] = itertools.chain( + self._connectable_scanners, self._non_connectable_scanners + ) + else: + scanners = self._connectable_scanners + return [ + BluetoothScannerDevice(scanner, *device_adv) + for scanner in scanners + if ( + device_adv := scanner.discovered_devices_and_advertisement_data.get( + address + ) + ) + ] + + def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: + """ + Return all of discovered addresses. + + Include addresses from all the scanners including duplicates. + """ + yield from itertools.chain.from_iterable( + scanner.discovered_devices_and_advertisement_data + for scanner in self._connectable_scanners + ) + if not connectable: + yield from itertools.chain.from_iterable( + scanner.discovered_devices_and_advertisement_data + for scanner in self._non_connectable_scanners + ) + + def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: + """Return all of combined best path to discovered from all the scanners.""" + histories = self._connectable_history if connectable else self._all_history + return [history.device for history in histories.values()] + + def async_setup_unavailable_tracking(self) -> None: + """Set up the unavailable tracking.""" + self._schedule_unavailable_tracking() + + def _schedule_unavailable_tracking(self) -> None: + """Schedule the unavailable tracking.""" + if TYPE_CHECKING: + assert self._loop is not None + loop = self._loop + self._cancel_unavailable_tracking = loop.call_at( + loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable + ) + + def _async_check_unavailable(self) -> None: + """Watch for unavailable devices and cleanup state history.""" + monotonic_now = MONOTONIC_TIME() + connectable_history = self._connectable_history + all_history = self._all_history + tracker = self._advertisement_tracker + intervals = tracker.intervals + + for connectable in (True, False): + if connectable: + unavailable_callbacks = self._connectable_unavailable_callbacks + else: + unavailable_callbacks = self._unavailable_callbacks + history = connectable_history if connectable else all_history + disappeared = set(history).difference( + self._async_all_discovered_addresses(connectable) + ) + for address in disappeared: + if not connectable: + # + # For non-connectable devices we also check the device has exceeded + # the advertising interval before we mark it as unavailable + # since it may have gone to sleep and since we do not need an active + # connection to it we can only determine its availability + # by the lack of advertisements + if advertising_interval := ( + intervals.get(address) or self._fallback_intervals.get(address) + ): + advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS + else: + advertising_interval = ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + ) + time_since_seen = monotonic_now - all_history[address].time + if time_since_seen <= advertising_interval: + continue + + # The second loop (connectable=False) is responsible for removing + # the device from all the interval tracking since it is no longer + # available for both connectable and non-connectable + tracker.async_remove_fallback_interval(address) + tracker.async_remove_address(address) + self._address_disappeared(address) + + service_info = history.pop(address) + + if not (callbacks := unavailable_callbacks.get(address)): + continue + + for callback in callbacks: + try: + callback(service_info) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in unavailable callback") + + self._schedule_unavailable_tracking() + + def _address_disappeared(self, address: str) -> None: + """ + Call when an address disappears from the stack. + + This method is intended to be overridden by subclasses. + """ + + def _prefer_previous_adv_from_different_source( + self, + old: BluetoothServiceInfoBleak, + new: BluetoothServiceInfoBleak, + ) -> bool: + """Prefer previous advertisement from a different source if it is better.""" + if new.time - old.time > ( + stale_seconds := self._intervals.get( + new.address, + self._fallback_intervals.get( + new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + ), + ) + ): + # If the old advertisement is stale, any new advertisement is preferred + if self._debug: + _LOGGER.debug( + ( + "%s (%s): Switching from %s to %s (time elapsed:%s > stale" + " seconds:%s)" + ), + new.name, + new.address, + self._async_describe_source(old), + self._async_describe_source(new), + new.time - old.time, + stale_seconds, + ) + return False + if (new.rssi or NO_RSSI_VALUE) - RSSI_SWITCH_THRESHOLD > ( + old.rssi or NO_RSSI_VALUE + ): + # If new advertisement is RSSI_SWITCH_THRESHOLD more, + # the new one is preferred. + if self._debug: + _LOGGER.debug( + ( + "%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >" + " old rssi:%s)" + ), + new.name, + new.address, + self._async_describe_source(old), + self._async_describe_source(new), + new.rssi, + RSSI_SWITCH_THRESHOLD, + old.rssi, + ) + return False + return True + + def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: + """ + Handle a new advertisement from any scanner. + + Callbacks from all the scanners arrive here. + """ + # Pre-filter noisy apple devices as they can account for 20-35% of the + # traffic on a typical network. + if ( + (manufacturer_data := service_info.manufacturer_data) + and APPLE_MFR_ID in manufacturer_data + and manufacturer_data[APPLE_MFR_ID][0] not in APPLE_START_BYTES_WANTED + and len(manufacturer_data) == 1 + and not service_info.service_data + ): + return + + address = service_info.device.address + all_history = self._all_history + connectable = service_info.connectable + connectable_history = self._connectable_history + old_connectable_service_info = connectable and connectable_history.get(address) + source = service_info.source + # This logic is complex due to the many combinations of scanners + # that are supported. + # + # We need to handle multiple connectable and non-connectable scanners + # and we need to handle the case where a device is connectable on one scanner + # but not on another. + # + # The device may also be connectable only by a scanner that has worse + # signal strength than a non-connectable scanner. + # + # all_history - the history of all advertisements from all scanners with the + # best advertisement from each scanner + # connectable_history - the history of all connectable advertisements from all + # scanners with the best advertisement from each + # connectable scanner + # + if ( + (old_service_info := all_history.get(address)) + and source != old_service_info.source + and (scanner := self._sources.get(old_service_info.source)) + and scanner.scanning + and self._prefer_previous_adv_from_different_source( + old_service_info, service_info + ) + ): + # If we are rejecting the new advertisement and the device is connectable + # but not in the connectable history or the connectable source is the same + # as the new source, we need to add it to the connectable history + if connectable: + if old_connectable_service_info and ( + # If its the same as the preferred source, we are done + # as we know we prefer the old advertisement + # from the check above + (old_connectable_service_info is old_service_info) + # If the old connectable source is different from the preferred + # source, we need to check it as well to see if we prefer + # the old connectable advertisement + or ( + source != old_connectable_service_info.source + and ( + connectable_scanner := self._sources.get( + old_connectable_service_info.source + ) + ) + and connectable_scanner.scanning + and self._prefer_previous_adv_from_different_source( + old_connectable_service_info, service_info + ) + ) + ): + return + + connectable_history[address] = service_info + + return + + if connectable: + connectable_history[address] = service_info + + all_history[address] = service_info + + # Track advertisement intervals to determine when we need to + # switch adapters or mark a device as unavailable + tracker = self._advertisement_tracker + if (last_source := tracker.sources.get(address)) and last_source != source: + # Source changed, remove the old address from the tracker + tracker.async_remove_address(address) + if address not in tracker.intervals: + tracker.async_collect(service_info) + + # If the advertisement data is the same as the last time we saw it, we + # don't need to do anything else unless its connectable and we are missing + # connectable history for the device so we can make it available again + # after unavailable callbacks. + if ( + # Ensure its not a connectable device missing from connectable history + not (connectable and not old_connectable_service_info) + # Than check if advertisement data is the same + and old_service_info + and not ( + 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 + + if not connectable and old_connectable_service_info: + # Since we have a connectable path and our BleakClient will + # route any connection attempts to the connectable path, we + # mark the service_info as connectable so that the callbacks + # will be called and the device can be discovered. + service_info = BluetoothServiceInfoBleak( + name=service_info.name, + address=service_info.address, + rssi=service_info.rssi, + manufacturer_data=service_info.manufacturer_data, + service_data=service_info.service_data, + service_uuids=service_info.service_uuids, + source=service_info.source, + device=service_info.device, + advertisement=service_info.advertisement, + connectable=True, + time=service_info.time, + ) + + if (connectable or old_connectable_service_info) and ( + bleak_callbacks := self._bleak_callbacks + ): + # Bleak callbacks must get a connectable device + device = service_info.device + advertisement_data = service_info.advertisement + for callback_filters in bleak_callbacks: + _dispatch_bleak_callback(*callback_filters, device, advertisement_data) + + self._discover_service_info(service_info) + + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: + """ + Discover a new service info. + + This method is intended to be overridden by subclasses. + """ + + 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 + + def async_track_unavailable( + self, + callback: Callable[[BluetoothServiceInfoBleak], None], + address: str, + connectable: bool, + ) -> Callable[[], None]: + """Register a callback.""" + if connectable: + unavailable_callbacks = self._connectable_unavailable_callbacks + else: + unavailable_callbacks = self._unavailable_callbacks + unavailable_callbacks.setdefault(address, []).append(callback) + + def _async_remove_callback() -> None: + unavailable_callbacks[address].remove(callback) + if not unavailable_callbacks[address]: + del unavailable_callbacks[address] + + return _async_remove_callback + + def async_ble_device_from_address( + self, address: str, connectable: bool + ) -> BLEDevice | None: + """Return the BLEDevice if present.""" + histories = self._connectable_history if connectable else self._all_history + if history := histories.get(address): + return history.device + return None + + def async_address_present(self, address: str, connectable: bool) -> bool: + """Return if the address is present.""" + histories = self._connectable_history if connectable else self._all_history + return address in histories + + def async_discovered_service_info( + self, connectable: bool + ) -> Iterable[BluetoothServiceInfoBleak]: + """Return all the discovered services info.""" + histories = self._connectable_history if connectable else self._all_history + return histories.values() + + def async_last_service_info( + self, address: str, connectable: bool + ) -> BluetoothServiceInfoBleak | None: + """Return the last service info for an address.""" + histories = self._connectable_history if connectable else self._all_history + return histories.get(address) + + def async_register_scanner( + self, + scanner: BaseHaScanner, + connectable: bool, + connection_slots: int | None = None, + ) -> CALLBACK_TYPE: + """Register a new scanner.""" + _LOGGER.debug("Registering scanner %s", scanner.name) + if connectable: + scanners = self._connectable_scanners + else: + scanners = self._non_connectable_scanners + + def _unregister_scanner() -> None: + _LOGGER.debug("Unregistering scanner %s", scanner.name) + self._advertisement_tracker.async_remove_source(scanner.source) + scanners.remove(scanner) + del self._sources[scanner.source] + if connection_slots: + self.slot_manager.remove_adapter(scanner.adapter) + + scanners.append(scanner) + self._sources[scanner.source] = scanner + if connection_slots: + self.slot_manager.register_adapter(scanner.adapter, connection_slots) + return _unregister_scanner + + def async_register_bleak_callback( + self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] + ) -> CALLBACK_TYPE: + """Register a callback.""" + callback_entry = (callback, filters) + self._bleak_callbacks.append(callback_entry) + + def _remove_callback() -> None: + self._bleak_callbacks.remove(callback_entry) + + # Replay the history since otherwise we miss devices + # that were already discovered before the callback was registered + # or we are in passive mode + for history in self._connectable_history.values(): + _dispatch_bleak_callback( + callback, filters, history.device, history.advertisement + ) + + return _remove_callback + + def async_release_connection_slot(self, device: BLEDevice) -> None: + """Release a connection slot.""" + self.slot_manager.release_slot(device) + + def async_allocate_connection_slot(self, device: BLEDevice) -> bool: + """Allocate a connection slot.""" + return self.slot_manager.allocate_slot(device) + + def async_get_learned_advertising_interval(self, address: str) -> float | None: + """Get the learned advertising interval for a MAC address.""" + return self._intervals.get(address) + + def async_get_fallback_availability_interval(self, address: str) -> float | None: + """Get the fallback availability timeout for a MAC address.""" + return self._fallback_intervals.get(address) + + def async_set_fallback_availability_interval( + self, address: str, interval: float + ) -> None: + """Override the fallback availability timeout for a MAC address.""" + self._fallback_intervals[address] = interval diff --git a/src/habluetooth/usage.py b/src/habluetooth/usage.py new file mode 100644 index 0000000..257622d --- /dev/null +++ b/src/habluetooth/usage.py @@ -0,0 +1,51 @@ +"""bluetooth usage utility to handle multiple instances.""" + +from __future__ import annotations + +import bleak +import bleak_retry_connector +from bleak.backends.service import BleakGATTServiceCollection + +from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper + +ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner +ORIGINAL_BLEAK_CLIENT = bleak.BleakClient +ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE = ( + bleak_retry_connector.BleakClientWithServiceCache +) +ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = bleak_retry_connector.BleakClient + + +def install_multiple_bleak_catcher() -> None: + """ + Wrap the bleak classes to return the shared instance. + + In case multiple instances are detected. + """ + bleak.BleakScanner = HaBleakScannerWrapper + bleak.BleakClient = HaBleakClientWrapper + bleak_retry_connector.BleakClientWithServiceCache = HaBleakClientWithServiceCache + bleak_retry_connector.BleakClient = HaBleakClientWrapper + + +def uninstall_multiple_bleak_catcher() -> None: + """Unwrap the bleak classes.""" + bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER + bleak.BleakClient = ORIGINAL_BLEAK_CLIENT + bleak_retry_connector.BleakClientWithServiceCache = ( + ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE + ) + bleak_retry_connector.BleakClient = ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT + + +class HaBleakClientWithServiceCache(HaBleakClientWrapper): + """A BleakClient that implements service caching.""" + + def set_cached_services(self, services: BleakGATTServiceCollection | None) -> None: + """ + Set the cached services. + + No longer used since bleak 0.17+ has service caching built-in. + + This was only kept for backwards compatibility. + """ diff --git a/src/habluetooth/wrappers.py b/src/habluetooth/wrappers.py new file mode 100644 index 0000000..c1277fa --- /dev/null +++ b/src/habluetooth/wrappers.py @@ -0,0 +1,385 @@ +"""Bleak wrappers for bluetooth.""" +from __future__ import annotations + +import asyncio +import contextlib +import inspect +import logging +from collections.abc import Callable +from dataclasses import dataclass +from functools import partial +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 ( + NO_RSSI_VALUE, + ble_device_description, + clear_cache, + device_source, +) + +from .base_scanner import BaseHaScanner, BluetoothScannerDevice +from .const import CALLBACK_TYPE +from .manager import get_manager + +FILTER_UUIDS: Final = "UUIDs" +_LOGGER = logging.getLogger(__name__) + + +if TYPE_CHECKING: + from .manager import BluetoothManager + + +@dataclass(slots=True) +class _HaWrappedBleakBackend: + """Wrap bleak backend to make it usable by Home Assistant.""" + + device: BLEDevice + scanner: BaseHaScanner + client: type[BaseBleakClient] + source: str | None + + +class HaBleakScannerWrapper(BaseBleakScanner): + """A wrapper that uses the single instance.""" + + def __init__( + self, + *args: Any, + detection_callback: AdvertisementDataCallback | None = None, + service_uuids: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Initialize the BleakScanner.""" + self._detection_cancel: CALLBACK_TYPE | None = None + self._mapped_filters: dict[str, set[str]] = {} + self._advertisement_data_callback: AdvertisementDataCallback | None = None + self._background_tasks: set[asyncio.Task[Any]] = set() + remapped_kwargs = { + "detection_callback": detection_callback, + "service_uuids": service_uuids or [], + **kwargs, + } + self._map_filters(*args, **remapped_kwargs) + super().__init__( + detection_callback=detection_callback, service_uuids=service_uuids or [] + ) + + @classmethod + async def discover(cls, timeout: float = 5.0, **kwargs: Any) -> list[BLEDevice]: + """Discover devices.""" + return list(get_manager().async_discovered_devices(True)) + + async def stop(self, *args: Any, **kwargs: Any) -> None: + """Stop scanning for devices.""" + + async def start(self, *args: Any, **kwargs: Any) -> None: + """Start scanning for devices.""" + + def _map_filters(self, *args: Any, **kwargs: Any) -> bool: + """Map the filters.""" + mapped_filters = {} + if filters := kwargs.get("filters"): + if filter_uuids := filters.get(FILTER_UUIDS): + mapped_filters[FILTER_UUIDS] = set(filter_uuids) + else: + _LOGGER.warning("Only %s filters are supported", FILTER_UUIDS) + if service_uuids := kwargs.get("service_uuids"): + mapped_filters[FILTER_UUIDS] = set(service_uuids) + if mapped_filters == self._mapped_filters: + return False + self._mapped_filters = mapped_filters + return True + + def set_scanning_filter(self, *args: Any, **kwargs: Any) -> None: + """Set the filters to use.""" + if self._map_filters(*args, **kwargs): + self._setup_detection_callback() + + def _cancel_callback(self) -> None: + """Cancel callback.""" + if self._detection_cancel: + self._detection_cancel() + self._detection_cancel = None + + @property + def discovered_devices(self) -> list[BLEDevice]: + """Return a list of discovered devices.""" + return list(get_manager().async_discovered_devices(True)) + + def register_detection_callback( + self, callback: AdvertisementDataCallback | None + ) -> Callable[[], None]: + """ + Register a detection callback. + + The callback is called when a device is discovered or has a property changed. + + This method takes the callback and registers it with the long running scanner. + """ + self._advertisement_data_callback = callback + self._setup_detection_callback() + if TYPE_CHECKING: + assert self._detection_cancel is not None + return self._detection_cancel + + def _setup_detection_callback(self) -> None: + """Set up the detection callback.""" + if self._advertisement_data_callback is None: + return + callback = self._advertisement_data_callback + self._cancel_callback() + super().register_detection_callback(self._advertisement_data_callback) + manager = get_manager() + + if not inspect.iscoroutinefunction(callback): + detection_callback = callback + else: + + def detection_callback( + ble_device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + task = asyncio.create_task(callback(ble_device, advertisement_data)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + self._detection_cancel = manager.async_register_bleak_callback( + detection_callback, self._mapped_filters + ) + + def __del__(self) -> None: + """Delete the BleakScanner.""" + if self._detection_cancel: + # Nothing to do if event loop is already closed + with contextlib.suppress(RuntimeError): + asyncio.get_running_loop().call_soon_threadsafe(self._detection_cancel) + + +def _rssi_sorter_with_connection_failure_penalty( + device: BluetoothScannerDevice, + connection_failure_count: dict[BaseHaScanner, int], + rssi_diff: int, +) -> float: + """ + Get a sorted list of scanner, device, advertisement data. + + Adjusting for previous connection failures. + + When a connection fails, we want to try the next best adapter so we + apply a penalty to the RSSI value to make it less likely to be chosen + for every previous connection failure. + + We use the 51% of the RSSI difference between the first and second + best adapter as the penalty. This ensures we will always try the + best adapter twice before moving on to the next best adapter since + the first failure may be a transient service resolution issue. + """ + base_rssi = device.advertisement.rssi or NO_RSSI_VALUE + if connect_failures := connection_failure_count.get(device.scanner): + if connect_failures > 1 and not rssi_diff: + rssi_diff = 1 + return base_rssi - (rssi_diff * connect_failures * 0.51) + return base_rssi + + +class HaBleakClientWrapper(BleakClient): + """ + Wrap the BleakClient to ensure it does not shutdown our scanner. + + If an address is passed into BleakClient instead of a BLEDevice, + bleak will quietly start a new scanner under the hood to resolve + the address. This can cause a conflict with our scanner. We need + to handle translating the address to the BLEDevice in this case + to avoid the whole stack from getting stuck in an in progress state + when an integration does this. + """ + + def __init__( # pylint: disable=super-init-not-called + 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): + self.__address = address_or_ble_device.address + else: + self.__address = address_or_ble_device + self.__disconnected_callback = disconnected_callback + self.__timeout = timeout + self.__connect_failures: dict[BaseHaScanner, int] = {} + self._backend: BaseBleakClient | None = None + + @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 + + async def clear_cache(self) -> bool: + """Clear the GATT cache.""" + if self._backend is not None and hasattr(self._backend, "clear_cache"): + return await self._backend.clear_cache() + return await clear_cache(self.__address) + + 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( + self._make_disconnected_callback(callback), + **kwargs, + ) + + def _make_disconnected_callback( + self, callback: Callable[[BleakClient], None] | None + ) -> Callable[[], None] | None: + """ + Make the disconnected callback. + + https://github.com/hbldh/bleak/pull/1256 + The disconnected callback needs to get the top level + BleakClientWrapper instance, not the backend instance. + + The signature of the callback for the backend is: + Callable[[], None] + + To make this work we need to wrap the callback in a partial + that passes the BleakClientWrapper instance as the first + argument. + """ + return None if callback is None else partial(callback, self) + + async def connect(self, **kwargs: Any) -> bool: + """Connect to the specified GATT server.""" + manager = get_manager() + if manager.shutdown: + raise BleakError("Bluetooth is already shutdown") + if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("%s: Looking for backend to connect", self.__address) + wrapped_backend = self._async_get_best_available_backend_and_device(manager) + device = wrapped_backend.device + scanner = wrapped_backend.scanner + self._backend = wrapped_backend.client( + device, + disconnected_callback=self._make_disconnected_callback( + self.__disconnected_callback + ), + timeout=self.__timeout, + ) + if debug_logging: + # Only lookup the description if we are going to log it + description = ble_device_description(device) + _, adv = scanner.discovered_devices_and_advertisement_data[device.address] + rssi = adv.rssi + _LOGGER.debug( + "%s: Connecting via %s (last rssi: %s)", description, scanner.name, rssi + ) + 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: + self.__connect_failures[scanner] = ( + self.__connect_failures.get(scanner, 0) + 1 + ) + if not wrapped_backend.source: + manager.async_release_connection_slot(device) + + if debug_logging: + _LOGGER.debug( + "%s: Connected via %s (last rssi: %s)", description, scanner.name, rssi + ) + return connected + + def _async_get_backend_for_ble_device( + self, manager: BluetoothManager, scanner: BaseHaScanner, ble_device: BLEDevice + ) -> _HaWrappedBleakBackend | None: + """Get the backend for a BLEDevice.""" + if not (source := device_source(ble_device)): + # If client is not defined in details + # its the client for this platform + if not manager.async_allocate_connection_slot(ble_device): + return None + cls = get_platform_client_backend_type() + return _HaWrappedBleakBackend(ble_device, scanner, cls, source) + + # Make sure the backend can connect to the device + # as some backends have connection limits + if not scanner.connector or not scanner.connector.can_connect(): + return None + + return _HaWrappedBleakBackend( + ble_device, scanner, scanner.connector.client, source + ) + + def _async_get_best_available_backend_and_device( + self, manager: BluetoothManager + ) -> _HaWrappedBleakBackend: + """ + Get a best available backend and device for the given address. + + This method will return the backend with the best rssi + that has a free connection slot. + """ + address = self.__address + devices = manager.async_scanner_devices_by_address(self.__address, True) + sorted_devices = sorted( + devices, + key=lambda device: device.advertisement.rssi or NO_RSSI_VALUE, + reverse=True, + ) + + # If we have connection failures we adjust the rssi sorting + # to prefer the adapter/scanner with the less failures so + # we don't keep trying to connect with an adapter + # that is failing + if self.__connect_failures and len(sorted_devices) > 1: + # We use the rssi diff between to the top two + # to adjust the rssi sorter so that each failure + # will reduce the rssi sorter by the diff amount + rssi_diff = ( + sorted_devices[0].advertisement.rssi + - sorted_devices[1].advertisement.rssi + ) + adjusted_rssi_sorter = partial( + _rssi_sorter_with_connection_failure_penalty, + connection_failure_count=self.__connect_failures, + rssi_diff=rssi_diff, + ) + sorted_devices = sorted( + devices, + key=adjusted_rssi_sorter, + reverse=True, + ) + + for device in sorted_devices: + if backend := self._async_get_backend_for_ble_device( + manager, device.scanner, device.ble_device + ): + return backend + + raise BleakError( + "No backend with an available connection slot that can reach address" + f" {address} was found" + ) + + async def disconnect(self) -> bool: + """Disconnect from the device.""" + if self._backend is None: + return True + return await self._backend.disconnect() From 84a15e48c4d1bbcb54b51723bf4ac244de02d601 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 11:41:49 -1000 Subject: [PATCH 2/5] feat: add some more exports --- src/habluetooth/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/habluetooth/__init__.py b/src/habluetooth/__init__.py index 33030b3..933dae9 100644 --- a/src/habluetooth/__init__.py +++ b/src/habluetooth/__init__.py @@ -15,8 +15,11 @@ from .manager import BluetoothManager, get_manager, set_manager from .models import HaBluetoothConnector from .scanner import BluetoothScanningMode, HaScanner, ScannerStartError +from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper __all__ = [ + "HaBleakScannerWrapper", + "HaBleakClientWrapper", "BluetoothManager", "get_manager", "set_manager", From 6bf769b7d05d1a5c53ebd3b90ec69667ea75b760 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 11:47:42 -1000 Subject: [PATCH 3/5] feat: add some more exports --- src/habluetooth/__init__.py | 4 ++-- src/habluetooth/manager.py | 18 ------------------ src/habluetooth/models.py | 22 +++++++++++++++++++++- src/habluetooth/wrappers.py | 2 +- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/habluetooth/__init__.py b/src/habluetooth/__init__.py index 933dae9..9efed5c 100644 --- a/src/habluetooth/__init__.py +++ b/src/habluetooth/__init__.py @@ -12,8 +12,8 @@ SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) -from .manager import BluetoothManager, get_manager, set_manager -from .models import HaBluetoothConnector +from .manager import BluetoothManager +from .models import HaBluetoothConnector, get_manager, set_manager from .scanner import BluetoothScanningMode, HaScanner, ScannerStartError from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 087bd6f..9882ab2 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -52,24 +52,6 @@ _LOGGER = logging.getLogger(__name__) -class CentralBluetoothManager: - """Central Bluetooth Manager.""" - - manager: BluetoothManager | None = None - - -def get_manager() -> BluetoothManager: - """Get the BluetoothManager.""" - if TYPE_CHECKING: - assert CentralBluetoothManager.manager is not None - return CentralBluetoothManager.manager - - -def set_manager(manager: BluetoothManager) -> None: - """Set the BluetoothManager.""" - CentralBluetoothManager.manager = manager - - def _dispatch_bleak_callback( callback: AdvertisementDataCallback | None, filters: dict[str, set[str]], diff --git a/src/habluetooth/models.py b/src/habluetooth/models.py index f1982d9..baa7df1 100644 --- a/src/habluetooth/models.py +++ b/src/habluetooth/models.py @@ -4,14 +4,34 @@ from collections.abc import Callable from dataclasses import dataclass from enum import Enum -from typing import Final +from typing import TYPE_CHECKING, Final from bleak import BaseBleakClient from bluetooth_data_tools import monotonic_time_coarse +from .manager import BluetoothManager + MONOTONIC_TIME: Final = monotonic_time_coarse +class CentralBluetoothManager: + """Central Bluetooth Manager.""" + + manager: BluetoothManager | None = None + + +def get_manager() -> BluetoothManager: + """Get the BluetoothManager.""" + if TYPE_CHECKING: + assert CentralBluetoothManager.manager is not None + return CentralBluetoothManager.manager + + +def set_manager(manager: BluetoothManager) -> None: + """Set the BluetoothManager.""" + CentralBluetoothManager.manager = manager + + @dataclass(slots=True) class HaBluetoothConnector: """Data for how to connect a BLEDevice from a given scanner.""" diff --git a/src/habluetooth/wrappers.py b/src/habluetooth/wrappers.py index c1277fa..f3868df 100644 --- a/src/habluetooth/wrappers.py +++ b/src/habluetooth/wrappers.py @@ -27,7 +27,7 @@ from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import CALLBACK_TYPE -from .manager import get_manager +from .models import get_manager FILTER_UUIDS: Final = "UUIDs" _LOGGER = logging.getLogger(__name__) From 2547592a8aceaae1b41587d81a108e61da97c9e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 11:51:59 -1000 Subject: [PATCH 4/5] feat: add some more exports --- src/habluetooth/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/habluetooth/models.py b/src/habluetooth/models.py index baa7df1..797480b 100644 --- a/src/habluetooth/models.py +++ b/src/habluetooth/models.py @@ -9,7 +9,8 @@ from bleak import BaseBleakClient from bluetooth_data_tools import monotonic_time_coarse -from .manager import BluetoothManager +if TYPE_CHECKING: + from .manager import BluetoothManager MONOTONIC_TIME: Final = monotonic_time_coarse From dcc151578581a784de300f64c2a321cfa76841ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 12:11:12 -1000 Subject: [PATCH 5/5] fix: use monotonic_time_coarse everywhere --- src/habluetooth/base_scanner.py | 11 +++++------ src/habluetooth/manager.py | 3 +-- src/habluetooth/models.py | 5 +---- src/habluetooth/scanner.py | 6 +++--- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/habluetooth/base_scanner.py b/src/habluetooth/base_scanner.py index a8642b5..5e38d50 100644 --- a/src/habluetooth/base_scanner.py +++ b/src/habluetooth/base_scanner.py @@ -24,7 +24,6 @@ from .models import HaBluetoothConnector SCANNER_WATCHDOG_INTERVAL_SECONDS: Final = SCANNER_WATCHDOG_INTERVAL.total_seconds() -MONOTONIC_TIME: Final = monotonic_time_coarse _LOGGER = logging.getLogger(__name__) @@ -91,7 +90,7 @@ def _async_stop_scanner_watchdog(self) -> None: def _async_setup_scanner_watchdog(self) -> None: """If something has restarted or updated, we need to restart the scanner.""" - self._start_time = self._last_detection = MONOTONIC_TIME() + self._start_time = self._last_detection = monotonic_time_coarse() if not self._cancel_watchdog: self._schedule_watchdog() @@ -113,7 +112,7 @@ def _async_call_scanner_watchdog(self) -> None: def _async_watchdog_triggered(self) -> bool: """Check if the watchdog has been triggered.""" - time_since_last_detection = MONOTONIC_TIME() - self._last_detection + time_since_last_detection = monotonic_time_coarse() - self._last_detection _LOGGER.debug( "%s: Scanner watchdog time_since_last_detection: %s", self.name, @@ -175,7 +174,7 @@ async def async_diagnostics(self) -> dict[str, Any]: "scanning": self.scanning, "type": self.__class__.__name__, "last_detection": self._last_detection, - "monotonic_time": MONOTONIC_TIME(), + "monotonic_time": monotonic_time_coarse(), "discovered_devices_and_advertisement_data": [ { "name": device.name, @@ -251,7 +250,7 @@ def _schedule_expire_devices(self) -> None: def _async_expire_devices(self) -> None: """Expire old devices.""" - now = MONOTONIC_TIME() + now = monotonic_time_coarse() expired = [ address for address, timestamp in self._discovered_device_timestamps.items() @@ -379,7 +378,7 @@ def _async_on_advertisement( async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" - now = MONOTONIC_TIME() + now = monotonic_time_coarse() return await super().async_diagnostics() | { "connectable": self.connectable, "discovered_device_timestamps": self._discovered_device_timestamps, diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 9882ab2..9038b7b 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -47,7 +47,6 @@ APPLE_DEVICE_ID_START_BYTE, } -MONOTONIC_TIME: Final = monotonic_time_coarse _LOGGER = logging.getLogger(__name__) @@ -267,7 +266,7 @@ def _schedule_unavailable_tracking(self) -> None: def _async_check_unavailable(self) -> None: """Watch for unavailable devices and cleanup state history.""" - monotonic_now = MONOTONIC_TIME() + monotonic_now = monotonic_time_coarse() connectable_history = self._connectable_history all_history = self._all_history tracker = self._advertisement_tracker diff --git a/src/habluetooth/models.py b/src/habluetooth/models.py index 797480b..f4871b8 100644 --- a/src/habluetooth/models.py +++ b/src/habluetooth/models.py @@ -4,16 +4,13 @@ from collections.abc import Callable from dataclasses import dataclass from enum import Enum -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING from bleak import BaseBleakClient -from bluetooth_data_tools import monotonic_time_coarse if TYPE_CHECKING: from .manager import BluetoothManager -MONOTONIC_TIME: Final = monotonic_time_coarse - class CentralBluetoothManager: """Central Bluetooth Manager.""" diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 4889217..1f273f3 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -16,7 +16,7 @@ from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback from bleak_retry_connector import restore_discoveries from bluetooth_adapters import DEFAULT_ADDRESS -from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME +from bluetooth_data_tools import monotonic_time_coarse from dbus_fast import InvalidMessageError from home_assistant_bluetooth import BluetoothServiceInfoBleak @@ -186,7 +186,7 @@ def _async_detection_callback( Currently this is used to feed the callbacks into the central manager. """ - callback_time = MONOTONIC_TIME() + callback_time = monotonic_time_coarse() if ( advertisement_data.local_name or advertisement_data.manufacturer_data @@ -347,7 +347,7 @@ def _async_scanner_watchdog(self) -> None: async def _async_restart_scanner(self) -> None: """Restart the scanner.""" async with self._start_stop_lock: - time_since_last_detection = MONOTONIC_TIME() - self._last_detection + time_since_last_detection = monotonic_time_coarse() - self._last_detection # Stop the scanner but not the watchdog # since we want to try again later if it's still quiet await self._async_stop_scanner()