From 047e18bc4e3b0e904ee338b48b683f5a21b02c31 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 25 Nov 2022 16:37:55 -0600 Subject: [PATCH] backends/scanner: add handle_early_stop hooks All supported platforms provide some sort of hook that we can use to get a callback when scanning stops. On Mac and Linux, there is only a simple boolean value indicating if scanning is currently in progress. Windows and Android provide additional error information, but this is ignored for now since it isn't available cross-platform. What we actually do with this hook is not yet implemented. --- .../bluezdbus/advertisement_monitor.py | 12 +++++-- bleak/backends/bluezdbus/manager.py | 35 +++++++++++++++++-- bleak/backends/bluezdbus/scanner.py | 2 ++ .../corebluetooth/CentralManagerDelegate.py | 18 ++++++++-- bleak/backends/corebluetooth/scanner.py | 2 +- bleak/backends/p4android/scanner.py | 4 +++ bleak/backends/scanner.py | 3 ++ bleak/backends/winrt/scanner.py | 30 +++++++++++++--- 8 files changed, 92 insertions(+), 14 deletions(-) diff --git a/bleak/backends/bluezdbus/advertisement_monitor.py b/bleak/backends/bluezdbus/advertisement_monitor.py index 9bdee086..5d66de09 100644 --- a/bleak/backends/bluezdbus/advertisement_monitor.py +++ b/bleak/backends/bluezdbus/advertisement_monitor.py @@ -7,7 +7,7 @@ """ import logging -from typing import Iterable, NamedTuple, Tuple, Union, no_type_check +from typing import Callable, Iterable, NamedTuple, Tuple, Union, no_type_check from dbus_fast.service import ServiceInterface, dbus_property, method, PropertyAccess @@ -34,6 +34,9 @@ class OrPattern(NamedTuple): OrPatternLike = Union[OrPattern, Tuple[int, AdvertisementDataType, bytes]] +ReleasedCallback = Callable[[], None] + + class AdvertisementMonitor(ServiceInterface): """ Implementation of the org.bluez.AdvertisementMonitor1 D-Bus interface. @@ -49,21 +52,24 @@ class AdvertisementMonitor(ServiceInterface): """ def __init__( - self, - or_patterns: Iterable[OrPatternLike], + self, or_patterns: Iterable[OrPatternLike], released_callback: ReleasedCallback ): """ Args: or_patterns: List of or patterns that will be returned by the ``Patterns`` property. + released_callback: + A callback that is called when the D-bus "Release" method is called. """ super().__init__(defs.ADVERTISEMENT_MONITOR_INTERFACE) # dbus_fast marshaling requires list instead of tuple self._or_patterns = [list(p) for p in or_patterns] + self._released_callback = released_callback @method() def Release(self): logger.debug("Release") + self._released_callback() @method() def Activate(self): diff --git a/bleak/backends/bluezdbus/manager.py b/bleak/backends/bluezdbus/manager.py index 8ee6e5d0..41c68996 100644 --- a/bleak/backends/bluezdbus/manager.py +++ b/bleak/backends/bluezdbus/manager.py @@ -91,6 +91,14 @@ class DeviceRemovedCallbackAndState(NamedTuple): """ +DiscoveryStoppedCallback = Callable[[], None] + + +class DiscoveryStoppedCallbackAndState(NamedTuple): + callback: DiscoveryStoppedCallback + adapter_path: str + + DeviceConnectedChangedCallback = Callable[[bool], None] """ A callback that is called when a device's "Connected" property changes. @@ -167,6 +175,7 @@ def __init__(self): self._advertisement_callbacks: List[CallbackAndState] = [] self._device_removed_callbacks: List[DeviceRemovedCallbackAndState] = [] + self._discovery_stopped_callbacks: List[DiscoveryStoppedCallbackAndState] = [] self._device_watchers: Set[DeviceWatcher] = set() self._condition_callbacks: Set[Callable] = set() self._services_cache: Dict[str, BleakGATTServiceCollection] = {} @@ -312,6 +321,7 @@ async def active_scan( filters: Dict[str, Variant], advertisement_callback: AdvertisementCallback, device_removed_callback: DeviceRemovedCallback, + discovery_stopped_callback: DiscoveryStoppedCallback, ) -> Callable[[], Coroutine]: """ Configures the advertisement data filters and starts scanning. @@ -323,6 +333,9 @@ async def active_scan( A callable that will be called when new advertisement data is received. device_removed_callback: A callable that will be called when a device is removed from BlueZ. + discovery_stopped_callback: + A callable that will be called if discovery is stopped early + (before stop was requested by calling the return value). Returns: An async function that is used to stop scanning and remove the filters. @@ -342,6 +355,13 @@ async def active_scan( ) self._device_removed_callbacks.append(device_removed_callback_and_state) + discovery_stopped_callback_and_state = DiscoveryStoppedCallbackAndState( + discovery_stopped_callback, adapter_path + ) + self._discovery_stopped_callbacks.append( + discovery_stopped_callback_and_state + ) + try: # Apply the filters reply = await self._bus.call( @@ -375,6 +395,9 @@ async def stop() -> None: self._device_removed_callbacks.remove( device_removed_callback_and_state ) + self._discovery_stopped_callbacks.remove( + discovery_stopped_callback_and_state + ) async with self._bus_lock: reply = await self._bus.call( @@ -413,6 +436,7 @@ async def passive_scan( filters: List[OrPatternLike], advertisement_callback: AdvertisementCallback, device_removed_callback: DeviceRemovedCallback, + discovery_stopped_callback: DiscoveryStoppedCallback, ) -> Callable[[], Coroutine]: """ Configures the advertisement data filters and starts scanning. @@ -444,7 +468,7 @@ async def passive_scan( self._device_removed_callbacks.append(device_removed_callback_and_state) try: - monitor = AdvertisementMonitor(filters) + monitor = AdvertisementMonitor(filters, discovery_stopped_callback) # this should be a unique path to allow multiple python interpreters # running bleak and multiple scanners within a single interpreter @@ -828,7 +852,14 @@ def _parse_msg(self, message: Message): # then call any callbacks so they will be called with the # updated state - if interface == defs.DEVICE_INTERFACE: + if interface == defs.ADAPTER_INTERFACE: + if "Discovering" in changed and not self_interface["Discovering"]: + for ( + discovery_stopped_callback, + _, + ) in self._discovery_stopped_callbacks: + discovery_stopped_callback() + elif interface == defs.DEVICE_INTERFACE: # handle advertisement watchers self._run_advertisement_callbacks( diff --git a/bleak/backends/bluezdbus/scanner.py b/bleak/backends/bluezdbus/scanner.py index d9cce225..9ca2654a 100644 --- a/bleak/backends/bluezdbus/scanner.py +++ b/bleak/backends/bluezdbus/scanner.py @@ -186,6 +186,7 @@ async def start(self): self._or_patterns, self._handle_advertising_data, self._handle_device_removed, + self.handle_early_stop, ) else: self._stop = await manager.active_scan( @@ -193,6 +194,7 @@ async def start(self): self._filters, self._handle_advertising_data, self._handle_device_removed, + self.handle_early_stop, ) async def stop(self): diff --git a/bleak/backends/corebluetooth/CentralManagerDelegate.py b/bleak/backends/corebluetooth/CentralManagerDelegate.py index 42726f30..daf6f073 100644 --- a/bleak/backends/corebluetooth/CentralManagerDelegate.py +++ b/bleak/backends/corebluetooth/CentralManagerDelegate.py @@ -10,7 +10,7 @@ import logging import sys import threading -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, Iterable, Optional if sys.version_info < (3, 11): from async_timeout import timeout as async_timeout @@ -49,6 +49,7 @@ DisconnectCallback = Callable[[], None] +ScanningStoppedCallback = Callable[[], None] class CentralManagerDelegate(NSObject): @@ -93,6 +94,8 @@ def init(self) -> Optional["CentralManagerDelegate"]: if self.central_manager.state() != CBManagerStatePoweredOn: raise BleakError("Bluetooth device is turned off") + self._scanning_stopped_callback: Optional[ScanningStoppedCallback] + # isScanning property was added in 10.13 if objc.macos_available(10, 13): self.central_manager.addObserver_forKeyPath_options_context_( @@ -110,7 +113,11 @@ def __del__(self): # User defined functions @objc.python_method - async def start_scan(self, service_uuids) -> None: + async def start_scan( + self, + service_uuids: Iterable[str], + scanning_stopped_callback: ScanningStoppedCallback, + ) -> None: service_uuids = ( NSArray.alloc().initWithArray_( list(map(CBUUID.UUIDWithString_, service_uuids)) @@ -133,8 +140,11 @@ async def start_scan(self, service_uuids) -> None: else: await asyncio.sleep(0.1) + self._scanning_stopped_callback = scanning_stopped_callback + @objc.python_method async def stop_scan(self) -> None: + self._scanning_stopped_callback = None self.central_manager.stopScan() # The `isScanning` property was added in macOS 10.13, so before that @@ -199,11 +209,13 @@ def _changed_is_scanning(self, is_scanning: bool) -> None: else: if self._did_stop_scanning_event: self._did_stop_scanning_event.set() + if self._scanning_stopped_callback: + self._scanning_stopped_callback() def observeValueForKeyPath_ofObject_change_context_( self, keyPath: NSString, object: Any, change: NSDictionary, context: int ) -> None: - logger.debug("'%s' changed", keyPath) + logger.debug("'%s' changed: %r", keyPath, change) if keyPath != "isScanning": return diff --git a/bleak/backends/corebluetooth/scanner.py b/bleak/backends/corebluetooth/scanner.py index bfcd2e30..a7807ed5 100644 --- a/bleak/backends/corebluetooth/scanner.py +++ b/bleak/backends/corebluetooth/scanner.py @@ -152,7 +152,7 @@ def callback(p: CBPeripheral, a: Dict[str, Any], r: int) -> None: self._callback(device, advertisement_data) self._manager.callbacks[id(self)] = callback - await self._manager.start_scan(self._service_uuids) + await self._manager.start_scan(self._service_uuids, self.handle_early_stop) async def stop(self): await self._manager.stop_scan() diff --git a/bleak/backends/p4android/scanner.py b/bleak/backends/p4android/scanner.py index 609e5846..11770dae 100644 --- a/bleak/backends/p4android/scanner.py +++ b/bleak/backends/p4android/scanner.py @@ -140,6 +140,9 @@ def handle_permissions(permissions, grantResults): ) self.__javascanner.flushPendingScanResults(self.__callback.java) + # REVISIT: we shouldn't wait and check for error here, instead we should + # just allow the stopped early callback to handle this and let the user + # decide what to do. try: async with async_timeout(0.2): await scanfuture @@ -292,6 +295,7 @@ def result_state(self, status_str, name, *data): @java_method("(I)V") def onScanFailed(self, errorCode): + self._loop.call_soon_threadsafe(self._scanner.handle_early_stop) self.result_state(defs.ScanFailed(errorCode).name, "onScan") @java_method("(Landroid/bluetooth/le/ScanResult;)V") diff --git a/bleak/backends/scanner.py b/bleak/backends/scanner.py index 359c6ce5..168ef184 100644 --- a/bleak/backends/scanner.py +++ b/bleak/backends/scanner.py @@ -212,6 +212,9 @@ def create_or_update_device( return device + def handle_early_stop(self) -> None: + ... + @abc.abstractmethod async def start(self): """Start scanning for devices""" diff --git a/bleak/backends/winrt/scanner.py b/bleak/backends/winrt/scanner.py index 1da9553c..658076bf 100644 --- a/bleak/backends/winrt/scanner.py +++ b/bleak/backends/winrt/scanner.py @@ -1,14 +1,17 @@ import asyncio +import functools import logging import sys from typing import Dict, List, NamedTuple, Optional from uuid import UUID from bleak_winrt.windows.devices.bluetooth.advertisement import ( - BluetoothLEScanningMode, - BluetoothLEAdvertisementWatcher, BluetoothLEAdvertisementReceivedEventArgs, BluetoothLEAdvertisementType, + BluetoothLEAdvertisementWatcher, + BluetoothLEAdvertisementWatcherStatus, + BluetoothLEAdvertisementWatcherStoppedEventArgs, + BluetoothLEScanningMode, ) if sys.version_info[:2] < (3, 8): @@ -222,12 +225,29 @@ def _received_handler( self._callback(device, advertisement_data) - def _stopped_handler(self, sender, e): + def _handle_stopped_threadsafe( + self, + loop: asyncio.AbstractEventLoop, + sender: BluetoothLEAdvertisementWatcher, + e: BluetoothLEAdvertisementWatcherStoppedEventArgs, + ) -> None: + logger.debug("watcher status: %s, error: %s", sender.status.name, e.error.name) + + loop.call_soon_threadsafe( + self._handle_stopped, + sender.status == BluetoothLEAdvertisementWatcherStatus.ABORTED, + ) + + def _handle_stopped(self, from_error: bool) -> None: logger.debug( "{0} devices found. Watcher status: {1}.".format( - len(self.seen_devices), self.watcher.status + len(self.seen_devices), self.watcher.status.name ) ) + + if from_error: + self.handle_early_stop() + self._stopped_event.set() async def start(self): @@ -245,7 +265,7 @@ async def start(self): lambda s, e: event_loop.call_soon_threadsafe(self._received_handler, s, e) ) self._stopped_token = self.watcher.add_stopped( - lambda s, e: event_loop.call_soon_threadsafe(self._stopped_handler, s, e) + functools.partial(self._handle_stopped_threadsafe, event_loop) ) if self._signal_strength_filter is not None: