Skip to content

Commit

Permalink
BleakClient: add callbacks arg to pair() method
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
dlech committed Nov 18, 2022
1 parent 9472a10 commit 589c975
Show file tree
Hide file tree
Showing 9 changed files with 210 additions and 54 deletions.
18 changes: 16 additions & 2 deletions bleak/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
"""
Expand Down
52 changes: 52 additions & 0 deletions bleak/agent.py
Original file line number Diff line number Diff line change
@@ -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.
"""
17 changes: 9 additions & 8 deletions bleak/backends/bluezdbus/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions bleak/backends/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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()

Expand Down
26 changes: 7 additions & 19 deletions bleak/backends/corebluetooth/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/BestPracticesForSettingUpYourIOSDeviceAsAPeripheral/BestPracticesForSettingUpYourIOSDeviceAsAPeripheral.html#//apple_ref/doc/uid/TP40013257-CH5-SW1>`_
- `Stack Overflow post #1 <https://stackoverflow.com/questions/25254932/can-you-pair-a-bluetooth-le-device-in-an-ios-app>`_
- `Stack Overflow post #2 <https://stackoverflow.com/questions/47546690/ios-bluetooth-pairing-request-dialog-can-i-know-the-users-choice>`_
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.")

Expand Down
18 changes: 9 additions & 9 deletions bleak/backends/p4android/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
26 changes: 12 additions & 14 deletions bleak/backends/winrt/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions bleak/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
88 changes: 88 additions & 0 deletions examples/pairing_agent.py
Original file line number Diff line number Diff line change
@@ -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))

0 comments on commit 589c975

Please sign in to comment.