Skip to content
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

Pairing agent with support for peripherals sending Slave Security Request #1133

Draft
wants to merge 4 commits into
base: pairing-agent
Choose a base branch
from
Draft
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
16 changes: 13 additions & 3 deletions bleak/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = {},
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 16 additions & 11 deletions bleak/backends/bluezdbus/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,35 +39,39 @@ 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
# https://github.com/python/mypy/issues/6583

@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)
Expand All @@ -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}"
Expand All @@ -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()
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions bleak/backends/bluezdbus/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -393,6 +402,7 @@ async def pair(
member="Pair",
)
)
# TODO: Call "CancelPairing" if this task is cancelled

try:
assert_reply(reply)
Expand Down
4 changes: 4 additions & 0 deletions bleak/backends/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions bleak/backends/corebluetooth/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import asyncio
import logging
import uuid
import warnings
from typing import Optional, Union

from CoreBluetooth import (
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions bleak/backends/p4android/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions bleak/backends/winrt/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})"

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

Expand All @@ -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__":
Expand All @@ -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))