Skip to content

Commit 4e46255

Browse files
committed
bluez: implement pairing agent
1 parent 589c975 commit 4e46255

File tree

4 files changed

+218
-24
lines changed

4 files changed

+218
-24
lines changed

bleak/backends/bluezdbus/agent.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""
2+
Agent
3+
-----
4+
5+
This module contains types associated with the BlueZ D-Bus `agent api
6+
<https://github.com/bluez/bluez/blob/master/doc/agent-api.txt>`.
7+
"""
8+
9+
import asyncio
10+
import contextlib
11+
import logging
12+
import os
13+
from typing import Set, no_type_check
14+
15+
from dbus_fast import DBusError, Message
16+
from dbus_fast.aio import MessageBus
17+
from dbus_fast.service import ServiceInterface, method
18+
19+
from bleak.backends.device import BLEDevice
20+
21+
from ...agent import BaseBleakAgentCallbacks
22+
from . import defs
23+
from .manager import get_global_bluez_manager
24+
from .utils import assert_reply
25+
26+
logger = logging.getLogger(__name__)
27+
28+
29+
class Agent(ServiceInterface):
30+
"""
31+
Implementation of the org.bluez.Agent1 D-Bus interface.
32+
"""
33+
34+
def __init__(self, callbacks: BaseBleakAgentCallbacks):
35+
"""
36+
Args:
37+
"""
38+
super().__init__(defs.AGENT_INTERFACE)
39+
self._callbacks = callbacks
40+
self._tasks: Set[asyncio.Task] = set()
41+
42+
async def _create_ble_device(self, device_path: str) -> BLEDevice:
43+
manager = await get_global_bluez_manager()
44+
props = manager.get_device_props(device_path)
45+
return BLEDevice(
46+
props["Address"], props["Alias"], {"path": device_path, "props": props}
47+
)
48+
49+
@method()
50+
def Release(self):
51+
logger.debug("Release")
52+
53+
# REVISIT: mypy is broke, so we have to add redundant @no_type_check
54+
# https://github.com/python/mypy/issues/6583
55+
56+
@method()
57+
@no_type_check
58+
async def RequestPinCode(self, device: "o") -> "s": # noqa: F821
59+
logger.debug("RequestPinCode %s", device)
60+
raise NotImplementedError
61+
62+
@method()
63+
@no_type_check
64+
async def DisplayPinCode(self, device: "o", pincode: "s"): # noqa: F821
65+
logger.debug("DisplayPinCode %s %s", device, pincode)
66+
raise NotImplementedError
67+
68+
@method()
69+
@no_type_check
70+
async def RequestPasskey(self, device: "o") -> "u": # noqa: F821
71+
logger.debug("RequestPasskey %s", device)
72+
73+
ble_device = await self._create_ble_device(device)
74+
75+
task = asyncio.create_task(self._callbacks.request_pin(ble_device))
76+
self._tasks.add(task)
77+
78+
try:
79+
pin = await task
80+
except asyncio.CancelledError:
81+
raise DBusError("org.bluez.Error.Canceled", "task canceled")
82+
finally:
83+
self._tasks.remove(task)
84+
85+
if not pin:
86+
raise DBusError("org.bluez.Error.Rejected", "user rejected")
87+
88+
return int(pin)
89+
90+
@method()
91+
@no_type_check
92+
async def DisplayPasskey(
93+
self, device: "o", passkey: "u", entered: "q" # noqa: F821
94+
):
95+
passkey = f"{passkey:06}"
96+
logger.debug("DisplayPasskey %s %s %d", device, passkey, entered)
97+
raise NotImplementedError
98+
99+
@method()
100+
@no_type_check
101+
async def RequestConfirmation(self, device: "o", passkey: "u"): # noqa: F821
102+
passkey = f"{passkey:06}"
103+
logger.debug("RequestConfirmation %s %s", device, passkey)
104+
raise NotImplementedError
105+
106+
@method()
107+
@no_type_check
108+
async def RequestAuthorization(self, device: "o"): # noqa: F821
109+
logger.debug("RequestAuthorization %s", device)
110+
raise NotImplementedError
111+
112+
@method()
113+
@no_type_check
114+
async def AuthorizeService(self, device: "o", uuid: "s"): # noqa: F821
115+
logger.debug("AuthorizeService %s", device, uuid)
116+
raise NotImplementedError
117+
118+
@method()
119+
@no_type_check
120+
def Cancel(self): # noqa: F821
121+
logger.debug("Cancel")
122+
for t in self._tasks:
123+
t.cancel()
124+
125+
126+
@contextlib.asynccontextmanager
127+
async def bluez_agent(bus: MessageBus, callbacks: BaseBleakAgentCallbacks):
128+
agent = Agent(callbacks)
129+
130+
# REVISIT: implement passing capability if needed
131+
# "DisplayOnly", "DisplayYesNo", "KeyboardOnly", "NoInputNoOutput", "KeyboardDisplay"
132+
capability = ""
133+
134+
# this should be a unique path to allow multiple python interpreters
135+
# running bleak and multiple agents at the same time
136+
agent_path = f"/org/bleak/agent/{os.getpid()}/{id(agent)}"
137+
138+
bus.export(agent_path, agent)
139+
140+
try:
141+
reply = await bus.call(
142+
Message(
143+
destination=defs.BLUEZ_SERVICE,
144+
path="/org/bluez",
145+
interface=defs.AGENT_MANAGER_INTERFACE,
146+
member="RegisterAgent",
147+
signature="os",
148+
body=[agent_path, capability],
149+
)
150+
)
151+
152+
assert_reply(reply)
153+
154+
try:
155+
yield
156+
finally:
157+
reply = await bus.call(
158+
Message(
159+
destination=defs.BLUEZ_SERVICE,
160+
path="/org/bluez",
161+
interface=defs.AGENT_MANAGER_INTERFACE,
162+
member="UnregisterAgent",
163+
signature="o",
164+
body=[agent_path],
165+
)
166+
)
167+
168+
assert_reply(reply)
169+
170+
finally:
171+
bus.unexport(agent_path, agent)

bleak/backends/bluezdbus/client.py

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
BLE Client for BlueZ on Linux
44
"""
55
import asyncio
6+
import contextlib
67
import logging
78
import os
89
import sys
@@ -22,12 +23,19 @@
2223

2324
from ... import BleakScanner
2425
from ...agent import BaseBleakAgentCallbacks
25-
from ...exc import BleakDBusError, BleakError, BleakDeviceNotFoundError
26+
from ...exc import (
27+
BleakDBusError,
28+
BleakDeviceNotFoundError,
29+
BleakError,
30+
BleakPairingCancelledError,
31+
BleakPairingFailedError,
32+
)
2633
from ..characteristic import BleakGATTCharacteristic
2734
from ..client import BaseBleakClient, NotifyCallback
2835
from ..device import BLEDevice
2936
from ..service import BleakGATTServiceCollection
3037
from . import defs
38+
from .agent import bluez_agent
3139
from .characteristic import BleakGATTCharacteristicBlueZDBus
3240
from .manager import get_global_bluez_manager
3341
from .scanner import BleakScannerBlueZDBus
@@ -343,9 +351,6 @@ async def pair(
343351
Pair with the peripheral.
344352
"""
345353

346-
if callbacks:
347-
raise NotImplementedError
348-
349354
# See if it is already paired.
350355
reply = await self._bus.call(
351356
Message(
@@ -377,29 +382,31 @@ async def pair(
377382

378383
logger.debug("Pairing to BLE device @ %s", self.address)
379384

380-
reply = await self._bus.call(
381-
Message(
382-
destination=defs.BLUEZ_SERVICE,
383-
path=self._device_path,
384-
interface=defs.DEVICE_INTERFACE,
385-
member="Pair",
385+
async with contextlib.nullcontext() if callbacks is None else bluez_agent(
386+
self._bus, callbacks
387+
):
388+
reply = await self._bus.call(
389+
Message(
390+
destination=defs.BLUEZ_SERVICE,
391+
path=self._device_path,
392+
interface=defs.DEVICE_INTERFACE,
393+
member="Pair",
394+
)
386395
)
387-
)
388-
assert_reply(reply)
389396

390-
reply = await self._bus.call(
391-
Message(
392-
destination=defs.BLUEZ_SERVICE,
393-
path=self._device_path,
394-
interface=defs.PROPERTIES_INTERFACE,
395-
member="Get",
396-
signature="ss",
397-
body=[defs.DEVICE_INTERFACE, "Paired"],
398-
)
399-
)
400-
assert_reply(reply)
397+
try:
398+
assert_reply(reply)
399+
except BleakDBusError as e:
400+
if e.dbus_error == "org.bluez.Error.AuthenticationCanceled":
401+
raise BleakPairingCancelledError from e
402+
403+
if e.dbus_error == "org.bluez.Error.AuthenticationFailed":
404+
raise BleakPairingFailedError from e
401405

402-
return reply.body[0].value
406+
raise
407+
408+
# for backwards compatibility
409+
return True
403410

404411
async def unpair(self) -> bool:
405412
"""Unpair with the peripheral.

bleak/backends/bluezdbus/defs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
ADAPTER_INTERFACE = "org.bluez.Adapter1"
1818
ADVERTISEMENT_MONITOR_INTERFACE = "org.bluez.AdvertisementMonitor1"
1919
ADVERTISEMENT_MONITOR_MANAGER_INTERFACE = "org.bluez.AdvertisementMonitorManager1"
20+
AGENT_INTERFACE = "org.bluez.Agent1"
21+
AGENT_MANAGER_INTERFACE = "org.bluez.AgentManager1"
2022
DEVICE_INTERFACE = "org.bluez.Device1"
2123
BATTERY_INTERFACE = "org.bluez.Battery1"
2224

bleak/backends/bluezdbus/manager.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,20 @@ async def get_services(
621621

622622
return services
623623

624+
def get_device_props(self, device_path: str) -> Device1:
625+
"""
626+
Gets the current properties of a device.
627+
628+
Args:
629+
device_path: The D-Bus object path of the device.
630+
631+
Returns:
632+
The current properties.
633+
"""
634+
return cast(
635+
Device1, self._properties[device_path][defs.DEVICE_INTERFACE].copy()
636+
)
637+
624638
def get_device_name(self, device_path: str) -> str:
625639
"""
626640
Gets the value of the "Name" property for a device.

0 commit comments

Comments
 (0)