From f1409a02406e079b1c90f7699e1e6e58db0cc81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bojan=20Poto=C4=8Dnik?= Date: Thu, 24 Nov 2022 13:01:00 +0100 Subject: [PATCH 1/3] Add BleakNoPassiveScanError exception This exception can be used to e.g. retry scanning with scan_mode adjusted to "active" on systems not supporting the "passive" scan. --- CHANGELOG.rst | 1 + bleak/__init__.py | 2 +- bleak/backends/bluezdbus/manager.py | 7 ++++--- bleak/backends/corebluetooth/scanner.py | 11 ++++++----- bleak/exc.py | 8 ++++++++ 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a95c77f87..b65865fd9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,7 @@ Added * Added ``BleakScanner.find_device_by_name()`` class method. * Added optional command line argument to use debug log level to all applicable examples. * Make sure the disconnect monitor task is properly cancelled on the BlueZ client. +* Added ``BleakNoPassiveScanError`` exception. Changed ------- diff --git a/bleak/__init__.py b/bleak/__init__.py index 2ced4b02d..cee393f8d 100644 --- a/bleak/__init__.py +++ b/bleak/__init__.py @@ -90,7 +90,7 @@ class BleakScanner: scanning_mode: Set to ``"passive"`` to avoid the ``"active"`` scanning mode. Passive scanning is not supported on macOS! Will raise - :class:`BleakError` if set to ``"passive"`` on macOS. + :class:`BleakNoPassiveScanError` if set to ``"passive"`` on macOS. bluez: Dictionary of arguments specific to the BlueZ backend. cb: diff --git a/bleak/backends/bluezdbus/manager.py b/bleak/backends/bluezdbus/manager.py index cfb826ed7..598cd7be7 100644 --- a/bleak/backends/bluezdbus/manager.py +++ b/bleak/backends/bluezdbus/manager.py @@ -27,7 +27,7 @@ from dbus_fast import BusType, Message, MessageType, Variant, unpack_variants from dbus_fast.aio.message_bus import MessageBus -from ...exc import BleakDBusError, BleakError +from ...exc import BleakDBusError, BleakError, BleakNoPassiveScanError from ..service import BleakGATTServiceCollection from . import defs from .advertisement_monitor import AdvertisementMonitor, OrPatternLike @@ -470,8 +470,9 @@ async def passive_scan( reply.message_type == MessageType.ERROR and reply.error_name == "org.freedesktop.DBus.Error.UnknownMethod" ): - raise BleakError( - "passive scanning on Linux requires BlueZ >= 5.55 with --experimental enabled and Linux kernel >= 5.10" + raise BleakNoPassiveScanError( + "passive scanning on Linux requires BlueZ >= 5.55 with --experimental enabled" + " and Linux kernel >= 5.10" ) assert_reply(reply) diff --git a/bleak/backends/corebluetooth/scanner.py b/bleak/backends/corebluetooth/scanner.py index bfcd2e30e..fa07655f2 100644 --- a/bleak/backends/corebluetooth/scanner.py +++ b/bleak/backends/corebluetooth/scanner.py @@ -11,7 +11,7 @@ from CoreBluetooth import CBPeripheral from Foundation import NSBundle -from ...exc import BleakError +from ...exc import BleakNoPassiveScanError from ..scanner import AdvertisementData, AdvertisementDataCallback, BaseBleakScanner from .CentralManagerDelegate import CentralManagerDelegate from .utils import cb_uuid_to_str @@ -54,8 +54,8 @@ class BleakScannerCoreBluetooth(BaseBleakScanner): macOS >= 12.0, < 12.3 (unless you create an app with ``py2app``). scanning_mode: Set to ``"passive"`` to avoid the ``"active"`` scanning mode. Not - supported on macOS! Will raise :class:`BleakError` if set to - ``"passive"`` + supported on macOS! Will raise :class:`BleakNoPassiveScanError` + if set to ``"passive"`` **timeout (float): The scanning timeout to be used, in case of missing ``stopScan_`` method. @@ -77,7 +77,7 @@ def __init__( self._use_bdaddr = cb.get("use_bdaddr", False) if scanning_mode == "passive": - raise BleakError("macOS does not support passive scanning") + raise BleakNoPassiveScanError("macOS does not support passive scanning") self._manager = CentralManagerDelegate.alloc().init() self._timeout: float = kwargs.get("timeout", 5.0) @@ -89,7 +89,8 @@ def __init__( # See https://github.com/hbldh/bleak/issues/720 if NSBundle.mainBundle().bundleIdentifier() == "org.python.python": logger.error( - "macOS 12.0, 12.1 and 12.2 require non-empty service_uuids kwarg, otherwise no advertisement data will be received" + "macOS 12.0, 12.1 and 12.2 require non-empty service_uuids kwarg," + " otherwise no advertisement data will be received" ) async def start(self): diff --git a/bleak/exc.py b/bleak/exc.py index 2126de157..5f76a3ff0 100644 --- a/bleak/exc.py +++ b/bleak/exc.py @@ -63,6 +63,14 @@ def __str__(self) -> str: return (name + " " + details) if details else name +class BleakNoPassiveScanError(BleakError): + """ + Exception raised when passive scanning mode is tried to be used on the system not supporting it + """ + + pass + + CONTROLLER_ERROR_CODES = { 0x00: "Success", 0x01: "Unknown HCI Command", From 106bbcaa4fd343517535bb41dc54347a633825b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bojan=20Poto=C4=8Dnik?= Date: Wed, 30 Nov 2022 14:40:47 +0100 Subject: [PATCH 2/3] bluezdbus/scanner: Detect AdvertisementMonitor not registering --- CHANGELOG.rst | 1 + .../bluezdbus/advertisement_monitor.py | 14 +++++-- bleak/backends/bluezdbus/manager.py | 38 +++++++++++++++++-- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b65865fd9..0e5c26639 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,7 @@ Added * Added optional command line argument to use debug log level to all applicable examples. * Make sure the disconnect monitor task is properly cancelled on the BlueZ client. * Added ``BleakNoPassiveScanError`` exception. +* Make sure that BlueZ Advertisement Monitor is actually registered when passive scanning. Solves #1136. Changed ------- diff --git a/bleak/backends/bluezdbus/advertisement_monitor.py b/bleak/backends/bluezdbus/advertisement_monitor.py index 9bdee086f..7e9cc948f 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]] +StatusCallback = Callable[[bool], None] + + class AdvertisementMonitor(ServiceInterface): """ Implementation of the org.bluez.AdvertisementMonitor1 D-Bus interface. @@ -49,25 +52,30 @@ class AdvertisementMonitor(ServiceInterface): """ def __init__( - self, - or_patterns: Iterable[OrPatternLike], + self, or_patterns: Iterable[OrPatternLike], status_callback: StatusCallback ): """ Args: or_patterns: List of or patterns that will be returned by the ``Patterns`` property. + status_callback: + A callback that is called with argument ``True`` when the D-bus "Activate" + method is called, or with ``False`` when "Release" 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._status_callback = status_callback @method() def Release(self): logger.debug("Release") + self._status_callback(False) @method() def Activate(self): logger.debug("Activate") + self._status_callback(True) # REVISIT: mypy is broke, so we have to add redundant @no_type_check # https://github.com/python/mypy/issues/6583 diff --git a/bleak/backends/bluezdbus/manager.py b/bleak/backends/bluezdbus/manager.py index 598cd7be7..1d980a054 100644 --- a/bleak/backends/bluezdbus/manager.py +++ b/bleak/backends/bluezdbus/manager.py @@ -9,6 +9,7 @@ import asyncio import logging import os +import sys from typing import ( Any, Callable, @@ -24,6 +25,11 @@ ) from weakref import WeakKeyDictionary +if sys.version_info < (3, 11): + from async_timeout import timeout as async_timeout +else: + from asyncio import timeout as async_timeout + from dbus_fast import BusType, Message, MessageType, Variant, unpack_variants from dbus_fast.aio.message_bus import MessageBus @@ -448,12 +454,20 @@ async def passive_scan( ) self._device_removed_callbacks.append(device_removed_callback_and_state) + # If advertisement monitor is released before the scanning is stopped, it means that the + # kernel does not support passive scanning and error was returned when trying to execute + # MGMT command "Add Adv Patterns Monitor" (see #1136). Otherwise, monitor is activated + # and starts to receive advertisement packets. + monitor_activated = asyncio.Queue() + try: - monitor = AdvertisementMonitor(filters) + monitor = AdvertisementMonitor(filters, monitor_activated.put_nowait) # this should be a unique path to allow multiple python interpreters # running bleak and multiple scanners within a single interpreter - monitor_path = f"/org/bleak/{os.getpid()}/{id(monitor)}" + monitor_path = ( + f"/org/bleak/{os.getpid()}/{type(monitor).__name__}_{id(monitor)}" + ) reply = await self._bus.call( Message( @@ -505,7 +519,17 @@ async def stop(): ) assert_reply(reply) - return stop + try: + # Advertising Monitor will be "immediately" activated or released + async with async_timeout(1): + if await monitor_activated.get(): + # Advertising Monitor has been activated + return stop + + except asyncio.TimeoutError: + pass + + # Do not call await stop() here as the bus is already locked except BaseException: # if starting scanning failed, don't leak the callbacks @@ -513,6 +537,14 @@ async def stop(): self._device_removed_callbacks.remove(device_removed_callback_and_state) raise + # Reaching here means that the Advertising Monitor has not been successfully activated + await stop() + + raise BleakNoPassiveScanError( + "Advertising Monitor (required for passive scanning) is not supported by this kernel" + " (Linux kernel >= 5.10 is required)" + ) + def add_device_watcher( self, device_path: str, From aaa57889a013934d1da7f5e5ccddd91704d09c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bojan=20Poto=C4=8Dnik?= Date: Thu, 24 Nov 2022 12:32:55 +0100 Subject: [PATCH 3/3] examples/passive_scan: Add example using passive scanning mode In case of BlueZ, using passive scanning mode is not simply passing "passive" as a ``scan_mode`` parameter, but requires more arguments. This example showcases that. --- CHANGELOG.rst | 3 +- examples/passive_scan.py | 128 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 examples/passive_scan.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0e5c26639..c608b28bc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,7 +17,8 @@ Added * Added optional command line argument to use debug log level to all applicable examples. * Make sure the disconnect monitor task is properly cancelled on the BlueZ client. * Added ``BleakNoPassiveScanError`` exception. -* Make sure that BlueZ Advertisement Monitor is actually registered when passive scanning. Solves #1136. +* Added check to verify that BlueZ Advertisement Monitor was actually registered. Solves #1136. +* Added ``passive_scan.py`` example. Changed ------- diff --git a/examples/passive_scan.py b/examples/passive_scan.py new file mode 100644 index 000000000..1dc8da147 --- /dev/null +++ b/examples/passive_scan.py @@ -0,0 +1,128 @@ +""" +Scanner using passive scanning mode +-------------- + +Example similar to detection_callback.py, but using passive scanning + +Updated on 2022-11-24 by bojanpotocnik + +""" +import argparse +import asyncio +import logging +from typing import Optional, List, Dict, Any + +import bleak +from bleak import AdvertisementData, BLEDevice, BleakScanner + +logger = logging.getLogger(__name__) + + +def _get_os_specific_scanning_params( + uuids: Optional[List[str]], + rssi: Optional[int] = None, + macos_use_bdaddr: bool = False, +) -> Dict[str, Any]: + def get_bluez_dbus_scanning_params() -> Dict[str, Any]: + from bleak.assigned_numbers import AdvertisementDataType + from bleak.backends.bluezdbus.advertisement_monitor import OrPattern + from bleak.backends.bluezdbus.scanner import ( + BlueZScannerArgs, + BlueZDiscoveryFilters, + ) + + filters = BlueZDiscoveryFilters( + # UUIDs= Added below, because it cannot be None + # RSSI= Added below, because it cannot be None + Transport="le", + DuplicateData=True, + ) + if uuids: + filters["UUIDs"] = uuids + if rssi: + filters["RSSI"] = rssi + + # or_patterns ar required for BlueZ passive scanning + or_patterns = [ + # General Discoverable (peripherals) + OrPattern(0, AdvertisementDataType.FLAGS, b"\x02"), + # BR/EDR Not Supported (BLE peripherals) + OrPattern(0, AdvertisementDataType.FLAGS, b"\x04"), + # General Discoverable, BR/EDR Not Supported (BLE peripherals) + OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), + # General Discoverable, LE and BR/EDR Capable (Controller), LE and BR/EDR Capable (Host) (computers, phones) + OrPattern(0, AdvertisementDataType.FLAGS, b"\x1A"), + ] + + return {"bluez": BlueZScannerArgs(filters=filters, or_patterns=or_patterns)} + + def get_core_bluetooth_scanning_params() -> Dict[str, Any]: + from bleak.backends.corebluetooth.scanner import CBScannerArgs + + return {"cb": CBScannerArgs(use_bdaddr=macos_use_bdaddr)} + + return { + "BleakScannerBlueZDBus": get_bluez_dbus_scanning_params, + "BleakScannerCoreBluetooth": get_core_bluetooth_scanning_params, + # "BleakScannerP4Android": get_p4android_scanning_params, + # "BleakScannerWinRT": get_winrt_scanning_params, + }.get(bleak.get_platform_scanner_backend_type().__name__, lambda: {})() + + +async def scan(args: argparse.Namespace, passive_mode: bool): + def scan_callback(device: BLEDevice, adv_data: AdvertisementData): + logger.info("%s: %r", device.address, adv_data) + + async with BleakScanner( + detection_callback=scan_callback, + **_get_os_specific_scanning_params( + uuids=args.services, macos_use_bdaddr=args.macos_use_bdaddr + ), + scanning_mode="passive" if passive_mode else "active", + ): + await asyncio.sleep(60) + + +async def main(args: argparse.Namespace): + try: + await scan(args, passive_mode=True) + except bleak.exc.BleakNoPassiveScanError as e: + if args.fallback: + logger.warning( + f"Passive scanning not possible, using active scanning ({e})" + ) + await scan(args, passive_mode=False) + else: + logger.error(f"Passive scanning not possible ({e})") + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)-15s %(name)-8s %(levelname)s: %(message)s", + ) + + parser = argparse.ArgumentParser() + parser.add_argument( + "--fallback", + action="store_true", + help="fallback to active scanning mode if passive mode is not possible", + ) + parser.add_argument( + "--macos-use-bdaddr", + action="store_true", + help="when true use Bluetooth address instead of UUID on macOS", + ) + parser.add_argument( + "--services", + metavar="", + nargs="*", + help="UUIDs of one or more services to filter for", + ) + + arguments = parser.parse_args() + + try: + asyncio.run(main(arguments)) + except KeyboardInterrupt: + pass