From 9472a10f2324caee532c4cfdc7e9381212931e35 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 29 Oct 2022 21:36:41 -0500 Subject: [PATCH 1/3] BleakClient: add close() method This adds a close() method to the BleakClient class and backends. This is used to explicitly close an resource, like the D-Bus socket in the BlueZ backend. In the BlueZ backend, the D-Bus socket is no longer closed on disconnect. This is needed to avoid problems with D-Bus methods failing with EOFError() or hanging forever because the socket was disconnect. --- CHANGELOG.rst | 7 ++ bleak/__init__.py | 11 +++ bleak/backends/bluezdbus/client.py | 107 +++++++++++------------------ bleak/backends/client.py | 3 + 4 files changed, 60 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cda3f51e..e18aa099 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,11 +10,17 @@ and this project adheres to `Semantic Versioning = 3.11. * Deprecated ``BLEDevice.rssi`` and ``BLEDevice.metadata``. Fixes #1025. * ``BLEDevice`` now uses ``__slots__`` to reduce memory usage. +* BlueZ no longer closes D-Bus socket on disconnect of ``BleakClient``. `0.19.4`_ (2022-11-06) @@ -42,6 +48,7 @@ Fixed * Fixed cache mode when retrying get services in WinRT backend. Merged #1102. * Fixed ``KeyError`` crash in BlueZ backend when removing non-existent property. Fixes #1107. + `0.19.1`_ (2022-10-29) ====================== diff --git a/bleak/__init__.py b/bleak/__init__.py index f2c49807..ad4027f9 100644 --- a/bleak/__init__.py +++ b/bleak/__init__.py @@ -438,6 +438,17 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc_val, exc_tb): await self.disconnect() + self.close() + + def __del__(self): + # Remember kids: __del__is NOT guaranteed to run EVER! + # Use the context manager or call close explicitly. + # This is only here for people with long running programs that didn't + # follow that advice so that they don't run out of file descriptors. + self.close() + + def close(self): + self._backend.close() # Connectivity methods diff --git a/bleak/backends/bluezdbus/client.py b/bleak/backends/bluezdbus/client.py index 6c419f22..0c1a154d 100644 --- a/bleak/backends/bluezdbus/client.py +++ b/bleak/backends/bluezdbus/client.py @@ -65,8 +65,14 @@ def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs): self._device_path = None self._device_info = None - # D-Bus message bus - self._bus: Optional[MessageBus] = None + # Each client needs it's own D-Bus connection to avoid a quirk where + # BlueZ automatically enables notifications on reconnection (before + # we can add a handler for them) and also ensures that the pairing + # agent will only handle handle requests for this specific connection. + self._bus: MessageBus = MessageBus( + bus_type=BusType.SYSTEM, negotiate_unix_fd=True + ) + # tracks device watcher subscription self._remove_device_watcher: Optional[Callable] = None # private backing for is_connected property @@ -81,6 +87,9 @@ def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs): # used to override mtu_size property self._mtu_size: Optional[int] = None + def close(self): + self._bus.disconnect() + # Connectivity methods async def connect(self, dangerous_use_bleak_cache: bool = False, **kwargs) -> bool: @@ -127,11 +136,8 @@ async def connect(self, dangerous_use_bleak_cache: bool = False, **kwargs) -> bo manager = await get_global_bluez_manager() - # Each BLE connection session needs a new D-Bus connection to avoid a - # BlueZ quirk where notifications are automatically enabled on reconnect. - self._bus = await MessageBus( - bus_type=BusType.SYSTEM, negotiate_unix_fd=True - ).connect() + if not self._bus.connected: + await self._bus.connect() def on_connected_changed(connected: bool) -> None: if not connected: @@ -218,31 +224,27 @@ def on_value_changed(char_path: str, value: bytes) -> None: # if connection was successful but get_services() raises (e.g. # because task was cancelled), the we still need to disconnect # before passing on the exception. - if self._bus: - # If disconnected callback already fired, this will be a no-op - # since self._bus will be None and the _cleanup_all call will - # have already disconnected. - try: - reply = await self._bus.call( - Message( - destination=defs.BLUEZ_SERVICE, - interface=defs.DEVICE_INTERFACE, - path=self._device_path, - member="Disconnect", - ) - ) - try: - assert_reply(reply) - except BleakDBusError as e: - # if the object no longer exists, then we know we - # are disconnected for sure, so don't need to log a - # warning about it - if e.dbus_error != ErrorType.UNKNOWN_OBJECT.value: - raise - except Exception as e: - logger.warning( - f"Failed to cancel connection ({self._device_path}): {e}" + try: + reply = await self._bus.call( + Message( + destination=defs.BLUEZ_SERVICE, + interface=defs.DEVICE_INTERFACE, + path=self._device_path, + member="Disconnect", ) + ) + try: + assert_reply(reply) + except BleakDBusError as e: + # if the object no longer exists, then we know we + # are disconnected for sure, so don't need to log a + # warning about it + if e.dbus_error != ErrorType.UNKNOWN_OBJECT.value: + raise + except Exception as e: + logger.warning( + f"Failed to cancel connection ({self._device_path}): {e}" + ) raise except BaseException: @@ -287,30 +289,14 @@ def _cleanup_all(self) -> None: """ logger.debug(f"_cleanup_all({self._device_path})") + # Reset all stored services. + self.services = BleakGATTServiceCollection() + self._services_resolved = False + if self._remove_device_watcher: self._remove_device_watcher() self._remove_device_watcher = None - - if not self._bus: - logger.debug(f"already disconnected ({self._device_path})") - return - - # Try to disconnect the System Bus. - try: - self._bus.disconnect() - except Exception as e: - logger.error( - f"Attempt to disconnect system bus failed ({self._device_path}): {e}" - ) - else: - # Critical to remove the `self._bus` object here to since it was - # closed above. If not, calls made to it later could lead to - # a stuck client. - self._bus = None - - # Reset all stored services. - self.services = BleakGATTServiceCollection() - self._services_resolved = False + self._is_connected = False async def disconnect(self) -> bool: """Disconnect from the specified GATT server. @@ -324,13 +310,6 @@ async def disconnect(self) -> bool: """ logger.debug(f"Disconnecting ({self._device_path})") - if self._bus is None: - # No connection exists. Either one hasn't been created or - # we have already called disconnect and closed the D-Bus - # connection. - logger.debug(f"already disconnected ({self._device_path})") - return True - if self._disconnecting_event: # another call to disconnect() is already in progress logger.debug(f"already in progress ({self._device_path})") @@ -354,10 +333,6 @@ async def disconnect(self) -> bool: finally: self._disconnecting_event = None - # sanity check to make sure _cleanup_all() was triggered by the - # "PropertiesChanged" signal handler and that it completed successfully - assert self._bus is None - return True async def pair(self, *args, **kwargs) -> bool: @@ -481,9 +456,7 @@ def is_connected(self) -> bool: Boolean representing connection status. """ - return self._DeprecatedIsConnectedReturn( - False if self._bus is None else self._is_connected - ) + return self._DeprecatedIsConnectedReturn(self._is_connected) async def _acquire_mtu(self) -> None: """Acquires the MTU for this device by calling the "AcquireWrite" or @@ -877,8 +850,6 @@ async def start_notify( self._notification_callbacks[characteristic.path] = callback - assert self._bus is not None - reply = await self._bus.call( Message( destination=defs.BLUEZ_SERVICE, diff --git a/bleak/backends/client.py b/bleak/backends/client.py index 6bcdf760..75b628e7 100644 --- a/bleak/backends/client.py +++ b/bleak/backends/client.py @@ -49,6 +49,9 @@ def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs): self._timeout = kwargs.get("timeout", 10.0) self._disconnected_callback = kwargs.get("disconnected_callback") + def close(self): + pass + @property @abc.abstractmethod def mtu_size(self) -> int: From 589c975d88d8e53e4884d22c61fc3de38697a45b Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 29 Oct 2022 22:13:31 -0500 Subject: [PATCH 2/3] BleakClient: add callbacks arg to pair() method This adds a callbacks arg to programmatically responding to pairing requests instead of allowing the OS to prompt the user. This just adds the parameter but does not provide an implementation yet. --- bleak/__init__.py | 18 +++++- bleak/agent.py | 52 +++++++++++++++ bleak/backends/bluezdbus/client.py | 17 ++--- bleak/backends/client.py | 7 +- bleak/backends/corebluetooth/client.py | 26 ++------ bleak/backends/p4android/client.py | 18 +++--- bleak/backends/winrt/client.py | 26 ++++---- bleak/exc.py | 12 ++++ examples/pairing_agent.py | 88 ++++++++++++++++++++++++++ 9 files changed, 210 insertions(+), 54 deletions(-) create mode 100644 bleak/agent.py create mode 100644 examples/pairing_agent.py diff --git a/bleak/__init__.py b/bleak/__init__.py index ad4027f9..f37f1058 100644 --- a/bleak/__init__.py +++ b/bleak/__init__.py @@ -38,6 +38,7 @@ else: from typing import Literal +from .agent import BaseBleakAgentCallbacks from .backends.characteristic import BleakGATTCharacteristic from .backends.client import BaseBleakClient, get_platform_client_backend_type from .backends.device import BLEDevice @@ -493,7 +494,9 @@ async def disconnect(self) -> bool: """ return await self._backend.disconnect() - async def pair(self, *args, **kwargs) -> bool: + async def pair( + self, callbacks: Optional[BaseBleakAgentCallbacks] = None, **kwargs + ) -> bool: """ Pair with the specified GATT server. @@ -502,11 +505,22 @@ async def pair(self, *args, **kwargs) -> bool: that a characteristic that requires authentication is read or written. This method may have backend-specific additional keyword arguments. + Args: + callbacks: + Optional callbacks for confirming or requesting pin. This is + only supported on Linux and Windows. If omitted, the OS will + handle the pairing request. + Returns: Always returns ``True`` for backwards compatibility. + Raises: + BleakPairingCancelledError: + if pairing was canceled before it completed (device disconnected, etc.) + BleakPairingFailedError: + if pairing failed (rejected, wrong pin, etc.) """ - return await self._backend.pair(*args, **kwargs) + return await self._backend.pair(callbacks, **kwargs) async def unpair(self) -> bool: """ diff --git a/bleak/agent.py b/bleak/agent.py new file mode 100644 index 00000000..40a2b6e3 --- /dev/null +++ b/bleak/agent.py @@ -0,0 +1,52 @@ +import abc +from typing import Optional + +from .backends.device import BLEDevice + + +class BaseBleakAgentCallbacks(abc.ABC): + @abc.abstractmethod + async def confirm(self, device: BLEDevice) -> bool: + """ + Implementers should prompt the user to confirm or reject the pairing + request. + + Returns: + ``True`` to accept the pairing request or ``False`` to reject it. + """ + + @abc.abstractmethod + async def confirm_pin(self, device: BLEDevice, pin: str) -> bool: + """ + Implementers should display the pin code to the user and prompt the + user to validate the pin code and confirm or reject the pairing request. + + Args: + pin: The pin code to be confirmed. + + Returns: + ``True`` to accept the pairing request or ``False`` to reject it. + """ + + @abc.abstractmethod + async def display_pin(self, device: BLEDevice, pin: str) -> None: + """ + Implementers should display the pin code to the user. + + This method should block indefinitely until it canceled (i.e. + ``await asyncio.Event().wait()``). + + Args: + pin: The pin code to be confirmed. + """ + + @abc.abstractmethod + async def request_pin(self, device: BLEDevice) -> Optional[str]: + """ + Implementers should prompt the user to enter a pin code to accept the + pairing request or to reject the paring request. + + Returns: + A string containing the pin code to accept the pairing request or + ``None`` to reject it. + """ diff --git a/bleak/backends/bluezdbus/client.py b/bleak/backends/bluezdbus/client.py index 0c1a154d..3478741c 100644 --- a/bleak/backends/bluezdbus/client.py +++ b/bleak/backends/bluezdbus/client.py @@ -21,6 +21,7 @@ from dbus_fast.signature import Variant from ... import BleakScanner +from ...agent import BaseBleakAgentCallbacks from ...exc import BleakDBusError, BleakError, BleakDeviceNotFoundError from ..characteristic import BleakGATTCharacteristic from ..client import BaseBleakClient, NotifyCallback @@ -335,16 +336,16 @@ async def disconnect(self) -> bool: return True - async def pair(self, *args, **kwargs) -> bool: - """Pair with the peripheral. + async def pair( + self, callbacks: Optional[BaseBleakAgentCallbacks], **kwargs + ) -> bool: + """ + Pair with the peripheral. + """ - You can use ConnectDevice method if you already know the MAC address of the device. - Else you need to StartDiscovery, Trust, Pair and Connect in sequence. + if callbacks: + raise NotImplementedError - Returns: - Boolean regarding success of pairing. - - """ # See if it is already paired. reply = await self._bus.call( Message( diff --git a/bleak/backends/client.py b/bleak/backends/client.py index 75b628e7..d06b887d 100644 --- a/bleak/backends/client.py +++ b/bleak/backends/client.py @@ -13,10 +13,11 @@ from typing import Callable, Optional, Type, Union from warnings import warn +from ..agent import BaseBleakAgentCallbacks from ..exc import BleakError -from .service import BleakGATTServiceCollection from .characteristic import BleakGATTCharacteristic from .device import BLEDevice +from .service import BleakGATTServiceCollection NotifyCallback = Callable[[bytearray], None] @@ -105,7 +106,9 @@ async def disconnect(self) -> bool: raise NotImplementedError() @abc.abstractmethod - async def pair(self, *args, **kwargs) -> bool: + async def pair( + self, callbacks: Optional[BaseBleakAgentCallbacks], **kwargs + ) -> bool: """Pair with the peripheral.""" raise NotImplementedError() diff --git a/bleak/backends/corebluetooth/client.py b/bleak/backends/corebluetooth/client.py index fb91b93c..30c9f207 100644 --- a/bleak/backends/corebluetooth/client.py +++ b/bleak/backends/corebluetooth/client.py @@ -17,7 +17,8 @@ from Foundation import NSArray, NSData from ... import BleakScanner -from ...exc import BleakError, BleakDeviceNotFoundError +from ...agent import BaseBleakAgentCallbacks +from ...exc import BleakDeviceNotFoundError, BleakError from ..characteristic import BleakGATTCharacteristic from ..client import BaseBleakClient, NotifyCallback from ..device import BLEDevice @@ -154,24 +155,11 @@ def mtu_size(self) -> int: + 3 ) - async def pair(self, *args, **kwargs) -> bool: - """Attempt to pair with a peripheral. - - .. note:: - - This is not available on macOS since there is not explicit method to do a pairing, Instead the docs - state that it "auto-pairs" when trying to read a characteristic that requires encryption, something - Bleak cannot do apparently. - - Reference: - - - `Apple Docs `_ - - `Stack Overflow post #1 `_ - - `Stack Overflow post #2 `_ - - Returns: - Boolean regarding success of pairing. - + async def pair( + self, callbacks: Optional[BaseBleakAgentCallbacks], **kwargs + ) -> bool: + """ + Attempt to pair with a peripheral. """ raise NotImplementedError("Pairing is not available in Core Bluetooth.") diff --git a/bleak/backends/p4android/client.py b/bleak/backends/p4android/client.py index 3a19a183..a4186f45 100644 --- a/bleak/backends/p4android/client.py +++ b/bleak/backends/p4android/client.py @@ -11,6 +11,7 @@ from android.broadcast import BroadcastReceiver from jnius import java_method +from ...agent import BaseBleakAgentCallbacks from ...exc import BleakError from ..characteristic import BleakGATTCharacteristic from ..client import BaseBleakClient, NotifyCallback @@ -156,16 +157,15 @@ async def disconnect(self) -> bool: return True - async def pair(self, *args, **kwargs) -> bool: - """Pair with the peripheral. - - You can use ConnectDevice method if you already know the MAC address of the device. - Else you need to StartDiscovery, Trust, Pair and Connect in sequence. - - Returns: - Boolean regarding success of pairing. - + async def pair( + self, callbacks: Optional[BaseBleakAgentCallbacks], **kwargs + ) -> bool: + """ + Pair with the peripheral. """ + if callbacks is not None: + warnings.warn("callbacks ignored on Android", RuntimeWarning, stacklevel=2) + loop = asyncio.get_running_loop() bondedFuture = loop.create_future() diff --git a/bleak/backends/winrt/client.py b/bleak/backends/winrt/client.py index af5c906c..a323695e 100644 --- a/bleak/backends/winrt/client.py +++ b/bleak/backends/winrt/client.py @@ -57,6 +57,7 @@ from bleak_winrt.windows.storage.streams import Buffer from ... import BleakScanner +from ...agent import BaseBleakAgentCallbacks from ...exc import PROTOCOL_ERROR_CODES, BleakDeviceNotFoundError, BleakError from ..characteristic import BleakGATTCharacteristic from ..client import BaseBleakClient, NotifyCallback @@ -457,21 +458,18 @@ def mtu_size(self) -> int: """Get ATT MTU size for active connection""" return self._session.max_pdu_size - async def pair(self, protection_level: int = None, **kwargs) -> bool: - """Attempts to pair with the device. - - Keyword Args: - protection_level (int): A ``DevicePairingProtectionLevel`` enum value: - - 1. None - Pair the device using no levels of protection. - 2. Encryption - Pair the device using encryption. - 3. EncryptionAndAuthentication - Pair the device using - encryption and authentication. (This will not work in Bleak...) - - Returns: - Boolean regarding success of pairing. - + async def pair( + self, + callbacks: Optional[BaseBleakAgentCallbacks], + protection_level: int = None, + **kwargs, + ) -> bool: """ + Attempts to pair with the device. + """ + if callbacks: + raise NotImplementedError + # New local device information object created since the object from the requester isn't updated device_information = await DeviceInformation.create_from_id_async( self._requester.device_information.id diff --git a/bleak/exc.py b/bleak/exc.py index 2126de15..f0af6aca 100644 --- a/bleak/exc.py +++ b/bleak/exc.py @@ -27,6 +27,18 @@ def __init__(self, identifier: str, *args: object) -> None: self.identifier = identifier +class BleakPairingCancelledError(BleakError): + """ + Specialized exception to indicate that pairing was canceled. + """ + + +class BleakPairingFailedError(BleakError): + """ + Specialized exception to indicate that pairing failed. + """ + + class BleakDBusError(BleakError): """Specialized exception type for D-Bus errors.""" diff --git a/examples/pairing_agent.py b/examples/pairing_agent.py new file mode 100644 index 00000000..b16d269e --- /dev/null +++ b/examples/pairing_agent.py @@ -0,0 +1,88 @@ +import argparse +import asyncio +import sys + +from bleak import BleakScanner, BleakClient, BaseBleakAgentCallbacks +from bleak.backends.device import BLEDevice +from bleak.exc import BleakPairingCancelledError, BleakPairingFailedError + + +class AgentCallbacks(BaseBleakAgentCallbacks): + def __init__(self) -> None: + super().__init__() + self._reader = asyncio.StreamReader() + + async def __aenter__(self): + loop = asyncio.get_running_loop() + protocol = asyncio.StreamReaderProtocol(self._reader) + self._input_transport, _ = await loop.connect_read_pipe( + lambda: protocol, sys.stdin + ) + return self + + async def __aexit__(self, *args): + self._input_transport.close() + + async def _input(self, msg: str) -> str: + """ + Async version of the builtin input function. + """ + print(msg, end=" ", flush=True) + return (await self._reader.readline()).decode().strip() + + async def confirm(self, device: BLEDevice) -> bool: + print(f"{device.name} wants to pair.") + response = await self._input("confirm (y/n)?") + + return response.lower().startswith("y") + + async def confirm_pin(self, device: BLEDevice, pin: str) -> bool: + print(f"{device.name} wants to pair.") + response = await self._input(f"does {pin} match (y/n)?") + + return response.lower().startswith("y") + + async def display_pin(self, device: BLEDevice, pin: str) -> None: + print(f"{device.name} wants to pair.") + print(f"enter this pin on the device: {pin}") + # wait for cancellation + await asyncio.Event().wait() + + async def request_pin(self, device: BLEDevice) -> str: + print(f"{device.name} wants to pair.") + response = await self._input("enter pin:") + + return response + + +async def main(addr: str, unpair: bool) -> None: + if unpair: + print("unpairing...") + await BleakClient(addr).unpair() + + print("scanning...") + + device = await BleakScanner.find_device_by_address(addr) + + if device is None: + print("device was not found") + return + + async with BleakClient(device) as client, AgentCallbacks() as callbacks: + try: + await client.pair(callbacks) + except BleakPairingCancelledError: + print("paring was canceled") + except BleakPairingFailedError: + print("pairing failed (bad pin?)") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser("pairing_agent.py") + parser.add_argument("address", help="the Bluetooth address (or UUID on macOS)") + parser.add_argument( + "--unpair", action="store_true", help="unpair first before pairing" + ) + args = parser.parse_args() + + asyncio.run(main(args.address, args.unpair)) From 4e46255fe85a67798a127c8ec020d3e095ec78ba Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 29 Oct 2022 22:27:35 -0500 Subject: [PATCH 3/3] bluez: implement pairing agent --- bleak/backends/bluezdbus/agent.py | 171 ++++++++++++++++++++++++++++ bleak/backends/bluezdbus/client.py | 55 +++++---- bleak/backends/bluezdbus/defs.py | 2 + bleak/backends/bluezdbus/manager.py | 14 +++ 4 files changed, 218 insertions(+), 24 deletions(-) create mode 100644 bleak/backends/bluezdbus/agent.py diff --git a/bleak/backends/bluezdbus/agent.py b/bleak/backends/bluezdbus/agent.py new file mode 100644 index 00000000..66022f2e --- /dev/null +++ b/bleak/backends/bluezdbus/agent.py @@ -0,0 +1,171 @@ +""" +Agent +----- + +This module contains types associated with the BlueZ D-Bus `agent api +`. +""" + +import asyncio +import contextlib +import logging +import os +from typing import Set, no_type_check + +from dbus_fast import DBusError, Message +from dbus_fast.aio import MessageBus +from dbus_fast.service import ServiceInterface, method + +from bleak.backends.device import BLEDevice + +from ...agent import BaseBleakAgentCallbacks +from . import defs +from .manager import get_global_bluez_manager +from .utils import assert_reply + +logger = logging.getLogger(__name__) + + +class Agent(ServiceInterface): + """ + Implementation of the org.bluez.Agent1 D-Bus interface. + """ + + def __init__(self, callbacks: BaseBleakAgentCallbacks): + """ + Args: + """ + super().__init__(defs.AGENT_INTERFACE) + self._callbacks = callbacks + self._tasks: Set[asyncio.Task] = set() + + async def _create_ble_device(self, device_path: str) -> BLEDevice: + manager = await get_global_bluez_manager() + props = manager.get_device_props(device_path) + return BLEDevice( + props["Address"], props["Alias"], {"path": device_path, "props": props} + ) + + @method() + def Release(self): + logger.debug("Release") + + # REVISIT: mypy is broke, so we have to add redundant @no_type_check + # https://github.com/python/mypy/issues/6583 + + @method() + @no_type_check + async def RequestPinCode(self, device: "o") -> "s": # noqa: F821 + logger.debug("RequestPinCode %s", device) + raise NotImplementedError + + @method() + @no_type_check + async def DisplayPinCode(self, device: "o", pincode: "s"): # noqa: F821 + logger.debug("DisplayPinCode %s %s", device, pincode) + raise NotImplementedError + + @method() + @no_type_check + async def RequestPasskey(self, device: "o") -> "u": # noqa: F821 + logger.debug("RequestPasskey %s", device) + + ble_device = await self._create_ble_device(device) + + task = asyncio.create_task(self._callbacks.request_pin(ble_device)) + self._tasks.add(task) + + try: + pin = await task + except asyncio.CancelledError: + raise DBusError("org.bluez.Error.Canceled", "task canceled") + finally: + self._tasks.remove(task) + + if not pin: + raise DBusError("org.bluez.Error.Rejected", "user rejected") + + return int(pin) + + @method() + @no_type_check + async def DisplayPasskey( + self, device: "o", passkey: "u", entered: "q" # noqa: F821 + ): + passkey = f"{passkey:06}" + logger.debug("DisplayPasskey %s %s %d", device, passkey, entered) + raise NotImplementedError + + @method() + @no_type_check + async def RequestConfirmation(self, device: "o", passkey: "u"): # noqa: F821 + passkey = f"{passkey:06}" + logger.debug("RequestConfirmation %s %s", device, passkey) + raise NotImplementedError + + @method() + @no_type_check + async def RequestAuthorization(self, device: "o"): # noqa: F821 + logger.debug("RequestAuthorization %s", device) + raise NotImplementedError + + @method() + @no_type_check + async def AuthorizeService(self, device: "o", uuid: "s"): # noqa: F821 + logger.debug("AuthorizeService %s", device, uuid) + raise NotImplementedError + + @method() + @no_type_check + def Cancel(self): # noqa: F821 + logger.debug("Cancel") + for t in self._tasks: + t.cancel() + + +@contextlib.asynccontextmanager +async def bluez_agent(bus: MessageBus, callbacks: BaseBleakAgentCallbacks): + agent = Agent(callbacks) + + # REVISIT: implement passing capability if needed + # "DisplayOnly", "DisplayYesNo", "KeyboardOnly", "NoInputNoOutput", "KeyboardDisplay" + capability = "" + + # this should be a unique path to allow multiple python interpreters + # running bleak and multiple agents at the same time + agent_path = f"/org/bleak/agent/{os.getpid()}/{id(agent)}" + + bus.export(agent_path, agent) + + try: + reply = await bus.call( + Message( + destination=defs.BLUEZ_SERVICE, + path="/org/bluez", + interface=defs.AGENT_MANAGER_INTERFACE, + member="RegisterAgent", + signature="os", + body=[agent_path, capability], + ) + ) + + assert_reply(reply) + + try: + yield + finally: + reply = await bus.call( + Message( + destination=defs.BLUEZ_SERVICE, + path="/org/bluez", + interface=defs.AGENT_MANAGER_INTERFACE, + member="UnregisterAgent", + signature="o", + body=[agent_path], + ) + ) + + assert_reply(reply) + + finally: + bus.unexport(agent_path, agent) diff --git a/bleak/backends/bluezdbus/client.py b/bleak/backends/bluezdbus/client.py index 3478741c..f20f1174 100644 --- a/bleak/backends/bluezdbus/client.py +++ b/bleak/backends/bluezdbus/client.py @@ -3,6 +3,7 @@ BLE Client for BlueZ on Linux """ import asyncio +import contextlib import logging import os import sys @@ -22,12 +23,19 @@ from ... import BleakScanner from ...agent import BaseBleakAgentCallbacks -from ...exc import BleakDBusError, BleakError, BleakDeviceNotFoundError +from ...exc import ( + BleakDBusError, + BleakDeviceNotFoundError, + BleakError, + BleakPairingCancelledError, + BleakPairingFailedError, +) from ..characteristic import BleakGATTCharacteristic from ..client import BaseBleakClient, NotifyCallback from ..device import BLEDevice from ..service import BleakGATTServiceCollection from . import defs +from .agent import bluez_agent from .characteristic import BleakGATTCharacteristicBlueZDBus from .manager import get_global_bluez_manager from .scanner import BleakScannerBlueZDBus @@ -343,9 +351,6 @@ async def pair( Pair with the peripheral. """ - if callbacks: - raise NotImplementedError - # See if it is already paired. reply = await self._bus.call( Message( @@ -377,29 +382,31 @@ async def pair( logger.debug("Pairing to BLE device @ %s", self.address) - reply = await self._bus.call( - Message( - destination=defs.BLUEZ_SERVICE, - path=self._device_path, - interface=defs.DEVICE_INTERFACE, - member="Pair", + async with contextlib.nullcontext() if callbacks is None else bluez_agent( + self._bus, callbacks + ): + reply = await self._bus.call( + Message( + destination=defs.BLUEZ_SERVICE, + path=self._device_path, + interface=defs.DEVICE_INTERFACE, + member="Pair", + ) ) - ) - assert_reply(reply) - reply = await self._bus.call( - Message( - destination=defs.BLUEZ_SERVICE, - path=self._device_path, - interface=defs.PROPERTIES_INTERFACE, - member="Get", - signature="ss", - body=[defs.DEVICE_INTERFACE, "Paired"], - ) - ) - assert_reply(reply) + try: + assert_reply(reply) + except BleakDBusError as e: + if e.dbus_error == "org.bluez.Error.AuthenticationCanceled": + raise BleakPairingCancelledError from e + + if e.dbus_error == "org.bluez.Error.AuthenticationFailed": + raise BleakPairingFailedError from e - return reply.body[0].value + raise + + # for backwards compatibility + return True async def unpair(self) -> bool: """Unpair with the peripheral. diff --git a/bleak/backends/bluezdbus/defs.py b/bleak/backends/bluezdbus/defs.py index 8ec19af2..ba226d3c 100644 --- a/bleak/backends/bluezdbus/defs.py +++ b/bleak/backends/bluezdbus/defs.py @@ -17,6 +17,8 @@ ADAPTER_INTERFACE = "org.bluez.Adapter1" ADVERTISEMENT_MONITOR_INTERFACE = "org.bluez.AdvertisementMonitor1" ADVERTISEMENT_MONITOR_MANAGER_INTERFACE = "org.bluez.AdvertisementMonitorManager1" +AGENT_INTERFACE = "org.bluez.Agent1" +AGENT_MANAGER_INTERFACE = "org.bluez.AgentManager1" DEVICE_INTERFACE = "org.bluez.Device1" BATTERY_INTERFACE = "org.bluez.Battery1" diff --git a/bleak/backends/bluezdbus/manager.py b/bleak/backends/bluezdbus/manager.py index 8ee6e5d0..fcb49ff5 100644 --- a/bleak/backends/bluezdbus/manager.py +++ b/bleak/backends/bluezdbus/manager.py @@ -621,6 +621,20 @@ async def get_services( return services + def get_device_props(self, device_path: str) -> Device1: + """ + Gets the current properties of a device. + + Args: + device_path: The D-Bus object path of the device. + + Returns: + The current properties. + """ + return cast( + Device1, self._properties[device_path][defs.DEVICE_INTERFACE].copy() + ) + def get_device_name(self, device_path: str) -> str: """ Gets the value of the "Name" property for a device.