Skip to content

Commit 1cb129e

Browse files
JPHutchinsclaude
andcommitted
bleak/pairing: add the pairing callback API
This is the foundation the BlueZ and WinRT pairing implementations build on. Supersedes the bleak.agent module from #1864 / #1990 (renamed to bleak.pairing; "agent" is a BlueZ-ism). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ae2f589 commit 1cb129e

3 files changed

Lines changed: 447 additions & 0 deletions

File tree

bleak/_compat.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
from typing_extensions import TypeVarTuple as TypeVarTuple
1414
from typing_extensions import Unpack as Unpack
1515
from typing_extensions import assert_never as assert_never
16+
from typing_extensions import assert_type as assert_type
1617
else:
1718
from asyncio import timeout as timeout # noqa: F401
1819
from typing import Never as Never # noqa: F401
1920
from typing import Self as Self # noqa: F401
2021
from typing import TypeVarTuple as TypeVarTuple # noqa: F401
2122
from typing import Unpack as Unpack # noqa: F401
2223
from typing import assert_never as assert_never # noqa: F401
24+
from typing import assert_type as assert_type # noqa: F401
2325

2426
if sys.version_info < (3, 12):
2527
from typing_extensions import override as override

bleak/pairing.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""
2+
Pairing
3+
-------
4+
5+
Types for participating in the BLE pairing ceremony.
6+
7+
An application takes part in pairing by supplying an object that implements one or
8+
more of the capability protocols in this module -- :class:`SupportsConfirm`,
9+
:class:`SupportsRequestPasskey`, and :class:`SupportsDisplayPasskey`. Only the
10+
methods for the ceremonies the application can perform are implemented; the
11+
*absence* of a method means the application cannot take part in that ceremony.
12+
Which methods are present determines the input/output capability that Bleak
13+
advertises to the peer, and therefore which `Security Manager`_ pairing method is
14+
negotiated:
15+
16+
============================================ ===================== =======================
17+
Implemented methods Capability Ceremony
18+
============================================ ===================== =======================
19+
(none) ``NoInputNoOutput`` Just Works
20+
``confirm`` ``DisplayYesNo`` Numeric Comparison
21+
``request_passkey`` ``KeyboardOnly`` Passkey Entry (input)
22+
``display_passkey`` ``DisplayOnly`` Passkey Entry (output)
23+
``request_passkey`` + ``display_passkey`` ``KeyboardDisplay`` Passkey Entry (either)
24+
============================================ ===================== =======================
25+
26+
A ``confirm`` method also implies a display, since Numeric Comparison shows the
27+
value being compared, so combining it with another method upgrades the advertised
28+
capability (e.g. ``confirm`` + ``display_passkey`` is ``DisplayYesNo``, while
29+
``confirm`` + ``request_passkey`` is ``KeyboardDisplay``).
30+
31+
There are two equivalent ways to supply an implementation:
32+
33+
* any object -- a :class:`~typing.NamedTuple` is a natural fit -- that structurally
34+
implements the chosen methods, or
35+
* a subclass of the matching protocols -- :class:`SupportsConfirm`,
36+
:class:`SupportsRequestPasskey`, :class:`SupportsDisplayPasskey`. Their methods are
37+
abstract, so the type checker *and* the interpreter require you to implement every
38+
capability you inherit (merely inheriting is not enough); state may be carried on
39+
``self``, and the protocols may be combined freely by multiple inheritance.
40+
41+
Because detection is structural (see :func:`io_capability`), implement *only* the
42+
methods for the ceremonies you support and omit the rest; a method that is present
43+
but does not work (for example, one that always raises) still advertises the
44+
capability, which is almost never what you want.
45+
46+
The IO capabilities and their mapping to a pairing method follow the `Security
47+
Manager`_ specification: Bluetooth Core Specification v5.4, Vol 3, Part H, Section
48+
2.3.2 (IO capabilities) and Section 2.3.5.1 (mapping of IO capabilities to the key
49+
generation method).
50+
51+
.. _Security Manager: https://www.bluetooth.com/specifications/specs/core-specification/
52+
"""
53+
54+
from abc import abstractmethod
55+
from enum import Enum, auto
56+
from typing import Final, Protocol, TypeAlias, runtime_checkable
57+
58+
from bleak.backends.device import BLEDevice
59+
60+
MAX_PASSKEY: Final = 999999
61+
"""The largest valid BLE passkey; passkeys are six decimal digits (000000-999999)."""
62+
63+
64+
@runtime_checkable
65+
class SupportsConfirm(Protocol):
66+
"""Capability to take part in Numeric Comparison (``DisplayYesNo``)."""
67+
68+
@abstractmethod
69+
async def confirm(self, device: BLEDevice, passkey: int) -> bool:
70+
"""Numeric Comparison: receive *device* and the *passkey* shown on both
71+
devices; return ``True`` to accept the pairing or ``False`` to reject it."""
72+
73+
74+
@runtime_checkable
75+
class SupportsRequestPasskey(Protocol):
76+
"""Capability to enter a passkey for Passkey Entry (``KeyboardOnly``)."""
77+
78+
@abstractmethod
79+
async def request_passkey(self, device: BLEDevice) -> int | None:
80+
"""Passkey Entry (input): return the passkey the peer is displaying -- an
81+
integer from ``0`` to :data:`MAX_PASSKEY` -- or ``None`` to reject the
82+
pairing. ``0`` (``000000``) is itself a valid passkey, so rejection is
83+
signalled only by ``None``, never by a falsy return value."""
84+
85+
86+
@runtime_checkable
87+
class SupportsDisplayPasskey(Protocol):
88+
"""Capability to display a passkey for Passkey Entry (``DisplayOnly``)."""
89+
90+
@abstractmethod
91+
async def display_passkey(self, device: BLEDevice, passkey: int) -> None:
92+
"""Passkey Entry (output): display the given *passkey* for the user to enter
93+
on the peer device."""
94+
95+
96+
PairingCallbacks: TypeAlias = (
97+
SupportsConfirm | SupportsRequestPasskey | SupportsDisplayPasskey
98+
)
99+
"""An object implementing one or more pairing capabilities.
100+
101+
This is the type backends accept (as ``PairingCallbacks | None``, where ``None``
102+
means no callbacks and selects Just Works). Satisfy it with any object -- a
103+
:class:`~typing.NamedTuple` is a natural fit -- that defines the chosen methods, or
104+
by subclassing :class:`SupportsConfirm`, :class:`SupportsRequestPasskey`, and/or
105+
:class:`SupportsDisplayPasskey`.
106+
"""
107+
108+
109+
class IOCapability(Enum):
110+
"""The input/output capability advertised to the peer during pairing.
111+
112+
These are the five IO capabilities defined by the Bluetooth Core
113+
Specification (Vol 3, Part H, Section 2.3.2), named in Pythonic form (BlueZ,
114+
for example, spells them ``NoInputNoOutput``, ``DisplayYesNo``, and so on).
115+
"""
116+
117+
NO_INPUT_NO_OUTPUT = auto()
118+
"""No way to display a six-digit value and no way to enter one or answer yes/no."""
119+
120+
DISPLAY_YES_NO = auto()
121+
"""Can display a six-digit value and has two buttons the user can map to yes and no."""
122+
123+
KEYBOARD_ONLY = auto()
124+
"""Can enter the digits 0-9 and answer yes/no, but cannot display a value."""
125+
126+
DISPLAY_ONLY = auto()
127+
"""Can display a six-digit value but has no input to enter one or answer yes/no."""
128+
129+
KEYBOARD_DISPLAY = auto()
130+
"""Can both display a six-digit value and enter one; LE only."""
131+
132+
133+
def io_capability(callbacks: PairingCallbacks | None) -> IOCapability:
134+
"""Derive the advertised :class:`IOCapability` from *callbacks*.
135+
136+
Detection is structural: each capability is present when *callbacks* implements
137+
the corresponding method. ``None`` (no callbacks) maps to ``NoInputNoOutput``,
138+
which selects the Just Works ceremony on backends that support pairing. A
139+
``confirm`` method implies a display, since Numeric Comparison shows the value
140+
being confirmed.
141+
"""
142+
can_input = isinstance(callbacks, SupportsRequestPasskey)
143+
can_confirm = isinstance(callbacks, SupportsConfirm)
144+
can_display = isinstance(callbacks, SupportsDisplayPasskey) or can_confirm
145+
if can_input and can_display:
146+
return IOCapability.KEYBOARD_DISPLAY
147+
if can_input:
148+
return IOCapability.KEYBOARD_ONLY
149+
if can_confirm:
150+
return IOCapability.DISPLAY_YES_NO
151+
if can_display:
152+
return IOCapability.DISPLAY_ONLY
153+
return IOCapability.NO_INPUT_NO_OUTPUT

0 commit comments

Comments
 (0)