Skip to content

bluez: Verify that Advertisement Monitor has been registered #1140

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ 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.
* Added check to verify that BlueZ Advertisement Monitor was actually registered. Solves #1136.
* Added ``passive_scan.py`` example.

Changed
-------
Expand Down
2 changes: 1 addition & 1 deletion bleak/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 11 additions & 3 deletions bleak/backends/bluezdbus/advertisement_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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
Expand Down
45 changes: 39 additions & 6 deletions bleak/backends/bluezdbus/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import asyncio
import logging
import os
import sys
from typing import (
Any,
Callable,
Expand All @@ -24,10 +25,15 @@
)
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

from ...exc import BleakDBusError, BleakError
from ...exc import BleakDBusError, BleakError, BleakNoPassiveScanError
from ..service import BleakGATTServiceCollection
from . import defs
from .advertisement_monitor import AdvertisementMonitor, OrPatternLike
Expand Down Expand Up @@ -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(
Expand All @@ -470,8 +484,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)
Expand Down Expand Up @@ -504,14 +519,32 @@ 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
self._advertisement_callbacks.remove(callback_and_state)
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,
Expand Down
11 changes: 6 additions & 5 deletions bleak/backends/corebluetooth/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions bleak/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
128 changes: 128 additions & 0 deletions examples/passive_scan.py
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>

"""
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="<uuid>",
nargs="*",
help="UUIDs of one or more services to filter for",
)

arguments = parser.parse_args()

try:
asyncio.run(main(arguments))
except KeyboardInterrupt:
pass