diff --git a/bleak/__init__.py b/bleak/__init__.py index f37f1058..12e9d258 100644 --- a/bleak/__init__.py +++ b/bleak/__init__.py @@ -349,6 +349,13 @@ class BleakClient: Callback that will be scheduled in the event loop when the client is disconnected. The callable must take one argument, which will be this client object. + pairing_callbacks: + Optional callbacks used in the pairing process (e.g. displaying, + confirming, requesting pin). If provided here instead of to the + :meth:`pair` method as ``callbacks`` parameter, device will be + implicitly paired during connection establishment. This is useful + for devices sending Slave Security Request immediately after + connection, requiring pairing before GATT service discovery. timeout: Timeout in seconds passed to the implicit ``discover`` call when ``address_or_ble_device`` is not a :class:`BLEDevice`. Defaults to 10.0. @@ -385,6 +392,7 @@ def __init__( self, address_or_ble_device: Union[BLEDevice, str], disconnected_callback: Optional[Callable[[BleakClient], None]] = None, + pairing_callbacks: Optional[BaseBleakAgentCallbacks] = None, *, timeout: float = 10.0, winrt: WinRTClientArgs = {}, @@ -398,6 +406,7 @@ def __init__( self._backend = PlatformBleakClient( address_or_ble_device, disconnected_callback=disconnected_callback, + pairing_callbacks=pairing_callbacks, timeout=timeout, winrt=winrt, **kwargs, @@ -507,9 +516,10 @@ async def pair( 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. + Optional callbacks used in the pairing process (e.g. displaying, + confirming, 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. diff --git a/bleak/backends/bluezdbus/agent.py b/bleak/backends/bluezdbus/agent.py index 66022f2e..e4440377 100644 --- a/bleak/backends/bluezdbus/agent.py +++ b/bleak/backends/bluezdbus/agent.py @@ -39,15 +39,19 @@ def __init__(self, callbacks: BaseBleakAgentCallbacks): self._callbacks = callbacks self._tasks: Set[asyncio.Task] = set() - async def _create_ble_device(self, device_path: str) -> BLEDevice: + @staticmethod + async def _create_ble_device(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} + props["Address"], + props["Alias"], + {"path": device_path, "props": props}, + props.get("RSSI", -127), ) @method() - def Release(self): + def Release(self): # noqa: N802 logger.debug("Release") # REVISIT: mypy is broke, so we have to add redundant @no_type_check @@ -55,19 +59,19 @@ def Release(self): @method() @no_type_check - async def RequestPinCode(self, device: "o") -> "s": # noqa: F821 + async def RequestPinCode(self, device: "o") -> "s": # noqa: F821 N802 logger.debug("RequestPinCode %s", device) raise NotImplementedError @method() @no_type_check - async def DisplayPinCode(self, device: "o", pincode: "s"): # noqa: F821 + async def DisplayPinCode(self, device: "o", pincode: "s"): # noqa: F821 N802 logger.debug("DisplayPinCode %s %s", device, pincode) raise NotImplementedError @method() @no_type_check - async def RequestPasskey(self, device: "o") -> "u": # noqa: F821 + async def RequestPasskey(self, device: "o") -> "u": # noqa: F821 N802 logger.debug("RequestPasskey %s", device) ble_device = await self._create_ble_device(device) @@ -89,7 +93,7 @@ async def RequestPasskey(self, device: "o") -> "u": # noqa: F821 @method() @no_type_check - async def DisplayPasskey( + async def DisplayPasskey( # noqa: N802 self, device: "o", passkey: "u", entered: "q" # noqa: F821 ): passkey = f"{passkey:06}" @@ -98,26 +102,26 @@ async def DisplayPasskey( @method() @no_type_check - async def RequestConfirmation(self, device: "o", passkey: "u"): # noqa: F821 + async def RequestConfirmation(self, device: "o", passkey: "u"): # noqa: F821 N802 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 + async def RequestAuthorization(self, device: "o"): # noqa: F821 N802 logger.debug("RequestAuthorization %s", device) raise NotImplementedError @method() @no_type_check - async def AuthorizeService(self, device: "o", uuid: "s"): # noqa: F821 + async def AuthorizeService(self, device: "o", uuid: "s"): # noqa: F821 N802 logger.debug("AuthorizeService %s", device, uuid) raise NotImplementedError @method() @no_type_check - def Cancel(self): # noqa: F821 + def Cancel(self): # noqa: F821 N802 logger.debug("Cancel") for t in self._tasks: t.cancel() @@ -129,6 +133,7 @@ async def bluez_agent(bus: MessageBus, callbacks: BaseBleakAgentCallbacks): # REVISIT: implement passing capability if needed # "DisplayOnly", "DisplayYesNo", "KeyboardOnly", "NoInputNoOutput", "KeyboardDisplay" + # Note: If an empty string is used, BlueZ will fall back to "KeyboardDisplay". capability = "" # this should be a unique path to allow multiple python interpreters diff --git a/bleak/backends/bluezdbus/client.py b/bleak/backends/bluezdbus/client.py index f20f1174..e93992e6 100644 --- a/bleak/backends/bluezdbus/client.py +++ b/bleak/backends/bluezdbus/client.py @@ -96,6 +96,10 @@ def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs): # used to override mtu_size property self._mtu_size: Optional[int] = None + self._pairing_callbacks: Optional[BaseBleakAgentCallbacks] = kwargs.get( + "pairing_callbacks" + ) + def close(self): self._bus.disconnect() @@ -186,6 +190,11 @@ def on_value_changed(char_path: str, value: bytes) -> None: # # For additional details see https://github.com/bluez/bluez/issues/89 # + if self._pairing_callbacks: + # org.bluez.Device1.Pair() will connect to the remote device, initiate + # pairing and then retrieve all SDP records (or GATT primary services). + await self.pair(self._pairing_callbacks) + if not manager.is_connected(self._device_path): logger.debug("Connecting to BlueZ path %s", self._device_path) async with async_timeout(timeout): @@ -393,6 +402,7 @@ async def pair( member="Pair", ) ) + # TODO: Call "CancelPairing" if this task is cancelled try: assert_reply(reply) diff --git a/bleak/backends/client.py b/bleak/backends/client.py index d06b887d..59b2b3bb 100644 --- a/bleak/backends/client.py +++ b/bleak/backends/client.py @@ -35,6 +35,10 @@ class BaseBleakClient(abc.ABC): disconnected_callback (callable): Callback that will be scheduled in the event loop when the client is disconnected. The callable must take one argument, which will be this client object. + pairing_callbacks (BaseBleakAgentCallbacks): + Optional callbacks otherwise provided as ``callbacks`` parameter to the + :meth:`pair` method. If provided here, device will be implicitly paired + during connection establishment. """ def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs): diff --git a/bleak/backends/corebluetooth/client.py b/bleak/backends/corebluetooth/client.py index 30c9f207..acf88a80 100644 --- a/bleak/backends/corebluetooth/client.py +++ b/bleak/backends/corebluetooth/client.py @@ -6,6 +6,7 @@ import asyncio import logging import uuid +import warnings from typing import Optional, Union from CoreBluetooth import ( @@ -52,6 +53,13 @@ def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs): self._delegate: Optional[PeripheralDelegate] = None self._central_manager_delegate: Optional[CentralManagerDelegate] = None + if kwargs.get("pairing_callbacks"): + warnings.warn( + "Pairing is not available in Core Bluetooth.", + RuntimeWarning, + stacklevel=2, + ) + if isinstance(address_or_ble_device, BLEDevice): ( self._peripheral, diff --git a/bleak/backends/p4android/client.py b/bleak/backends/p4android/client.py index a4186f45..8ee3ba7c 100644 --- a/bleak/backends/p4android/client.py +++ b/bleak/backends/p4android/client.py @@ -45,6 +45,11 @@ def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs): self.__gatt = None self.__mtu = 23 + if kwargs.get("pairing_callbacks"): + warnings.warn( + "pairing_callbacks are ignored on Android", RuntimeWarning, stacklevel=2 + ) + def __del__(self): if self.__gatt is not None: self.__gatt.close() diff --git a/bleak/backends/winrt/client.py b/bleak/backends/winrt/client.py index a323695e..45e9f654 100644 --- a/bleak/backends/winrt/client.py +++ b/bleak/backends/winrt/client.py @@ -204,6 +204,13 @@ def __init__( self._session_status_changed_token: Optional[EventRegistrationToken] = None self._max_pdu_size_changed_token: Optional[EventRegistrationToken] = None + if kwargs.get("pairing_callbacks"): + warnings.warn( + "pairing_callbacks not yet implemented for Windows", + RuntimeWarning, + stacklevel=2, + ) + def __str__(self): return f"{type(self).__name__} ({self.address})" diff --git a/examples/pairing_agent.py b/examples/pairing_agent.py index b16d269e..12bd0dbd 100644 --- a/examples/pairing_agent.py +++ b/examples/pairing_agent.py @@ -4,7 +4,11 @@ from bleak import BleakScanner, BleakClient, BaseBleakAgentCallbacks from bleak.backends.device import BLEDevice -from bleak.exc import BleakPairingCancelledError, BleakPairingFailedError +from bleak.exc import ( + BleakPairingCancelledError, + BleakPairingFailedError, + BleakDeviceNotFoundError, +) class AgentCallbacks(BaseBleakAgentCallbacks): @@ -55,10 +59,14 @@ async def request_pin(self, device: BLEDevice) -> str: return response -async def main(addr: str, unpair: bool) -> None: +async def main(addr: str, unpair: bool, auto: bool) -> None: if unpair: print("unpairing...") - await BleakClient(addr).unpair() + try: + await BleakClient(addr).unpair() + print("unpaired") + except BleakDeviceNotFoundError: + print("device was not paired") print("scanning...") @@ -68,13 +76,26 @@ async def main(addr: str, unpair: bool) -> 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 auto: + print("connecting and pairing...") + + async with AgentCallbacks() as callbacks, BleakClient( + device, pairing_callbacks=callbacks + ) as client: + print(f"connection and pairing to {client.address} successful") + + else: + print("connecting...") + + async with BleakClient(device) as client, AgentCallbacks() as callbacks: + try: + print("pairing...") + await client.pair(callbacks) + print("pairing successful") + except BleakPairingCancelledError: + print("paring was canceled") + except BleakPairingFailedError: + print("pairing failed (bad pin?)") if __name__ == "__main__": @@ -83,6 +104,9 @@ async def main(addr: str, unpair: bool) -> None: parser.add_argument( "--unpair", action="store_true", help="unpair first before pairing" ) + parser.add_argument( + "--auto", action="store_true", help="automatically pair during connect" + ) args = parser.parse_args() - asyncio.run(main(args.address, args.unpair)) + asyncio.run(main(args.address, args.unpair, args.auto))