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