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 #1100

Draft
wants to merge 3 commits into
base: develop
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
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,17 @@ and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0
`Unreleased`_
=============

Added
-----
* Added ``BleakClient.close()`` method.


Changed
-------
* Dropped ``async-timeout`` dependency on Python >= 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)
Expand Down Expand Up @@ -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)
======================

Expand Down
29 changes: 27 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 @@ -438,6 +439,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

Expand Down Expand Up @@ -482,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 @@ -491,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.
"""
171 changes: 171 additions & 0 deletions bleak/backends/bluezdbus/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""
Agent
-----

This module contains types associated with the BlueZ D-Bus `agent api
<https://github.com/bluez/bluez/blob/master/doc/agent-api.txt>`.
"""

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}
Copy link
Contributor

@bojanpotocnik bojanpotocnik Nov 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
props["Address"], props["Alias"], {"path": device_path, "props": props}
props["Address"], props["Alias"], {"path": device_path, "props": props}, props.get("RSSI", -127)

because of bcae937#diff-d647d8e3f316fd939334bd8ed60d248095c8a6f69bc73dd0b369867609dc00a6R21

)

@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)
Loading