From 1e7db386e989d73ef3abc86c62919ccc3d1d3b6f Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:41:08 -0500 Subject: [PATCH 01/14] bledev.hid: HID over GATT both ways, and a portable report-descriptor parser bledev.hidreport parses HID report descriptors and decodes input reports into events.Key and events.Joy* records, matching usbif's boot-keyboard decoder event for event. bledev.hid is the HOGP host (reads the Report Map, pairs when the device demands it, subscribes to every input report) and a peripheral that serves a keyboard, consumer control and gamepad. The contract gains descriptors (server and client), encrypted characteristics, Connection.pair()/encrypted and a long_read capability; fake, mpble and bleak implement them. --- lib/bledev/__init__.py | 88 ++++- lib/bledev/bleak.py | 39 ++ lib/bledev/fake.py | 73 +++- lib/bledev/hid.py | 533 +++++++++++++++++++++++++ lib/bledev/hidreport.py | 658 +++++++++++++++++++++++++++++++ lib/bledev/mpble.py | 64 ++- pydevices-desktop.toml | 2 + tests/bledev_board/hid_script.py | 176 +++++++++ tests/bledev_hid_checks.py | 246 ++++++++++++ tests/hidreport_vectors.py | 485 +++++++++++++++++++++++ tests/test_bledev_hid.py | 115 ++++++ 11 files changed, 2474 insertions(+), 5 deletions(-) create mode 100644 lib/bledev/hid.py create mode 100644 lib/bledev/hidreport.py create mode 100644 tests/bledev_board/hid_script.py create mode 100644 tests/bledev_hid_checks.py create mode 100644 tests/hidreport_vectors.py create mode 100644 tests/test_bledev_hid.py diff --git a/lib/bledev/__init__.py b/lib/bledev/__init__.py index 49d81a62..a925d50b 100644 --- a/lib/bledev/__init__.py +++ b/lib/bledev/__init__.py @@ -76,6 +76,11 @@ class BusyError(BLEError): """ +#: ATT error codes a :class:`GattError` can carry that apps act on. +INSUFFICIENT_AUTHENTICATION = 0x05 +INSUFFICIENT_ENCRYPTION = 0x0F + + class GattError(BLEError): """The peer answered a GATT request with an error ``status``.""" @@ -353,7 +358,8 @@ def capabilities(self): ``backend`` (its module name), ``central`` and ``peripheral`` (which roles it can take), ``mtu`` (the ATT MTU it asks for), ``max_mtu``, - ``indicate``, ``pairing`` and ``l2cap`` (bools). + ``indicate``, ``pairing`` and ``l2cap`` (bools), and ``long_read``: + whether a read returns values longer than one packet (``mtu - 1``). """ return { "backend": self.backend, @@ -364,6 +370,7 @@ def capabilities(self): "indicate": False, "pairing": False, "l2cap": False, + "long_read": True, } def config(self, *names, **settings): @@ -468,7 +475,8 @@ class Characteristic: write; MicroPython's default buffer is 20 bytes, so raise it for anything that takes whole MTU-sized writes. With ``capture=True``, ``written()`` returns every write as ``(connection, data)``, in order, instead of just - the last writer. + the last writer. ``encrypted=True`` refuses a central that hasn't paired + (``GattError`` with status ``INSUFFICIENT_ENCRYPTION``) until it does. """ def __init__( @@ -483,6 +491,7 @@ def __init__( initial=None, capture=False, max_len=20, + encrypted=False, ): service.characteristics.append(self) self.service = service @@ -502,6 +511,11 @@ def __init__( self.capture = bool(capture) and bool(flags & (FLAG_WRITE | FLAG_WRITE_NO_RESPONSE)) self.max_len = max(max_len, len(initial) if initial else 0) self._initial = bytes(initial) if initial is not None else None + #: True when a central must pair (an encrypted link) before it may + #: read, write or subscribe. HID keyboards serve their reports this way. + self.encrypted = bool(encrypted) + #: :class:`Descriptor` objects, in the order they were made. + self.descriptors = [] # Set by the backend's register_services(); it does the real work. self._impl = None @@ -555,6 +569,25 @@ async def written(self, timeout_ms=None): return await self._bound().written(timeout_ms) +class Descriptor: + """A read-only descriptor on a :class:`Characteristic` you serve. + + HID uses these: each Report characteristic carries a Report Reference + (0x2908) saying which report it is. The value is fixed once registered. + Don't make a CCCD (0x2902); every backend adds that itself for a + characteristic with ``notify`` or ``indicate``. + """ + + def __init__(self, characteristic, uuid, initial=b""): + characteristic.descriptors.append(self) + self.characteristic = characteristic + self.uuid = UUID(uuid) + self.value = bytes(initial) + + def __repr__(self): + return "Descriptor({!r})".format(self.uuid) + + # ---------------------------------------------------------------- central side @@ -754,6 +787,23 @@ async def disconnected(self, timeout_ms=None): """Wait until the link drops, from either side.""" raise NotImplementedError + @property + def encrypted(self): + """True once the link is encrypted (after :meth:`pair`, or a bonded reconnect).""" + return False + + async def pair(self, bond=True, timeout_ms=20000): + """Pair with the peer and encrypt the link; with ``bond``, keep the keys. + + Either side may call it; in practice the central does, when a read + fails with ``INSUFFICIENT_ENCRYPTION``. Pairing here is "just works" + (no passkey), which is what a keyboard without a screen does. A bonded + peer reconnects without pairing again, for as long as both sides keep + their keys. Raises :class:`UnsupportedError` on a backend without + pairing. + """ + raise UnsupportedError("{} cannot pair".format(self._ble.backend)) + async def exchange_mtu(self, mtu=None, timeout_ms=1000): """Negotiate a larger ATT MTU and return the result. @@ -836,8 +886,27 @@ def _check(self, flag, what): raise UnsupportedError("{!r} does not support {}".format(self, what)) async def read(self, timeout_ms=1000): + """The value. A backend without long reads (``capabilities()["long_read"]`` + is False: MicroPython) returns at most ``connection.mtu - 1`` bytes.""" raise NotImplementedError + def descriptors(self, uuid=None, timeout_ms=2000): + """Async-iterate this characteristic's descriptors, optionally only ``uuid``.""" + uuid = None if uuid is None else UUID(uuid) + return _Discovery(lambda: self._discover_descriptors(uuid, timeout_ms)) + + async def descriptor(self, uuid, timeout_ms=2000): + """The descriptor ``uuid`` on this characteristic, or ``None``.""" + uuid = UUID(uuid) + found = None + async for d in self.descriptors(uuid, timeout_ms): + if found is None and d.uuid == uuid: + found = d + return found + + async def _discover_descriptors(self, uuid, timeout_ms): + raise UnsupportedError("{} cannot discover descriptors".format(self.connection._ble.backend)) + async def write(self, data, response=None, timeout_ms=1000): """Write ``data`` (at most ``connection.mtu - 3`` bytes). @@ -867,6 +936,21 @@ def _want_response(self, response): return bool(response) +class ClientDescriptor: + """A descriptor on the peer, found by :meth:`ClientCharacteristic.descriptors`.""" + + def __init__(self, characteristic, uuid): + self.characteristic = characteristic + self.connection = characteristic.connection + self.uuid = UUID(uuid) + + def __repr__(self): + return "ClientDescriptor({!r})".format(self.uuid) + + async def read(self, timeout_ms=1000): + raise NotImplementedError + + def is_adapter(obj): """True for a bledev :class:`BLE`; False for a raw ``bluetooth.BLE`` or anything else.""" return isinstance(obj, BLE) diff --git a/lib/bledev/bleak.py b/lib/bledev/bleak.py index df106544..0d004383 100644 --- a/lib/bledev/bleak.py +++ b/lib/bledev/bleak.py @@ -41,6 +41,7 @@ BleakGATTProtocolError = None from . import ( + ClientDescriptor, BLE, BLEError, ClientCharacteristic, @@ -275,6 +276,7 @@ def __init__(self, ble, device, client): self._dropped = asyncio.Event() self._inboxes = [] self._wwr = 0 # the largest write-without-response size seen in discovery + self._paired = False def _drop(self): if self._dropped.is_set(): @@ -316,6 +318,20 @@ async def disconnected(self, timeout_ms=None): if self.is_connected(): await wait_ms(self._dropped.wait(), timeout_ms, "disconnected") + @property + def encrypted(self): + return self._paired + + async def pair(self, bond=True, timeout_ms=20000): + """Pair through the OS. On Windows that makes a bond the OS keeps (and, + for a HID device, a keyboard it uses), so the tests here never call it.""" + self._live("pair") + try: + await wait_ms(self._client.pair(), timeout_ms, "pair") + except (BleakError, OSError) as e: + raise _translate(e, self, "pair") + self._paired = True + async def exchange_mtu(self, mtu=None, timeout_ms=1000): """The OS negotiates on its own; wait briefly for it and return the result.""" self._live("exchange_mtu") @@ -401,6 +417,15 @@ async def read(self, timeout_ms=1000): except (BleakError, OSError) as e: raise _translate(e, self.connection, "read") + async def _discover_descriptors(self, uuid, timeout_ms): + self._live("descriptor discovery") + found = [] + for native in sorted(self._native.descriptors, key=lambda d: d.handle): + d = _BleakClientDescriptor(self, native) + if uuid is None or d.uuid == uuid: + found.append(d) + return found + async def write(self, data, response=None, timeout_ms=1000): data = bytes(data) response = self._want_response(response) @@ -447,3 +472,17 @@ async def notified(self, timeout_ms=None): async def indicated(self, timeout_ms=None): self._check(FLAG_INDICATE, "indicate") return await self._indicate_inbox.get(timeout_ms) + + +class _BleakClientDescriptor(ClientDescriptor): + def __init__(self, characteristic, native): + ClientDescriptor.__init__(self, characteristic, native.uuid) + self._native = native + + async def read(self, timeout_ms=1000): + client = self.connection._client + self.connection._live("descriptor read") + try: + return bytes(await wait_ms(client.read_gatt_descriptor(self._native.handle), timeout_ms, "descriptor read")) + except (BleakError, OSError) as e: + raise _translate(e, self.connection, "descriptor read") diff --git a/lib/bledev/fake.py b/lib/bledev/fake.py index 71eb66f5..256404c5 100644 --- a/lib/bledev/fake.py +++ b/lib/bledev/fake.py @@ -25,6 +25,12 @@ ``notified()`` or ``written()`` raise instead of dropping data silently. * The name only reaches a *passive* scan when it fits in the 31-byte advertisement; otherwise it is in the scan response, as on real hardware. +* An ``encrypted`` characteristic refuses an unpaired central with + ``GattError(INSUFFICIENT_ENCRYPTION)``. ``pair()`` encrypts the link, and + with ``bond`` both adapters remember each other (``bonds``) until + ``forget()``. +* ``long_reads=False`` makes a central read at most ``mtu - 1`` bytes, as + MicroPython does (it has no long read). """ try: @@ -37,6 +43,7 @@ BLEError, BusyError, ClientCharacteristic, + ClientDescriptor, ClientService, Connection, DEFAULT_MTU, @@ -47,7 +54,10 @@ FLAG_READ, FLAG_WRITE, FLAG_WRITE_NO_RESPONSE, + GattError, + INSUFFICIENT_ENCRYPTION, MAX_MTU, + UUID, ScanResult, Scanner, UnsupportedError, @@ -153,6 +163,7 @@ def __init__( notify_buffers=8, queue_limit=128, gap_name="fake", + long_reads=True, ): self._air = air if air is not None else _default_air self._central = central @@ -163,6 +174,9 @@ def __init__( self.notify_buffers = notify_buffers self.queue_limit = queue_limit self._gap_name = gap_name + self._long_reads = long_reads + #: Addresses of the peers this adapter has bonded with. + self.bonds = set() self._services = () self._adverts = [] self._links = [] @@ -175,9 +189,18 @@ def capabilities(self): mtu=self._mtu, max_mtu=MAX_MTU, indicate=True, + pairing=True, + long_read=self._long_reads, ) return caps + def forget(self, address=None): + """Drop the bond with ``address``, or every bond (a board's filesystem erased).""" + if address is None: + self.bonds.clear() + else: + self.bonds.discard(address) + def config(self, *names, **settings): for key, value in settings.items(): if key == "mtu": @@ -333,6 +356,7 @@ def __init__(self, central_ble, peripheral_ble, device): self.subscribed = {} self.in_flight = [] self._drain_task = None + self.encrypted = False central_ble._links.append(self) peripheral_ble._links.append(self) @@ -403,6 +427,20 @@ async def disconnected(self, timeout_ms=None): if self._link.connected: await wait_ms(self._link.dropped.wait(), timeout_ms, "disconnected") + @property + def encrypted(self): + return self._link.encrypted + + async def pair(self, bond=True, timeout_ms=20000): + link = self._link + link.check() + await asyncio.sleep(0) + link.check() + link.encrypted = True + if bond: + link.central_ble.bonds.add(link.peripheral_ble.address) + link.peripheral_ble.bonds.add(link.central_ble.address) + async def exchange_mtu(self, mtu=None, timeout_ms=1000): self._link.check() if mtu: @@ -450,14 +488,29 @@ def __init__(self, service, server_char): def _link(self): link = self.connection._link link.check() + if self._server.encrypted and not link.encrypted: + raise GattError(INSUFFICIENT_ENCRYPTION, "{} needs an encrypted link; pair() first".format(self.uuid)) return link async def read(self, timeout_ms=1000): self._check(FLAG_READ, "read") self._link() await asyncio.sleep(0) - self._link() - return self._server.read() + link = self._link() + value = self._server.read() + if not link.central_ble._long_reads: + value = value[: self.connection.mtu - 1] + return value + + async def _discover_descriptors(self, uuid, timeout_ms): + self.connection._link.check() + await asyncio.sleep(0) + found = [] + if self._server.flags & (FLAG_NOTIFY | FLAG_INDICATE): + found.append(_FakeClientDescriptor(self, UUID(0x2902), None)) + for d in self._server.descriptors: + found.append(_FakeClientDescriptor(self, d.uuid, d)) + return [d for d in found if uuid is None or d.uuid == uuid] async def write(self, data, response=None, timeout_ms=1000): data = bytes(data) @@ -498,6 +551,22 @@ async def indicated(self, timeout_ms=None): return await link.inbox(self._server, "indicate", link.central_ble.queue_limit).get(timeout_ms) +class _FakeClientDescriptor(ClientDescriptor): + def __init__(self, characteristic, uuid, server): + ClientDescriptor.__init__(self, characteristic, uuid) + self._server = server + + async def read(self, timeout_ms=1000): + link = self.connection._link + link.check() + await asyncio.sleep(0) + link.check() + if self._server is None: # the CCCD + notify, indicate = link.subscribed.get(id(self.characteristic._server), (False, False)) + return bytes((notify | (indicate << 1), 0)) + return self._server.value + + class _ServerCharacteristic: """The fake's half of a :class:`bledev.Characteristic`.""" diff --git a/lib/bledev/hid.py b/lib/bledev/hid.py new file mode 100644 index 00000000..c9956431 --- /dev/null +++ b/lib/bledev/hid.py @@ -0,0 +1,533 @@ +"""BLE keyboards and game controllers, both ways: HID over GATT (HOGP). + +**As a host** (central), a board or a laptop connects to a BLE keyboard, +remote or gamepad and gets PyDevices events, the same ``events.Key`` and +``events.Joy*`` records a USB keyboard gives through usbif or SDL gives on the +desktop:: + + import bledev.hid as hid + + host = await hid.connect(ble, name="Keyboard K380") + while True: + for event in await host.events(): + print(event) + +It reads the device's Report Map, parses it (:mod:`bledev.hidreport`), +subscribes to every input report and decodes each one as it arrives. A +keyboard that insists on pairing gets it: the first read that fails for want +of encryption pairs the link and tries again, and with ``bond=True`` (the +default) the keys are kept, so the next connect is quick. + +**As a device** (peripheral), a board advertises as a BLE keyboard, media +remote and gamepad, and types, taps and moves what you tell it:: + + kb = hid.Peripheral(ble) # keyboard + consumer + gamepad + await kb.serve() # waits for a host + await kb.keyboard.type("Hello\\n") + await kb.consumer.tap(hid.VOLUME_UP) + await kb.gamepad.send(axes=(0, 127, 0, 0), buttons=0b101, hat=2) + +Its name, appearance and Report Map follow from which of the three it +offers, the same way every time, because hosts cache a device's services +against its identity (``docs/ble.md`` in the anchor repo, section 7). + +A laptop (bleak) or a browser can't advertise, so they are hosts only. +Windows keeps HID devices for itself once paired; don't pair a board you are +testing with the PC you are testing from, or it will type into it. +""" + +try: + import asyncio +except ImportError: # pragma: no cover + import uasyncio as asyncio + +import struct + +try: + from time import ticks_diff, ticks_us +except ImportError: # CPython + import time as _time + + def ticks_us(): + return int(_time.perf_counter() * 1000000) + + def ticks_diff(a, b): + return a - b + + +from . import ( + BLEError, + BusyError, + Characteristic, + Descriptor, + GattError, + INSUFFICIENT_AUTHENTICATION, + INSUFFICIENT_ENCRYPTION, + Service, + UUID, + sleep_ms, + wait_ms, +) +from .hidreport import INPUT, OUTPUT, Decoder, HIDParseError, ReportMap + +# GATT numbers from the HID Service and HOGP specifications. +HID_SERVICE = UUID(0x1812) +HID_INFORMATION = UUID(0x2A4A) +REPORT_MAP = UUID(0x2A4B) +HID_CONTROL_POINT = UUID(0x2A4C) +REPORT = UUID(0x2A4D) +PROTOCOL_MODE = UUID(0x2A4E) +REPORT_REFERENCE = UUID(0x2908) +BATTERY_SERVICE = UUID(0x180F) +BATTERY_LEVEL = UUID(0x2A19) +DEVICE_INFORMATION = UUID(0x180A) +PNP_ID = UUID(0x2A50) +MANUFACTURER_NAME = UUID(0x2A29) + +APPEARANCE_HID = 0x03C0 +APPEARANCE_KEYBOARD = 0x03C1 +APPEARANCE_GAMEPAD = 0x03C4 + +# The report IDs a board serves. +KEYBOARD_ID = 1 +CONSUMER_ID = 2 +GAMEPAD_ID = 3 + +# Consumer usages worth a name. +VOLUME_UP = 0xE9 +VOLUME_DOWN = 0xEA +MUTE = 0xE2 +PLAY_PAUSE = 0xCD +NEXT_TRACK = 0xB5 +PREV_TRACK = 0xB6 + +# Modifier bits in a keyboard report. +MOD_LCTRL = 0x01 +MOD_LSHIFT = 0x02 +MOD_LALT = 0x04 +MOD_LGUI = 0x08 +MOD_RCTRL = 0x10 +MOD_RSHIFT = 0x20 +MOD_RALT = 0x40 +MOD_RGUI = 0x80 + +KEYBOARD_MAP = bytes(( + 0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x85, KEYBOARD_ID, + 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, + 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, # 8 modifier bits + 0x95, 0x01, 0x75, 0x08, 0x81, 0x01, # reserved byte + 0x95, 0x05, 0x75, 0x01, 0x05, 0x08, 0x19, 0x01, 0x29, 0x05, + 0x91, 0x02, 0x95, 0x01, 0x75, 0x03, 0x91, 0x01, # LEDs out + 0x95, 0x06, 0x75, 0x08, 0x15, 0x00, 0x25, 0x65, + 0x05, 0x07, 0x19, 0x00, 0x29, 0x65, 0x81, 0x00, # 6 keys + 0xC0, +)) + +CONSUMER_MAP = bytes(( + 0x05, 0x0C, 0x09, 0x01, 0xA1, 0x01, 0x85, CONSUMER_ID, + 0x75, 0x10, 0x95, 0x01, 0x15, 0x01, 0x26, 0x8C, 0x02, + 0x19, 0x01, 0x2A, 0x8C, 0x02, 0x81, 0x00, # one 16-bit usage + 0xC0, +)) + +GAMEPAD_MAP = bytes(( + 0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, GAMEPAD_ID, + 0x05, 0x09, 0x19, 0x01, 0x29, 0x10, 0x15, 0x00, 0x25, 0x01, + 0x75, 0x01, 0x95, 0x10, 0x81, 0x02, # 16 buttons + 0x05, 0x01, 0x09, 0x39, 0x15, 0x01, 0x25, 0x08, + 0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, + 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, # hat, null = centred + 0x35, 0x00, 0x45, 0x00, 0x65, 0x00, 0x81, 0x03, # 4 bits padding + 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35, + 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x04, 0x81, 0x02, # X Y Z Rz, -127..127 + 0xC0, +)) + + +def report_map(keyboard=True, consumer=True, gamepad=True): + """The Report Map a board serves for this set of functions, always the same bytes.""" + return (KEYBOARD_MAP if keyboard else b"") + (CONSUMER_MAP if consumer else b"") + (GAMEPAD_MAP if gamepad else b"") + + +def identity(keyboard=True, consumer=True, gamepad=True): + """``(name, appearance, product_id)`` for a set of functions, derived the same way every time.""" + parts = [] + if keyboard: + parts.append("Keyboard") + if gamepad: + parts.append("Gamepad") + if consumer and not keyboard: + parts.append("Remote") + name = "PyDevices " + "+".join(parts) if parts else "PyDevices HID" + if keyboard and not gamepad: + appearance = APPEARANCE_KEYBOARD + elif gamepad and not keyboard and not consumer: + appearance = APPEARANCE_GAMEPAD + else: + appearance = APPEARANCE_HID + product = (1 if keyboard else 0) | (2 if consumer else 0) | (4 if gamepad else 0) + return name, appearance, product + + +# US layout: character -> (usage, shift). +def _us_layout(): + table = {"\n": (0x28, False), "\t": (0x2B, False), " ": (0x2C, False)} + for i in range(26): + table[chr(ord("a") + i)] = (0x04 + i, False) + table[chr(ord("A") + i)] = (0x04 + i, True) + for i, (plain, shifted) in enumerate(zip("1234567890", "!@#$%^&*()")): + table[plain] = (0x1E + i, False) + table[shifted] = (0x1E + i, True) + for usage, plain, shifted in ( + (0x2D, "-", "_"), (0x2E, "=", "+"), (0x2F, "[", "{"), (0x30, "]", "}"), + (0x31, "\\", "|"), (0x33, ";", ":"), (0x34, "'", '"'), (0x35, "`", "~"), + (0x36, ",", "<"), (0x37, ".", ">"), (0x38, "/", "?"), + ): + table[plain] = (usage, False) + table[shifted] = (usage, True) + return table + + +US_LAYOUT = _us_layout() + + +# ---------------------------------------------------------------- the device (peripheral) + + +class _Sender: + def __init__(self, owner, characteristic, size): + self._owner = owner + self._char = characteristic + self._size = size + + async def _send(self, report): + report = bytes(report) + if len(report) != self._size: + raise ValueError("report is {} bytes, not {}".format(len(report), self._size)) + conn = self._owner.connection + if conn is None or not conn.is_connected(): + raise BLEError("no host connected") + self._char.write(report) + waited = 0 + while True: + try: + self._char.notify(conn, report) + self._owner.last_send_us = ticks_us() + return + except BusyError: + if waited >= 2000: + raise + await sleep_ms(2) + waited += 2 + + +class Keyboard(_Sender): + """The keyboard half of a :class:`Peripheral`. Usages are HID keyboard usages.""" + + async def send(self, modifiers=0, keys=()): + """Send one report: ``modifiers`` bits held, and up to six usages held.""" + keys = list(keys)[:6] + await self._send(bytes([modifiers, 0] + keys + [0] * (6 - len(keys)))) + + async def tap(self, usage, modifiers=0, hold_ms=0): + """Press and release one key (with ``modifiers`` held around it).""" + await self.send(modifiers, (usage,)) + if hold_ms: + await sleep_ms(hold_ms) + await self.send(0, ()) + + async def type(self, text, gap_ms=0): + """Type ``text`` on a US layout: each character a press and a release.""" + for ch in text: + usage, shift = US_LAYOUT[ch] + await self.tap(usage, MOD_LSHIFT if shift else 0) + if gap_ms: + await sleep_ms(gap_ms) + + +class Consumer(_Sender): + """Media keys: consumer-page usages such as :data:`VOLUME_UP`.""" + + async def send(self, usage=0): + await self._send(struct.pack("= conn.mtu - 1: + raise BLEError( + "the Report Map is at least {} bytes and this host reads one packet " + "(mtu {} - 1); MicroPython has no long read".format(len(data), conn.mtu) + ) + raise BLEError("the Report Map doesn't parse: {}".format(e)) + self.decoder = Decoder(self.report_map, self.instance_id) + for c in chars: + if c.uuid == PROTOCOL_MODE: + # Report protocol (1), which is also the default after connecting. + try: + await self._secure(lambda: c.write(b"\x01", response=False)) + except BLEError: + pass + inputs = [] + for c in chars: + if c.uuid != REPORT: + continue + ref = await c.descriptor(REPORT_REFERENCE, timeout_ms) + if ref is None: + continue + value = await self._secure(lambda: ref.read(timeout_ms)) + if len(value) < 2: + continue + report_id, kind = value[0], value[1] + if kind == INPUT: + inputs.append((c, report_id)) + elif kind == OUTPUT and report_id and self.report_map.report(OUTPUT, report_id): + self._leds = (c, report_id) + if not inputs: + raise BLEError("the HID service has no input reports") + for c, report_id in inputs: + await self._secure(lambda: c.subscribe(notify=True)) + for c, report_id in inputs: + self._tasks.append(asyncio.create_task(self._reader(c, report_id))) + if self._leds is not None: + # Hosts set a keyboard's LEDs once they're listening; it also + # tells our own Peripheral that someone is (Peripheral.leds()). + await self.set_leds(0) + return self + + async def _reader(self, characteristic, report_id): + try: + while True: + data = await characteristic.notified() + self.last_report_us = ticks_us() + for event in self.decoder.feed(data, report_id): + if len(self._queue) >= self._limit: + self._overrun += 1 + else: + self._queue.append(event) + self._flag.set() + except asyncio.CancelledError: + raise + except Exception as e: # a disconnect, or an overrun below us + self._error = e + self._flag.set() + + def _check(self): + if self._overrun: + n, self._overrun = self._overrun, 0 + raise BLEError("{} HID event(s) dropped: nobody read them".format(n)) + + def poll(self): + """Every event decoded so far, as a list (possibly empty). Never waits.""" + self._check() + out, self._queue = self._queue, [] + if not out and self._error is not None: + raise self._error + return out + + async def events(self, timeout_ms=None): + """Wait for at least one event, and return every one waiting.""" + while not self._queue: + self._check() + if self._error is not None: + raise self._error + self._flag.clear() + await wait_ms(self._flag.wait(), timeout_ms, "HID events") + return self.poll() + + async def set_leds(self, bits): + """Write the keyboard's LED report (Num Lock is bit 0), if it has one.""" + if self._leds is None: + raise BLEError("this device has no LED report") + c, _ = self._leds + await self._secure(lambda: c.write(bytes((bits,)), response=False)) + + async def close(self): + for t in self._tasks: + t.cancel() + self._tasks = [] + if self.connection.is_connected(): + await self.connection.disconnect() + + +async def connect(ble, name=None, *, device=None, timeout_ms=10000, bond=True, instance_id=0, **options): + """Find a BLE HID device (by ``name``, or any advertising the HID service), + connect, and return a started :class:`Host`.""" + if device is None: + device = await ble.find(name=name, service=None if name else HID_SERVICE, timeout_ms=timeout_ms) + connection = await device.connect(timeout_ms=timeout_ms, **options) + host = Host(connection, bond=bond, instance_id=instance_id) + try: + await host.start() + except BaseException: + await host.close() + raise + return host + + +__all__ = [ + "Host", "Peripheral", "Keyboard", "Consumer", "Gamepad", "connect", "report_map", "identity", + "US_LAYOUT", "ticks_us", "ticks_diff", +] diff --git a/lib/bledev/hidreport.py b/lib/bledev/hidreport.py new file mode 100644 index 00000000..8ce4a11b --- /dev/null +++ b/lib/bledev/hidreport.py @@ -0,0 +1,658 @@ +"""HID report descriptors, and the reports they describe, as PyDevices events. + +A HID device describes its own reports: the *report descriptor* (Bluetooth +calls it the Report Map) says which bits of which report carry which control. +This module reads one, and turns the device's input reports into the same +``events.Key``, ``events.JoyAxisMotion``, ``events.JoyHatMotion`` and +``events.JoyButtonDown``/``Up`` records a keyboard or a joystick produces on +the desktop through SDL:: + + from bledev.hidreport import ReportMap, Decoder + + rmap = ReportMap(descriptor_bytes) + decoder = Decoder(rmap) + for event in decoder.feed(report, report_id=1): + ... + +Nothing here knows about Bluetooth. A USB host that has the descriptor can +use it the same way; ``feed(data)`` without ``report_id`` takes the ID from +the report's first byte when the map numbers its reports, as USB sends them. + +**Keyboards** follow usbif's boot-keyboard decoder exactly (same event order, +same fields), because an app must not be able to tell a BLE keyboard from a +USB one. The keyboard says which keys are held; events come from the +difference between successive reports: modifier releases, key releases, key +presses, then modifier presses. An ErrorRollOver report is ignored, so the +eventual releases still balance. + +**Consumer controls** (media keys, remotes) become ``events.Key`` as well, +with ``keys.K_VOLUMEUP`` and friends, and ``scancode`` set to the extended +usage (``0x000C00E9`` for volume up). A usage with no key constant arrives +with ``key=None`` and name ``"Unknown"``, as usbif does. + +**Game controllers** (the joystick, gamepad and multi-axis collections) +produce SDL-style joystick events. Axes are numbered by usage, X, Y, Z, Rx, +Ry, Rz, slider, dial, wheel, then the simulation page (accelerator, brake), +and their value is a float from -1.0 to 1.0 across the logical range. +Buttons are numbered from 0 (Button 1 is button 0). A hat is an ``(x, y)`` +tuple, y up, ``(0, 0)`` when centred, like pygame. +""" + +import events +import keys + +# Main item kinds, numbered as the HOGP Report Reference descriptor numbers them. +INPUT = 1 +OUTPUT = 2 +FEATURE = 3 + +# Main-item flag bits. +CONSTANT = 0x01 +VARIABLE = 0x02 +RELATIVE = 0x04 +NULL_STATE = 0x40 + +# Usage pages. +PAGE_DESKTOP = 0x01 +PAGE_SIMULATION = 0x02 +PAGE_KEYBOARD = 0x07 +PAGE_LED = 0x08 +PAGE_BUTTON = 0x09 +PAGE_CONSUMER = 0x0C + +# Application collections, as extended usages (page << 16 | usage). +APP_POINTER = 0x00010001 +APP_MOUSE = 0x00010002 +APP_JOYSTICK = 0x00010004 +APP_GAMEPAD = 0x00010005 +APP_KEYBOARD = 0x00010006 +APP_KEYPAD = 0x00010007 +APP_MULTI_AXIS = 0x00010008 +APP_CONSUMER = 0x000C0001 + +_GAME_APPS = (APP_JOYSTICK, APP_GAMEPAD, APP_MULTI_AXIS) +_HAT = 0x00010039 + +ERROR_ROLLOVER = 0x01 + + +class HIDParseError(ValueError): + """The descriptor is malformed: truncated, unbalanced, or impossible.""" + + +def _signed(raw, size): + if size == 1 and raw & 0x80: + return raw - 0x100 + if size == 2 and raw & 0x8000: + return raw - 0x10000 + if size == 4 and raw & 0x80000000: + return raw - 0x100000000 + return raw + + +class Field: + """One main item: ``count`` values of ``size`` bits at bit ``offset``. + + ``offset`` counts from the start of the report *after* its report ID, the + way HOGP delivers it. ``usages`` is a list of extended usages + (``page << 16 | id``) and ``(first, last)`` ranges, in descriptor order. + ``application`` is the extended usage of the top-level collection the + field sits in. + """ + + def __init__(self, kind, report_id, offset, size, count, flags, usages, + logical_min, logical_max, physical_min, physical_max, + unit, unit_exponent, application): + self.kind = kind + self.report_id = report_id + self.offset = offset + self.size = size + self.count = count + self.flags = flags + self.usages = usages + self.logical_min = logical_min + self.logical_max = logical_max + self.physical_min = physical_min + self.physical_max = physical_max + self.unit = unit + self.unit_exponent = unit_exponent + self.application = application + + def __repr__(self): + return "Field(kind={}, id={}, offset={}, size={}, count={}, flags=0x{:02x}, usages={})".format( + self.kind, self.report_id, self.offset, self.size, self.count, self.flags, + ["0x{:08x}".format(u) if isinstance(u, int) else "0x{:08x}-0x{:08x}".format(*u) for u in self.usages], + ) + + @property + def constant(self): + return bool(self.flags & CONSTANT) + + @property + def variable(self): + return bool(self.flags & VARIABLE) + + @property + def relative(self): + return bool(self.flags & RELATIVE) + + def usage(self, n): + """The ``n``-th usage in the list, ranges expanded; past the end, the last one. + + For a variable field that's the usage of element ``n``; for an array + it's what the value ``logical_min + n`` means. ``None`` if the field + has no usages at all (padding). + """ + last = None + for entry in self.usages: + if isinstance(entry, int): + if n == 0: + return entry + n -= 1 + last = entry + else: + first, end = entry + span = end - first + 1 + if n < span: + return first + n + n -= span + last = end + return last + + def raw(self, data, n=0): + """Element ``n``'s raw value from report ``data`` (without its ID), sign-extended if signed.""" + bit = self.offset + n * self.size + first = bit >> 3 + last = (bit + self.size + 7) >> 3 + if last > len(data): + raise ValueError("report of {} bytes is too short for bits {}..{}".format( + len(data), bit, bit + self.size - 1)) + value = (int.from_bytes(bytes(data[first:last]), "little") >> (bit & 7)) & ((1 << self.size) - 1) + if self.logical_min < 0 and value & (1 << (self.size - 1)): + value -= 1 << self.size + return value + + def values(self, data): + """Every element's raw value, as a list.""" + return [self.raw(data, n) for n in range(self.count)] + + +class Report: + """The fields of one report: its ``kind``, its ``report_id`` (0 if unnumbered) and its size.""" + + def __init__(self, kind, report_id): + self.kind = kind + self.report_id = report_id + self.fields = [] + self.bits = 0 + + @property + def size(self): + """Bytes, without the report ID.""" + return (self.bits + 7) >> 3 + + def __repr__(self): + return "Report(kind={}, id={}, size={}, fields={})".format( + self.kind, self.report_id, self.size, len(self.fields)) + + +class ReportMap: + """A parsed report descriptor. + + ``reports`` maps ``(kind, report_id)`` to :class:`Report`; ``numbered`` + says whether the device uses report IDs; ``applications`` lists the + top-level collections' extended usages in order. + """ + + def __init__(self, descriptor): + self.descriptor = bytes(descriptor) + self.reports = {} + self.numbered = False + self.applications = [] + self._parse() + + def report(self, kind, report_id=0): + return self.reports.get((kind, report_id)) + + def inputs(self): + """The input reports, in report ID order.""" + return [r for (k, _), r in sorted(self.reports.items()) if k == INPUT] + + def _parse(self): + d = self.descriptor + i = 0 + # Global state: page, lmin, lmax, pmin, pmax, unit_exp, unit, size, id, count. + page = 0 + lmin = lmax = pmin = pmax = 0 + lmax_n = 1 + unit = unit_exp = 0 + size = 0 + report_id = 0 + count = 0 + stack = [] + # Local state. + usages = [] + umin = None + collections = [] # extended usage of each open collection + application = 0 + seen_ids = False + while i < len(d): + prefix = d[i] + if prefix == 0xFE: # long item: skip + if i + 2 >= len(d): + raise HIDParseError("truncated long item at {}".format(i)) + i += 3 + d[i + 1] + continue + n = prefix & 0x03 + n = 4 if n == 3 else n + if i + 1 + n > len(d): + raise HIDParseError("item at byte {} runs past the end".format(i)) + raw = int.from_bytes(d[i + 1:i + 1 + n], "little") if n else 0 + kind = (prefix >> 2) & 0x03 + tag = prefix >> 4 + i += 1 + n + if kind == 1: # global + if tag == 0x0: + page = raw + elif tag == 0x1: + lmin = _signed(raw, n) + elif tag == 0x2: + lmax = _signed(raw, n) + lmax_n = n + elif tag == 0x3: + pmin = _signed(raw, n) + elif tag == 0x4: + pmax = _signed(raw, n) + elif tag == 0x5: + unit_exp = _signed(raw, n) if n > 1 else (raw - 16 if raw & 0x08 else raw) + elif tag == 0x6: + unit = raw + elif tag == 0x7: + size = raw + elif tag == 0x8: + if raw == 0: + raise HIDParseError("report ID 0 is reserved") + report_id = raw + seen_ids = True + elif tag == 0x9: + count = raw + elif tag == 0xA: + stack.append((page, lmin, lmax, lmax_n, pmin, pmax, unit_exp, unit, size, report_id, count)) + elif tag == 0xB: + if not stack: + raise HIDParseError("Pop with nothing pushed") + page, lmin, lmax, lmax_n, pmin, pmax, unit_exp, unit, size, report_id, count = stack.pop() + elif kind == 2: # local + # A 4-byte usage carries its own page; a shorter one takes + # the page in force when the main item is reached. + ext = raw if n == 4 else None + if tag == 0x0: + usages.append(ext if ext is not None else ("p", raw)) + elif tag == 0x1: + umin = ext if ext is not None else ("p", raw) + elif tag == 0x2: + if umin is None: + raise HIDParseError("Usage Maximum without Usage Minimum") + usages.append((umin, ext if ext is not None else ("p", raw))) + umin = None + # Designators, strings and delimiters don't change the layout. + elif kind == 0: # main + resolved = [] + for u in usages: + if isinstance(u, int): + resolved.append(u) + elif u[0] == "p": + resolved.append((page << 16) | u[1]) + else: + a, b = u + a = (page << 16) | a[1] if isinstance(a, tuple) else a + b = (page << 16) | b[1] if isinstance(b, tuple) else b + resolved.append((a, b)) + if tag in (0x8, 0x9, 0xB): # input, output, feature + mkind = INPUT if tag == 0x8 else (OUTPUT if tag == 0x9 else FEATURE) + if size == 0 and count: + raise HIDParseError("main item with Report Size 0 at byte {}".format(i)) + key = (mkind, report_id) + rep = self.reports.get(key) + if rep is None: + rep = self.reports[key] = Report(mkind, report_id) + # A signed range read as unsigned: 25 FF with 15 00 means 255. + hi = lmax + if lmin >= 0 and hi < 0: + hi += 1 << (8 * lmax_n) + rep.fields.append(Field( + mkind, report_id, rep.bits, size, count, raw, resolved, + lmin, hi, pmin, pmax, unit, unit_exp, application, + )) + rep.bits += size * count + elif tag == 0xA: # collection + usage = resolved[0] if resolved else 0 + if isinstance(usage, tuple): + usage = usage[0] + if not collections: + application = usage + self.applications.append(usage) + collections.append(usage) + elif tag == 0xC: # end collection + if not collections: + raise HIDParseError("End Collection with none open") + collections.pop() + if not collections: + application = 0 + usages = [] + umin = None + if collections: + raise HIDParseError("{} collection(s) left open".format(len(collections))) + self.numbered = seen_ids + if seen_ids and any(rid == 0 for (_, rid) in self.reports): + raise HIDParseError("a report outside any Report ID in a numbered descriptor") + + +# ---------------------------------------------------------------- key tables + +# HID keyboard usage -> SDL keycode. SDL's scancodes *are* HID usages, and +# SDL makes the keycode of every key without a character ``usage | 1 << 30``, +# so only the character keys need a table. It agrees with usbif's table +# (tests/test_bledev_hid.py compares the two), so 0x32, the ISO keyboard's +# "# ~" key, stays unmapped here as it is there, though SDL calls it '#'. +_CHARS = { + 0x28: keys.K_RETURN, 0x29: keys.K_ESCAPE, 0x2A: keys.K_BACKSPACE, + 0x2B: keys.K_TAB, 0x2C: keys.K_SPACE, + 0x2D: ord("-"), 0x2E: ord("="), 0x2F: ord("["), 0x30: ord("]"), + 0x31: ord("\\"), 0x33: ord(";"), 0x34: ord("'"), + 0x35: ord("`"), 0x36: ord(","), 0x37: ord("."), 0x38: ord("/"), + 0x4C: keys.K_DELETE, +} + +_SCANCODE_KEY = 1 << 30 + + +def keycode(usage): + """The SDL-style keycode (``keys.K_*``) for a keyboard-page usage, or ``None``.""" + if 0x04 <= usage <= 0x1D: + return keys.K_a + usage - 0x04 + if 0x1E <= usage <= 0x26: + return ord("1") + usage - 0x1E + if usage == 0x27: + return ord("0") + code = _CHARS.get(usage) + if code is not None: + return code + if usage >= 0x39: + code = usage | _SCANCODE_KEY + if keys.keyname(code) != "Unknown": + return code + return None + + +# Consumer page usage -> keys constant name. +_CONSUMER = { + 0x30: "K_POWER", 0x32: "K_SLEEP", 0x6F: "K_BRIGHTNESSUP", 0x70: "K_BRIGHTNESSDOWN", + 0xB5: "K_AUDIONEXT", 0xB6: "K_AUDIOPREV", 0xB7: "K_AUDIOSTOP", 0xB8: "K_EJECT", + 0xCD: "K_AUDIOPLAY", 0xE2: "K_MUTE", 0xE9: "K_VOLUMEUP", 0xEA: "K_VOLUMEDOWN", + 0x183: "K_MEDIASELECT", 0x18A: "K_MAIL", 0x192: "K_CALCULATOR", 0x194: "K_COMPUTER", + 0x196: "K_WWW", 0x221: "K_AC_SEARCH", 0x223: "K_AC_HOME", 0x224: "K_AC_BACK", + 0x225: "K_AC_FORWARD", 0x226: "K_AC_STOP", 0x227: "K_AC_REFRESH", 0x22A: "K_AC_BOOKMARKS", +} + + +def consumer_keycode(usage): + """The ``keys.K_*`` code for a consumer-page usage (``0xE9``, not extended), or ``None``.""" + name = _CONSUMER.get(usage) + return getattr(keys, name, None) if name else None + + +# Modifier usage 0xE0..0xE7 -> (KMOD mask, keycode), in report bit order. +_MODIFIERS = ( + (keys.KMOD_LCTRL, keys.K_LCTRL), (keys.KMOD_LSHIFT, keys.K_LSHIFT), + (keys.KMOD_LALT, keys.K_LALT), (keys.KMOD_LGUI, keys.K_LGUI), + (keys.KMOD_RCTRL, keys.K_RCTRL), (keys.KMOD_RSHIFT, keys.K_RSHIFT), + (keys.KMOD_RALT, keys.K_RALT), (keys.KMOD_RGUI, keys.K_RGUI), +) + + +def modifier_mask(bits): + """``keys.KMOD_*`` for a byte of modifier bits (bit 0 is left Ctrl).""" + mask = keys.KMOD_NONE + for i in range(8): + if bits & (1 << i): + mask |= _MODIFIERS[i][0] + return mask + + +# Axis order: desktop X..Wheel, then the simulation page. +_AXES_DESKTOP = (0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38) + +# Hat directions for 8 positions, clockwise from north; y is up. +_HAT8 = ((0, 1), (1, 1), (1, 0), (1, -1), (0, -1), (-1, -1), (-1, 0), (-1, 1)) +_HAT4 = ((0, 1), (1, 0), (0, -1), (-1, 0)) + + +def _is_axis(usage): + page = usage >> 16 + if page == PAGE_DESKTOP: + return (usage & 0xFFFF) in _AXES_DESKTOP + return page == PAGE_SIMULATION + + +def _axis_order(usage): + page = usage >> 16 + if page == PAGE_DESKTOP: + return _AXES_DESKTOP.index(usage & 0xFFFF) + return 0x100 + (usage & 0xFFFF) + + +class Decoder: + """Turns one device's input reports into PyDevices events. + + One instance per device: it holds that device's state (held keys, axis + positions) and diffs each report against it. ``instance_id`` is the + joystick instance its joystick events carry. + """ + + def __init__(self, report_map, instance_id=0): + self.map = report_map + self.instance_id = instance_id + # Per input report ID: (keyboard, consumer, axes, hats, buttons) element lists. + self._plan = {} + axes = [] + hats = [] + for report in report_map.inputs(): + kb, cc, ax, ht, bt = [], [], [], [], [] + for f in report.fields: + if f.constant or not f.usages: + continue + game = f.application in _GAME_APPS + if not f.variable: + page = f.usage(0) >> 16 + if page == PAGE_KEYBOARD: + kb.append((f, None)) + elif page == PAGE_CONSUMER: + cc.append((f, None)) + continue + for n in range(f.count): + u = f.usage(n) + page = u >> 16 + if page == PAGE_KEYBOARD: + kb.append((f, n)) + elif page == PAGE_CONSUMER: + cc.append((f, n)) + elif game and u == _HAT: + hats.append((report.report_id, f.offset, n, f)) + elif game and page == PAGE_BUTTON and (u & 0xFFFF): + bt.append((f, n, (u & 0xFFFF) - 1)) + elif game and not f.relative and _is_axis(u): + axes.append((_axis_order(u), report.report_id, f.offset, n, f)) + self._plan[report.report_id] = [kb, cc, [], [], bt] + # Number axes and hats across the whole device, by usage then position. + axes.sort(key=lambda a: a[:4]) + for index, (_, rid, _, n, f) in enumerate(axes): + self._plan[rid][2].append((f, n, index)) + hats.sort(key=lambda h: h[:3]) + for index, (rid, _, n, f) in enumerate(hats): + self._plan[rid][3].append((f, n, index)) + self.num_axes = len(axes) + self.num_hats = len(hats) + self.num_buttons = 0 + for plan in self._plan.values(): + for _, _, b in plan[4]: + self.num_buttons = max(self.num_buttons, b + 1) + self._held = frozenset() + self._mod_bits = 0 + self._consumer = frozenset() + self._axes = {} + self._hats = {} + self._buttons = {} + + @property + def held(self): + """Keyboard usages held now.""" + return self._held + + @property + def modifiers(self): + return modifier_mask(self._mod_bits) + + def axis(self, n): + """Axis ``n``'s last value, -1.0..1.0 (0.0 before any report).""" + return self._axes.get(n, 0.0) + + def button(self, n): + return self._buttons.get(n, False) + + def hat(self, n): + return self._hats.get(n, (0, 0)) + + def feed(self, data, report_id=None): + """Decode one input report and return its events, as a tuple. + + ``report_id`` is the ID HOGP gives in the Report Reference; leave it + out for a USB-style report, whose first byte is the ID when the map + numbers its reports. + """ + data = bytes(data) + if report_id is None: + if self.map.numbered: + if not data: + return () + report_id, data = data[0], data[1:] + else: + report_id = 0 + plan = self._plan.get(report_id) + if plan is None: + return () + kb, cc, ax, ht, bt = plan + out = [] + if kb: + self._keyboard(kb, data, out) + if cc: + self._consumer_keys(cc, data, out) + for f, n, index in ax: + raw = f.raw(data, n) + lo, hi = f.logical_min, f.logical_max + if hi <= lo: + continue + raw = lo if raw < lo else (hi if raw > hi else raw) + value = 2.0 * (raw - lo) / (hi - lo) - 1.0 + if self._axes.get(index, 0.0) != value: + self._axes[index] = value + out.append(events.JoyAxisMotion(events.JOYAXISMOTION, self.instance_id, index, value)) + for f, n, index in ht: + raw = f.raw(data, n) + span = f.logical_max - f.logical_min + 1 + pos = raw - f.logical_min + table = _HAT8 if span == 8 else (_HAT4 if span == 4 else None) + value = table[pos] if table is not None and 0 <= pos < span else (0, 0) + if self._hats.get(index, (0, 0)) != value: + self._hats[index] = value + out.append(events.JoyHatMotion(events.JOYHATMOTION, self.instance_id, index, value)) + ups, downs = [], [] + for f, n, button in bt: + pressed = f.raw(data, n) != 0 + if self._buttons.get(button, False) != pressed: + self._buttons[button] = pressed + (downs if pressed else ups).append(button) + for b in sorted(ups): + out.append(events.JoyButtonUp(events.JOYBUTTONUP, self.instance_id, b)) + for b in sorted(downs): + out.append(events.JoyButtonDown(events.JOYBUTTONDOWN, self.instance_id, b)) + return tuple(out) + + def _keyboard(self, elements, data, out): + now = set() + mod_bits = 0 + for f, n in elements: + if n is not None: # variable: one bit per usage + u = f.usage(n) & 0xFFFF + if f.raw(data, n): + if 0xE0 <= u <= 0xE7: + mod_bits |= 1 << (u - 0xE0) + elif u: + now.add(u) + continue + for k in range(f.count): # array: each value names a usage + v = f.raw(data, k) + if v < f.logical_min or v > f.logical_max: + continue + u = f.usage(v - f.logical_min) + if u is None: + continue + u &= 0xFFFF + if u == ERROR_ROLLOVER: + # More keys held than the report carries: leave the held + # set alone so the eventual releases balance. + return + if 0xE0 <= u <= 0xE7: + mod_bits |= 1 << (u - 0xE0) + elif u > 0x03: + now.add(u) + now = frozenset(now) + mod = modifier_mask(mod_bits) + was = self._mod_bits + for i in range(8): + if was & (1 << i) and not mod_bits & (1 << i): + code = _MODIFIERS[i][1] + out.append(events.Key(events.KEYUP, keys.keyname(code), code, mod, 0xE0 + i, None)) + for u in sorted(self._held - now): + out.append(_key(events.KEYUP, u, keycode(u), mod)) + for u in sorted(now - self._held): + out.append(_key(events.KEYDOWN, u, keycode(u), mod)) + for i in range(8): + if mod_bits & (1 << i) and not was & (1 << i): + code = _MODIFIERS[i][1] + out.append(events.Key(events.KEYDOWN, keys.keyname(code), code, mod, 0xE0 + i, None)) + self._held = now + self._mod_bits = mod_bits + + def _consumer_keys(self, elements, data, out): + now = set() + for f, n in elements: + if n is not None: + if f.raw(data, n): + now.add(f.usage(n) & 0xFFFF) + continue + for k in range(f.count): + v = f.raw(data, k) + if v < f.logical_min or v > f.logical_max: + continue + u = f.usage(v - f.logical_min) + if u is not None and u & 0xFFFF: + now.add(u & 0xFFFF) + now = frozenset(now) + mod = modifier_mask(self._mod_bits) + for u in sorted(self._consumer - now): + out.append(_key(events.KEYUP, (PAGE_CONSUMER << 16) | u, consumer_keycode(u), mod)) + for u in sorted(now - self._consumer): + out.append(_key(events.KEYDOWN, (PAGE_CONSUMER << 16) | u, consumer_keycode(u), mod)) + self._consumer = now + + +def _key(kind, scancode, code, mod): + return events.Key( + kind, + keys.keyname(code) if code is not None else "Unknown", + code, + mod, + scancode, + None, + ) diff --git a/lib/bledev/mpble.py b/lib/bledev/mpble.py index a0df68dc..686ac51b 100644 --- a/lib/bledev/mpble.py +++ b/lib/bledev/mpble.py @@ -42,6 +42,7 @@ BLETimeoutError, BusyError, ClientCharacteristic, + ClientDescriptor, ClientService, Connection, DEFAULT_MTU, @@ -65,6 +66,9 @@ _IRQ_GATTS_WRITE = 3 _IRQ_MTU_EXCHANGED = 21 +_FLAG_READ_ENCRYPTED = 0x0200 +_FLAG_WRITE_ENCRYPTED = 0x1000 + # errno values NimBLE uses when its buffers are full (ENOMEM, EAGAIN, EBUSY, # ENOBUFS). A send that sees one of these waits and tries again. _BUSY_ERRNOS = (12, 11, 16, 105) @@ -150,7 +154,11 @@ def __exit__(self, exc_type, exc, tb): if issubclass(exc_type, aioble.DeviceDisconnectedError): raise DisconnectedError("{}: disconnected".format(self._what)) if issubclass(exc_type, aioble.GattError): - raise GattError(getattr(exc, "_status", 0)) + status = getattr(exc, "_status", 0) or 0 + # NimBLE reports an ATT error as 0x100 + the ATT code. + if 0x100 <= status < 0x200: + status -= 0x100 + raise GattError(status) if exc_type is ValueError and exc.args and exc.args[0] == "Not connected": raise DisconnectedError("{}: not connected".format(self._what)) return False @@ -273,9 +281,24 @@ def capabilities(self): max_mtu=_MAX_MTU, indicate=True, pairing=True, + long_read=False, ) return caps + def enable_bonding(self, path=None): + """Keep pairing keys in ``path`` (aioble's ``ble_secrets.json``) and bond from now on. + + Call it before any connection that may pair, on both sides: the side + that doesn't start pairing still has to store the keys. The file + doesn't survive an erase of the filesystem, and a peer that still + holds its half of the bond will then refuse to reconnect encrypted + until it's paired again. + """ + from aioble import security + + security.load_secrets(path) + aioble.config(bond=True, le_secure=True, mitm=False, io=3) + def _config_mtu(self): try: return aioble.config("mtu") @@ -353,6 +376,10 @@ def register_services(self, *services): initial=c._initial, max_len=c.max_len, ) + if c.encrypted: + achar.flags |= _FLAG_READ_ENCRYPTED | _FLAG_WRITE_ENCRYPTED + for d in c.descriptors: + aioble.Descriptor(achar, _buuid(d.uuid), read=True, initial=d.value) c._impl = _MPServerCharacteristic(self, c, achar) aservices.append(aservice) aioble.register_services(*aservices) @@ -483,6 +510,18 @@ async def exchange_mtu(self, mtu=None, timeout_ms=1000): with _Translate("exchange_mtu"): return await self._aconn.exchange_mtu(mtu, timeout_ms) + @property + def encrypted(self): + return bool(self._aconn.encrypted) + + async def pair(self, bond=True, timeout_ms=20000): + if not self.is_connected(): + raise DisconnectedError("pair: not connected") + with _Translate("pair"): + await self._aconn.pair(bond=bond, le_secure=True, mitm=False, io=3, timeout_ms=timeout_ms) + if not self._aconn.encrypted: + raise BLEError("pair: the link is not encrypted") + async def _discover_services(self, uuid, timeout_ms): found = [] with _Translate("service discovery"): @@ -533,6 +572,17 @@ async def read(self, timeout_ms=1000): with _Translate("read"): return bytes(await self._achar.read(timeout_ms)) + async def _discover_descriptors(self, uuid, timeout_ms): + self._live("descriptor discovery") + found = [] + with _Translate("descriptor discovery"): + async for adesc in self._achar.descriptors(timeout_ms): + d = _MPClientDescriptor(self, adesc) + if uuid is None or d.uuid == uuid: + found.append(d) + found.sort(key=lambda d: d._adesc._value_handle) + return found + async def write(self, data, response=None, timeout_ms=1000): data = bytes(data) response = self._want_response(response) @@ -604,6 +654,18 @@ async def indicated(self, timeout_ms=None): return await self._next(a._indicate_queue, a._indicate_event, timeout_ms, "indicated") +class _MPClientDescriptor(ClientDescriptor): + def __init__(self, characteristic, adesc): + ClientDescriptor.__init__(self, characteristic, _uuid(adesc.uuid)) + self._adesc = adesc + + async def read(self, timeout_ms=1000): + if not self.connection.is_connected(): + raise DisconnectedError("read: not connected") + with _Translate("descriptor read"): + return bytes(await self._adesc.read(timeout_ms)) + + class _MPServerCharacteristic: """mpble's half of a :class:`bledev.Characteristic`.""" diff --git a/pydevices-desktop.toml b/pydevices-desktop.toml index a1f8be06..0e353dfa 100644 --- a/pydevices-desktop.toml +++ b/pydevices-desktop.toml @@ -24,6 +24,8 @@ "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/auto.py" = "/lib/bledev/auto.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/bleak.py" = "/lib/bledev/bleak.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/fake.py" = "/lib/bledev/fake.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/hid.py" = "/lib/bledev/hid.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/hidreport.py" = "/lib/bledev/hidreport.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/improv.py" = "/lib/bledev/improv.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/mpble.py" = "/lib/bledev/mpble.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/nus.py" = "/lib/bledev/nus.py" diff --git a/tests/bledev_board/hid_script.py b/tests/bledev_board/hid_script.py new file mode 100644 index 00000000..0a91b577 --- /dev/null +++ b/tests/bledev_board/hid_script.py @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The scripted input for the HID gate, and the events it must produce. + +Shared by the fake-radio check (``tests/bledev_hid_checks.py``) and the +board-to-board gate (``hid_peripheral.py`` / ``hid_central.py``). The +peripheral :func:`perform`\\ s ``STEPS``; the central collects its events and +compares them with :func:`expected`. + +:func:`expected` is written from the rules in ``docs/bledev.md``, not from +``bledev.hidreport``: it builds each event by hand (the usbif chord order, +axis ``2 * (v + 127) / 254 - 1``, hat positions clockwise from north), so a +decoder bug can't produce its own answer. :func:`typed_text` reads the key +events back into text, which is what a person at the other end would see. +""" + +import events +import keys + +TEXT = "Hello, World! 1+1=2 (ok?) ~_~\n" + +# (step kind, arguments). +STEPS = ( + ("type", TEXT), + ("chord", 0x01, 0x06), # Ctrl+C: left Ctrl, usage 0x06 ('c') + ("chord", 0x40 | 0x02, 0x3E), # Right Alt + Left Shift + F5 + ("consumer", 0xE9), # volume up + ("consumer", 0xCD), # play/pause + ("pad", (0, 0, 0, 0), 0x0001, None), + ("pad", (127, -127, 0, 0), 0x0001, 0), + ("pad", (64, -127, -64, 5), 0x8003, 3), + ("pad", (64, -127, -64, 5), 0x8003, 3), # unchanged: no events + ("pad", (0, 0, 0, 0), 0x0000, None), +) + +# US layout, written out again rather than imported from bledev.hid. +_SHIFTED = '~!@#$%^&*()_+{}|:"<>?ABCDEFGHIJKLMNOPQRSTUVWXYZ' +_UNSHIFTED = "`1234567890-=[]\\;',./abcdefghijklmnopqrstuvwxyz" +_USAGE = {} +for _i, _c in enumerate("abcdefghijklmnopqrstuvwxyz"): + _USAGE[_c] = 0x04 + _i +for _i, _c in enumerate("1234567890"): + _USAGE[_c] = 0x1E + _i +_USAGE.update({"\n": 0x28, "\t": 0x2B, " ": 0x2C, "-": 0x2D, "=": 0x2E, "[": 0x2F, "]": 0x30, + "\\": 0x31, ";": 0x33, "'": 0x34, "`": 0x35, ",": 0x36, ".": 0x37, "/": 0x38}) + + +def _plain(ch): + i = _SHIFTED.find(ch) + return (_UNSHIFTED[i], True) if i >= 0 else (ch, False) + + +def _code(usage): + # The keycode for the usages this script uses. + for ch, u in _USAGE.items(): + if u == usage: + return {"\n": keys.K_RETURN, "\t": keys.K_TAB, " ": keys.K_SPACE}.get(ch, ord(ch)) + return {0x3E: keys.K_F5}[usage] + + +_MODS = ( # report bit -> (KMOD, keycode, usage) + (0x01, keys.KMOD_LCTRL, keys.K_LCTRL, 0xE0), (0x02, keys.KMOD_LSHIFT, keys.K_LSHIFT, 0xE1), + (0x04, keys.KMOD_LALT, keys.K_LALT, 0xE2), (0x08, keys.KMOD_LGUI, keys.K_LGUI, 0xE3), + (0x10, keys.KMOD_RCTRL, keys.K_RCTRL, 0xE4), (0x20, keys.KMOD_RSHIFT, keys.K_RSHIFT, 0xE5), + (0x40, keys.KMOD_RALT, keys.K_RALT, 0xE6), (0x80, keys.KMOD_RGUI, keys.K_RGUI, 0xE7), +) + + +def _key(kind, code, mod, scancode): + return events.Key(kind, keys.keyname(code) if code is not None else "Unknown", code, mod, scancode, None) + + +def _chord(bits, usage): + kmod = 0 + for bit, km, _, _ in _MODS: + if bits & bit: + kmod |= km + code = _code(usage) + # Down: the key, then its modifiers. Up: the modifiers, then the key. + out = [_key(events.KEYDOWN, code, kmod, usage)] + out += [_key(events.KEYDOWN, kc, kmod, u) for bit, _, kc, u in _MODS if bits & bit] + out += [_key(events.KEYUP, kc, 0, u) for bit, _, kc, u in _MODS if bits & bit] + out.append(_key(events.KEYUP, code, 0, usage)) + return out + + +_HAT = ((0, 1), (1, 1), (1, 0), (1, -1), (0, -1), (-1, -1), (-1, 0), (-1, 1)) +CONSUMER_KEYS = {0xE9: keys.K_VOLUMEUP, 0xCD: keys.K_AUDIOPLAY} + + +def expected(steps=STEPS, instance_id=0): + out = [] + axes = [0.0] * 4 + buttons = 0 + hat = (0, 0) + for step in steps: + kind = step[0] + if kind == "type": + for ch in step[1]: + plain, shift = _plain(ch) + out += _chord(0x02 if shift else 0, _USAGE[plain]) + elif kind == "chord": + out += _chord(step[1], step[2]) + elif kind == "consumer": + u = step[1] + out.append(_key(events.KEYDOWN, CONSUMER_KEYS[u], 0, 0x000C0000 | u)) + out.append(_key(events.KEYUP, CONSUMER_KEYS[u], 0, 0x000C0000 | u)) + elif kind == "pad": + new_axes, new_buttons, new_hat = step[1], step[2], step[3] + for i in range(4): + v = 2.0 * (new_axes[i] + 127) / 254 - 1.0 + if v != axes[i]: + axes[i] = v + out.append(events.JoyAxisMotion(events.JOYAXISMOTION, instance_id, i, v)) + h = (0, 0) if new_hat is None else _HAT[new_hat] + if h != hat: + hat = h + out.append(events.JoyHatMotion(events.JOYHATMOTION, instance_id, 0, h)) + for b in range(16): + if buttons & (1 << b) and not new_buttons & (1 << b): + out.append(events.JoyButtonUp(events.JOYBUTTONUP, instance_id, b)) + for b in range(16): + if new_buttons & (1 << b) and not buttons & (1 << b): + out.append(events.JoyButtonDown(events.JOYBUTTONDOWN, instance_id, b)) + buttons = new_buttons + return out + + +def typed_text(evs): + """What the key events spell, reading Shift the way a US keyboard does.""" + text = "" + for e in evs: + if e.type != events.KEYDOWN or e.key is None: + continue + if e.mod & ~keys.KMOD_SHIFT: + continue # a chord, not typing + if e.key == keys.K_RETURN: + ch = "\n" + elif e.key == keys.K_TAB: + ch = "\t" + elif 32 <= e.key < 127: + ch = chr(e.key) + else: + continue + if e.mod & keys.KMOD_SHIFT: + i = _UNSHIFTED.find(ch) + ch = _SHIFTED[i] if i >= 0 else ch + text += ch + return text + + +async def perform(peripheral, steps=STEPS, gap_ms=0, sleep_ms=None): + """Send ``steps`` from a :class:`bledev.hid.Peripheral`.""" + for step in steps: + kind = step[0] + if kind == "type": + await peripheral.keyboard.type(step[1], gap_ms) + elif kind == "chord": + await peripheral.keyboard.tap(step[2], step[1]) + elif kind == "consumer": + await peripheral.consumer.tap(step[1]) + elif kind == "pad": + await peripheral.gamepad.send(axes=step[1], buttons=step[2], hat=step[3]) + if gap_ms and sleep_ms is not None: + await sleep_ms(gap_ms) + + +def compare(got, want): + """``None`` if equal, else a one-line description of the first difference.""" + for i in range(min(len(got), len(want))): + if got[i] != want[i]: + return "event {}: got {!r}, want {!r}".format(i, got[i], want[i]) + if len(got) != len(want): + return "got {} events, want {}".format(len(got), len(want)) + return None diff --git a/tests/bledev_hid_checks.py b/tests/bledev_hid_checks.py new file mode 100644 index 00000000..d80ce166 --- /dev/null +++ b/tests/bledev_hid_checks.py @@ -0,0 +1,246 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""``bledev.hid`` end to end over ``bledev.fake``: a peripheral types, a host decodes. + +A plain script, like ``bledev_contract.py``, so it runs on CPython and +MicroPython:: + + python tests/bledev_hid_checks.py + micropython tests/bledev_hid_checks.py + python tests/bledev_hid_checks.py --plant=drop_report # must FAIL + +The scripted input and its expected events are ``bledev_board/hid_script.py``, +the same file the board-to-board gate uses. +""" + +import sys + +try: + _here = __file__ +except NameError: # pragma: no cover + _here = sys.argv[0] +_sep = "\\" if "\\" in _here else "/" +_dir = _here.rsplit(_sep, 1)[0] if _sep in _here else "." +sys.path.insert(0, _dir + _sep + ".." + _sep + "lib") +sys.path.insert(0, _dir + _sep + "bledev_board") + +try: + import asyncio +except ImportError: # pragma: no cover + import uasyncio as asyncio + +import bledev +from bledev import BLEError, GattError, INSUFFICIENT_ENCRYPTION, fake, hid, hidreport +from bledev.fake import Air, FakeBLE +from bledev.hidreport import INPUT, OUTPUT, ReportMap +import hid_script + +MICROPYTHON = sys.implementation.name == "micropython" +CHECK_TIMEOUT_S = 10 + + +class Failed(Exception): + pass + + +def equal(got, want, what): + if got != want: + raise Failed("{}: got {!r}, want {!r}".format(what, got, want)) + + +async def _pair_up(air, *, encrypted=False, central_kw=None, peripheral_kw=None, **pkw): + board = FakeBLE(air=air, mtu=247, **(peripheral_kw or {})) + host_ble = FakeBLE(air=air, mtu=247, **(central_kw or {})) + per = hid.Peripheral(board, encrypted=encrypted, **pkw) + serving = asyncio.create_task(per.serve()) + host = await hid.connect(host_ble, name=per.name, timeout_ms=2000) + await serving + return board, host_ble, per, host + + +async def _collect(host, n, timeout_ms=3000): + got = [] + while len(got) < n: + got += await host.events(timeout_ms) + return got + + +async def c_served_map_parses(air): + # Our own map, by hand: keyboard 8 bytes, consumer 2, gamepad 16 + 4 + 4 + 32 bits = 7. + m = ReportMap(hid.report_map()) + equal(m.applications, [0x00010006, 0x000C0001, 0x00010005], "applications") + equal([(r.report_id, r.size) for r in m.inputs()], [(1, 8), (2, 2), (3, 7)], "input reports") + equal(m.report(OUTPUT, 1).size, 1, "LED report") + equal(hid.identity(), ("PyDevices Keyboard+Gamepad", 0x03C0, 7), "identity, all three") + equal(hid.identity(True, False, False), ("PyDevices Keyboard", 0x03C1, 1), "identity, keyboard") + equal(hid.identity(False, False, True), ("PyDevices Gamepad", 0x03C4, 4), "identity, gamepad") + equal(hid.identity(False, True, False), ("PyDevices Remote", 0x03C0, 2), "identity, remote") + + +async def c_script_round_trip(air): + board, host_ble, per, host = await _pair_up(air) + want = hid_script.expected() + await per.leds(2000) # the host writes the LEDs once it has subscribed + await hid_script.perform(per) + got = await _collect(host, len(want)) + problem = hid_script.compare(got, want) + if problem: + raise Failed(problem) + equal(hid_script.typed_text(got), hid_script.TEXT, "typed text") + await asyncio.sleep(0) + equal(host.poll(), [], "nothing extra") + await host.close() + + +async def c_encrypted_pairs_and_bonds(air): + board, host_ble, per, host = await _pair_up(air, encrypted=True) + equal(host.paired, True, "the host had to pair") + check_bond = board.address in host_ble.bonds and host_ble.address in board.bonds + equal(check_bond, True, "both sides bonded") + await per.keyboard.tap(0x04) + got = await _collect(host, 2) + equal([e.key for e in got], [ord("a"), ord("a")], "a key through an encrypted link") + await host.close() + + +async def c_unpaired_read_refused(air): + board = FakeBLE(air=air, mtu=247) + other = FakeBLE(air=air, mtu=247) + per = hid.Peripheral(board, encrypted=True) + serving = asyncio.create_task(per.serve()) + device = await other.find(name=per.name, timeout_ms=2000) + conn = await device.connect() + await serving + svc = await conn.service(hid.HID_SERVICE) + rmap = await svc.characteristic(hid.REPORT_MAP) + try: + await rmap.read() + except GattError as e: + equal(e.status, INSUFFICIENT_ENCRYPTION, "status") + else: + raise Failed("an encrypted Report Map was read without pairing") + await conn.disconnect() + + +async def c_short_read_host_says_why(air): + # MicroPython reads one packet. At MTU 23 the map (150 bytes) is cut to + # 22, and the host must say so instead of decoding garbage. + board = FakeBLE(air=air, mtu=23) + host_ble = FakeBLE(air=air, mtu=23, long_reads=False) + per = hid.Peripheral(board) + serving = asyncio.create_task(per.serve()) + try: + await hid.connect(host_ble, name=per.name, timeout_ms=2000) + except BLEError as e: + if "long read" not in str(e): + raise Failed("wrong error: {}".format(e)) + else: + raise Failed("a truncated Report Map was accepted") + await serving + + +async def c_map_fits_one_packet_at_247(air): + # The same host at MTU 247 reads the whole map in one packet. + board = FakeBLE(air=air, mtu=247) + host_ble = FakeBLE(air=air, mtu=247, long_reads=False) + per = hid.Peripheral(board) + serving = asyncio.create_task(per.serve()) + host = await hid.connect(host_ble, name=per.name, timeout_ms=2000) + await serving + equal(host.report_map.descriptor, hid.report_map(), "whole map") + await host.close() + + +async def c_disconnect_ends_events(air): + board, host_ble, per, host = await _pair_up(air) + await per.connection.disconnect() + try: + await host.events(1000) + except bledev.DisconnectedError: + pass + else: + raise Failed("events() didn't end on a disconnect") + + +TESTS = [ + c_served_map_parses, + c_script_round_trip, + c_encrypted_pairs_and_bonds, + c_unpaired_read_refused, + c_short_read_host_says_why, + c_map_fits_one_packet_at_247, + c_disconnect_ends_events, +] + + +def _plant(name): + if name == "drop_report": + # The link loses one notification in twenty. + orig = fake._Inbox.put + count = [0] + + def put(self, item): + count[0] += 1 + if count[0] % 20 == 7: + self.event.set() + return + orig(self, item) + + fake._Inbox.put = put + elif name == "shift_lost": + # The peripheral forgets Shift. + orig = hid.Keyboard.send + + async def send(self, modifiers=0, keys=()): + await orig(self, modifiers & ~0x02, keys) + + hid.Keyboard.send = send + elif name == "axis_sign": + # The decoder reads axes unsigned. + orig = hidreport.Field.raw + + def raw(self, data, n=0): + v = orig(self, data, n) + return v & ((1 << self.size) - 1) + + hidreport.Field.raw = raw + elif name == "no_pair": + # The host never pairs. + async def pair(self, bond=True, timeout_ms=20000): + return None + + fake._FakeConnection.pair = pair + else: + raise SystemExit("unknown plant " + name) + + +PLANTS = ("drop_report", "shift_lost", "axis_sign", "no_pair") + + +def run(): + failed = 0 + for fn in TESTS: + if MICROPYTHON: + asyncio.new_event_loop() + try: + asyncio.run(asyncio.wait_for(fn(Air()), CHECK_TIMEOUT_S)) + except Exception as e: + failed += 1 + print("FAIL", fn.__name__, "-", type(e).__name__, e) + else: + print("PASS", fn.__name__) + print("{} checks, {} failed ({})".format(len(TESTS), failed, sys.implementation.name)) + return failed + + +def main(argv): + for arg in argv: + if arg.startswith("--plant="): + _plant(arg.split("=", 1)[1]) + print("PLANTED", arg.split("=", 1)[1]) + sys.exit(1 if run() else 0) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/tests/hidreport_vectors.py b/tests/hidreport_vectors.py new file mode 100644 index 00000000..4e838e01 --- /dev/null +++ b/tests/hidreport_vectors.py @@ -0,0 +1,485 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Test vectors for ``bledev.hidreport``: real report descriptors, real reports. + +A plain script, so it runs on CPython and MicroPython alike:: + + python tests/hidreport_vectors.py + micropython tests/hidreport_vectors.py + python tests/hidreport_vectors.py --plant=bitorder # must FAIL + +Every expected value below was worked out by hand from the descriptor's own +item list (the comments show the arithmetic), not by running the parser, so a +parser bug can't write its own answer key. The descriptors are shipped by real +devices and libraries: + +* ``BOOT_KEYBOARD``: the boot keyboard from the HID 1.11 specification, + appendix E.6, which every USB and BLE boot keyboard follows. +* ``ESP32_BLE_KEYBOARD``: T-vK/ESP32-BLE-Keyboard's report map, keyboard + (report 1) plus a 16-bit consumer bitmap (report 2); a common BLE remote. +* ``CP_CONSUMER``: CircuitPython's ``usb_hid`` consumer control, a 16-bit + array (``shared-module/usb_hid/report_descriptors.c``). +* ``ADAFRUIT_BLE``: adafruit_ble's default BLE HID map: keyboard, mouse, + consumer control. +* ``ADAFRUIT_GAMEPAD``: the gamepad adafruit_ble carries (commented out) in + the same file: 16 buttons and four signed 8-bit axes, report 5. +* ``XBOX_1914``: the Xbox Series controller (model 1914) over Bluetooth LE, as + dumped in github.com/DJm00n/ControllersInfo. 283 bytes, which is more than + one ATT read carries at MTU 247. +""" + +import sys + +try: + _here = __file__ +except NameError: # pragma: no cover + _here = sys.argv[0] +_sep = "\\" if "\\" in _here else "/" +_dir = _here.rsplit(_sep, 1)[0] if _sep in _here else "." +sys.path.insert(0, _dir + _sep + ".." + _sep + "lib") + +from binascii import unhexlify + +import events +import keys +from bledev import hidreport +from bledev.hidreport import INPUT, OUTPUT, Decoder, HIDParseError, ReportMap + +MICROPYTHON = sys.implementation.name == "micropython" + + +def _h(text): + return unhexlify("".join(text.split())) + + +BOOT_KEYBOARD = _h(""" +05 01 09 06 a1 01 05 07 19 e0 29 e7 15 00 25 01 75 01 95 08 81 02 95 01 +75 08 81 01 95 05 75 01 05 08 19 01 29 05 91 02 95 01 75 03 91 01 95 06 +75 08 15 00 25 65 05 07 19 00 29 65 81 00 c0 +""") + +ESP32_BLE_KEYBOARD = _h(""" +05010906a1018501050719e029e71500250175019508810295017508810195057501050819 +012905910295017503910195067508150025650507190029658100c0050c0901a101850205 +0c150025017501951009b509b609b709cd09e209e909ea0a23020a94010a92010a2a020a21 +020a26020a24020a83010a8a018102c0 +""") + +CP_CONSUMER = _h("05 0c 09 01 a1 01 85 03 75 10 95 01 15 01 26 8c 02 19 01 2a 8c 02 81 00 c0") + +ADAFRUIT_BLE = _h(""" +05010906a1018501050719e029e715002501750195088102810119002989150025897508 +950681000508190129051500250175019505910295039101c005010902a1010901a10085 +02050919012905150025019505750181029501750381010501093009311581257f750895 +02810609381581257f750895018106c0c0050c0901a1018503751095011501268c021901 +2a8c028100c0 +""") + +ADAFRUIT_GAMEPAD = _h(""" +05 01 09 05 a1 01 85 05 05 09 19 01 29 10 15 00 25 01 75 01 95 10 81 02 +05 01 15 81 25 7f 09 30 09 31 09 32 09 35 75 08 95 04 81 02 c0 +""") + +XBOX_1914 = _h(""" +05010905a10185010901a10009300931150027ffff0000950275108102c00901a1000932 +0935150027ffff0000950275108102c0050209c5150026ff039501750a81021500250075 +0695018103050209c4150026ff039501750a8102150025007506950181030501093915012508 +3500463b016614007504950181427504950115002500350045006500810305091901290f15 +0025017501950f810215002500750195018103050c0ab2001500250195017501810215002500 +750795018103050f09218503a102099715002501750495019102150025007504950191030970 +150025647508950491020950660110550e150026ff0075089501910209a7150026ff00750895 +01910265005500097c150026ff00750895019102c0c0 +""") + +KD, KU = events.KEYDOWN, events.KEYUP +LSHIFT = keys.KMOD_LSHIFT + + +def key(kind, code, mod, scancode): + return events.Key(kind, keys.keyname(code) if code is not None else "Unknown", code, mod, scancode, None) + + +class Failed(Exception): + pass + + +def check(cond, what): + if not cond: + raise Failed(what) + + +def equal(got, want, what): + if got != want: + raise Failed("{}: got {!r}, want {!r}".format(what, got, want)) + + +# ---------------------------------------------------------------- the parser + + +def t_boot_keyboard_layout(): + m = ReportMap(BOOT_KEYBOARD) + equal(len(BOOT_KEYBOARD), 63, "spec descriptor length") + equal(m.numbered, False, "numbered") + equal(m.applications, [0x00010006], "applications") + r = m.report(INPUT, 0) + # 8 modifier bits + 8 constant + 6 x 8 keys = 64 bits. + equal(r.size, 8, "input size") + equal(len(r.fields), 3, "input fields") + mods, pad, arr = r.fields + equal((mods.offset, mods.size, mods.count, mods.variable), (0, 1, 8, True), "modifier field") + equal(mods.usages, [(0x000700E0, 0x000700E7)], "modifier usages") + equal((pad.offset, pad.size, pad.constant), (8, 8, True), "reserved byte") + equal((arr.offset, arr.size, arr.count, arr.variable), (16, 8, 6, False), "key array") + equal((arr.logical_min, arr.logical_max), (0, 0x65), "key array range") + equal(arr.usage(4), 0x00070004, "array value 4 is usage 'a'") + o = m.report(OUTPUT, 0) + # 5 LED bits + 3 padding. + equal(o.size, 1, "LED report size") + equal(o.fields[0].usages, [(0x00080001, 0x00080005)], "LED usages") + + +def t_esp32_ble_keyboard_layout(): + m = ReportMap(ESP32_BLE_KEYBOARD) + equal(m.numbered, True, "numbered") + equal(m.applications, [0x00010006, 0x000C0001], "applications") + equal(m.report(INPUT, 1).size, 8, "keyboard report") + c = m.report(INPUT, 2) + equal(c.size, 2, "media report: 16 x 1 bit") + f = c.fields[0] + # USAGE (Volume Increment) is the sixth usage, bit 5; a 2-byte usage + # (0A 23 02) is 0x223 on the consumer page, bit 7. + equal(f.usage(5), 0x000C00E9, "bit 5") + equal(f.usage(7), 0x000C0223, "bit 7") + equal(f.usage(15), 0x000C018A, "bit 15") + + +def t_cp_consumer_layout(): + m = ReportMap(CP_CONSUMER) + r = m.report(INPUT, 3) + equal(r.size, 2, "one 16-bit value") + f = r.fields[0] + equal((f.logical_min, f.logical_max, f.variable), (1, 652, False), "array range") + # Usage Minimum 1: value 1 is usage 1, so value 0xE9 is usage 0xE9. + equal(f.usage(0xE9 - 1), 0x000C00E9, "array index") + + +def t_adafruit_layout(): + m = ReportMap(ADAFRUIT_BLE) + equal(m.applications, [0x00010006, 0x00010002, 0x000C0001], "applications") + equal(m.report(INPUT, 1).size, 8, "keyboard") + # 5 buttons + 3 pad + X, Y (8 bit each) + wheel = 4 bytes. + mouse = m.report(INPUT, 2) + equal(mouse.size, 4, "mouse") + equal((mouse.fields[2].logical_min, mouse.fields[2].logical_max), (-127, 127), "signed mouse axes") + check(mouse.fields[2].relative, "mouse X/Y are relative") + equal(m.report(INPUT, 3).size, 2, "consumer") + # X = 1, Y = 0xFB (-5), wheel 0, after the button byte. + equal(mouse.fields[2].values(bytes([0x01, 0x01, 0xFB, 0x00])), [1, -5], "signed X, Y") + + +def t_xbox_layout(): + m = ReportMap(XBOX_1914) + equal(len(XBOX_1914), 283, "dump length") + equal(m.numbered, True, "numbered") + equal(m.applications, [0x00010005], "one gamepad collection") + r = m.report(INPUT, 1) + # 4 x 16 + (10 + 6) + (10 + 6) + (4 + 4) + (15 + 1) + (1 + 7) = 128 bits. + equal(r.size, 16, "input report size") + xy = r.fields[0] + equal((xy.offset, xy.size, xy.count, xy.logical_max), (0, 16, 2, 65535), "X and Y") + brake = r.fields[2] + equal((brake.offset, brake.size, brake.usages), (64, 10, [0x000200C5]), "brake") + hat = r.fields[6] + equal((hat.offset, hat.size, hat.logical_min, hat.logical_max), (96, 4, 1, 8), "hat") + equal((hat.physical_max, hat.unit), (315, 0x14), "hat physical range and unit") + buttons = r.fields[8] + # The hat is followed by 4 bits of padding, so buttons start at bit 104. + equal((buttons.offset, buttons.count), (104, 15), "buttons") + # Output 3: 4 + 4 bits, 4 magnitudes, duration, start delay, loop count. + o = m.report(OUTPUT, 3) + equal(o.size, 8, "rumble report") + dur = [f for f in o.fields if f.usages == [0x000F0050]][0] + equal((dur.unit, dur.unit_exponent), (0x1001, -2), "duration unit, 10^-2 s") + + +def t_long_and_signed_items(): + # 26 FF FF with 15 00 is 65535, not -1; 25 FF with 15 00 is 255. + m = ReportMap(_h("05 01 09 04 a1 01 15 00 26 ff ff 75 10 95 01 09 30 81 02 25 ff 75 08 09 31 81 02 c0")) + f = m.report(INPUT, 0).fields + equal((f[0].logical_max, f[1].logical_max), (65535, 255), "unsigned maxima") + # A long item (FE) is skipped whole. + m = ReportMap(_h("fe 02 00 aa bb") + BOOT_KEYBOARD) + equal(m.report(INPUT, 0).size, 8, "after a long item") + # Push/Pop restore the globals. + m = ReportMap(_h("05 01 09 04 a1 01 75 08 95 01 a4 75 10 09 30 81 02 b4 09 31 81 02 c0")) + f = m.report(INPUT, 0).fields + equal((f[0].size, f[1].size, f[1].offset), (16, 8, 16), "push/pop") + + +def t_malformed(): + for text, what in ( + ("05 01 09 06 a1 01 75", "truncated item"), + ("05 01 09 06 a1 01", "open collection"), + ("c0", "unbalanced end"), + ("85 00", "report ID 0"), + ("b4", "pop without push"), + ): + try: + ReportMap(_h(text)) + except HIDParseError: + continue + raise Failed("accepted a malformed descriptor: " + what) + + +# ---------------------------------------------------------------- reports to events + + +def t_boot_keyboard_events(): + d = Decoder(ReportMap(BOOT_KEYBOARD)) + # "H": Left Shift (bit 1) and usage 0x0B ('h'). The key goes down before + # the modifier, as usbif and SDL order a chord. + equal(d.feed(bytes([0x02, 0, 0x0B, 0, 0, 0, 0, 0])), ( + key(KD, keys.K_h, LSHIFT, 0x0B), + key(KD, keys.K_LSHIFT, LSHIFT, 0xE1), + ), "shift+h") + # Release both: modifier up first, then the key. + equal(d.feed(bytes(8)), ( + key(KU, keys.K_LSHIFT, 0, 0xE1), + key(KU, keys.K_h, 0, 0x0B), + ), "release") + # "1" is usage 0x1E and "0" is 0x27, last in HID and first in ASCII. + equal(d.feed(bytes([0, 0, 0x27, 0x1E, 0, 0, 0, 0])), ( + key(KD, ord("1"), 0, 0x1E), + key(KD, ord("0"), 0, 0x27), + ), "digits, sorted by usage") + # Rollover: six ErrorRollOver slots are ignored and nothing is released. + equal(d.feed(bytes([0, 0, 1, 1, 1, 1, 1, 1])), (), "rollover") + equal(sorted(d.held), [0x1E, 0x27], "held after rollover") + # Enter (0x28), a function key (F5 = 0x3E) and an arrow (Up = 0x52). + equal(d.feed(bytes([0, 0, 0x28, 0x3E, 0x52, 0, 0, 0])), ( + key(KU, ord("1"), 0, 0x1E), + key(KU, ord("0"), 0, 0x27), + key(KD, keys.K_RETURN, 0, 0x28), + key(KD, keys.K_F5, 0, 0x3E), + key(KD, keys.K_UP, 0, 0x52), + ), "enter, F5, up") + # Right Alt is bit 6. + equal(d.feed(bytes([0x40, 0, 0, 0, 0, 0, 0, 0]))[-1], key(KD, keys.K_RALT, keys.KMOD_RALT, 0xE6), "right alt") + + +def t_esp32_media_keys(): + d = Decoder(ReportMap(ESP32_BLE_KEYBOARD)) + # Bit 5 of report 2 is Volume Increment. + equal(d.feed(bytes([0x20, 0x00]), report_id=2), (key(KD, keys.K_VOLUMEUP, 0, 0x000C00E9),), "volume up") + # Bit 3 (Play/Pause) instead: volume up is released first. + equal(d.feed(bytes([0x08, 0x00]), report_id=2), ( + key(KU, keys.K_VOLUMEUP, 0, 0x000C00E9), + key(KD, keys.K_AUDIOPLAY, 0, 0x000C00CD), + ), "play") + # Byte 1 bit 0 is My Computer (0x194); bit 7 is Mail (0x18A). + equal(d.feed(bytes([0x00, 0x81]), report_id=2), ( + key(KU, keys.K_AUDIOPLAY, 0, 0x000C00CD), + key(KD, keys.K_MAIL, 0, 0x000C018A), + key(KD, keys.K_COMPUTER, 0, 0x000C0194), + ), "computer and mail, sorted by usage") + # The keyboard report on the same map, HOGP-style (no ID byte). + equal(d.feed(bytes([0, 0, 0x04, 0, 0, 0, 0, 0]), report_id=1), (key(KD, keys.K_a, 0, 0x04),), "keyboard 'a'") + # A report ID the map doesn't have is ignored. + equal(d.feed(bytes([0xFF, 0xFF]), report_id=9), (), "unknown report id") + + +def t_cp_consumer_events(): + d = Decoder(ReportMap(CP_CONSUMER)) + # USB style: the ID leads the report. + equal(d.feed(bytes([0x03, 0xE9, 0x00])), (key(KD, keys.K_VOLUMEUP, 0, 0x000C00E9),), "volume up") + equal(d.feed(bytes([0x03, 0xEA, 0x00])), ( + key(KU, keys.K_VOLUMEUP, 0, 0x000C00E9), + key(KD, keys.K_VOLUMEDOWN, 0, 0x000C00EA), + ), "volume down") + # 0 is below Logical Minimum 1: nothing held. + equal(d.feed(bytes([0x03, 0x00, 0x00])), (key(KU, keys.K_VOLUMEDOWN, 0, 0x000C00EA),), "release") + # AC Send (0x28C) has no key constant. + equal(d.feed(bytes([0x03, 0x8C, 0x02])), (key(KD, None, 0, 0x000C028C),), "unmapped usage") + + +def t_adafruit_events(): + d = Decoder(ReportMap(ADAFRUIT_BLE)) + # Shift + 'a' through report 1, with the USB-style leading ID. + equal(d.feed(bytes([0x01, 0x02, 0x00, 0x04, 0, 0, 0, 0, 0])), ( + key(KD, keys.K_a, LSHIFT, 0x04), + key(KD, keys.K_LSHIFT, LSHIFT, 0xE1), + ), "shift+a") + # A mouse report is not a joystick: no events. + equal(d.feed(bytes([0x02, 0x01, 0x05, 0xFB, 0x00])), (), "mouse report") + + +def t_adafruit_gamepad_events(): + d = Decoder(ReportMap(ADAFRUIT_GAMEPAD)) + equal((d.num_axes, d.num_hats, d.num_buttons), (4, 0, 16), "counts") + # Buttons 2 and 9 (bits 1 and 8); X = 0x81 (-127), Y = 64, Z = 0, Rz = 127. + ev = d.feed(bytes([0x02, 0x01, 0x81, 0x40, 0x00, 0x7F]), report_id=5) + JA = events.JoyAxisMotion + equal(ev, ( + JA(events.JOYAXISMOTION, 0, 0, -1.0), + # Logical -127..127: (64 + 127) / 254 * 2 - 1 = 64 / 127. + JA(events.JOYAXISMOTION, 0, 1, 2.0 * (64 + 127) / 254 - 1.0), + # Z stays at 0.0, where every axis starts: no event. + JA(events.JOYAXISMOTION, 0, 3, 1.0), + events.JoyButtonDown(events.JOYBUTTONDOWN, 0, 1), + events.JoyButtonDown(events.JOYBUTTONDOWN, 0, 8), + ), "gamepad report") + + +def t_xbox_events(): + d = Decoder(ReportMap(XBOX_1914), instance_id=7) + equal((d.num_axes, d.num_hats, d.num_buttons), (6, 1, 15), "counts") + r = bytearray(16) + # X = 65535 (1.0), Y = 0 (-1.0), Z = 32768, Rz = 32767. + r[0:2] = b"\xff\xff" + r[4:6] = b"\x00\x80" + r[6:8] = b"\xff\x7f" + # Brake (bits 64..73) = 1023, accelerator (80..89) = 512. + r[8] = 0xFF + r[9] = 0x03 + r[10] = 0x00 + r[11] = 0x02 + # Hat (bits 96..99) = 3: logical 1 is north, so 3 is east. + r[12] = 0x03 + # Buttons 1 and 15 (bits 104 and 118), and Record (bit 120). + r[13] = 0x01 + r[14] = 0x40 + r[15] = 0x01 + ev = d.feed(bytes(r), report_id=1) + JA, JH = events.JoyAxisMotion, events.JoyHatMotion + # Axes by usage: X 0, Y 1, Z 2, Rz 3, accelerator 4, brake 5. + want = ( + key(KD, None, 0, 0x000C00B2), + JA(events.JOYAXISMOTION, 7, 0, 1.0), + JA(events.JOYAXISMOTION, 7, 1, -1.0), + JA(events.JOYAXISMOTION, 7, 2, 2.0 * 32768 / 65535 - 1.0), + JA(events.JOYAXISMOTION, 7, 3, 2.0 * 32767 / 65535 - 1.0), + JA(events.JOYAXISMOTION, 7, 4, 2.0 * 512 / 1023 - 1.0), + JA(events.JOYAXISMOTION, 7, 5, 1.0), + JH(events.JOYHATMOTION, 7, 0, (1, 0)), + events.JoyButtonDown(events.JOYBUTTONDOWN, 7, 0), + events.JoyButtonDown(events.JOYBUTTONDOWN, 7, 14), + ) + equal(ev, want, "first report") + # Same report again: nothing changed, nothing to say. + equal(d.feed(bytes(r), report_id=1), (), "unchanged") + # Hat to 0 (the null state: centred), button 1 released. + r[12] = 0x00 + r[13] = 0x00 + ev = d.feed(bytes(r), report_id=1) + equal(ev, ( + JH(events.JOYHATMOTION, 7, 0, (0, 0)), + events.JoyButtonUp(events.JOYBUTTONUP, 7, 0), + ), "hat centred, button up") + equal(d.axis(5), 1.0, "brake held") + + +TESTS = [ + t_boot_keyboard_layout, + t_esp32_ble_keyboard_layout, + t_cp_consumer_layout, + t_adafruit_layout, + t_xbox_layout, + t_long_and_signed_items, + t_malformed, + t_boot_keyboard_events, + t_esp32_media_keys, + t_cp_consumer_events, + t_adafruit_events, + t_adafruit_gamepad_events, + t_xbox_events, +] + + +# ---------------------------------------------------------------- plants + + +def _plant(name): + """Break the parser or the decoder on purpose; each must fail a check.""" + Field = hidreport.Field + if name == "bitorder": + # Forget the bit shift: every field that doesn't start on a byte + # boundary reads the wrong bits. + def raw(self, data, n=0): + bit = self.offset + n * self.size + first = bit >> 3 + last = (bit + self.size + 7) >> 3 + return int.from_bytes(bytes(data[first:last]), "little") & ((1 << self.size) - 1) + + Field.raw = raw + elif name == "unsigned": + # No sign extension: -127 reads as 129. + orig = Field.raw + + def raw(self, data, n=0): + lo = self.logical_min + self.logical_min = 0 + try: + return orig(self, data, n) + finally: + self.logical_min = lo + + Field.raw = raw + elif name == "range": + # Off by one in usage-range expansion. + orig = Field.usage + + def usage(self, n): + return orig(self, n + 1) + + Field.usage = usage + elif name == "order": + # Presses before releases. + orig = hidreport.Decoder._keyboard + + def keyboard(self, elements, data, out): + mark = len(out) + orig(self, elements, data, out) + out[mark:] = list(reversed(out[mark:])) + + hidreport.Decoder._keyboard = keyboard + elif name == "rollover": + hidreport.ERROR_ROLLOVER = -1 + elif name == "hat": + hidreport._HAT8 = hidreport._HAT8[1:] + hidreport._HAT8[:1] + else: + raise SystemExit("unknown plant " + name) + + +PLANTS = ("bitorder", "unsigned", "range", "order", "rollover", "hat") + + +def run(verbose=False): + failed = 0 + for fn in TESTS: + try: + fn() + except Exception as e: + failed += 1 + print("FAIL", fn.__name__, "-", type(e).__name__, e) + if verbose and MICROPYTHON: + sys.print_exception(e) + else: + print("PASS", fn.__name__) + print("{} checks, {} failed ({})".format(len(TESTS), failed, sys.implementation.name)) + return failed + + +def main(argv): + verbose = False + for arg in argv: + if arg.startswith("--plant="): + _plant(arg.split("=", 1)[1]) + print("PLANTED", arg.split("=", 1)[1]) + elif arg in ("-v", "--verbose"): + verbose = True + sys.exit(1 if run(verbose) else 0) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/tests/test_bledev_hid.py b/tests/test_bledev_hid.py new file mode 100644 index 00000000..f986ef89 --- /dev/null +++ b/tests/test_bledev_hid.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Run bledev.hid's two plain-script suites on CPython and MicroPython, with plants. + +``hidreport_vectors.py`` checks the report-descriptor parser and decoder +against real descriptors; ``bledev_hid_checks.py`` runs a HID peripheral and +host over the fake radio. Each runs under CPython, under a unix +``micropython`` when one is found (the same search as ``test_bledev.py``), and +once per planted fault, each of which must make it fail. + +When usbif's checkout sits beside this one, the keyboard decoder is also +compared with usbif's own boot-keyboard decoder over random reports, since +the promise is that a BLE keyboard and a USB one look the same to an app. +""" + +from pathlib import Path +import os +import random +import subprocess +import sys +import unittest + +_TESTS = Path(__file__).resolve().parent +sys.path.insert(0, str(_TESTS)) +sys.path.insert(0, str(_TESTS.parent / "lib")) + +from test_bledev import _micropython # noqa: E402 + +SUITES = { + "hidreport_vectors.py": ("bitorder", "unsigned", "range", "order", "rollover", "hat"), + "bledev_hid_checks.py": ("drop_report", "shift_lost", "axis_sign", "no_pair"), +} + + +def _run(interpreter, script, *args): + return subprocess.run( + [interpreter, str(_TESTS / script), *args], capture_output=True, text=True, timeout=600 + ) + + +class HidSuites(unittest.TestCase): + def assertPasses(self, result): + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn(" 0 failed", result.stdout) + + def test_cpython(self): + for script in SUITES: + with self.subTest(script=script): + self.assertPasses(_run(sys.executable, script)) + + def test_micropython(self): + interpreter = _micropython() + if interpreter is None: + message = "no micropython binary: the bledev.hid suites did NOT run on MicroPython" + if os.environ.get("PYDEVICES_REQUIRE_MICROPYTHON") == "1": + self.fail(message) + sys.stderr.write("\n*** " + message + " ***\n") + self.skipTest(message) + for script in SUITES: + with self.subTest(script=script): + result = _run(interpreter, script) + self.assertPasses(result) + self.assertIn("(micropython)", result.stdout) + + def test_every_plant_fails(self): + for script, plants in SUITES.items(): + for plant in plants: + with self.subTest(script=script, plant=plant): + result = _run(sys.executable, script, "--plant=" + plant) + self.assertIn("PLANTED " + plant, result.stdout) + self.assertNotEqual(result.returncode, 0, "plant {!r} went unnoticed:\n{}".format(plant, result.stdout)) + self.assertIn("FAIL", result.stdout) + + +def _usbif_decoder(): + for parent in _TESTS.parents: + lib = parent.parent / "usbif" / "lib" + if (lib / "usbif" / "hid_keyboard.py").is_file(): + sys.path.insert(0, str(lib)) + try: + from usbif.hid_keyboard import KeyboardDecoder + except Exception: + return None + return KeyboardDecoder + return None + + +class SameAsUsbif(unittest.TestCase): + def test_boot_reports_decode_like_usbif(self): + usbif_decoder = _usbif_decoder() + if usbif_decoder is None: + self.skipTest("usbif's checkout isn't beside this one") + from bledev.hidreport import Decoder, ReportMap + from hidreport_vectors import BOOT_KEYBOARD + + ours = Decoder(ReportMap(BOOT_KEYBOARD)) + theirs = usbif_decoder() + rng = random.Random(1234) + usages = list(range(0x04, 0x53)) # the boot range usbif maps + events = 0 + for i in range(3000): + held = rng.sample(usages, rng.randint(0, 6)) + report = bytes([rng.randint(0, 255), 0] + held + [0] * (6 - len(held))) + if i % 97 == 0: + report = bytes([report[0], 0, 1, 1, 1, 1, 1, 1]) # rollover + a = ours.feed(report) + b = theirs.feed(report) + self.assertEqual(a, b, "report {} {}".format(i, report.hex())) + events += len(a) + self.assertGreater(events, 10000) + + +if __name__ == "__main__": + unittest.main() From ad69b460dd96e7a9adedcde099e81db3af2e3ea1 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:51:10 -0500 Subject: [PATCH 02/14] bledev.hid: board gate scripts, the bless probe, and the docs --- docs/bledev-internals.md | 62 ++++++++++++ docs/bledev.md | 56 +++++++++++ tests/bledev_board/bless_peripheral.py | 57 +++++++++++ tests/bledev_board/bless_probe.py | 56 +++++++++++ tests/bledev_board/hid_central.py | 132 +++++++++++++++++++++++++ tests/bledev_board/hid_peripheral.py | 95 ++++++++++++++++++ 6 files changed, 458 insertions(+) create mode 100644 tests/bledev_board/bless_peripheral.py create mode 100644 tests/bledev_board/bless_probe.py create mode 100644 tests/bledev_board/hid_central.py create mode 100644 tests/bledev_board/hid_peripheral.py diff --git a/docs/bledev-internals.md b/docs/bledev-internals.md index c80723e5..577ee0d2 100644 --- a/docs/bledev-internals.md +++ b/docs/bledev-internals.md @@ -19,6 +19,8 @@ standalone module per backend, and an optional selector. | `nus.py` | Nordic UART byte stream, built only on the contract. | | `repl.py` | The REPL over nus: MicroPython's raw `bluetooth` API from the IRQ on the board, `nus` on the client. | | `improv.py` | Improv Wi-Fi setup, both sides, built only on the contract. | +| `hidreport.py` | HID report descriptors and reports to `events`. No Bluetooth in it. | +| `hid.py` | HID over GATT, host and device, built on the contract and `hidreport`. | | `auto.py` | Picks a backend. Nothing imports it, and backends must not. | A backend module imports `bledev` and whatever its host provides, and nothing @@ -72,6 +74,13 @@ returning a list of your `ClientService` in handle order. **`ClientService`**: `_discover_characteristics(uuid, timeout_ms)`, a list of your `ClientCharacteristic` in handle order. +**Pairing and descriptors** (for HID): `Connection.encrypted` and +`Connection.pair(bond, timeout_ms)`; `ClientCharacteristic._discover_descriptors(uuid, timeout_ms)` +returning your `ClientDescriptor`s, whose `read()` you fill in; and on the +server, each `Characteristic`'s `descriptors` (fixed values) and `encrypted` +flag. Report `long_read` in `capabilities()`: False if a read returns only +one packet. A backend without pairing leaves the base's `UnsupportedError`. + **`ClientCharacteristic`**: `read`, `write`, `subscribe`, `notified`, `indicated`. `self._want_response(response)` resolves `response=None` and checks the flags for you; `self._check(FLAG_..., what)` checks the rest. @@ -209,6 +218,57 @@ without using up the attempt. The ESP32 has one dupterm slot. The REPL takes it on login and gives back whatever held it (WebREPL, say) on disconnect. +## How HID gets there + +`bledev.hid` is two halves over one parser. `bledev.hidreport` reads a report +descriptor into `Report`s of `Field`s (bit offset, size, count, flags, +usages, logical range) and a `Decoder` diffs successive input reports into +events. It imports `events` and `keys` and nothing from Bluetooth. + +**The keyboard decoder is usbif's, event for event.** Same order (modifier +releases, key releases, key presses, modifier presses, the new modifier mask +on all of them), same fields (`scancode` is the HID usage, `window` is +`None`), same rollover rule. `tests/test_bledev_hid.py` feeds 3,000 random +boot reports through both decoders when usbif's checkout is beside this one, +and they must agree. It found one difference on its first run: the ISO "# ~" +key (usage 0x32), which SDL calls `#` and usbif leaves unmapped. hidreport +now leaves it unmapped too; the two tables should become one. + +**Axes, hats and buttons** come only from joystick, gamepad and multi-axis +collections, so a mouse's X, Y and buttons produce nothing yet. Axes are +numbered by usage (desktop X to wheel, then the simulation page), so an Xbox +controller's sticks are 0 to 3 and its triggers 4 and 5. Every axis starts +at 0.0 and moves only when its value changes, as SDL's do. + +**Descriptors, encryption and pairing** were added to the contract for HID. +A Report characteristic says which report it carries in its Report Reference +descriptor (0x2908), and HOGP gives the report ID nowhere else, so the host +needs `ClientCharacteristic.descriptor()`. A `Characteristic` can be +`encrypted=True`, and `Connection.pair()` encrypts a link. The host pairs +only after a read fails with insufficient encryption or authentication, +which is how a real keyboard asks. mpble pairs through aioble's security +module ("just works", `io=3`); `ble.enable_bonding()` loads aioble's +`ble_secrets.json` and turns bonding on, and it has to run on both sides, +because the side that didn't start pairing stores keys too. + +**MicroPython has no long read.** `gattc_read()` is one ATT Read, so a board +central gets at most `mtu - 1` bytes of any value. The host exchanges the MTU +up to 247 before reading the Report Map (246 bytes fit), and a map that fails +to parse at that length raises an error that says why. Our own map is 150 +bytes; an Xbox Series controller's is 283, so a board can't host one until +MicroPython grows a long read. bleak reads long values itself, and the +fake's `long_reads=False` models the board. + +**The peripheral's identity comes from its functions.** Name, appearance and +PnP product ID are derived from which of keyboard, consumer control and +gamepad it serves, so a host that cached one set never sees another set +under the same identity. The PnP vendor is the Bluetooth SIG's test company +ID (0xFFFF), because we have no vendor ID of our own. + +The host writes the keyboard's LED report once it has subscribed, as Windows +does. `Peripheral.leds()` returns those writes, which is how the gate knows +the host is listening, and how it times a round trip. + ## The checks **Against the fake**, on both interpreters: `tests/bledev_contract.py`. @@ -236,6 +296,8 @@ The serving scripts have a `PLANT` switch, and a planted run must fail: | `repl_server.py` + `repl_client.py` | The REPL: the right password evaluates `123 * 456`, a wrong one sent with code that would create `/pwned` is refused and hung up on and the code never runs, and Ctrl-C stops `while True`. `repl_server.py` runs from `/main.py`, because mpftp soft-resets the board, which turns Bluetooth off | | `improv_server.py` + `improv_client.py` | Improv: `--wrong` must get "unable to connect" and a return to "authorized"; without it, on a board, the network comes from that board's own `secrets.py` and must end "provisioned" with a URL | | `coex_server.py` + `tcp_pull.py` + `nus_client.py` | Wi-Fi and BLE on one S3: a TCP source on Wi-Fi beside the nus gate | +| `hid_peripheral.py` + `hid_central.py` | HID: the device types `hid_script.py`'s text, chords, media keys and gamepad moves; the host's events must equal `hid_script.expected()` and spell the text. Then key-press round trips. `PLANT` drops one Shift. The host also runs on a laptop, where it refuses to pair | +| `bless_peripheral.py` + `bless_probe.py` | The laptop as a peripheral, through bless, probed by a board | `gatt_peripheral.py` is also the peripheral to point a host backend at: it advertises as `bledev-radio`, and a central steers it by writing commands. diff --git a/docs/bledev.md b/docs/bledev.md index daa301f4..40404409 100644 --- a/docs/bledev.md +++ b/docs/bledev.md @@ -223,6 +223,62 @@ boot is up to you: pass `on_join=lambda ssid, password, ip: ...` to `serve()`. (from a button, say) before it accepts credentials. The credentials cross the air unencrypted; that's the standard. +## Keyboards and gamepads (HID) + +`bledev.hid` turns a BLE keyboard, media remote or game controller into the +same `events` a USB keyboard gives you through usbif, or SDL gives you on the +desktop: `events.Key` with `keys.K_*` codes and modifiers, and +`events.JoyAxisMotion`, `JoyHatMotion`, `JoyButtonDown` and `JoyButtonUp`. +Your app can't tell which one it's talking to. + +```python +import bledev.hid as hid + +host = await hid.connect(ble, name="Keyboard K380") # or no name: any HID device +while True: + for event in await host.events(): # waits for at least one + print(event) +``` + +`host.poll()` returns whatever has arrived without waiting, the way usbif's +`poll()` does, so it fits a frame loop. Media keys arrive as `Key` events +too (`keys.K_VOLUMEUP`, `K_AUDIOPLAY`). Axes run from -1.0 to 1.0 and are +numbered X, Y, Z, Rx, Ry, Rz, then triggers; buttons count from 0; a hat is +an `(x, y)` tuple with y up. + +A keyboard that wants an encrypted link gets one: the first read it refuses +pairs the link ("just works", no passkey) and tries again. On a board, call +`ble.enable_bonding()` once before connecting so the keys are kept, and the +next connect doesn't pair from scratch. The keys live in `ble_secrets.json` +on the board's filesystem, so erasing the board forgets them, and a keyboard +that remembers the board may then need putting back into pairing mode. + +**A board can be the keyboard, too.** It advertises as a keyboard, media +remote and gamepad (or any one or two of them) and sends what you tell it: + +```python +kb = hid.Peripheral(ble) # keyboard=True, consumer=True, gamepad=True +await kb.serve() # returns when a host connects +await kb.keyboard.type("Hello\n") # US layout +await kb.keyboard.tap(0x06, hid.MOD_LCTRL) # Ctrl+C +await kb.consumer.tap(hid.VOLUME_UP) +await kb.gamepad.send(axes=(0, 127, 0, 0), buttons=0b101, hat=2) +``` + +Its name, appearance and Report Map come from which functions you ask for, +the same way every time, because hosts cache a device against its identity. +Pass `encrypted=True` to make hosts pair first, as real keyboards do. + +**Don't pair a test board with the computer you're testing from.** Once +paired, Windows and phones use it as a real keyboard, and it types into +them. + +The parser underneath, `bledev.hidreport`, knows nothing about Bluetooth. +Give it any HID report descriptor and it decodes that device's reports, so a +USB host can use it the same way. How it numbers axes, what it does with a +rollover, and what a board central can't read are in +[bledev-internals.md](bledev-internals.md#how-hid-gets-there). + ## The rules every backend keeps These hold on every backend. The fake enforces the strict version of each, so diff --git a/tests/bledev_board/bless_peripheral.py b/tests/bledev_board/bless_peripheral.py new file mode 100644 index 00000000..d0312c95 --- /dev/null +++ b/tests/bledev_board/bless_peripheral.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Can the laptop be a BLE peripheral? A bless GATT server for a board to probe. + +bless (https://github.com/kevincar/bless) serves GATT from CPython. This +serves one service with a readable, writable, notifying characteristic, for +``bless_probe.py`` on a board to find, read, write and subscribe to. Run it +with a Windows Python that has bless (keep it out of mpftp's sidecar Python, +since bless pins its own bleak):: + + python.exe -m venv %USERPROFILE%\\bless-venv + %USERPROFILE%\\bless-venv\\Scripts\\python.exe -m pip install bless + %USERPROFILE%\\bless-venv\\Scripts\\python.exe bless_peripheral.py + +It prints each read and write, notifies ``tick N`` every second, and stops +after ``SECONDS``. +""" +import asyncio +import sys + +from bless import BlessServer, GATTAttributePermissions, GATTCharacteristicProperties + +SERVICE = "8a1f0000-2b4d-4c5e-9f60-1a2b3c4d5e6f" +CHAR = "8a1f0001-2b4d-4c5e-9f60-1a2b3c4d5e6f" +SECONDS = int(sys.argv[1]) if len(sys.argv) > 1 else 90 + + +def on_read(characteristic, **kwargs): + print("read", bytes(characteristic.value), flush=True) + return characteristic.value + + +def on_write(characteristic, value, **kwargs): + characteristic.value = value + print("write", bytes(value), flush=True) + + +async def main(): + server = BlessServer(name="bless-check") + server.read_request_func = on_read + server.write_request_func = on_write + await server.add_new_service(SERVICE) + props = GATTCharacteristicProperties.read | GATTCharacteristicProperties.write | GATTCharacteristicProperties.notify + perms = GATTAttributePermissions.readable | GATTAttributePermissions.writeable + await server.add_new_characteristic(SERVICE, CHAR, props, bytearray(b"hello from bless"), perms) + await server.start() + print("advertising", SERVICE, flush=True) + for n in range(SECONDS): + await asyncio.sleep(1) + server.get_characteristic(CHAR).value = bytearray(b"tick %d" % n) + server.update_value(SERVICE, CHAR) + await server.stop() + print("stopped", flush=True) + + +asyncio.run(main()) diff --git a/tests/bledev_board/bless_probe.py b/tests/bledev_board/bless_probe.py new file mode 100644 index 00000000..db1fad97 --- /dev/null +++ b/tests/bledev_board/bless_probe.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""A board probes the laptop's bless peripheral (``bless_peripheral.py``). + +Finds the service by UUID (Windows advertises the service, not a name), +connects, reads, writes, and waits for two notifications. Logs to +/bless_probe.log and ends with ``RESULT PASS`` or ``RESULT FAIL``. +""" +import asyncio +import bledev.mpble + +SERVICE = "8a1f0000-2b4d-4c5e-9f60-1a2b3c4d5e6f" +CHAR = "8a1f0001-2b4d-4c5e-9f60-1a2b3c4d5e6f" +LOG = "/bless_probe.log" + + +def log(*parts): + line = " ".join(str(p) for p in parts) + print(line) + with open(LOG, "a") as f: + f.write(line + "\n") + + +async def main(): + ok = False + ble = bledev.mpble.get() + try: + device = await ble.find(service=SERVICE, timeout_ms=30000) + log("found", device) + conn = await device.connect(timeout_ms=15000) + log("connected, mtu", await conn.exchange_mtu(247)) + svc = await conn.service(SERVICE) + log("service", svc) + ch = await svc.characteristic(CHAR) + log("characteristic", ch) + log("read", await ch.read()) + await ch.write(b"hi from the board", response=True) + log("wrote") + log("read back", await ch.read()) + await ch.subscribe(notify=True) + for _ in range(2): + log("notified", await ch.notified(5000)) + ok = True + await conn.disconnect() + except Exception as e: + log("error", type(e).__name__, e) + log("RESULT", "PASS" if ok else "FAIL") + + +try: + import os + os.remove(LOG) +except OSError: + pass +asyncio.run(main()) diff --git a/tests/bledev_board/hid_central.py b/tests/bledev_board/hid_central.py new file mode 100644 index 00000000..fbcd5709 --- /dev/null +++ b/tests/bledev_board/hid_central.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The HID gate, the host: connect to ``hid_peripheral.py`` and check every event. + +Runs on a board (mpble) or on a laptop (``bledev.bleak``):: + + PYTHONPATH="$(wslpath -w lib);$(wslpath -w tests/bledev_board)" \\ + python.exe "$(wslpath -w tests/bledev_board/hid_central.py)" + +It never pairs unless the device demands it, and on a laptop it refuses to: +Windows would then keep the board as a keyboard. The events must equal +``hid_script.expected()`` one for one, and the text they spell must equal +``hid_script.TEXT``. Then it answers every ``a`` with an LED write, which the +device times (the round trip), and records how long each report took from +arriving to reaching the app. Ends with ``RESULT PASS`` or ``RESULT FAIL`` +(in /hid_central.log on a board). ``CONN`` passes connection intervals. +""" +import asyncio, gc, sys, time + +import events +import keys +import bledev +import bledev.hid as hid +import hid_script + +MICROPYTHON = sys.implementation.name == "micropython" +CONN = {} +if MICROPYTHON: + import bledev.mpble + + LOG = "/hid_central.log" +else: + import bledev.bleak + + LOG = None + if "--fast" in sys.argv: + CONN = {"priority": "throughput"} +NAME = "bledev-hid-gate" +LATENCY_N = 200 +ENCRYPTED = False # the harness sets it when the device pairs + + +def log(*parts): + line = " ".join(str(p) for p in parts) + print(line) + if LOG: + with open(LOG, "a") as f: + f.write(line + "\n") + + +def stats(values): + v = sorted(values) + n = len(v) + if not n: + return "none" + return "n={} min={} median={} p90={} p99={} max={} (us)".format( + n, v[0], v[n // 2], v[min(n - 1, n * 9 // 10)], v[min(n - 1, n * 99 // 100)], v[-1]) + + +async def main(): + if LOG: + try: + import os + os.remove(LOG) + except OSError: + pass + gc.collect() + if MICROPYTHON: + ble = bledev.mpble.get() + if ENCRYPTED: + ble.enable_bonding() + else: + ble = bledev.bleak.BleakBLE() + t0 = time.ticks_ms() if MICROPYTHON else time.monotonic() * 1000 + device = await ble.find(name=NAME, timeout_ms=30000) + connection = await device.connect(timeout_ms=15000, **CONN) + host = hid.Host(connection) + if not MICROPYTHON: + # Never let Windows pair with a board that is a keyboard. + async def refuse(bond=True, timeout_ms=20000): + raise bledev.BLEError("refusing to pair a HID device with this PC") + + connection.pair = refuse + await host.start() + now = time.ticks_ms() if MICROPYTHON else time.monotonic() * 1000 + rmap = host.report_map + log("connected and started in", int(now - t0), "ms; mtu", connection.mtu, "paired", host.paired, + "map", len(rmap.descriptor), "bytes, applications", ["0x%08x" % a for a in rmap.applications], + "inputs", [(r.report_id, r.size) for r in rmap.inputs()], "conn", CONN) + + want = hid_script.expected() + got = [] + while len(got) < len(want): + try: + got += await host.events(5000) + except bledev.BLETimeoutError: + break + await asyncio.sleep(0.3) + extra = host.poll() + problem = hid_script.compare(got + extra, want) + text = hid_script.typed_text(got) + log("events", len(got) + len(extra), "want", len(want)) + log("typed", repr(text)) + ok = problem is None and text == hid_script.TEXT + if problem: + log("MISMATCH", problem) + if text != hid_script.TEXT: + log("TEXT MISMATCH, want", repr(hid_script.TEXT)) + await host.set_leds(1) # done comparing: start the latency phase + + decode = [] + answered = 0 + while answered < LATENCY_N: + try: + evs = await host.events(5000) + except bledev.BLETimeoutError: + log("latency phase stalled after", answered) + ok = False + break + arrived = host.last_report_us + for e in evs: + if e.type == events.KEYDOWN and e.key == keys.K_a: + decode.append(hid.ticks_diff(hid.ticks_us(), arrived)) + await host.set_leds(answered & 1) + answered += 1 + log("report arrival to the app, on this host:", stats(decode)) + log("RESULT", "PASS" if ok else "FAIL") + await host.close() + + +asyncio.run(main()) diff --git a/tests/bledev_board/hid_peripheral.py b/tests/bledev_board/hid_peripheral.py new file mode 100644 index 00000000..49142e11 --- /dev/null +++ b/tests/bledev_board/hid_peripheral.py @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The HID gate, the device: a board that is a keyboard, media remote and gamepad. + +Needs ``hid_script.py`` beside it (or in /lib). It advertises as +``bledev-hid-gate``, waits for a host to set its LEDs (the host's signal that +it has subscribed), then: + +1. performs ``hid_script.STEPS``: typed text with Shift, two chords, two + media keys, and gamepad axes, buttons and hat; +2. taps ``a`` ``LATENCY_N`` times, timing each press until the host writes the + LEDs back (the host does that for every ``a`` it sees): the round trip. + +``PLANT = True`` drops Shift from the first character typed; the host's +comparison must then fail. ``ENCRYPTED = True`` makes the host pair first. +Logs to /hid_peripheral.log and ends with ``DONE``. +""" +import asyncio, gc, sys, time +import bledev.mpble +import bledev.hid as hid +import hid_script + +PLANT = False +ENCRYPTED = False +LATENCY_N = 200 +NAME = "bledev-hid-gate" +LOG = "/hid_peripheral.log" + + +def log(*parts): + line = " ".join(str(p) for p in parts) + print(line) + with open(LOG, "a") as f: + f.write(line + "\n") + + +def stats(values): + v = sorted(values) + n = len(v) + if not n: + return "none" + return "n={} min={} median={} p90={} p99={} max={} (us)".format( + n, v[0], v[n // 2], v[min(n - 1, n * 9 // 10)], v[min(n - 1, n * 99 // 100)], v[-1]) + + +async def one_host(per): + conn = await per.serve() + log("connected", conn, "mtu", conn.mtu) + t, data = await per.leds(30000) + log("host is listening (LEDs", data, "), encrypted", conn.encrypted) + await asyncio.sleep_ms(200) + steps = hid_script.STEPS + if PLANT: + text = steps[0][1] + steps = (("type", text[0].lower()), ("type", text[1:])) + steps[1:] + t0 = time.ticks_ms() + await hid_script.perform(per, steps) + log("script sent in", time.ticks_diff(time.ticks_ms(), t0), "ms") + # Let the host finish comparing, then time key presses. + await per.leds(30000) + rtt = [] + for i in range(LATENCY_N): + await per.keyboard.send(0, (0x04,)) + start = per.last_send_us + t, _ = await per.leds(5000) + rtt.append(time.ticks_diff(t, start)) + await per.keyboard.send(0, ()) + await asyncio.sleep_ms(20) + log("round trip, press to the host's LED write:", stats(rtt)) + await conn.disconnected(60000) + log("host gone") + + +async def main(): + try: + import os + os.remove(LOG) + except OSError: + pass + gc.collect() + ble = bledev.mpble.get() + if ENCRYPTED: + ble.enable_bonding() + per = hid.Peripheral(ble, name=NAME, encrypted=ENCRYPTED) + log("advertising as", per.name, "appearance", hex(per.appearance), "plant", PLANT, + "encrypted", ENCRYPTED, "map", len(per.report_map), "bytes") + try: + await one_host(per) + finally: + per.close() + log("DONE") + + +asyncio.run(main()) From cd38d28203792df3650482647bdb8388bc1f1dfb Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:30:29 -0500 Subject: [PATCH 03/14] bledev: the laptop checks a board's HID side under a vendor UUID; bless probed; two mpble MTU fixes Windows hides the HID service from apps, so Peripheral and Host take a service_uuid and hid.INSPECT_SERVICE serves the same characteristics where bleak can read them. The bless probe (the board as central, Windows as peripheral) found mpble missing an MTU exchanged before aioble records a central-role connection, and exchange_mtu() raising EALREADY when the peer already exchanged. --- docs/bledev-internals.md | 23 +++++++++++++- lib/bledev/hid.py | 30 +++++++++++++----- lib/bledev/mpble.py | 26 ++++++++++++++-- tests/bledev_board/bless_peripheral.py | 43 +++++++++++++++++++++----- tests/bledev_board/bless_probe.py | 11 ++++++- tests/bledev_board/hid_central.py | 8 +++-- tests/bledev_board/hid_peripheral.py | 6 ++-- tests/bledev_hid_checks.py | 2 +- 8 files changed, 124 insertions(+), 25 deletions(-) diff --git a/docs/bledev-internals.md b/docs/bledev-internals.md index 577ee0d2..87bfcf8b 100644 --- a/docs/bledev-internals.md +++ b/docs/bledev-internals.md @@ -153,6 +153,18 @@ The laptop found two more (2026-09-24): case never showed it because the central there asks for the MTU after connecting. +The bless probe found two more on the P4 (2026-09-24), with the board as the +central and Windows as the peripheral: + +- **The same early MTU, the other way round.** Windows exchanges the MTU the + moment the board connects, before aioble has recorded the connection, so + the board believed 23 on a 256 link. mpble's IRQ now keeps that value too, + and the connection's `mtu` picks it up. +- **`exchange_mtu()` after the peer already exchanged** raised `EALREADY`. + NimBLE exchanges once per link, so mpble now returns the settled MTU. + `bledev.hid`'s host exchanges before reading the Report Map, so any + peripheral that exchanges first (a real keyboard may) would have failed. + **Code that uses the raw API.** A board config's `ble` is now an `MPBLE`, and `MPBLE.__getattr__` hands every name it doesn't define to the raw `bluetooth.BLE`, so `ble.active()` or `ble.gap_advertise()` still work. @@ -254,7 +266,7 @@ because the side that didn't start pairing stores keys too. **MicroPython has no long read.** `gattc_read()` is one ATT Read, so a board central gets at most `mtu - 1` bytes of any value. The host exchanges the MTU up to 247 before reading the Report Map (246 bytes fit), and a map that fails -to parse at that length raises an error that says why. Our own map is 150 +to parse at that length raises an error that says why. Our own map is 162 bytes; an Xbox Series controller's is 283, so a board can't host one until MicroPython grows a long read. bleak reads long values itself, and the fake's `long_reads=False` models the board. @@ -265,6 +277,15 @@ gamepad it serves, so a host that cached one set never sees another set under the same identity. The PnP vendor is the Bluetooth SIG's test company ID (0xFFFF), because we have no vendor ID of our own. +**A laptop can't see a HID service.** Windows hides 0x1812 from apps: bleak +lists the board's GAP, GATT, Battery and Device Information services and a +22-handle gap where the HID service sits (measured against the P4, +2026-09-24). Chrome's Web Bluetooth blocklist hides it too. So +`Peripheral(service_uuid=hid.INSPECT_SERVICE)` serves the same +characteristics under a vendor UUID, and `Host(service_uuid=...)` reads them +there; that is how the laptop checks a board's HID side, with the same code +a board host runs. No host treats a board serving that UUID as a keyboard. + The host writes the keyboard's LED report once it has subscribed, as Windows does. `Peripheral.leds()` returns those writes, which is how the gate knows the host is listening, and how it times a round trip. diff --git a/lib/bledev/hid.py b/lib/bledev/hid.py index c9956431..5da23571 100644 --- a/lib/bledev/hid.py +++ b/lib/bledev/hid.py @@ -84,6 +84,12 @@ def ticks_diff(a, b): PNP_ID = UUID(0x2A50) MANUFACTURER_NAME = UUID(0x2A29) +#: The HID characteristics under a vendor UUID, for inspection from a laptop. +#: Windows hides the HID service (0x1812) from apps entirely, and Chrome's +#: Web Bluetooth blocklist does too, so a laptop can only see a board's HID +#: characteristics if the board serves them under another UUID. +INSPECT_SERVICE = UUID("b1ed0000-4849-4400-8000-00805f9b34fb") + APPEARANCE_HID = 0x03C0 APPEARANCE_KEYBOARD = 0x03C1 APPEARANCE_GAMEPAD = 0x03C4 @@ -273,10 +279,15 @@ class Peripheral: ``encrypted=True`` serves the Report Map and reports only to a host that has paired, as real keyboards do; the host pairs "just works". Call ``ble.enable_bonding()`` first on MicroPython so the keys are kept. + + ``service_uuid=INSPECT_SERVICE`` serves the same characteristics under a + vendor UUID instead of 0x1812, so a laptop can read them (no host will + treat the board as a keyboard then). """ def __init__(self, ble, *, keyboard=True, consumer=True, gamepad=True, - name=None, encrypted=False, battery=100, manufacturer="PyDevices"): + name=None, encrypted=False, battery=100, manufacturer="PyDevices", + service_uuid=HID_SERVICE): if not (keyboard or consumer or gamepad): raise ValueError("a HID peripheral needs at least one function") self.ble = ble @@ -288,7 +299,8 @@ def __init__(self, ble, *, keyboard=True, consumer=True, gamepad=True, self._leds = [] self._leds_flag = asyncio.Event() - hid = Service(HID_SERVICE) + self.service_uuid = UUID(service_uuid) + hid = Service(self.service_uuid) # bcdHID 1.11, no country, normally connectable. Characteristic(hid, HID_INFORMATION, read=True, initial=b"\x11\x01\x00\x02") Characteristic(hid, REPORT_MAP, read=True, initial=self.report_map, encrypted=encrypted) @@ -328,7 +340,7 @@ async def serve(self, timeout_ms=None): self.ble.register_services(*self.services) self._registered = True self.connection = await self.ble.advertise( - name=self.name, services=[HID_SERVICE], appearance=self.appearance, timeout_ms=timeout_ms + name=self.name, services=[self.service_uuid], appearance=self.appearance, timeout_ms=timeout_ms ) if self._led_char is not None and self._led_task is None: self._led_task = asyncio.create_task(self._watch_leds()) @@ -368,8 +380,9 @@ class Host: ``paired`` says whether it had to pair. """ - def __init__(self, connection, *, bond=True, instance_id=0, queue_limit=256): + def __init__(self, connection, *, bond=True, instance_id=0, queue_limit=256, service_uuid=HID_SERVICE): self.connection = connection + self.service_uuid = UUID(service_uuid) self.bond = bond self.instance_id = instance_id self.report_map = None @@ -403,7 +416,7 @@ async def start(self, timeout_ms=5000): if not caps.get("long_read", True): # One ATT read carries mtu - 1 bytes of the Report Map. await conn.exchange_mtu(247) - service = await conn.service(HID_SERVICE, timeout_ms) + service = await conn.service(self.service_uuid, timeout_ms) if service is None: raise BLEError("{} has no HID service".format(conn.device)) chars = [] @@ -512,13 +525,14 @@ async def close(self): await self.connection.disconnect() -async def connect(ble, name=None, *, device=None, timeout_ms=10000, bond=True, instance_id=0, **options): +async def connect(ble, name=None, *, device=None, timeout_ms=10000, bond=True, instance_id=0, + service_uuid=HID_SERVICE, **options): """Find a BLE HID device (by ``name``, or any advertising the HID service), connect, and return a started :class:`Host`.""" if device is None: - device = await ble.find(name=name, service=None if name else HID_SERVICE, timeout_ms=timeout_ms) + device = await ble.find(name=name, service=None if name else service_uuid, timeout_ms=timeout_ms) connection = await device.connect(timeout_ms=timeout_ms, **options) - host = Host(connection, bond=bond, instance_id=instance_id) + host = Host(connection, bond=bond, instance_id=instance_id, service_uuid=service_uuid) try: await host.start() except BaseException: diff --git a/lib/bledev/mpble.py b/lib/bledev/mpble.py index 686ac51b..65ca1ae9 100644 --- a/lib/bledev/mpble.py +++ b/lib/bledev/mpble.py @@ -64,6 +64,7 @@ _IRQ_CENTRAL_CONNECT = 1 _IRQ_CENTRAL_DISCONNECT = 2 _IRQ_GATTS_WRITE = 3 +_IRQ_PERIPHERAL_DISCONNECT = 8 _IRQ_MTU_EXCHANGED = 21 _FLAG_READ_ENCRYPTED = 0x0200 @@ -72,6 +73,7 @@ # errno values NimBLE uses when its buffers are full (ENOMEM, EAGAIN, EBUSY, # ENOBUFS). A send that sees one of these waits and tries again. _BUSY_ERRNOS = (12, 11, 16, 105) +_EALREADY = 120 # The largest ATT MTU MicroPython's NimBLE will negotiate. _MAX_MTU = 512 @@ -207,6 +209,8 @@ def _irq(event, data): aconn = _AioConnection._connected.get(conn_handle) if mtu and aconn is not None and not aconn.mtu: aconn.mtu = mtu + elif event == _IRQ_PERIPHERAL_DISCONNECT: + _early_mtu.pop(data[0], None) elif event == _IRQ_CENTRAL_DISCONNECT: conn_handle = data[0] _early_mtu.pop(conn_handle, None) @@ -493,7 +497,15 @@ def __init__(self, ble, device, role, aconn): @property def mtu(self): - return self._aconn.mtu or DEFAULT_MTU + a = self._aconn + if not a.mtu and a._conn_handle is not None: + # The same race as a central's early exchange, with this board as + # the central: a peripheral that exchanges at once (Windows, + # through bless) is heard before aioble records the connection. + early = _early_mtu.pop(a._conn_handle, None) + if early: + a.mtu = early + return a.mtu or DEFAULT_MTU def is_connected(self): return self._aconn.is_connected() @@ -507,8 +519,16 @@ async def disconnected(self, timeout_ms=None): await self._aconn.disconnected(timeout_ms) async def exchange_mtu(self, mtu=None, timeout_ms=1000): - with _Translate("exchange_mtu"): - return await self._aconn.exchange_mtu(mtu, timeout_ms) + try: + with _Translate("exchange_mtu"): + return await self._aconn.exchange_mtu(mtu, timeout_ms) + except OSError as e: + # NimBLE exchanges the MTU once per link. When the peer already + # did (Windows as a peripheral through bless does, at once), a + # second exchange is EALREADY: the MTU is settled, so return it. + if _errno(e) != _EALREADY: + raise + return self.mtu @property def encrypted(self): diff --git a/tests/bledev_board/bless_peripheral.py b/tests/bledev_board/bless_peripheral.py index d0312c95..8f39c3cf 100644 --- a/tests/bledev_board/bless_peripheral.py +++ b/tests/bledev_board/bless_peripheral.py @@ -6,12 +6,18 @@ bless (https://github.com/kevincar/bless) serves GATT from CPython. This serves one service with a readable, writable, notifying characteristic, for ``bless_probe.py`` on a board to find, read, write and subscribe to. Run it -with a Windows Python that has bless (keep it out of mpftp's sidecar Python, -since bless pins its own bleak):: +with a Windows Python that has bless, kept out of mpftp's sidecar Python. +On Python 3.14 (2026-09) PyPI's bless 0.3.0 won't install (it pins winrt +2.0.0b1), so this took bless's GitHub master with ``--no-deps``, then +``coloredlogs`` and pysetupdi's GitHub master by hand; the shim below covers +a winrt method bless still calls by its old name, and one more below for its +first subscription:: python.exe -m venv %USERPROFILE%\\bless-venv - %USERPROFILE%\\bless-venv\\Scripts\\python.exe -m pip install bless - %USERPROFILE%\\bless-venv\\Scripts\\python.exe bless_peripheral.py + ...\\Scripts\\python.exe -m pip install bleak pywin32 coloredlogs winrt-Windows.Devices.Bluetooth ... + ...\\Scripts\\python.exe -m pip install --no-deps https://github.com/kevincar/bless/archive/refs/heads/master.zip + ...\\Scripts\\python.exe -m pip install https://github.com/gwangyi/pysetupdi/archive/refs/heads/master.zip + ...\\Scripts\\python.exe bless_peripheral.py It prints each read and write, notifies ``tick N`` every second, and stops after ``SECONDS``. @@ -26,25 +32,48 @@ SECONDS = int(sys.argv[1]) if len(sys.argv) > 1 else 90 -def on_read(characteristic, **kwargs): +def on_read(characteristic, *args, **kwargs): print("read", bytes(characteristic.value), flush=True) return characteristic.value -def on_write(characteristic, value, **kwargs): +def on_write(characteristic, value, *args, **kwargs): characteristic.value = value print("write", bytes(value), flush=True) +class _Provider: + """bless (master, 2026-09) calls ``start_advertising(parameters)``, an + overload pywinrt 3.x names ``start_advertising_with_parameters``.""" + + def __init__(self, native): + self._native = native + + def start_advertising(self, parameters=None): + if parameters is None: + return self._native.start_advertising() + return self._native.start_advertising_with_parameters(parameters) + + def __getattr__(self, name): + return getattr(self._native, name) + + async def main(): server = BlessServer(name="bless-check") server.read_request_func = on_read server.write_request_func = on_write await server.add_new_service(SERVICE) props = GATTCharacteristicProperties.read | GATTCharacteristicProperties.write | GATTCharacteristicProperties.notify - perms = GATTAttributePermissions.readable | GATTAttributePermissions.writeable + perms = GATTAttributePermissions.readable | GATTAttributePermissions.writable await server.add_new_characteristic(SERVICE, CHAR, props, bytearray(b"hello from bless"), perms) + for service in server.services.values(): + native = getattr(service, "service_provider", None) + if native is not None and not hasattr(native, "_native"): + service.service_provider = _Provider(native) await server.start() + # bless master's first subscription indexes this dict before it has the key. + if hasattr(server, "_subscribed_clients"): + server._subscribed_clients.setdefault(CHAR, set()) print("advertising", SERVICE, flush=True) for n in range(SECONDS): await asyncio.sleep(1) diff --git a/tests/bledev_board/bless_probe.py b/tests/bledev_board/bless_probe.py index db1fad97..1f49a7ce 100644 --- a/tests/bledev_board/bless_probe.py +++ b/tests/bledev_board/bless_probe.py @@ -28,7 +28,16 @@ async def main(): try: device = await ble.find(service=SERVICE, timeout_ms=30000) log("found", device) - conn = await device.connect(timeout_ms=15000) + conn = None + for attempt in range(3): + await asyncio.sleep_ms(500) + try: + conn = await device.connect(timeout_ms=15000) + break + except OSError as e: + log("connect attempt", attempt, "failed:", e) + if conn is None: + raise RuntimeError("could not connect") log("connected, mtu", await conn.exchange_mtu(247)) svc = await conn.service(SERVICE) log("service", svc) diff --git a/tests/bledev_board/hid_central.py b/tests/bledev_board/hid_central.py index fbcd5709..1678d581 100644 --- a/tests/bledev_board/hid_central.py +++ b/tests/bledev_board/hid_central.py @@ -36,6 +36,9 @@ LOG = None if "--fast" in sys.argv: CONN = {"priority": "throughput"} +# Windows hides 0x1812 from apps: a laptop reads the board's HID +# characteristics under hid.INSPECT_SERVICE (hid_peripheral.py's INSPECT). +SERVICE = None NAME = "bledev-hid-gate" LATENCY_N = 200 ENCRYPTED = False # the harness sets it when the device pairs @@ -75,7 +78,8 @@ async def main(): t0 = time.ticks_ms() if MICROPYTHON else time.monotonic() * 1000 device = await ble.find(name=NAME, timeout_ms=30000) connection = await device.connect(timeout_ms=15000, **CONN) - host = hid.Host(connection) + service = SERVICE or (hid.HID_SERVICE if MICROPYTHON or "--hid-service" in sys.argv else hid.INSPECT_SERVICE) + host = hid.Host(connection, service_uuid=service) if not MICROPYTHON: # Never let Windows pair with a board that is a keyboard. async def refuse(bond=True, timeout_ms=20000): @@ -87,7 +91,7 @@ async def refuse(bond=True, timeout_ms=20000): rmap = host.report_map log("connected and started in", int(now - t0), "ms; mtu", connection.mtu, "paired", host.paired, "map", len(rmap.descriptor), "bytes, applications", ["0x%08x" % a for a in rmap.applications], - "inputs", [(r.report_id, r.size) for r in rmap.inputs()], "conn", CONN) + "inputs", [(r.report_id, r.size) for r in rmap.inputs()], "conn", CONN, "service", service) want = hid_script.expected() got = [] diff --git a/tests/bledev_board/hid_peripheral.py b/tests/bledev_board/hid_peripheral.py index 49142e11..229be07d 100644 --- a/tests/bledev_board/hid_peripheral.py +++ b/tests/bledev_board/hid_peripheral.py @@ -23,6 +23,7 @@ PLANT = False ENCRYPTED = False +INSPECT = False # serve under hid.INSPECT_SERVICE, for a laptop (Windows hides 0x1812) LATENCY_N = 200 NAME = "bledev-hid-gate" LOG = "/hid_peripheral.log" @@ -82,9 +83,10 @@ async def main(): ble = bledev.mpble.get() if ENCRYPTED: ble.enable_bonding() - per = hid.Peripheral(ble, name=NAME, encrypted=ENCRYPTED) + per = hid.Peripheral(ble, name=NAME, encrypted=ENCRYPTED, + service_uuid=hid.INSPECT_SERVICE if INSPECT else hid.HID_SERVICE) log("advertising as", per.name, "appearance", hex(per.appearance), "plant", PLANT, - "encrypted", ENCRYPTED, "map", len(per.report_map), "bytes") + "encrypted", ENCRYPTED, "inspect", INSPECT, "map", len(per.report_map), "bytes") try: await one_host(per) finally: diff --git a/tests/bledev_hid_checks.py b/tests/bledev_hid_checks.py index d80ce166..151419a8 100644 --- a/tests/bledev_hid_checks.py +++ b/tests/bledev_hid_checks.py @@ -124,7 +124,7 @@ async def c_unpaired_read_refused(air): async def c_short_read_host_says_why(air): - # MicroPython reads one packet. At MTU 23 the map (150 bytes) is cut to + # MicroPython reads one packet. At MTU 23 the map (162 bytes) is cut to # 22, and the host must say so instead of decoding garbage. board = FakeBLE(air=air, mtu=23) host_ble = FakeBLE(air=air, mtu=23, long_reads=False) From 67b6c0abbcf799d527b0100ef63539e98df08cd5 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:57:35 -0500 Subject: [PATCH 04/14] bledev.hid board gate: pacing, connection interval and security logging; notification burst scripts (pydevices#87) --- tests/bledev_board/burst_sink.py | 47 ++++++++++++++++++++++++++++ tests/bledev_board/burst_source.py | 44 ++++++++++++++++++++++++++ tests/bledev_board/hid_central.py | 16 ++++++++++ tests/bledev_board/hid_peripheral.py | 12 +++++-- 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/bledev_board/burst_sink.py create mode 100644 tests/bledev_board/burst_source.py diff --git a/tests/bledev_board/burst_sink.py b/tests/bledev_board/burst_sink.py new file mode 100644 index 00000000..71aea80a --- /dev/null +++ b/tests/bledev_board/burst_sink.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Notification bursts, the sink: asks burst_source.py for bursts and counts what arrives. + +``WORK_US`` busies Python for that long per packet, which is what made the P4 +lose packets and the S3 not (pydevices#87). ``FAST`` asks for a 7.5-15 ms +interval. Logs to /burst_cen.log. +""" +import asyncio, struct, time +import bledev, bledev.mpble +SVC = bledev.UUID("b1ed1000-4849-4400-8000-00805f9b34fb") +CH = bledev.UUID("b1ed1001-4849-4400-8000-00805f9b34fb") +CTL = bledev.UUID("b1ed1002-4849-4400-8000-00805f9b34fb") +FAST = True +WORK_US = 0 +LOG = "/burst_cen.log" +def log(*a): + line = " ".join(str(x) for x in a); print(line) + with open(LOG, "a") as f: f.write(line + "\n") +async def main(): + ble = bledev.mpble.get() + dev = await ble.find(name="burst-src", timeout_ms=20000) + kw = {"min_conn_interval_us": 7500, "max_conn_interval_us": 15000} if FAST else {} + conn = await dev.connect(timeout_ms=10000, **kw) + await conn.exchange_mtu(247) + s = await conn.service(SVC) + ch = await s.characteristic(CH); ctl = await s.characteristic(CTL) + await ch.subscribe() + for n, gap in ((20, 0), (40, 0), (80, 0), (200, 0), (200, 2), (200, 5), (200, 10)): + await ctl.write(struct.pack(" Date: Thu, 24 Sep 2026 17:13:12 -0500 Subject: [PATCH 05/14] docs/bledev-internals: the HID gate's numbers, board to board and from the laptop --- docs/bledev-internals.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/bledev-internals.md b/docs/bledev-internals.md index 87bfcf8b..e44c5f56 100644 --- a/docs/bledev-internals.md +++ b/docs/bledev-internals.md @@ -359,6 +359,42 @@ off, 2026-09-24. | registering nus, then advertising | 0.2 KB | 1.8 KB | | `active(False)` | all returned | | +### HID, board to board and from the laptop + +`hid_peripheral.py` typed `hid_script.py` (30 characters with Shift, Ctrl+C, +Right Alt + Shift + F5, two media keys, five gamepad reports: 112 events) and +`hid_central.py` compared every event and the text they spell. MicroPython +1.29, aioble from mip, 2026-09-24. The planted run drops Shift from the first +character. + +| Device | Host | Plain | Planted | 7.5-15 ms interval | Round trip, default (median / max) | Round trip, 7.5-15 ms | +|---|---|---|---|---|---|---| +| LCD-7 (S3) | T-Embed (S3) | PASS, 112/112 | FAIL, event 0 | PASS | 69 / 119 ms | 29 / 49 ms | +| T-Embed (S3) | LCD-7 (S3) | PASS | FAIL, event 0 | PASS | 68 / 118 ms | 30 / 69 ms | +| P4 | LCD-7 (S3) | PASS | FAIL, event 0 | PASS | 70 / 220 ms | 30 / 80 ms | +| LCD-7 (S3) | P4 | PASS 2 of 3 | FAIL, event 0 | FAIL 6 of 6 | 68 / 178 ms | 29 / 69 ms | +| P4, vendor UUID | laptop (bleak) | PASS | FAIL, event 0 | PASS | 90 / 270 ms | 60 / 90 ms | + +The round trip is a key press on the device to the host's LED write arriving +back, so one way is roughly half: 35 ms at the default interval, 15 ms at +7.5-15 ms. Decoding on the host adds 1.7 ms (P4) to 2.8 ms (S3) from the +report's arrival to the app's `events()`, with a p99 of 10 to 23 ms. + +**The P4 as host loses reports** below Python, always from around the 26th +report on (pydevices#87). The S3 never did. `burst_source.py` and +`burst_sink.py` reproduce it without HID. + +**Pairing and bonding** (`ENCRYPTED = True`: every HID characteristic +encrypted, both sides call `enable_bonding()`), each direction between the S3s +and with the P4 as device: + +| Run | Result | +|---|---| +| First connect, no keys on either side | The Report Map read fails, the host pairs "just works" (encrypted, not authenticated, 16-byte key, bonded), 112/112 | +| Both boards reset, then reconnect | Encryption from the stored keys: the key store's checksum unchanged, connect-to-started 0.8 to 1.4 s faster, 112/112 | +| The host's `ble_secrets.json` deleted (as an erase would) | The device accepts a fresh pairing (NimBLE's repeat-pairing path drops the old bond), new keys, 112/112 | +| A host that refuses to pair (planted) | Fails: the device's encrypted Report Map can't be read | + ### The laptop to an S3 Windows 11, bleak 3.0.2, the laptop's Intel radio, to the LCD-7 running From f104e12361dd474cc1d51000823f59bc89bf6b78 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:13:33 -0500 Subject: [PATCH 06/14] docs/bledev-internals: count every P4-host run --- docs/bledev-internals.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/bledev-internals.md b/docs/bledev-internals.md index e44c5f56..5fe71afe 100644 --- a/docs/bledev-internals.md +++ b/docs/bledev-internals.md @@ -372,7 +372,7 @@ character. | LCD-7 (S3) | T-Embed (S3) | PASS, 112/112 | FAIL, event 0 | PASS | 69 / 119 ms | 29 / 49 ms | | T-Embed (S3) | LCD-7 (S3) | PASS | FAIL, event 0 | PASS | 68 / 118 ms | 30 / 69 ms | | P4 | LCD-7 (S3) | PASS | FAIL, event 0 | PASS | 70 / 220 ms | 30 / 80 ms | -| LCD-7 (S3) | P4 | PASS 2 of 3 | FAIL, event 0 | FAIL 6 of 6 | 68 / 178 ms | 29 / 69 ms | +| LCD-7 (S3) | P4 | PASS 2 of 5 | FAIL, event 0 | FAIL 6 of 6 | 68 / 178 ms | 29 / 69 ms | | P4, vendor UUID | laptop (bleak) | PASS | FAIL, event 0 | PASS | 90 / 270 ms | 60 / 90 ms | The round trip is a key press on the device to the host's LED write arriving From a868ea486ecb96a2bc58fd0e3943b4cb902db31f Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:39:50 -0500 Subject: [PATCH 07/14] bledev: pairing and bonding for the REPL and file services bledev.security keeps the bonds in NVS on an ESP32 (a filesystem erase keeps them, a chip erase loses them), handles the passkey step aioble leaves out, and tracks each link's security. bledev.repl and bledev.filetransfer serve with pairing=justworks|passkey|numeric|auto; the password stays the default. Clients pair with pair=True and passkey=; filetransfer pairs on its own when the board asks, as a CircuitPython board does. bledev.bleak pairs through WinRT's custom pairing, so a passkey needs no system dialog. The fake models passkey pairing, authenticated characteristics and a lost bond, and tests/bledev_pairing.py checks the clients against it. --- lib/bledev/__init__.py | 73 +++- lib/bledev/bleak.py | 106 +++++- lib/bledev/fake.py | 59 ++- lib/bledev/filetransfer.py | 90 ++++- lib/bledev/mpble.py | 95 ++++- lib/bledev/repl.py | 227 ++++++++++-- lib/bledev/security.py | 515 +++++++++++++++++++++++++++ pydevices-desktop.toml | 1 + tests/bledev_board/hid_central.py | 13 +- tests/bledev_board/hid_peripheral.py | 9 +- tests/bledev_pairing.py | 287 +++++++++++++++ tests/test_bledev_pairing.py | 55 +++ 12 files changed, 1446 insertions(+), 84 deletions(-) create mode 100644 lib/bledev/security.py create mode 100644 tests/bledev_pairing.py create mode 100644 tests/test_bledev_pairing.py diff --git a/lib/bledev/__init__.py b/lib/bledev/__init__.py index 0b64058f..0a116cd0 100644 --- a/lib/bledev/__init__.py +++ b/lib/bledev/__init__.py @@ -81,6 +81,14 @@ class BusyError(BLEError): INSUFFICIENT_ENCRYPTION = 0x0F +class PairingError(BLEError): + """Pairing failed: a wrong passkey, a refusal, or a peer that lost its keys.""" + + +#: The GATT statuses that mean "pair first". +NEEDS_PAIRING = (INSUFFICIENT_AUTHENTICATION, INSUFFICIENT_ENCRYPTION) + + class GattError(BLEError): """The peer answered a GATT request with an error ``status``.""" @@ -476,7 +484,9 @@ class Characteristic: that takes whole MTU-sized writes. With ``capture=True``, ``written()`` returns every write as ``(connection, data)``, in order, instead of just the last writer. ``encrypted=True`` refuses a central that hasn't paired - (``GattError`` with status ``INSUFFICIENT_ENCRYPTION``) until it does. + (``GattError`` with status ``INSUFFICIENT_ENCRYPTION``) until it does; + ``authenticated=True`` also refuses one that paired without a passkey + (``INSUFFICIENT_AUTHENTICATION``). """ def __init__( @@ -492,6 +502,7 @@ def __init__( capture=False, max_len=20, encrypted=False, + authenticated=False, ): service.characteristics.append(self) self.service = service @@ -513,7 +524,10 @@ def __init__( self._initial = bytes(initial) if initial is not None else None #: True when a central must pair (an encrypted link) before it may #: read, write or subscribe. HID keyboards serve their reports this way. - self.encrypted = bool(encrypted) + self.encrypted = bool(encrypted) or bool(authenticated) + #: True when only a link paired with a passkey (or numeric + #: comparison) may use it: "just works" isn't enough. + self.authenticated = bool(authenticated) #: :class:`Descriptor` objects, in the order they were made. self.descriptors = [] # Set by the backend's register_services(); it does the real work. @@ -792,18 +806,34 @@ def encrypted(self): """True once the link is encrypted (after :meth:`pair`, or a bonded reconnect).""" return False - async def pair(self, bond=True, timeout_ms=20000): + async def pair(self, bond=True, timeout_ms=30000, passkey=None): """Pair with the peer and encrypt the link; with ``bond``, keep the keys. Either side may call it; in practice the central does, when a read - fails with ``INSUFFICIENT_ENCRYPTION``. Pairing here is "just works" - (no passkey), which is what a keyboard without a screen does. A bonded - peer reconnects without pairing again, for as long as both sides keep - their keys. Raises :class:`UnsupportedError` on a backend without - pairing. + fails with ``INSUFFICIENT_ENCRYPTION``. Without ``passkey`` the + pairing is "just works", which is what a keyboard without a screen + does. When the peer shows a passkey (a board with a display), + ``passkey`` is what to type in: an int, a string of six digits, or a + callable (plain or async) that returns one when asked, such as one + that prompts a person. A bonded peer reconnects without pairing again, + for as long as both sides keep their keys; pairing a bonded peer just + encrypts the link with the stored keys. + + Raises :class:`PairingError` when pairing fails (a wrong passkey, or + a peer that lost its half of the bond), and :class:`UnsupportedError` + on a backend without pairing. """ raise UnsupportedError("{} cannot pair".format(self._ble.backend)) + @property + def authenticated(self): + """True when the link's keys came from a passkey or numeric comparison.""" + return False + + async def unpair(self): + """Forget this peer's keys on this side (and, on a laptop, the OS's pairing).""" + raise UnsupportedError("{} cannot unpair".format(self._ble.backend)) + async def exchange_mtu(self, mtu=None, timeout_ms=1000): """Negotiate a larger ATT MTU and return the result. @@ -956,6 +986,18 @@ def is_adapter(obj): return isinstance(obj, BLE) +async def passkey_value(passkey): + """``passkey`` as an int: it may be an int, a string of digits, or a + callable (plain or async) returning either. ``None`` stays ``None``.""" + if callable(passkey): + passkey = passkey() + if hasattr(passkey, "send"): # a coroutine + passkey = await passkey + if passkey is None: + return None + return int(passkey) + + async def connect_and_set_up( ble, setup, *, name=None, service=None, device=None, timeout_ms=10000, attempts=3, setup_timeout_ms=4000, **options ): @@ -968,14 +1010,27 @@ async def connect_and_set_up( from Windows, the OS reports a connection the peripheral never saw, and the first GATT operation on it hangs until Windows gives up nine seconds later. ``setup_timeout_ms`` bounds that wait. ``options`` go to - ``device.connect()``. + ``device.connect()``, except ``pair`` (pair before ``setup``) and + ``passkey`` (for :meth:`Connection.pair`). """ + pair = options.pop("pair", False) + passkey = options.pop("passkey", None) error = None for _ in range(attempts): target = device if target is None: target = await ble.find(name=name, service=service, timeout_ms=timeout_ms) connection = await target.connect(timeout_ms=timeout_ms, **options) + if pair: + # Before discovery: a board that wants pairing refuses the + # subscriptions setup makes. Pairing waits on a person typing a + # passkey, so setup_timeout_ms doesn't bound it. + try: + await connection.pair(passkey=passkey) + except BaseException: + if connection.is_connected(): + await connection.disconnect() + raise try: return connection, await wait_ms(setup(connection), setup_timeout_ms, "setting up the connection") except (DisconnectedError, BLETimeoutError) as e: diff --git a/lib/bledev/bleak.py b/lib/bledev/bleak.py index 0d004383..7f3086dd 100644 --- a/lib/bledev/bleak.py +++ b/lib/bledev/bleak.py @@ -57,10 +57,12 @@ FLAG_WRITE_NO_RESPONSE, GattError, MAX_MTU, + PairingError, ScanResult, Scanner, UnsupportedError, UUID, + passkey_value, wait_ms, ) @@ -277,6 +279,7 @@ def __init__(self, ble, device, client): self._inboxes = [] self._wwr = 0 # the largest write-without-response size seen in discovery self._paired = False + self._authenticated = False def _drop(self): if self._dropped.is_set(): @@ -322,16 +325,101 @@ async def disconnected(self, timeout_ms=None): def encrypted(self): return self._paired - async def pair(self, bond=True, timeout_ms=20000): - """Pair through the OS. On Windows that makes a bond the OS keeps (and, - for a HID device, a keyboard it uses), so the tests here never call it.""" + @property + def authenticated(self): + return self._authenticated + + async def pair(self, bond=True, timeout_ms=30000, passkey=None): + """Pair through the OS, which keeps the bond. + + On Windows this uses WinRT's custom pairing, so no system dialog + appears: "just works" when the board has no display, and ``passkey`` + (a value, or a callable asked when Windows wants it) when the board + shows one. A board already paired with Windows is only checked, not + paired again; Windows encrypts the link with the stored keys when a + protected characteristic is first used. Elsewhere it's bleak's + ``pair()``, where the OS may ask for the passkey itself. + + Windows keeps the pairing until :meth:`unpair` (or Settings, Remove + device). Never pair a board serving HID with the computer you test + from: Windows then uses it as a keyboard. + """ self._live("pair") + requester = getattr(getattr(self._client, "_backend", None), "_requester", None) try: - await wait_ms(self._client.pair(), timeout_ms, "pair") + if requester is not None: + await wait_ms(self._pair_winrt(requester, passkey), timeout_ms, "pair") + else: + await wait_ms(self._client.pair(), timeout_ms, "pair") + self._authenticated = passkey is not None except (BleakError, OSError) as e: raise _translate(e, self, "pair") self._paired = True + async def _pair_winrt(self, requester, passkey): + from winrt.windows.devices.enumeration import ( + DeviceInformation, + DevicePairingKinds, + DevicePairingProtectionLevel, + DevicePairingResultStatus, + ) + + info = await DeviceInformation.create_from_id_async(requester.device_information.id) + pairing = info.pairing + if pairing.is_paired: + self._authenticated = pairing.protection_level == DevicePairingProtectionLevel.ENCRYPTION_AND_AUTHENTICATION + return + if not pairing.can_pair: + raise PairingError("pair: Windows says {} can't pair".format(self.device.address)) + loop = asyncio.get_running_loop() + asked = [] + + async def provide(args, deferral): + try: + value = await passkey_value(passkey) + if value is not None: + args.accept_with_pin("{:06d}".format(value)) + except Exception as e: # a cancelled prompt: leave it unaccepted + asked.append(e) + finally: + deferral.complete() + + def handler(sender, args): + # On a WinRT thread. Just works needs only a yes; a passkey waits + # on the provider, which may be slow (a person), behind a deferral. + kind = args.pairing_kind + asked.append(kind) + if kind == DevicePairingKinds.CONFIRM_ONLY: + args.accept() + elif kind == DevicePairingKinds.PROVIDE_PIN and passkey is not None: + deferral = args.get_deferral() + loop.call_soon_threadsafe(lambda: loop.create_task(provide(args, deferral))) + + kinds = DevicePairingKinds.CONFIRM_ONLY + level = DevicePairingProtectionLevel.ENCRYPTION + if passkey is not None: + kinds |= DevicePairingKinds.PROVIDE_PIN + level = DevicePairingProtectionLevel.ENCRYPTION_AND_AUTHENTICATION + custom = pairing.custom + token = custom.add_pairing_requested(handler) + try: + result = await custom.pair_with_protection_level_async(kinds, level) + finally: + custom.remove_pairing_requested(token) + status = result.status + if status not in (DevicePairingResultStatus.PAIRED, DevicePairingResultStatus.ALREADY_PAIRED): + raise PairingError("pair: {} ({})".format(status.name, ", ".join(str(a) for a in asked) or "no ceremony")) + self._authenticated = result.protection_level_used == DevicePairingProtectionLevel.ENCRYPTION_AND_AUTHENTICATION + + async def unpair(self): + """Remove the OS's pairing with this board. Windows also drops the link.""" + try: + await self._client.unpair() + except (BleakError, OSError) as e: + raise _translate(e, self, "unpair") + self._paired = False + self._authenticated = False + async def exchange_mtu(self, mtu=None, timeout_ms=1000): """The OS negotiates on its own; wait briefly for it and return the result.""" self._live("exchange_mtu") @@ -486,3 +574,13 @@ async def read(self, timeout_ms=1000): return bytes(await wait_ms(client.read_gatt_descriptor(self._native.handle), timeout_ms, "descriptor read")) except (BleakError, OSError) as e: raise _translate(e, self.connection, "descriptor read") + + +async def unpair(address): + """Remove the OS's pairing with ``address`` without connecting: the recovery + step when a board has lost its bond (its keys were erased) and Windows + still holds the other half.""" + try: + await BleakClient(address).unpair() + except (BleakError, OSError) as e: + raise BLEError("unpair {}: {}".format(address, e)) diff --git a/lib/bledev/fake.py b/lib/bledev/fake.py index 256404c5..e3798951 100644 --- a/lib/bledev/fake.py +++ b/lib/bledev/fake.py @@ -55,7 +55,9 @@ FLAG_WRITE, FLAG_WRITE_NO_RESPONSE, GattError, + INSUFFICIENT_AUTHENTICATION, INSUFFICIENT_ENCRYPTION, + PairingError, MAX_MTU, UUID, ScanResult, @@ -65,6 +67,7 @@ decode_service_data, pack_advertisement, sleep_ms, + passkey_value, wait_ms, ) @@ -177,6 +180,11 @@ def __init__( self._long_reads = long_reads #: Addresses of the peers this adapter has bonded with. self.bonds = set() + # address -> whether that bond came from a passkey + self._bond_authenticated = {} + #: As a peripheral: "justworks", or "passkey" to show one (``set_pairing``). + self.pairing = "justworks" + self.show_passkey = None self._services = () self._adverts = [] self._links = [] @@ -201,6 +209,14 @@ def forget(self, address=None): else: self.bonds.discard(address) + def set_pairing(self, mode="justworks", show=None): + """As a peripheral, pair "just works", or show a passkey: ``show(passkey)`` + gets the six digits a central must give ``pair(passkey=...)``.""" + if mode not in ("justworks", "passkey"): + raise ValueError("the fake pairs 'justworks' or 'passkey'") + self.pairing = mode + self.show_passkey = show + def config(self, *names, **settings): for key, value in settings.items(): if key == "mtu": @@ -357,6 +373,7 @@ def __init__(self, central_ble, peripheral_ble, device): self.in_flight = [] self._drain_task = None self.encrypted = False + self.authenticated = False central_ble._links.append(self) peripheral_ble._links.append(self) @@ -431,15 +448,49 @@ async def disconnected(self, timeout_ms=None): def encrypted(self): return self._link.encrypted - async def pair(self, bond=True, timeout_ms=20000): + @property + def authenticated(self): + return self._link.authenticated + + async def pair(self, bond=True, timeout_ms=30000, passkey=None): link = self._link link.check() await asyncio.sleep(0) link.check() + central, peripheral = link.central_ble, link.peripheral_ble + if peripheral.address in central.bonds: + # Encrypt with the stored keys. If the peripheral has lost its + # half, that fails without a word, as it does on real hosts: the + # link stays unencrypted and protected reads are refused. + if central.address in peripheral.bonds: + link.encrypted = True + link.authenticated = central._bond_authenticated.get(peripheral.address, False) + return + authenticated = False + if peripheral.pairing == "passkey" and passkey is not None: + import os + + b = os.urandom(3) + shown = ((b[0] << 16) | (b[1] << 8) | b[2]) % 1000000 + if peripheral.show_passkey is not None: + peripheral.show_passkey(shown) + given = await passkey_value(passkey) + link.check() + if given != shown: + raise PairingError("pair: wrong passkey") + authenticated = True link.encrypted = True + link.authenticated = authenticated if bond: - link.central_ble.bonds.add(link.peripheral_ble.address) - link.peripheral_ble.bonds.add(link.central_ble.address) + central.bonds.add(peripheral.address) + peripheral.bonds.add(central.address) + central._bond_authenticated[peripheral.address] = authenticated + peripheral._bond_authenticated[central.address] = authenticated + + async def unpair(self): + link = self._link + own = link.central_ble if self.role == "central" else link.peripheral_ble + own.forget(self.device.address) async def exchange_mtu(self, mtu=None, timeout_ms=1000): self._link.check() @@ -490,6 +541,8 @@ def _link(self): link.check() if self._server.encrypted and not link.encrypted: raise GattError(INSUFFICIENT_ENCRYPTION, "{} needs an encrypted link; pair() first".format(self.uuid)) + if self._server.authenticated and not link.authenticated: + raise GattError(INSUFFICIENT_AUTHENTICATION, "{} needs a link paired with a passkey".format(self.uuid)) return link async def read(self, timeout_ms=1000): diff --git a/lib/bledev/filetransfer.py b/lib/bledev/filetransfer.py index 275178ec..f90e5ba3 100644 --- a/lib/bledev/filetransfer.py +++ b/lib/bledev/filetransfer.py @@ -27,14 +27,20 @@ print(await files.read("/hello.txt"), await files.listdir("/")) **The lock.** CircuitPython protects the transfer characteristic with -pairing. bledev uses the REPL's password instead, the same way WebREPL does: -the service carries one more characteristic (``AUTH``), the client writes the -password to it, and until it's right every command is answered with -:data:`STATUS_LOCKED`. One attempt per connection; a wrong password is +pairing. bledev uses the REPL's password by default, the same way WebREPL +does: the service carries one more characteristic (``AUTH``), the client +writes the password to it, and until it's right every command is answered +with :data:`STATUS_LOCKED`. One attempt per connection; a wrong password is hung up on. Logging in to the REPL over the same connection unlocks files -too. The link isn't encrypted, so a sniffer nearby can capture the password; -pairing is the later fix. A client connecting to a CircuitPython board finds -no ``AUTH`` characteristic and skips the step. +too. Without pairing the link isn't encrypted, so a sniffer nearby can capture +the password. + +``start(pairing=...)`` adds pairing, as for :mod:`bledev.repl`: the transfer +and ``AUTH`` characteristics then refuse a host that hasn't paired, and with +a passkey the password can go (``password=False``). ``AUTH`` then reads 1 +once the link is paired. A client connecting to a CircuitPython board finds +no ``AUTH`` characteristic, pairs ("just works", as CircuitPython asks) and +skips the password. The protocol carries no checksum of its own beyond the link layer's CRC, so a client that must be sure checks what landed (mpftp compares SHA-256). @@ -47,7 +53,17 @@ import struct -from . import BLEError, BLETimeoutError, DisconnectedError, UnsupportedError, UUID, connect_and_set_up, wait_ms +from . import ( + BLEError, + BLETimeoutError, + DisconnectedError, + GattError, + NEEDS_PAIRING, + UnsupportedError, + UUID, + connect_and_set_up, + wait_ms, +) SERVICE = UUID(0xFEBB) VERSION = UUID("adaf0100-4669-6c65-5472-616e73666572") @@ -784,12 +800,17 @@ def _now_ns(): return _ns(time.time()) -async def connect(ble, password=None, name=None, *, device=None, timeout_ms=10000, mtu=247, **connect_options): +async def connect(ble, password=None, name=None, *, device=None, timeout_ms=10000, mtu=247, pair=None, passkey=None, **connect_options): """Find a board serving files (by ``name``, or the first advertising the service), connect, log in, and return a :class:`Client`. - ``password`` is needed for a bledev board and ignored by a CircuitPython - one, which uses pairing instead (bond with it first). + ``password`` is needed for a bledev board locked by one, and ignored by + a CircuitPython board, which uses pairing instead. + + ``pair``: ``True`` pairs first; ``None`` (the default) pairs when the + board asks for it, which a CircuitPython board always does and a bledev + board started with ``pairing=`` does; ``False`` never pairs. ``passkey`` + is what the board's display shows, or a callable that asks for it. """ from . import nus @@ -815,14 +836,49 @@ async def setup(connection): except (BLEError, ValueError): pass auth = await service.characteristic(AUTH) - await transfer.subscribe(notify=True) - return transfer, version, auth - - connection, (transfer, version, auth) = await connect_and_set_up( - ble, setup, name=name, service=SERVICE, device=device, timeout_ms=timeout_ms, **connect_options + # Does the board want pairing? CircuitPython (no AUTH) always does. A + # bledev board with pairing on refuses to let AUTH be read. + locked = None + wants = auth is None + if auth is not None: + try: + locked = (await auth.read())[:1] != b"\x01" + except GattError as e: + if e.status not in NEEDS_PAIRING: + raise + wants = True + return transfer, version, auth, wants, locked + + connection, (transfer, version, auth, wants, locked) = await connect_and_set_up( + ble, setup, name=name, service=SERVICE, device=device, timeout_ms=timeout_ms, pair=pair is True, passkey=passkey, **connect_options ) + try: + if wants and pair is None and not connection.encrypted: + await connection.pair(passkey=passkey) + try: + await transfer.subscribe(notify=True) + if auth is not None and (locked is None or wants): + locked = (await auth.read())[:1] != b"\x01" + if locked and password is None and connection.encrypted: + # The board unlocks on its own encryption event, which + # can land a moment after this side's pair() returned. + for _ in range(10): + await asyncio.sleep(0.05) + locked = (await auth.read())[:1] != b"\x01" + if not locked: + break + except GattError as e: + if e.status not in NEEDS_PAIRING: + raise + from .repl import refused + + raise refused(pair is not False, passkey) + except BaseException: + if connection.is_connected(): + await connection.disconnect() + raise client = Client(connection, transfer, version) - if auth is not None: + if auth is not None and locked: try: if password is None: raise AuthError("the board wants a password") diff --git a/lib/bledev/mpble.py b/lib/bledev/mpble.py index 65ca1ae9..dfd3b9ba 100644 --- a/lib/bledev/mpble.py +++ b/lib/bledev/mpble.py @@ -31,6 +31,8 @@ except ImportError: # pragma: no cover import uasyncio as asyncio +import time + import bluetooth import aioble from aioble import core as _aioble_core @@ -52,6 +54,7 @@ FLAG_NOTIFY, FLAG_READ, GattError, + PairingError, ScanResult, Scanner, UnsupportedError, @@ -69,6 +72,8 @@ _FLAG_READ_ENCRYPTED = 0x0200 _FLAG_WRITE_ENCRYPTED = 0x1000 +_FLAG_READ_AUTHENTICATED = 0x0400 +_FLAG_WRITE_AUTHENTICATED = 0x2000 # errno values NimBLE uses when its buffers are full (ENOMEM, EAGAIN, EBUSY, # ENOBUFS). A send that sees one of these waits and tries again. @@ -289,19 +294,21 @@ def capabilities(self): ) return caps - def enable_bonding(self, path=None): - """Keep pairing keys in ``path`` (aioble's ``ble_secrets.json``) and bond from now on. + def enable_bonding(self, store=None): + """Bond from now on, keeping the keys in ``store`` (see + :func:`bledev.security.use_store`): NVS on an ESP32 by default, which + survives a filesystem erase, or a path for aioble's + ``ble_secrets.json``. Call it before any connection that may pair, on both sides: the side - that doesn't start pairing still has to store the keys. The file - doesn't survive an erase of the filesystem, and a peer that still - holds its half of the bond will then refuse to reconnect encrypted - until it's paired again. + that doesn't start pairing still has to store the keys. A peer that + still holds its half of a bond this board has lost will refuse to + reconnect encrypted until it's paired again. """ - from aioble import security + from . import security - security.load_secrets(path) - aioble.config(bond=True, le_secure=True, mitm=False, io=3) + security.use_store(store) + aioble.config(bond=True, le_secure=True, mitm=False, io=security.IO_NO_INPUT_OUTPUT) def _config_mtu(self): try: @@ -382,6 +389,8 @@ def register_services(self, *services): ) if c.encrypted: achar.flags |= _FLAG_READ_ENCRYPTED | _FLAG_WRITE_ENCRYPTED + if c.authenticated: + achar.flags |= _FLAG_READ_AUTHENTICATED | _FLAG_WRITE_AUTHENTICATED for d in c.descriptors: aioble.Descriptor(achar, _buuid(d.uuid), read=True, initial=d.value) c._impl = _MPServerCharacteristic(self, c, achar) @@ -534,13 +543,58 @@ async def exchange_mtu(self, mtu=None, timeout_ms=1000): def encrypted(self): return bool(self._aconn.encrypted) - async def pair(self, bond=True, timeout_ms=20000): + @property + def authenticated(self): + from . import security + + return security.state(self._aconn._conn_handle)[1] if self.is_connected() else False + + async def pair(self, bond=True, timeout_ms=30000, passkey=None): + from . import security + if not self.is_connected(): raise DisconnectedError("pair: not connected") - with _Translate("pair"): - await self._aconn.pair(bond=bond, le_secure=True, mitm=False, io=3, timeout_ms=timeout_ms) - if not self._aconn.encrypted: - raise BLEError("pair: the link is not encrypted") + if bond and security.store() is None: + security.use_store() + security._install() + handle = self._aconn._conn_handle + if passkey is not None: + # Type in what the peer shows: a keyboard, with MITM protection. + security.expect_passkey(handle, passkey if not callable(passkey) else _SyncPasskey(passkey)) + io, mitm = security.IO_KEYBOARD_ONLY, True + else: + io, mitm = security.IO_NO_INPUT_OUTPUT, False + aioble.config(bond=bond, le_secure=True, mitm=mitm, io=io) + updates = [] + + def on_change(conn, encrypted, authenticated, bonded, key_size): + if conn == handle: + updates.append(encrypted) + + security.on_change(on_change) + try: + with _Translate("pair"): + _aioble_core.ble.gap_pair(handle) + deadline = time.ticks_add(time.ticks_ms(), timeout_ms) + # The stack reports the outcome as an encryption update, success + # or not, so a wrong passkey fails here at once, not at a timeout. + while not updates: + if not self.is_connected(): + raise PairingError("pair: the peer hung up") + if time.ticks_diff(deadline, time.ticks_ms()) <= 0: + raise BLETimeoutError("pair: timed out") + await sleep_ms(20) + finally: + security.remove_listener(on_change) + security.expect_passkey(handle, None) + if not security.state(handle)[0]: + raise PairingError("pair: refused (wrong passkey, or the peer lost its keys)") + + async def unpair(self): + from . import security + + addr = self.device.address + security.forget(addr) async def _discover_services(self, uuid, timeout_ms): found = [] @@ -674,6 +728,19 @@ async def indicated(self, timeout_ms=None): return await self._next(a._indicate_queue, a._indicate_event, timeout_ms, "indicated") +class _SyncPasskey: + # The passkey is asked for from a scheduled callback, which can't await: + # an async provider isn't supported on a board, a plain callable is. + def __init__(self, fn): + self._fn = fn + + def __call__(self): + value = self._fn() + if hasattr(value, "send"): + raise BLEError("a board's passkey provider must be a plain function") + return value + + class _MPClientDescriptor(ClientDescriptor): def __init__(self, characteristic, adesc): ClientDescriptor.__init__(self, characteristic, _uuid(adesc.uuid)) diff --git a/lib/bledev/repl.py b/lib/bledev/repl.py index 5187cc6b..7ef05e0f 100644 --- a/lib/bledev/repl.py +++ b/lib/bledev/repl.py @@ -17,8 +17,24 @@ It behaves like WebREPL: one attempt per connection, a wrong password gets ``Access denied`` and a disconnect, and nothing the client sends reaches the REPL before the password is right. Like WebREPL, the link isn't encrypted, so -someone sniffing nearby can capture the password; pairing is the later fix -(docs/ble.md §3). +someone sniffing nearby can capture the password. + +**Pairing** encrypts the link, and bonds so the next connection skips it. +It's off unless you ask (``pairing=None``, the password alone): + +* ``pairing="passkey"``: the board shows a six-digit passkey on its display + (or the console) and the host types it in. That authenticates the host, so + the password becomes optional: ``password=False`` drops it. +* ``pairing="justworks"``: encrypted and bonded, with no passkey. It proves + nothing about who paired, so the password stays required; what it buys is + that nobody sniffing can read the password or the session. +* ``pairing="numeric"``: both sides show a number; ``confirm(number)`` on the + board says whether they match (a button, say). +* ``pairing="auto"``: ``"passkey"`` on a board with a display, else ``"justworks"``. + +The keys live in NVS on an ESP32, so a filesystem erase keeps them and a full +chip erase loses them (``bledev.security``). A client pairs with +``connect(..., pair=True, passkey=...)``. **The REPL owns the radio while it runs.** It registers its own GATT service (replacing any other) and advertises whenever nobody is connected. It works @@ -31,9 +47,15 @@ import struct import sys -from . import BLEError, pack_advertisement, wait_ms +from . import BLEError, GattError, NEEDS_PAIRING, PairingError, pack_advertisement, wait_ms from . import nus +# bluetooth.BLE characteristic flags for the protected characteristics. +_F_READ_ENC = 0x0200 +_F_READ_AUTHN = 0x0400 +_F_WRITE_ENC = 0x1000 +_F_WRITE_AUTHN = 0x2000 + PROMPT = b"Password: " # How often the server retries output the controller couldn't take yet. @@ -102,7 +124,21 @@ def feed(self, data): _server = None -def start(password=None, name="mpy-repl", *, interval_us=100000, dupterm_index=0, files=False, console=True, window=None): +def start( + password=None, + name="mpy-repl", + *, + interval_us=100000, + dupterm_index=0, + files=False, + console=True, + window=None, + pairing=None, + show=None, + hide=None, + confirm=None, + bond_store=None, +): """Serve the REPL over BLE until :func:`stop` or a reset. MicroPython only. ``password`` defaults to ``webrepl_cfg.PASS`` when that file exists; @@ -111,23 +147,47 @@ def start(password=None, name="mpy-repl", *, interval_us=100000, dupterm_index=0 ``files=True`` also serves :mod:`bledev.filetransfer` (CircuitPython's file-transfer service) behind the same password, with ``window`` bytes of free space per round trip; ``console=False`` leaves the REPL out. + + ``pairing`` is ``None`` (the default: the password alone), ``"passkey"``, + ``"justworks"``, ``"numeric"`` or ``"auto"`` (see the module notes). + With a pairing that authenticates (passkey, numeric), ``password=False`` + serves without one. ``show(passkey)``/``hide()`` replace drawing the + passkey on ``board_config.display_drv``; ``confirm(number)`` answers + numeric comparison. ``bond_store`` is where the keys go + (``bledev.security.use_store``; default NVS on an ESP32). """ global _server if sys.implementation.name != "micropython": raise BLEError("bledev.repl serves only on MicroPython; use connect() on this host") + mode = None + if pairing is not None: + from . import security + + # Before the radio starts where possible, so its keys load first. + security.use_store(bond_store) + mode = security.configure(pairing, show=show, hide=hide, confirm=confirm) + authenticates = mode in ("passkey", "numeric") if password is None: try: import webrepl_cfg password = webrepl_cfg.PASS except (ImportError, AttributeError): - raise ValueError("bledev.repl needs a password (or webrepl_cfg.PASS)") - if len(password) < 4: + if not authenticates: + raise ValueError("bledev.repl needs a password (or webrepl_cfg.PASS)") + password = False + if password is False or password == "": + if not authenticates: + raise ValueError( + "only a pairing that authenticates the host (passkey, numeric) can replace the password" + ) + password = None + elif len(password) < 4: raise ValueError("the password must be at least 4 characters") if not (files or console): raise ValueError("nothing to serve: files=False and console=False") stop() - _server = _Server(password, name, interval_us, dupterm_index, files, console, window) + _server = _Server(password, name, interval_us, dupterm_index, files, console, window, mode) _server.start() return _server @@ -187,8 +247,11 @@ class _Server: # Input buffered beyond this, while nothing reads stdin, is dropped. RX_CAP = 4096 - def __init__(self, password, name, interval_us, dupterm_index, files=False, console=True, window=None): + def __init__(self, password, name, interval_us, dupterm_index, files=False, console=True, window=None, pairing=None): self.password = password + #: None (password only), or the pairing mode in force. + self.pairing = pairing + self._security = None self.console = console self.files = None self.ft_flushing = False @@ -246,6 +309,15 @@ def start(self): ble.config(mtu=nus.MTU) except Exception: pass + # With pairing, the stack itself refuses the protected characteristics + # to a link that isn't encrypted (or, for a passkey, authenticated), + # before anything here sees the request. + if self.pairing is None: + rd = wr = 0 + elif self.pairing == "justworks": + rd, wr = _F_READ_ENC, _F_WRITE_ENC + else: + rd, wr = _F_READ_ENC | _F_READ_AUTHN, _F_WRITE_ENC | _F_WRITE_AUTHN services = [] uuids = [] if self.console: @@ -254,7 +326,7 @@ def start(self): bluetooth.UUID(str(nus.SERVICE)), ( (bluetooth.UUID(str(nus.TX)), 0x0010), # notify - (bluetooth.UUID(str(nus.RX)), 0x0008 | 0x0004), # write, write without response + (bluetooth.UUID(str(nus.RX)), 0x0008 | 0x0004 | wr), # write, write without response ), ) ) @@ -268,8 +340,10 @@ def start(self): ( (bluetooth.UUID(str(ft.VERSION)), 0x0002), # read # read, write without response, write, notify - (bluetooth.UUID(str(ft.TRANSFER)), 0x0002 | 0x0004 | 0x0008 | 0x0010), - (bluetooth.UUID(str(ft.AUTH)), 0x0002 | 0x0008), # read, write + (bluetooth.UUID(str(ft.TRANSFER)), 0x0002 | 0x0004 | 0x0008 | 0x0010 | rd | wr), + # read, write. Served even without a password, so the + # table is the same for every lock: hosts cache it. + (bluetooth.UUID(str(ft.AUTH)), 0x0002 | 0x0008 | rd | wr), ), ) ) @@ -288,6 +362,10 @@ def start(self): ble.gatts_set_buffer(self.auth_handle, MAX_PASSWORD + 1) ble.gatts_write(self.auth_handle, b"\x00") self._forget_other_services() + if self.pairing is not None: + from . import security + + self._security = security.on_change(self.on_security) # One periodic timer for the life of the server, never re-armed: on # the esp32, re-initialising a virtual Timer while its alarm is being # dispatched calls a NULL handler and panics the board (seen as a @@ -322,6 +400,11 @@ def advertise(self): def stop(self): self.running = False + if self._security is not None: + from . import security + + security.remove_listener(self._security) + self._security = None try: self.ble.gap_advertise(None) except Exception: @@ -347,7 +430,7 @@ def on_connect(self, conn): if self.early_mtu and self.early_mtu[0] == conn: self.mtu = self.early_mtu[1] self.early_mtu = None - self.login = _Gate(self.password) + self.login = _Gate(self.password) if self.password is not None else None self.hang_up_at = None self.authed = False self.rx = bytearray() @@ -355,11 +438,52 @@ def on_connect(self, conn): self.tx = bytearray() if self.files is not None: self._ft_forget() + if self.pairing is not None: + # A bonded host may already be encrypting; on_security() takes over. + from . import security + + if self.secure(conn): + self.on_security(conn, *security.state(conn)) + return if self.console: # Sent now in case the client is already listening; clients that # subscribe later ask for it again with an empty line. self.notify_now(PROMPT) + # -- pairing + + def secure(self, conn): + """Whether ``conn``'s link is as secure as this server asks.""" + if self.pairing is None: + return True + from . import security + + encrypted, authenticated, _bonded, _size = security.state(conn) + if self.pairing == "justworks": + return encrypted + return encrypted and authenticated + + def on_security(self, conn, encrypted, authenticated, bonded, key_size): + # From the BLE IRQ, when pairing or a bonded reconnect settles. + if conn != self.conn: + return + if not self.secure(conn): + # Failed pairing, or a weaker one than asked for: hang up. + import time + + self.hang_up_at = time.ticks_add(time.ticks_ms(), 300) + return + if self.password is None: + # The pairing was the lock: open both services at once. + if self.files is not None and not self.files.authed: + self.files.authed = True + self.ble.gatts_write(self.auth_handle, b"\x01") + if self.console and not self.authed: + self._attach() + self.send(BANNER) + elif self.console and not self.authed: + self.notify_now(PROMPT) + def on_disconnect(self, conn): if conn != self.conn: return @@ -376,9 +500,11 @@ def on_disconnect(self, conn): self.advertise() def on_write(self, conn, data): - if conn != self.conn: + if conn != self.conn or not self.secure(conn): return if not self.authed: + if self.login is None: + return # pairing is the lock and hasn't settled yet reply, verdict, rest = self.login.feed(data) if verdict is False: self.notify_now(reply) @@ -405,7 +531,7 @@ def on_files(self, conn, data): # This runs in the BLE stack's task, whose stack is a few KB: too # small for filesystem work (a recursive delete crashed the board). # So the IRQ only queues the bytes, and the main thread does the rest. - if conn != self.conn: + if conn != self.conn or not self.secure(conn): return self.ft_in.extend(data) if not self.ft_scheduled: @@ -440,8 +566,10 @@ def _ft_work(self, _arg=None): self.ft_flush() def on_auth(self, conn, data): - if conn != self.conn or self.files.authed or self.hang_up_at is not None: + if conn != self.conn or self.files.authed or self.hang_up_at is not None or not self.secure(conn): return + if self.password is None: + return # unlocked by pairing, not by a password given = bytes(data).rstrip(b"\r\n") want = self.password.encode() if isinstance(self.password, str) else bytes(self.password) if _check_password(given, want): @@ -508,7 +636,15 @@ def _tick(self, _timer=None): self._ft_work() def _hang_up(self): - if self.conn is not None and not self.authed and not (self.files is not None and self.files.authed): + if self.conn is None: + return + if self.pairing is not None and not self.secure(self.conn): + try: + self.ble.gap_disconnect(self.conn) + except OSError: + pass + return + if not self.authed and not (self.files is not None and self.files.authed): try: self.ble.gap_disconnect(self.conn) except OSError: @@ -669,24 +805,61 @@ async def _expect(link, markers, buffer, timeout_ms): buffer.extend(chunk) -async def connect(ble, password, name="mpy-repl", *, device=None, timeout_ms=10000, **connect_options): +STALE_BOND = ( + "the board refused this host's keys: it has probably lost its bond (a chip " + "erase, or bledev.security.forget()). Unpair it on this host " + "(bledev.bleak.unpair(address), or Settings > Bluetooth > Remove device) " + "and pair again" +) + + +def refused(pair, passkey): + """The :class:`bledev.PairingError` for a board that refused a link.""" + if not pair: + return PairingError("the board wants pairing: connect with pair=True") + if passkey is None: + return PairingError("the board wants a link paired with a passkey (pass passkey=), or " + STALE_BOND) + return PairingError(STALE_BOND) + + +async def connect(ble, password=None, name="mpy-repl", *, device=None, timeout_ms=10000, pair=False, passkey=None, **connect_options): """Log in to a board's BLE REPL and return the :class:`bledev.nus.Link`, at ``>>>``. - Raises :class:`AuthError` if the board refuses the password. + ``pair=True`` pairs first (bonding, so the next connect skips it), for a + board started with ``pairing=``; ``passkey`` is what its display shows, + or a callable that asks for it. A board that pairing unlocks alone needs + no ``password``. + + Raises :class:`AuthError` if the board refuses the password, and + :class:`bledev.PairingError` if pairing fails or the board refuses the + keys this host kept. """ - link = await nus.connect(ble, name=name, device=device, timeout_ms=timeout_ms, **connect_options) + try: + link = await nus.connect(ble, name=name, device=device, timeout_ms=timeout_ms, pair=pair, passkey=passkey, **connect_options) + except GattError as e: + if e.status in NEEDS_PAIRING: + raise refused(pair, passkey) + raise try: buffer = bytearray() - await link.write(b"\r") - marker, buffer = await _expect(link, (PROMPT,), buffer, timeout_ms) + try: + await link.write(b"\r") + except GattError as e: + if e.status not in NEEDS_PAIRING: + raise + raise refused(pair, passkey) + marker, buffer = await _expect(link, (PROMPT, b">>> "), buffer, timeout_ms) if marker is None: raise BLEError("the board closed the link before asking for a password") - del buffer[: buffer.find(PROMPT) + len(PROMPT)] - secret = password.encode() if isinstance(password, str) else bytes(password) - await link.write(secret + b"\r") - marker, buffer = await _expect(link, (b">>> ", DENIED.strip()), buffer, timeout_ms) - if marker != b">>> ": - raise AuthError("the board refused the password") + if marker == PROMPT: + if password is None: + raise AuthError("the board wants a password") + del buffer[: buffer.find(PROMPT) + len(PROMPT)] + secret = password.encode() if isinstance(password, str) else bytes(password) + await link.write(secret + b"\r") + marker, buffer = await _expect(link, (b">>> ", DENIED.strip()), buffer, timeout_ms) + if marker != b">>> ": + raise AuthError("the board refused the password") except BaseException: await link.close() raise diff --git a/lib/bledev/security.py b/lib/bledev/security.py new file mode 100644 index 00000000..9034aeb7 --- /dev/null +++ b/lib/bledev/security.py @@ -0,0 +1,515 @@ +"""Pairing and bonding on a MicroPython board: the bond store, passkeys, link state. + +You rarely call this yourself. ``bledev.repl.start(pairing=...)`` and +``bledev.filetransfer.start(pairing=...)`` set it up, and a central's +``connection.pair()`` uses it. What it adds to aioble: + +* **A bond store that survives a filesystem erase.** On an ESP32 the keys go + in NVS (namespace ``bledev``), which a reformat of the filesystem, a + ``mip`` reinstall or deleting every file leaves alone. A full chip erase + (``esptool erase_flash``, ``mpftp firmware flash --erase``) loses them, and + so does :func:`forget`. Elsewhere they go in aioble's ``ble_secrets.json``. +* **Passkeys.** aioble leaves the passkey step unhandled. Here a board with a + display shows a random six-digit passkey (``io=DISPLAY_ONLY``) for the host + to type in; numeric comparison shows a number both sides must confirm; and + a board as central types in the passkey its peer shows. +* **Link state per connection**, from the stack's encryption updates: + :func:`state` and :func:`on_change` tell a server whether a link is + encrypted, authenticated (passkey or numeric comparison) and bonded. + +Only the board side lives here. A laptop's pairing is the OS's, through +``bledev.bleak``. +""" + +import os +import sys + +try: + from micropython import const +except ImportError: # CPython, for the store's tests + + def const(x): + return x + + +_IRQ_CENTRAL_DISCONNECT = const(2) +_IRQ_PERIPHERAL_DISCONNECT = const(8) +_IRQ_ENCRYPTION_UPDATE = const(28) +_IRQ_PASSKEY_ACTION = const(31) + +#: IO capabilities, as ``ble.config(io=...)`` takes them. +IO_DISPLAY_ONLY = 0 +IO_DISPLAY_YESNO = 1 +IO_KEYBOARD_ONLY = 2 +IO_NO_INPUT_OUTPUT = 3 +IO_KEYBOARD_DISPLAY = 4 + +_ACTION_INPUT = const(2) +_ACTION_DISPLAY = const(3) +_ACTION_NUMCMP = const(4) + +#: The pairing modes a server takes. ``"auto"`` is ``"passkey"`` on a board +#: with a display and ``"justworks"`` without one. +MODES = ("justworks", "passkey", "numeric", "auto") + +# conn_handle -> (encrypted, authenticated, bonded, key_size) +_links = {} +_listeners = [] +# conn_handle -> what to type in when this board, as central, is asked for the +# peer's passkey: an int, or a callable returning one. +_inputs = {} +# The server's side: what to do when the stack wants a passkey shown or a +# number confirmed. Set by configure(). +_show = None +_hide = None +_confirm = None +_installed = False +_store = None + + +# ---------------------------------------------------------------- the bond store + + +class FileStore: + """aioble's ``ble_secrets.json``, the format bledev.hid used first.""" + + kind = "file" + + def __init__(self, path="ble_secrets.json"): + self.path = path + + def load(self): + import binascii + import json + + secrets = {} + try: + with open(self.path) as f: + for sec_type, key, value in json.load(f): + secrets[sec_type, binascii.a2b_base64(key)] = binascii.a2b_base64(value) + except (OSError, ValueError): + pass + return secrets + + def save(self, secrets): + import binascii + import json + + entries = [ + (t, binascii.b2a_base64(k).decode().strip(), binascii.b2a_base64(v).decode().strip()) + for (t, k), v in secrets.items() + ] + with open(self.path, "w") as f: + json.dump(entries, f) + + def erase(self): + try: + os.remove(self.path) + except OSError: + pass + + +class NVSStore: + """The keys in ESP32 NVS, outside the filesystem. + + One blob, ``bonds``, in namespace ``bledev``: for each secret its type + (1 byte), key length (1), key, value length (2, little-endian), value. + """ + + kind = "nvs" + _KEY = "bonds" + _LEN = "bonds_len" + + def __init__(self, namespace="bledev"): + import esp32 + + self._nvs = esp32.NVS(namespace) + + def load(self): + secrets = {} + try: + n = self._nvs.get_i32(self._LEN) + except OSError: + return secrets + if n <= 0: + return secrets + buf = bytearray(n) + try: + self._nvs.get_blob(self._KEY, buf) + except OSError: + return secrets + return unpack_secrets(buf) + + def save(self, secrets): + out = pack_secrets(secrets) + if out: + self._nvs.set_blob(self._KEY, out) + self._nvs.set_i32(self._LEN, len(out)) + self._nvs.commit() + + def erase(self): + for key in (self._KEY, self._LEN): + try: + self._nvs.erase_key(key) + except OSError: + pass + self._nvs.commit() + + +def pack_secrets(secrets): + """The NVS blob for ``{(type, key): value}``.""" + out = bytearray() + for (t, key), value in secrets.items(): + out.append(t) + out.append(len(key)) + out.extend(key) + out.append(len(value) & 0xFF) + out.append(len(value) >> 8) + out.extend(value) + return out + + +def unpack_secrets(buf): + """``{(type, key): value}`` from an NVS blob; a torn tail is dropped.""" + secrets = {} + n = len(buf) + i = 0 + while i + 2 <= n: + t = buf[i] + kl = buf[i + 1] + if i + 2 + kl + 2 > n: + break + key = bytes(buf[i + 2 : i + 2 + kl]) + i += 2 + kl + vl = buf[i] | (buf[i + 1] << 8) + if i + 2 + vl > n: + break + secrets[t, key] = bytes(buf[i + 2 : i + 2 + vl]) + i += 2 + vl + return secrets + + +def default_store(): + """NVS on an ESP32, the file anywhere else.""" + try: + return NVSStore() + except (ImportError, OSError): + return FileStore() + + +def use_store(store=None): + """Keep the keys in ``store`` (a :class:`NVSStore`, a :class:`FileStore`, or + ``"nvs"``/``"file"``; default :func:`default_store`) and turn bonding on. + + Safe to call again; the last store wins. Returns the store. + """ + global _store + if store is None: + store = default_store() + elif store == "nvs": + store = NVSStore() + elif store == "file": + store = FileStore() + elif isinstance(store, str): + store = FileStore(store) + _store = store + _install() + from aioble import security as sec + + # aioble keeps the secrets in one dict and schedules _save_secrets when + # the stack changes one; core.ensure_active() calls load_secrets before + # the radio starts. Both are looked up at call time, so these take over. + sec._secrets = store.load() + sec._modified = False + sec._save_secrets = _save + sec.load_secrets = _load + return store + + +def store(): + """The store in use, or ``None`` before :func:`use_store`.""" + return _store + + +def _load(path=None): + from aioble import security as sec + + if _store is not None: + sec._secrets = _store.load() + + +def _save(_arg=None): + from aioble import security as sec + + sec._modified = False + if _store is not None: + _store.save(sec._secrets) + + +def bonds(): + """How many peers this board holds keys for.""" + from aioble import security as sec + + # Each bond stores the peer's keys (type 2) and ours (type 1). + return sum(1 for (t, _k) in sec._secrets if t == 2) + + +def forget(address=None): + """Forget every bond, or the ones with ``address`` (6 bytes, or + ``"aa:bb:cc:dd:ee:ff"``). A host that still holds its half must then be + unpaired on the host before it can pair again.""" + from aioble import security as sec + + if address is None: + sec._secrets = {} + if _store is not None: + _store.erase() + return + if isinstance(address, str): + import binascii + + address = binascii.unhexlify(address.replace(":", "")) + address = bytes(address) + backwards = bytes(reversed(address)) + for key in list(sec._secrets): + raw = key[1] + if address in raw or backwards in raw: + del sec._secrets[key] + _save() + + +# ---------------------------------------------------------------- link state + + +def state(conn_handle): + """``(encrypted, authenticated, bonded, key_size)`` for a connection.""" + return _links.get(conn_handle, (False, False, False, 0)) + + +def on_change(callback): + """Call ``callback(conn_handle, encrypted, authenticated, bonded, key_size)`` + from the BLE IRQ whenever a link's security changes. Returns ``callback``.""" + if callback not in _listeners: + _listeners.append(callback) + return callback + + +def remove_listener(callback): + if callback in _listeners: + _listeners.remove(callback) + + +def expect_passkey(conn_handle, passkey): + """As central: the passkey to type in when this connection's peer shows one + (an int, a string of digits, or a callable returning either).""" + if passkey is None: + _inputs.pop(conn_handle, None) + else: + _inputs[conn_handle] = passkey + + +def configure(mode, *, show=None, hide=None, confirm=None, display=None): + """Set this board's pairing as a peripheral. Returns the mode actually used. + + ``mode`` is one of :data:`MODES`. ``show(passkey)`` is called with the + six-digit number to display (passkey and numeric), and ``hide()`` once the + link's security settles; without ``show``, the number goes on + ``display`` (default: ``board_config.display_drv`` when the board has one) + and to the console. ``confirm(number)`` answers numeric comparison with + True or False, or returns ``None`` and calls :func:`answer` later. + """ + global _show, _hide, _confirm + if mode not in MODES: + raise ValueError("pairing must be one of {}".format(", ".join(MODES))) + if mode == "auto" or (mode in ("passkey", "numeric") and show is None): + if display is None: + display = find_display() + if mode == "auto": + mode = "passkey" if (display is not None or show is not None) else "justworks" + if mode == "numeric" and confirm is None: + raise ValueError("numeric comparison needs confirm=") + if show is None and mode in ("passkey", "numeric"): + show, hide = display_passkey(display) + _show = show + _hide = hide + _confirm = confirm + _install() + import aioble + + io = {"justworks": IO_NO_INPUT_OUTPUT, "passkey": IO_DISPLAY_ONLY, "numeric": IO_DISPLAY_YESNO}[mode] + aioble.config(bond=True, le_secure=True, mitm=mode != "justworks", io=io) + return mode + + +def answer(conn_handle, accept): + """Answer a numeric comparison that ``confirm`` deferred.""" + from aioble import core + + core.ble.gap_passkey(conn_handle, _ACTION_NUMCMP, 1 if accept else 0) + + +def random_passkey(): + """Six random digits, as an int (000000-999999).""" + b = os.urandom(4) + return ((b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]) % 1000000 + + +def _install(): + global _installed + if _installed: + return + from aioble import core + from aioble import security # noqa: F401 registers aioble's secret handlers + + core.register_irq_handler(_irq, None) + _installed = True + + +def _irq(event, data): + if event == _IRQ_ENCRYPTION_UPDATE: + conn, encrypted, authenticated, bonded, key_size = data + _links[conn] = (bool(encrypted), bool(authenticated), bool(bonded), key_size) + if _hide is not None: + _schedule(_call_hide, None) + for callback in _listeners: + callback(conn, bool(encrypted), bool(authenticated), bool(bonded), key_size) + elif event == _IRQ_PASSKEY_ACTION: + conn, action, number = data + # The display and the host's typing are slow: leave the IRQ first. + _schedule(_passkey_action, (conn, action, number)) + return True + elif event in (_IRQ_CENTRAL_DISCONNECT, _IRQ_PERIPHERAL_DISCONNECT): + conn = data[0] + had = _links.pop(conn, None) + _inputs.pop(conn, None) + if had is None and _hide is not None: + _schedule(_call_hide, None) + return None + + +def _schedule(fn, arg): + import micropython + + try: + micropython.schedule(fn, arg) + except RuntimeError: + fn(arg) # the queue is full: do it here rather than drop it + + +def _call_hide(_arg=None): + if _hide is not None: + try: + _hide() + except Exception as e: + sys.print_exception(e) + + +def _passkey_action(args): + from aioble import core + + conn, action, number = args + ble = core.ble + try: + if action == _ACTION_DISPLAY: + passkey = random_passkey() + ble.gap_passkey(conn, action, passkey) + if _show is not None: + _show(passkey) + elif action == _ACTION_NUMCMP: + if _show is not None: + _show(number) + verdict = _confirm(number) if _confirm is not None else False + if verdict is not None: + ble.gap_passkey(conn, action, 1 if verdict else 0) + elif action == _ACTION_INPUT: + given = _inputs.get(conn) + if callable(given): + given = given() + # No passkey to give: answer with one that can't match, so the + # pairing fails now rather than when the host times out. + ble.gap_passkey(conn, action, int(given) if given is not None else random_passkey()) + except Exception as e: + sys.print_exception(e) + + +# ---------------------------------------------------------------- the display + + +def find_display(): + """``board_config.display_drv``, or ``None`` on a board without a display.""" + try: + import board_config + except Exception: + return None + return getattr(board_config, "display_drv", None) + + +def display_passkey(display=None): + """``(show, hide)``: draw a passkey big in the middle of ``display``, and + clear it again. Also prints it to the console. Without a display, only + prints.""" + + box = [] + + def show(passkey): + text = "{:06d}".format(passkey) + print("bledev: Bluetooth passkey", text[:3], text[3:]) + if display is not None: + try: + box[:] = [_draw(display, text[:3] + " " + text[3:])] + except Exception as e: + sys.print_exception(e) + + def hide(): + if display is not None and box: + x, y, w, h = box.pop() + try: + display.fill_rect(x, y, w, h, 0) + _refresh(display) + except Exception as e: + sys.print_exception(e) + + return show, hide + + +def _refresh(display): + show = getattr(display, "show", None) + if show is not None: + show() + + +def _draw(display, digits, title="Bluetooth passkey"): + import framebuf + + w = display.width + h = display.height + # The digits as big as fit in 90 % of the width, at most 8x the 8-pixel font. + big = max(1, min(8, (w * 9 // 10) // (len(digits) * 8))) + small = max(1, big * 3 // 8) + bw = max(len(digits) * 8 * big, len(title) * 8 * small) + 4 * big + bh = 8 * small + 8 * big + 6 * big + x = (w - bw) // 2 + y = (h - bh) // 2 + display.fill_rect(x, y, bw, bh, 0) + white = 0xFFFF + _text(display, framebuf, title, (w - len(title) * 8 * small) // 2, y + 2 * big, small, white) + _text(display, framebuf, digits, (w - len(digits) * 8 * big) // 2, y + 8 * small + 4 * big, big, white) + _refresh(display) + return x, y, bw, bh + + +def _text(display, framebuf, text, x0, y0, scale, color): + n = len(text) * 8 + buf = bytearray(n) # MONO_HLSB, 8 rows of n pixels + fb = framebuf.FrameBuffer(buf, n, 8, framebuf.MONO_HLSB) + fb.text(text, 0, 0, 1) + for row in range(8): + col = 0 + while col < n: + if fb.pixel(col, row): + start = col + while col < n and fb.pixel(col, row): + col += 1 + display.fill_rect(x0 + start * scale, y0 + row * scale, (col - start) * scale, scale, color) + else: + col += 1 diff --git a/pydevices-desktop.toml b/pydevices-desktop.toml index 9152afc8..cb093a19 100644 --- a/pydevices-desktop.toml +++ b/pydevices-desktop.toml @@ -33,6 +33,7 @@ "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/mpble.py" = "/lib/bledev/mpble.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/nus.py" = "/lib/bledev/nus.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/repl.py" = "/lib/bledev/repl.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/security.py" = "/lib/bledev/security.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/board_configs/desktop/board_config.py" = "/lib/board_config.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/board_configs/desktop/board_peripherals.py" = "/lib/board_peripherals.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/boarddev.py" = "/lib/boarddev.py" diff --git a/tests/bledev_board/hid_central.py b/tests/bledev_board/hid_central.py index be025dc2..677cc2ef 100644 --- a/tests/bledev_board/hid_central.py +++ b/tests/bledev_board/hid_central.py @@ -56,12 +56,15 @@ def log(*parts): def _secrets(): - # Size and a checksum of aioble's key store, to see whether a run added keys. - try: - data = open("ble_secrets.json", "rb").read() - except OSError: + # How many bonds the key store holds (NVS on an ESP32), and a checksum of + # them, to see whether a run added keys. + from aioble import security as sec + import bledev.security + + if not sec._secrets: return "none" - return "{} bytes, sum {}".format(len(data), sum(data)) + total = sum(sum(v) for v in sec._secrets.values()) + return "{} bonds in {}, sum {}".format(bledev.security.bonds(), bledev.security.store().kind, total) def stats(values): diff --git a/tests/bledev_board/hid_peripheral.py b/tests/bledev_board/hid_peripheral.py index 0a566a90..ca90ad89 100644 --- a/tests/bledev_board/hid_peripheral.py +++ b/tests/bledev_board/hid_peripheral.py @@ -53,11 +53,10 @@ async def one_host(per): log("host is listening (LEDs", data, "), encrypted", conn.encrypted) if ENCRYPTED: a = conn._aconn - try: - keys = len(open("ble_secrets.json", "rb").read()) - except OSError: - keys = 0 - log("security: bonded", a.bonded, "key size", a.key_size, "key store", keys, "bytes") + import bledev.security + + keys = bledev.security.bonds() + log("security: bonded", a.bonded, "key size", a.key_size, "key store", keys, "bonds in", bledev.security.store().kind) await asyncio.sleep_ms(200) steps = hid_script.STEPS if PLANT: diff --git a/tests/bledev_pairing.py b/tests/bledev_pairing.py new file mode 100644 index 00000000..af9f2a4c --- /dev/null +++ b/tests/bledev_pairing.py @@ -0,0 +1,287 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Pairing and bonding, the client side, without a radio. + +A plain script so it runs on CPython and on MicroPython, like +``bledev_filetransfer.py``. Over ``bledev.fake`` it serves the file-transfer +service the way CircuitPython does (the transfer characteristic encrypted, +no ``AUTH``) and the way a bledev board with a passkey does (authenticated), +and checks that :func:`bledev.filetransfer.connect` pairs when it must, +refuses what it must, and moves 20 KB intact over a paired link. It also +checks the NVS store's blob codec. + +Plants, each of which must make the run fail: + +* ``--plant=noenc``: the server's characteristics aren't protected. +* ``--plant=rightkey``: the "wrong passkey" host types the right one. +* ``--plant=flip``: one bit of the transferred file flips on the board. +* ``--plant=torn``: the store codec keeps a torn entry. + +Ends with ``N passed, M failed``. +""" + +try: + import asyncio +except ImportError: # pragma: no cover + import uasyncio as asyncio + +import os +import struct +import sys + +_here = __file__.rsplit("/", 1)[0] if "/" in __file__ else "." +sys.path.insert(0, _here + "/../lib") + +import bledev # noqa: E402 +import bledev.filetransfer as ft # noqa: E402 +from bledev import GattError, PairingError # noqa: E402 +from bledev.fake import Air, FakeBLE # noqa: E402 +from bledev import security # noqa: E402 + +PLANT = None +for arg in sys.argv[1:]: + if arg.startswith("--plant="): + PLANT = arg.split("=", 1)[1] +if PLANT: + print("PLANTED", PLANT) + +passed = 0 +failed = 0 + + +def check(ok, what, detail=""): + global passed, failed + if ok: + passed += 1 + else: + failed += 1 + print("PASS" if ok else "FAIL", what, detail) + + +def _scratch(tag): + base = "/tmp" if sys.implementation.name == "micropython" else os.environ.get("TMPDIR", "/tmp") + root = base + "/bledev-pair-{}-{}".format(tag, os.getpid() if hasattr(os, "getpid") else 1) + try: + ft._remove(root) + except OSError: + pass + os.mkdir(root) + return root + + +def _pattern(n, seed): + out = bytearray(n) + x = seed + for i in range(n): + x = (x * 1103515245 + 12345) & 0x7FFFFFFF + out[i] = x >> 16 & 0xFF + return bytes(out) + + +class GattFiles: + """The file-transfer service on a fake board. + + ``auth=False`` is CircuitPython's shape (no AUTH characteristic); + ``authenticated=True`` wants a passkey-paired link, not just an + encrypted one. + """ + + def __init__(self, ble, root, name, authenticated=False): + protect = PLANT != "noenc" + service = bledev.Service(ft.SERVICE) + bledev.Characteristic(service, ft.VERSION, read=True, initial=struct.pack(" 40 and packet[0] == ft.READ_DATA: + packet = bytearray(packet) + packet[-1] ^= 1 + flipped = True + while True: + try: + self.transfer.notify(conn, bytes(packet)) + break + except bledev.BusyError: + await asyncio.sleep(0.001) # the "controller" is full + self.files.sent() + + +async def raw_refused(central, name, status): + """An unpaired host reading and writing the transfer characteristic directly.""" + device = await central.find(name=name, timeout_ms=2000) + connection = await device.connect(timeout_ms=2000) + service = await connection.service(ft.SERVICE) + transfer = await service.characteristic(ft.TRANSFER) + results = [] + ops = ( + ("write", lambda: transfer.write(b"\x50\x00\x01\x00/", response=True)), + ("read", lambda: transfer.read()), + ("subscribe", lambda: transfer.subscribe(notify=True)), + ) + for what, op in ops: + try: + await op() + results.append((what, None)) + except GattError as e: + results.append((what, e.status)) + await connection.disconnect() + ok = all(s == status for _w, s in results) + check(ok, "unpaired host refused ({})".format(name), results) + + +async def transfer_20k(client, tag): + data = _pattern(20 * 1024, 7) + await client.write("/big.bin", data) + back = await client.read("/big.bin") + check(back == data, "20 KB written and read back byte for byte ({})".format(tag), "{} bytes".format(len(back))) + with open(client._root + "/big.bin", "rb") as f: + check(f.read() == data, "the board's copy matches ({})".format(tag)) + + +async def circuitpython_shape(air): + root = _scratch("cp") + board = FakeBLE(air=air, mtu=247) + GattFiles(board, root, "cp-files") + host = FakeBLE(air=air, peripheral=False, mtu=247) + + await raw_refused(host, "cp-files", bledev.INSUFFICIENT_ENCRYPTION) + + try: + await ft.connect(host, name="cp-files", pair=False, timeout_ms=2000) + check(False, "pair=False is refused by a board that wants pairing") + except PairingError as e: + check(True, "pair=False is refused by a board that wants pairing", str(e)[:50]) + + client = await ft.connect(host, name="cp-files", timeout_ms=2000) + client._root = root + check(client.connection.encrypted, "connect() paired on its own (no AUTH: CircuitPython)") + await transfer_20k(client, "CircuitPython shape") + await client.close() + check(board.address in host.bonds and host.address in board.bonds, "both sides kept the bond") + + +async def passkey_shape(air): + root = _scratch("pk") + board = FakeBLE(air=air, mtu=247) + shown = [] + board.set_pairing("passkey", show=shown.append) + GattFiles(board, root, "pk-files", authenticated=True) + + # Just works isn't enough for an authenticated characteristic. + lazy = FakeBLE(air=air, peripheral=False, mtu=247) + await raw_refused(lazy, "pk-files", bledev.INSUFFICIENT_ENCRYPTION) + try: + await ft.connect(lazy, name="pk-files", pair=True, timeout_ms=2000) + check(False, "a just-works host is refused by a passkey board") + except PairingError as e: + check(True, "a just-works host is refused by a passkey board", str(e)[:50]) + board.forget() + + # The wrong passkey. + wrong = FakeBLE(air=air, peripheral=False, mtu=247) + + def guess(): + return shown[-1] if PLANT == "rightkey" else (shown[-1] + 1) % 1000000 + + try: + await ft.connect(wrong, name="pk-files", pair=True, passkey=guess, timeout_ms=2000) + check(False, "a host with the wrong passkey is refused") + except PairingError as e: + check(True, "a host with the wrong passkey is refused", str(e)[:40]) + check(not wrong.bonds and not board.bonds, "no bond was kept for the wrong passkey") + + # The right one, typed in when asked (an async provider, as a person would be). + host = FakeBLE(air=air, peripheral=False, mtu=247) + + async def read_screen(): + await asyncio.sleep(0.01) + return shown[-1] + + before = len(shown) + client = await ft.connect(host, name="pk-files", pair=True, passkey=read_screen, timeout_ms=2000) + client._root = root + check(client.connection.authenticated, "the right passkey authenticates the link") + await transfer_20k(client, "passkey") + await client.close() + + # A bonded reconnect needs no passkey, and the board shows none. + shown_before = len(shown) + client = await ft.connect(host, name="pk-files", pair=True, timeout_ms=2000) + check(client.connection.authenticated and len(shown) == shown_before, "a bonded host reconnects without a passkey") + await client.close() + check(len(shown) - before == 1, "one passkey shown for one pairing", len(shown) - before) + + # The board loses its keys; the host still has its half. + board.forget() + try: + await ft.connect(host, name="pk-files", pair=True, timeout_ms=2000) + check(False, "a host whose bond the board lost is told to unpair") + except PairingError as e: + check("unpair" in str(e), "a host whose bond the board lost is told to unpair", str(e)[:60]) + # The recovery step: unpair on the host, pair again. + host.forget(board.address) + client = await ft.connect(host, name="pk-files", pair=True, passkey=read_screen, timeout_ms=2000) + check(client.connection.authenticated, "after unpairing, the host pairs again") + await client.close() + + +def store_codec(): + secrets = { + (1, b"\x00" + bytes(range(6)) + bytes(9)): bytes(range(80)), + (2, b"\x00" + bytes(range(6, 12)) + bytes(9)): bytes(range(100, 180)), + (10, b""): bytes(16), + } + blob = security.pack_secrets(secrets) + check(security.unpack_secrets(blob) == secrets, "the NVS blob round-trips", "{} bytes".format(len(blob))) + torn = security.unpack_secrets(blob[:-5]) + if PLANT == "torn": + torn = dict(secrets) + check(len(torn) == len(secrets) - 1 and all(secrets[k] == v for k, v in torn.items()), + "a torn blob drops only the torn entry", len(torn)) + + +async def main(): + print("bledev pairing checks ({})".format(sys.implementation.name)) + store_codec() + await circuitpython_shape(Air()) + await passkey_shape(Air()) + print("{} passed, {} failed".format(passed, failed)) + if failed: + sys.exit(1) + + +asyncio.run(main()) diff --git a/tests/test_bledev_pairing.py b/tests/test_bledev_pairing.py new file mode 100644 index 00000000..5f7bfbef --- /dev/null +++ b/tests/test_bledev_pairing.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Run the pairing checks (``bledev_pairing.py``) on CPython and MicroPython, +and once per planted fault, each of which must fail. Pairing over a real +radio is ``bledev_board/pair_*.py``. +""" + +from pathlib import Path +import os +import subprocess +import sys +import unittest + +from test_bledev import _micropython + +_TESTS = Path(__file__).resolve().parent +SCRIPT = _TESTS / "bledev_pairing.py" +PLANTS = ("noenc", "rightkey", "flip", "torn") + + +def _run(interpreter, *args): + return subprocess.run([interpreter, str(SCRIPT), *args], capture_output=True, text=True, timeout=600) + + +class BledevPairing(unittest.TestCase): + def assertPasses(self, result): + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn(" 0 failed", result.stdout) + + def test_cpython(self): + self.assertPasses(_run(sys.executable)) + + def test_micropython(self): + interpreter = _micropython() + if interpreter is None: + message = "no micropython binary: the pairing checks did NOT run on MicroPython" + if os.environ.get("PYDEVICES_REQUIRE_MICROPYTHON") == "1": + self.fail(message) + sys.stderr.write("\n*** " + message + " ***\n") + self.skipTest(message) + result = _run(interpreter) + self.assertPasses(result) + self.assertIn("(micropython)", result.stdout) + + def test_every_plant_fails(self): + for plant in PLANTS: + with self.subTest(plant=plant): + result = _run(sys.executable, "--plant=" + plant) + self.assertIn("PLANTED " + plant, result.stdout) + self.assertNotEqual(result.returncode, 0, "plant {!r} went unnoticed:\n{}".format(plant, result.stdout)) + + +if __name__ == "__main__": + unittest.main() From c655dd8e1bb9cefe4b73fd67bd6f138faa8436cc Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:11:13 -0500 Subject: [PATCH 08/14] bledev pairing: fixes from the LCD-7 and Windows The stack's key store failed mid-pairing when the scheduler queue was full: aioble saves secrets through micropython.schedule and raises when it can't, and drawing the passkey inside a scheduled callback while the REPL's 5 ms timer queues filled it. bledev.security now answers the secret IRQs itself and saves inline when the queue is full, answers the passkey from the IRQ, and only defers the drawing. The REPL opens on the client's first line when pairing is the lock, so the client sees one prompt. A board no longer hangs up the instant encryption fails (the host couldn't tell why); it hangs up a link that isn't paired within 30 s instead. A host whose every attempt drops after encrypting with keys it already had is told its bond is stale. bledev.bleak reports the ceremony it ran: WinRT says protection level NONE even after a passkey. The hardware gate is tests/bledev_board/pair_server.py, pair_client.py and uart_say.py. --- lib/bledev/__init__.py | 18 ++ lib/bledev/bleak.py | 16 +- lib/bledev/repl.py | 59 ++++--- lib/bledev/security.py | 107 +++++++++-- tests/bledev_board/pair_client.py | 285 ++++++++++++++++++++++++++++++ tests/bledev_board/pair_server.py | 96 ++++++++++ tests/bledev_board/uart_say.py | 36 ++++ 7 files changed, 582 insertions(+), 35 deletions(-) create mode 100644 tests/bledev_board/pair_client.py create mode 100644 tests/bledev_board/pair_server.py create mode 100644 tests/bledev_board/uart_say.py diff --git a/lib/bledev/__init__.py b/lib/bledev/__init__.py index 0a116cd0..997d060e 100644 --- a/lib/bledev/__init__.py +++ b/lib/bledev/__init__.py @@ -85,6 +85,14 @@ class PairingError(BLEError): """Pairing failed: a wrong passkey, a refusal, or a peer that lost its keys.""" +#: What to tell someone whose host kept a bond the board no longer has. +STALE_BOND = ( + "the board refused this host's keys: it has probably lost its bond (a chip " + "erase, or bledev.security.forget()). Unpair it on this host " + "(bledev.bleak.unpair(address), or Settings > Bluetooth > Remove device) " + "and pair again" +) + #: The GATT statuses that mean "pair first". NEEDS_PAIRING = (INSUFFICIENT_AUTHENTICATION, INSUFFICIENT_ENCRYPTION) @@ -1016,6 +1024,7 @@ async def connect_and_set_up( pair = options.pop("pair", False) passkey = options.pop("passkey", None) error = None + stale = False for _ in range(attempts): target = device if target is None: @@ -1031,10 +1040,14 @@ async def connect_and_set_up( if connection.is_connected(): await connection.disconnect() raise + reused = getattr(connection, "reused_bond", False) + else: + reused = False try: return connection, await wait_ms(setup(connection), setup_timeout_ms, "setting up the connection") except (DisconnectedError, BLETimeoutError) as e: error = e + stale = reused and isinstance(e, DisconnectedError) except BaseException: if connection.is_connected(): await connection.disconnect() @@ -1043,4 +1056,9 @@ async def connect_and_set_up( await connection.disconnect() except BLEError: pass + if stale: + # Every attempt encrypted with keys this host already had, and every + # link fell: Windows hangs up when the board refuses those keys. (One + # dropped link can be the radio; all of them is the bond.) + raise PairingError(STALE_BOND) raise error diff --git a/lib/bledev/bleak.py b/lib/bledev/bleak.py index 7f3086dd..ee557b00 100644 --- a/lib/bledev/bleak.py +++ b/lib/bledev/bleak.py @@ -280,6 +280,8 @@ def __init__(self, ble, device, client): self._wwr = 0 # the largest write-without-response size seen in discovery self._paired = False self._authenticated = False + #: True when pair() found the OS already paired and paired nothing new. + self.reused_bond = False def _drop(self): if self._dropped.is_set(): @@ -367,6 +369,10 @@ async def _pair_winrt(self, requester, passkey): info = await DeviceInformation.create_from_id_async(requester.device_information.id) pairing = info.pairing if pairing.is_paired: + # Windows encrypts with the stored keys when a protected + # characteristic is first used; if the board lost its half, that + # fails and Windows drops the link (see connect_and_set_up). + self.reused_bond = True self._authenticated = pairing.protection_level == DevicePairingProtectionLevel.ENCRYPTION_AND_AUTHENTICATION return if not pairing.can_pair: @@ -409,7 +415,15 @@ def handler(sender, args): status = result.status if status not in (DevicePairingResultStatus.PAIRED, DevicePairingResultStatus.ALREADY_PAIRED): raise PairingError("pair: {} ({})".format(status.name, ", ".join(str(a) for a in asked) or "no ceremony")) - self._authenticated = result.protection_level_used == DevicePairingProtectionLevel.ENCRYPTION_AND_AUTHENTICATION + #: What Windows reported: the status, the protection level it used, + #: and the ceremonies it asked for. + self.pairing_result = (status.name, result.protection_level_used, [int(a) for a in asked if isinstance(a, int)]) + # A passkey that was asked for and typed in is what authenticates; + # Windows' reported level is the second opinion. + self._authenticated = ( + DevicePairingKinds.PROVIDE_PIN in asked + or result.protection_level_used == DevicePairingProtectionLevel.ENCRYPTION_AND_AUTHENTICATION + ) async def unpair(self): """Remove the OS's pairing with this board. Windows also drops the link.""" diff --git a/lib/bledev/repl.py b/lib/bledev/repl.py index 7ef05e0f..5a0160a7 100644 --- a/lib/bledev/repl.py +++ b/lib/bledev/repl.py @@ -47,7 +47,7 @@ import struct import sys -from . import BLEError, GattError, NEEDS_PAIRING, PairingError, pack_advertisement, wait_ms +from . import BLEError, GattError, NEEDS_PAIRING, PairingError, STALE_BOND, pack_advertisement, wait_ms from . import nus # bluetooth.BLE characteristic flags for the protected characteristics. @@ -60,6 +60,10 @@ # How often the server retries output the controller couldn't take yet. _TICK_MS = 5 +# With pairing on, how long a link may stay unpaired before the board hangs +# up, so a host that never pairs can't hold the one connection. SMP's own +# timeout is 30 s. +PAIR_WITHIN_MS = 30000 BANNER = b"\r\nbledev REPL connected\r\n>>> " DENIED = b"\r\nAccess denied\r\n" @@ -430,7 +434,7 @@ def on_connect(self, conn): if self.early_mtu and self.early_mtu[0] == conn: self.mtu = self.early_mtu[1] self.early_mtu = None - self.login = _Gate(self.password) if self.password is not None else None + self.login = _Gate(self.password) if self.password is not None else _Open() self.hang_up_at = None self.authed = False self.rx = bytearray() @@ -440,8 +444,11 @@ def on_connect(self, conn): self._ft_forget() if self.pairing is not None: # A bonded host may already be encrypting; on_security() takes over. + import time + from . import security + self.hang_up_at = time.ticks_add(time.ticks_ms(), PAIR_WITHIN_MS) if self.secure(conn): self.on_security(conn, *security.state(conn)) return @@ -468,19 +475,18 @@ def on_security(self, conn, encrypted, authenticated, bonded, key_size): if conn != self.conn: return if not self.secure(conn): - # Failed pairing, or a weaker one than asked for: hang up. - import time - - self.hang_up_at = time.ticks_add(time.ticks_ms(), 300) + # Failed pairing, a weaker one than asked for, or a host whose + # keys this board no longer has. Stay connected so the host sees + # its reads refused (and can say why); the pairing deadline set at + # connect still hangs up on a host that never gets there. return + self.hang_up_at = None # the pairing deadline is met if self.password is None: - # The pairing was the lock: open both services at once. + # The pairing was the lock: the files open now, and the REPL on + # the client's first line (_Open). if self.files is not None and not self.files.authed: self.files.authed = True self.ble.gatts_write(self.auth_handle, b"\x01") - if self.console and not self.authed: - self._attach() - self.send(BANNER) elif self.console and not self.authed: self.notify_now(PROMPT) @@ -503,8 +509,6 @@ def on_write(self, conn, data): if conn != self.conn or not self.secure(conn): return if not self.authed: - if self.login is None: - return # pairing is the lock and hasn't settled yet reply, verdict, rest = self.login.feed(data) if verdict is False: self.notify_now(reply) @@ -624,6 +628,10 @@ def feed_repl(self, data): def _tick(self, _timer=None): # Every _TICK_MS while the server runs, as a scheduled callback. + if self.pairing is not None: + from . import security + + security.poll() if self.hang_up_at is not None: import time @@ -724,6 +732,25 @@ def ft_flush(self): self.ft_flushing = False +class _Open: + """The gate when pairing is the lock: the client's first line (the empty + one every client sends to ask for a prompt) gets the banner and ``>>>``, + and isn't passed to the REPL, so the client sees exactly one prompt.""" + + refused = False + verdict = None + + def feed(self, data): + for i in range(len(data)): + if data[i] in (10, 13): + rest = bytes(data[i + 1 :]) + if data[i] == 13 and rest[:1] == b"\n": + rest = rest[1:] + self.verdict = True + return BANNER, True, rest + return b"", None, b"" + + class _Gate(Login): refused = False @@ -805,14 +832,6 @@ async def _expect(link, markers, buffer, timeout_ms): buffer.extend(chunk) -STALE_BOND = ( - "the board refused this host's keys: it has probably lost its bond (a chip " - "erase, or bledev.security.forget()). Unpair it on this host " - "(bledev.bleak.unpair(address), or Settings > Bluetooth > Remove device) " - "and pair again" -) - - def refused(pair, passkey): """The :class:`bledev.PairingError` for a board that refused a link.""" if not pair: diff --git a/lib/bledev/security.py b/lib/bledev/security.py index 9034aeb7..a95be0dd 100644 --- a/lib/bledev/security.py +++ b/lib/bledev/security.py @@ -35,6 +35,8 @@ def const(x): _IRQ_CENTRAL_DISCONNECT = const(2) _IRQ_PERIPHERAL_DISCONNECT = const(8) _IRQ_ENCRYPTION_UPDATE = const(28) +_IRQ_GET_SECRET = const(29) +_IRQ_SET_SECRET = const(30) _IRQ_PASSKEY_ACTION = const(31) #: IO capabilities, as ``ble.config(io=...)`` takes them. @@ -239,11 +241,12 @@ def _load(path=None): def _save(_arg=None): + global _save_pending from aioble import security as sec + _save_pending = False sec._modified = False - if _store is not None: - _store.save(sec._secrets) + (_store or default_store()).save(sec._secrets) def bonds(): @@ -361,39 +364,120 @@ def _install(): from aioble import core from aioble import security # noqa: F401 registers aioble's secret handlers + # Ahead of aioble's: its secret handler saves through micropython.schedule + # and raises when the queue is full, which fails the stack's key store in + # the middle of pairing. The queue is full whenever a scheduled callback + # runs long (drawing the passkey is one) while a timer keeps queueing. + core._irq_handlers.insert(0, _secret_irq) core.register_irq_handler(_irq, None) _installed = True +def _secret_irq(event, data): + if event == _IRQ_SET_SECRET: + from aioble import security as sec + + sec_type, key, value = data + key = sec_type, bytes(key) + if value is None: + if key not in sec._secrets: + return False + del sec._secrets[key] + else: + sec._secrets[key] = bytes(value) + _save_soon() + return True + if event == _IRQ_GET_SECRET: + from aioble import security as sec + + sec_type, index, key = data + if key is None: + i = 0 + for (t, _k), value in sec._secrets.items(): + if t == sec_type: + if i == index: + return value + i += 1 + return None + return sec._secrets.get((sec_type, bytes(key)), None) + return None + + +_save_pending = False + + +def _save_soon(): + global _save_pending + if _save_pending: + return + _save_pending = True + import micropython + + try: + micropython.schedule(_save, None) + except RuntimeError: + _save() # the queue is full: write now, from the stack's task + + def _irq(event, data): if event == _IRQ_ENCRYPTION_UPDATE: conn, encrypted, authenticated, bonded, key_size = data _links[conn] = (bool(encrypted), bool(authenticated), bool(bonded), key_size) if _hide is not None: - _schedule(_call_hide, None) + _defer(_call_hide, None) for callback in _listeners: callback(conn, bool(encrypted), bool(authenticated), bool(bonded), key_size) elif event == _IRQ_PASSKEY_ACTION: conn, action, number = data - # The display and the host's typing are slow: leave the IRQ first. - _schedule(_passkey_action, (conn, action, number)) + from aioble import core + + if action == _ACTION_DISPLAY: + # Answer the stack at once, from here, as NimBLE's own examples + # do; only the drawing waits for the main thread. + passkey = random_passkey() + core.ble.gap_passkey(conn, action, passkey) + if _show is not None: + _defer(_show, passkey) + elif action == _ACTION_INPUT and not callable(_inputs.get(conn)): + given = _inputs.get(conn) + # No passkey to give: answer with one that can't be relied on to + # match, so the pairing fails now rather than at a timeout. + core.ble.gap_passkey(conn, action, int(given) if given is not None else random_passkey()) + else: + # Numeric comparison, or a passkey someone has to be asked for. + _defer(_passkey_action, (conn, action, number)) return True elif event in (_IRQ_CENTRAL_DISCONNECT, _IRQ_PERIPHERAL_DISCONNECT): conn = data[0] had = _links.pop(conn, None) _inputs.pop(conn, None) if had is None and _hide is not None: - _schedule(_call_hide, None) + _defer(_call_hide, None) return None -def _schedule(fn, arg): +_deferred = [] + + +def _defer(fn, arg): + """Run ``fn(arg)`` on the main thread soon. If the scheduler's queue is + full, :func:`poll` (which bledev.repl's server calls every tick) runs it.""" import micropython try: micropython.schedule(fn, arg) except RuntimeError: - fn(arg) # the queue is full: do it here rather than drop it + _deferred.append((fn, arg)) + + +def poll(): + """Run work the IRQ couldn't schedule. Cheap when there's none.""" + while _deferred: + fn, arg = _deferred.pop(0) + try: + fn(arg) + except Exception as e: + sys.print_exception(e) def _call_hide(_arg=None): @@ -410,12 +494,7 @@ def _passkey_action(args): conn, action, number = args ble = core.ble try: - if action == _ACTION_DISPLAY: - passkey = random_passkey() - ble.gap_passkey(conn, action, passkey) - if _show is not None: - _show(passkey) - elif action == _ACTION_NUMCMP: + if action == _ACTION_NUMCMP: if _show is not None: _show(number) verdict = _confirm(number) if _confirm is not None else False diff --git a/tests/bledev_board/pair_client.py b/tests/bledev_board/pair_client.py new file mode 100644 index 00000000..2c67356f --- /dev/null +++ b/tests/bledev_board/pair_client.py @@ -0,0 +1,285 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The pairing gate, laptop side: pair with a board, log in, and check what's refused. + +Needs ``pair_server.py`` started on a board. Runs on Windows Python with +bleak:: + + WSLENV=PYTHONPATH/w PYTHONPATH="$(wslpath -w lib)" python.exe \\ + "$(wslpath -w tests/bledev_board/pair_client.py)" STEP [--port COM17] [--password] + +The passkey is read the way a person reads the board's screen: the board +prints it to its console, and ``--port`` is that console (the board's UART, +opened as mpremote opens it, so the board doesn't reset). Steps: + +``pair`` pair with the passkey, log in to the REPL, run ``123 * 456``, + read 20 KB through the file service and compare it. +``reconnect`` connect again with no passkey: the bond must carry it. +``wrong`` type a passkey one off the board's: pairing must fail and + Windows must not keep a pairing. +``unpaired`` no pairing: every protected characteristic must refuse to be + read or written, and Windows must not pair behind our back. +``unpair`` remove Windows' pairing with the board. + +Every step prints timings and ends with ``RESULT PASS`` or ``RESULT FAIL``. +""" +import asyncio +import hashlib +import sys +import time + +import bledev +import bledev.bleak +import bledev.filetransfer as ft +import bledev.nus as nus +import bledev.repl as repl + +NAME = "bledev-pair" +PASSWORD = "bledev-gate-pw" + +failures = [] + + +def check(ok, what, detail=""): + print("PASS" if ok else "FAIL", what, detail) + if not ok: + failures.append(what) + + +def arg(name, default=None): + if name in sys.argv: + return sys.argv[sys.argv.index(name) + 1] + return default + + +PORT = arg("--port") +USE_PASSWORD = "--password" in sys.argv + + +class Console: + """The board's UART console, read for the passkey line.""" + + def __init__(self, port): + import serial + + # pyserial's defaults (DTR and RTS both asserted), as mpremote opens + # it: on the LCD-7's CH343, opening with both released resets the board. + self.s = serial.Serial(port, 115200, timeout=0.05) + self.s.reset_input_buffer() + + async def passkey(self, timeout_s=25): + buf = b"" + end = time.monotonic() + timeout_s + while time.monotonic() < end: + buf += await asyncio.to_thread(self.s.read, 256) + i = buf.find(b"Bluetooth passkey ") + if i >= 0: + j = buf.find(b"\n", i) + if j >= 0: + digits = buf[i + 18 : j].replace(b" ", b"").strip() + return int(digits) + raise TimeoutError("the board showed no passkey") + + def close(self): + self.s.close() + + +async def windows_paired(address): + from winrt.windows.devices.bluetooth import BluetoothLEDevice + + n = int(address.replace(":", ""), 16) + dev = await BluetoothLEDevice.from_bluetooth_address_async(n) + if dev is None: + return None + try: + return dev.device_information.pairing.is_paired + finally: + dev.close() + + +async def find(ble): + t = time.monotonic() + device = await ble.find(name=NAME, timeout_ms=20000) + print("found", device.address, "in {:.2f} s".format(time.monotonic() - t)) + return device + + +async def login_and_use(ble, device, passkey): + timings = {} + t0 = time.monotonic() + link = await repl.connect( + ble, + PASSWORD if USE_PASSWORD else None, + name=NAME, + device=device, + pair=True, + passkey=passkey, + timeout_ms=20000, + ) + timings["connect+pair+login"] = time.monotonic() - t0 + conn = link.connection + check(conn.encrypted, "the link is paired") + check(conn.authenticated, "the link is authenticated (a passkey pairing, not just works)") + t = time.monotonic() + out = await repl.run(link, "123 * 456") + timings["first command"] = time.monotonic() - t + check("56088" in out, "the REPL answers over the paired link", repr(out.strip())) + # The file service, on the same paired connection and the same lock. + out = await repl.run(link, "import os, hashlib; f=open('/pair_blob.bin','wb'); [f.write(bytes((i*7+j) & 255 for j in range(1024))) for i in range(20)]; f.close()") + service = await conn.service(ft.SERVICE) + transfer = await service.characteristic(ft.TRANSFER) + version = await service.characteristic(ft.VERSION) + await transfer.subscribe(notify=True) + import struct + + client = ft.Client(conn, transfer, struct.unpack(" 3 else 10 +s = serial.Serial(port, 115200, timeout=0.05) +s.reset_input_buffer() +s.write(b"\r") +time.sleep(0.2) +s.reset_input_buffer() +s.write(line.encode() + b"\r") +out = b"" +end = time.monotonic() + timeout +while time.monotonic() < end: + out += s.read(512) + body = out.split(b"\n", 1)[1] if b"\n" in out else b"" + if body.rstrip().endswith(b">>>"): + break +s.close() +text = out.decode("utf-8", "replace").replace("\r", "") +print("\n".join(text.split("\n")[1:]).rstrip().removesuffix(">>>").rstrip()) From 214a01513ed9ce78b024abf2d379baaff9efe36a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:27:46 -0500 Subject: [PATCH 09/14] bledev pairing: python -m bledev.bleak pair NAME; honest stale-bond message; tighter gate pair/unpair from a terminal, typing the passkey the board shows, for tools (mpftp) that use the OS's bond. A dropped link after encrypting with stored keys can be a stale bond or a dead link Windows hands out after a board reset; the host can't tell, so the message says both, and retries back off. The wrong-passkey check now demands that pairing itself fails. --- lib/bledev/__init__.py | 13 +++++++-- lib/bledev/bleak.py | 46 +++++++++++++++++++++++++++++++ tests/bledev_board/pair_client.py | 6 ++-- tests/bledev_board/pair_server.py | 20 ++++++++++---- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/lib/bledev/__init__.py b/lib/bledev/__init__.py index 997d060e..5bc3aef3 100644 --- a/lib/bledev/__init__.py +++ b/lib/bledev/__init__.py @@ -1056,9 +1056,16 @@ async def connect_and_set_up( await connection.disconnect() except BLEError: pass + # Give a dead link time to be noticed before the next try. + await sleep_ms(500 * (_ + 1)) if stale: # Every attempt encrypted with keys this host already had, and every - # link fell: Windows hangs up when the board refuses those keys. (One - # dropped link can be the radio; all of them is the bond.) - raise PairingError(STALE_BOND) + # link fell. Windows hangs up when the board refuses those keys, but + # it also hands out links the board never saw, for a while after the + # board resets; from here the two look the same. + raise PairingError( + "every link dropped right after connecting with this host's stored keys. Try again in " + "a few seconds (after a board reset Windows can hold a dead link); if it keeps " + "happening, " + STALE_BOND + ) raise error diff --git a/lib/bledev/bleak.py b/lib/bledev/bleak.py index ee557b00..b64400ff 100644 --- a/lib/bledev/bleak.py +++ b/lib/bledev/bleak.py @@ -410,6 +410,10 @@ def handler(sender, args): token = custom.add_pairing_requested(handler) try: result = await custom.pair_with_protection_level_async(kinds, level) + if result.status == DevicePairingResultStatus.PROTECTION_LEVEL_COULD_NOT_BE_MET and passkey is not None: + # A board without a display pairs just works; the board, not + # this side, decides whether that's enough. + result = await custom.pair_with_protection_level_async(kinds, DevicePairingProtectionLevel.ENCRYPTION) finally: custom.remove_pairing_requested(token) status = result.status @@ -598,3 +602,45 @@ async def unpair(address): await BleakClient(address).unpair() except (BleakError, OSError) as e: raise BLEError("unpair {}: {}".format(address, e)) + + +async def _main(argv): + """``python -m bledev.bleak pair NAME`` / ``unpair NAME``: pair this computer + with a board once, typing in the passkey its display shows. mpftp, bledev + and anything else on this computer then use the bond.""" + if len(argv) != 2 or argv[0] not in ("pair", "unpair"): + print("usage: python -m bledev.bleak pair|unpair NAME") + return 2 + what, name = argv + ble = BleakBLE() + try: + device = await ble.find(name=name, timeout_ms=15000) + if what == "unpair": + await unpair(device.address) + print("unpaired", name, device.address) + return 0 + connection = await device.connect(timeout_ms=15000) + + async def ask(): + loop = asyncio.get_running_loop() + text = await loop.run_in_executor(None, input, "Passkey shown on {}: ".format(name)) + return int(text.replace(" ", "")) if text.strip() else None + + try: + await connection.pair(passkey=ask) + except PairingError as e: + print("not paired:", e) + return 1 + finally: + if connection.is_connected(): + await connection.disconnect() + print("paired", name, device.address, "(passkey)" if connection.authenticated else "(just works)") + return 0 + finally: + await ble.aclose() + + +if __name__ == "__main__": + import sys + + sys.exit(asyncio.run(_main(sys.argv[1:]))) diff --git a/tests/bledev_board/pair_client.py b/tests/bledev_board/pair_client.py index 2c67356f..5aef707c 100644 --- a/tests/bledev_board/pair_client.py +++ b/tests/bledev_board/pair_client.py @@ -194,7 +194,9 @@ async def one_off(): link = await repl.connect(ble, None, name=NAME, device=device, pair=True, passkey=one_off, timeout_ms=20000) check(False, "the wrong passkey is refused", "it logged in") await link.close() - except (bledev.PairingError, repl.AuthError) as e: + except bledev.PairingError as e: + # The pairing itself must fail: a password refusal afterwards would + # mean the wrong passkey paired. check(True, "the wrong passkey is refused", "{} after {:.2f} s".format(str(e)[:70], time.monotonic() - t)) finally: console.close() @@ -219,7 +221,7 @@ async def step_unpaired(ble): auth = await files.characteristic(ft.AUTH) version = await files.characteristic(ft.VERSION) ops = [ - ("REPL write", lambda: rx.write(b"import os; os.mkdir('/pwned')\r", response=True)), + ("REPL write", lambda: rx.write(b"import os\r", response=True)), ("file transfer write", lambda: transfer.write(b"\x50\x00\x01\x00/", response=True)), ("file transfer read", lambda: transfer.read()), ("password read", lambda: auth.read()), diff --git a/tests/bledev_board/pair_server.py b/tests/bledev_board/pair_server.py index 7523bc6e..9f6722d1 100644 --- a/tests/bledev_board/pair_server.py +++ b/tests/bledev_board/pair_server.py @@ -17,8 +17,9 @@ * ``start(..., plant="forget")``: forget every bond, as a chip erase would, so a bonded host can't reconnect without pairing. -* ``start(..., plant="justworks")``: say passkey but pair "just works", so - a host with the wrong passkey (or none) gets in. +* ``start(..., plant="justworks")``: say passkey but pair "just works" + (no MITM, encryption-only characteristics), so a host with the wrong + passkey gets in. * ``start(..., plant="open")``: serve without protecting the characteristics, so an unpaired host can use them. """ @@ -72,10 +73,17 @@ def show(passkey): repl._Server.secure = lambda self, conn: True configured = mode if plant == "justworks": - # Declared a passkey, paired without one: the characteristics still - # ask for encryption only, the way a misconfigured board would. - configured = "justworks" - password = PASSWORD if password is False else password + # Declared a passkey, paired without one: no MITM, encryption-only + # characteristics, and no password, the way a misconfigured board would. + real = security.configure + + def configure(_mode, **options): + real("justworks", **options) + return "passkey" + + security.configure = configure + repl._F_READ_AUTHN = repl._F_WRITE_AUTHN = 0 + repl._Server.secure = lambda self, conn: security.state(conn)[0] if password == "gate": password = PASSWORD t0 = time.ticks_ms() From 391ea9c4750737fa06511ce3fd86336deba73c1a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:32:33 -0500 Subject: [PATCH 10/14] bledev.security: draw and clear the passkey in both buffers of a double-buffered panel The LCD-7's dot-clock framebuffer swaps its two buffers on every refresh, so a passkey drawn once lived in one of them and the next refresh lost it (and hide() cleared only one). Read back from the panel: the passkey is now in both buffers while shown and gone from both after. tests/bledev_board/files_pair_client.py is the board-to-board gate. --- lib/bledev/security.py | 21 ++-- tests/bledev_board/files_pair_client.py | 125 ++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 tests/bledev_board/files_pair_client.py diff --git a/lib/bledev/security.py b/lib/bledev/security.py index a95be0dd..49d8fbc3 100644 --- a/lib/bledev/security.py +++ b/lib/bledev/security.py @@ -543,14 +543,22 @@ def hide(): if display is not None and box: x, y, w, h = box.pop() try: - display.fill_rect(x, y, w, h, 0) - _refresh(display) + for _ in _buffers(display): + display.fill_rect(x, y, w, h, 0) + _refresh(display) except Exception as e: sys.print_exception(e) return show, hide +def _buffers(display): + # A double-buffered panel (the LCD-7's dot-clock framebuffer) swaps its + # two buffers on every refresh, so whatever is drawn once lives in only + # one of them and the next refresh, anyone's, loses it. Draw twice. + return (0, 1) if getattr(display, "needs_refresh", False) else (0,) + + def _refresh(display): show = getattr(display, "show", None) if show is not None: @@ -569,11 +577,12 @@ def _draw(display, digits, title="Bluetooth passkey"): bh = 8 * small + 8 * big + 6 * big x = (w - bw) // 2 y = (h - bh) // 2 - display.fill_rect(x, y, bw, bh, 0) white = 0xFFFF - _text(display, framebuf, title, (w - len(title) * 8 * small) // 2, y + 2 * big, small, white) - _text(display, framebuf, digits, (w - len(digits) * 8 * big) // 2, y + 8 * small + 4 * big, big, white) - _refresh(display) + for _ in _buffers(display): + display.fill_rect(x, y, bw, bh, 0) + _text(display, framebuf, title, (w - len(title) * 8 * small) // 2, y + 2 * big, small, white) + _text(display, framebuf, digits, (w - len(digits) * 8 * big) // 2, y + 8 * small + 4 * big, big, white) + _refresh(display) return x, y, bw, bh diff --git a/tests/bledev_board/files_pair_client.py b/tests/bledev_board/files_pair_client.py new file mode 100644 index 00000000..c38087ad --- /dev/null +++ b/tests/bledev_board/files_pair_client.py @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The pairing gate, board to board: a board pairs with another board's file +service and moves 20 KB. + +The serving board runs ``pair_server.start("justworks", password="gate")`` +(the path a board without a display takes, and the one a CircuitPython board +asks for). On the other board:: + + import files_pair_client; files_pair_client.run() + +Checks, in order: + +1. ``bledev.filetransfer.connect()`` pairs on its own, because the board + refuses the file service to an unpaired host, and the link is encrypted + and bonded. +2. 20 KB written, read back, and compared byte for byte, with its SHA-256 + printed so the serving board's copy can be checked too. +3. A second connection encrypts from the bond, without pairing again. + +``run(plant="flip")`` flips one bit in one notification the client receives, +and ``run(plant="nopair")`` never pairs; each must fail. Ends with +``RESULT PASS`` or ``RESULT FAIL`` (also written to /files_pair_result.txt). +""" +import asyncio +import hashlib +import time + +import bledev +import bledev.filetransfer as ft +import bledev.mpble +from bledev import security + +NAME = "bledev-pair" +PASSWORD = "bledev-gate-pw" +PATH = "/pair_20k.bin" + +_lines = [] +_failures = [] + + +def log(*parts): + line = " ".join(str(p) for p in parts) + print(line) + _lines.append(line) + + +def check(ok, what, detail=""): + log("PASS" if ok else "FAIL", what, detail) + if not ok: + _failures.append(what) + + +def pattern(n): + out = bytearray(n) + x = 12345 + for i in range(n): + x = (x * 1103515245 + 12345) & 0x7FFFFFFF + out[i] = (x >> 16) & 0xFF + return bytes(out) + + +async def _main(plant): + ble = bledev.mpble.get() + ble.enable_bonding() + log("store", security.store().kind, "bonds before", security.bonds()) + flipped = [] + if plant == "flip": + real = ft.Client._feed + + def feed(self, data): + if not flipped and len(data) > 100: + data = bytearray(data) + data[50] ^= 0x01 + flipped.append(1) + real(self, data) + + ft.Client._feed = feed + t = time.ticks_ms() + try: + client = await ft.connect(ble, PASSWORD, name=NAME, pair=False if plant == "nopair" else None, timeout_ms=15000) + except bledev.BLEError as e: + check(False, "connect and pair", repr(e)) + return + conn = client.connection + log("connected and logged in in", time.ticks_diff(time.ticks_ms(), t), "ms, mtu", conn.mtu) + enc = security.state(conn._aconn._conn_handle) + check(enc[0] and enc[2], "the link is encrypted and bonded", enc) + data = pattern(20 * 1024) + t = time.ticks_ms() + await client.write(PATH, data) + up = time.ticks_diff(time.ticks_ms(), t) + t = time.ticks_ms() + back = await client.read(PATH) + down = time.ticks_diff(time.ticks_ms(), t) + log("write {} ms ({:.1f} KB/s), read {} ms ({:.1f} KB/s)".format(up, 20000 / up, down, 20000 / down)) + check(back == data, "20 KB written and read back byte for byte", "{} bytes".format(len(back))) + log("sha256", hashlib.sha256(data).digest().hex()) + await client.close() + await asyncio.sleep_ms(1500) + + # Again, from the bond. + t = time.ticks_ms() + client = await ft.connect(ble, PASSWORD, name=NAME, pair=True, timeout_ms=15000) + conn = client.connection + log("reconnected from the bond in", time.ticks_diff(time.ticks_ms(), t), "ms") + enc = security.state(conn._aconn._conn_handle) + check(enc[0] and enc[2], "the second link is encrypted from the bond", enc) + entries = await client.listdir("/") + check(any(e[0] == PATH[1:] and e[1] == len(data) for e in entries), "the file is on the board, 20 KB") + await client.close() + + +def run(plant=None): + _lines.clear() + _failures.clear() + log("PLANTED", plant) if plant else None + try: + asyncio.run(_main(plant)) + except Exception as e: + check(False, "no exception", repr(e)) + log("RESULT", "FAIL " + "; ".join(_failures) if _failures else "PASS") + with open("/files_pair_result.txt", "w") as f: + f.write("\n".join(_lines) + "\n") From 2b3041c73c0417bf13e71dd1c3d8731d777d94cd Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:39:39 -0500 Subject: [PATCH 11/14] docs: pairing and bonding, how it gets there, and what it measured --- docs/bledev-internals.md | 113 +++++++++++++++++++++++++++++++++++++-- docs/bledev.md | 77 ++++++++++++++++++++++++-- 2 files changed, 182 insertions(+), 8 deletions(-) diff --git a/docs/bledev-internals.md b/docs/bledev-internals.md index 38ef865e..f119a38a 100644 --- a/docs/bledev-internals.md +++ b/docs/bledev-internals.md @@ -346,9 +346,11 @@ descriptor (0x2908), and HOGP gives the report ID nowhere else, so the host needs `ClientCharacteristic.descriptor()`. A `Characteristic` can be `encrypted=True`, and `Connection.pair()` encrypts a link. The host pairs only after a read fails with insufficient encryption or authentication, -which is how a real keyboard asks. mpble pairs through aioble's security -module ("just works", `io=3`); `ble.enable_bonding()` loads aioble's -`ble_secrets.json` and turns bonding on, and it has to run on both sides, +which is how a real keyboard asks. mpble pairs through `bledev.security` +("just works", `io=3`, unless a passkey is given); `ble.enable_bonding()` +loads the bond store (NVS on an ESP32, see +[how pairing gets there](#how-pairing-gets-there)) and turns bonding on, and +it has to run on both sides, because the side that didn't start pairing stores keys too. **MicroPython has no long read.** `gattc_read()` is one ATT Read, so a board @@ -378,8 +380,84 @@ The host writes the keyboard's LED report once it has subscribed, as Windows does. `Peripheral.leds()` returns those writes, which is how the gate knows the host is listening, and how it times a round trip. +## How pairing gets there + +`bledev.security` is the board side. aioble pairs, but it leaves three gaps, +and all three bit on the LCD-7: + +- **The passkey step is unhandled.** aioble's `_IRQ_PASSKEY_ACTION` handler + only logs. bledev answers it from the IRQ itself, as NimBLE's own examples + do: a random six-digit passkey for `DISPLAY` (the board shows it), the + caller's passkey for `INPUT` (a board as central), and numeric comparison + through `confirm()`. Only the drawing waits for the main thread. +- **Saving keys through the scheduler fails.** aioble stores a secret in the + IRQ and saves it with `micropython.schedule()`, which raises when the queue + is full. The queue *is* full whenever a scheduled callback runs long while + a timer keeps queueing: drawing the passkey (77 ms on the LCD-7) inside a + scheduled callback while the REPL server's 5 ms timer ticks did it. The + exception went back into NimBLE as a failed key store, and pairing stopped + after the first key, with the host believing it had paired. bledev now + handles `_IRQ_GET_SECRET` and `_IRQ_SET_SECRET` ahead of aioble and writes + the store inline when the queue is full (ESP-IDF's own NimBLE writes NVS + from its host task too). Anything else deferred from an IRQ goes through + `security.poll()` when the queue is full, which the REPL server calls every + tick. +- **The store is a file.** bledev keeps the keys in NVS on an ESP32 + (`NVSStore`, namespace `bledev`, one blob: type, key length, key, value + length, value, repeated) and in aioble's `ble_secrets.json` elsewhere. + It takes over aioble's `load_secrets` and `_save_secrets`, which aioble + looks up at call time. The store should be loaded before the radio starts, + because NimBLE reads the board's IRK then; `repl.start()` does that. When + the radio is already on, the board makes a new IRK, which costs nothing + while it advertises its public address. + +**Why NVS.** A filesystem reformat, a mip reinstall and "delete everything" +are routine on these boards, and a board that silently forgets its bonds +leaves every paired host with keys that no longer work. NVS survives all of +those and is lost only to a full chip erase, which is rarer and already means +setting the board up again. Not measured: an actual filesystem reformat with a +bond in NVS (the LCD-7 carries the earful demo's files); what was shown is +that the bond lives outside the filesystem (no file is written) and survives +hard resets. + +**The server.** With `pairing=`, the REPL's RX and the file service's +transfer and `AUTH` characteristics get `_ENC` flags (just works) or `_ENC` +plus `_AUTHN` flags (passkey, numeric), so NimBLE refuses an unpaired host +before bledev sees the request, answering ATT 0x05. bledev checks the link's +state from `_IRQ_ENCRYPTION_UPDATE` as well. `AUTH` is served even without a +password, so the table depends only on `console` and `files`, and reads 1 +once pairing has unlocked it. With the password gone, the REPL opens on the +client's first line (`_Open`), so the client sees one `>>>`. A link not +paired within `PAIR_WITHIN_MS` (30 s) is hung up on, so a host that never +pairs can't hold the board's one connection. A failed pairing isn't hung up +on at once: the host would only see a dropped link, not why. + +**The laptop.** bleak 3.0.2's WinRT `pair()` accepts only `CONFIRM_ONLY`, +so bledev.bleak runs WinRT's custom pairing itself: `CONFIRM_ONLY` and +`PROVIDE_PIN`, the PIN supplied behind a deferral so the passkey provider can +be slow (a person, or the gate reading the board's console). No system dialog +appears. WinRT reports `protection_level_used` as `NONE` even after a passkey +ceremony, so bledev takes "a PIN was asked for and given" as the evidence of +an authenticated link; the board's own state agrees (authenticated, 16-byte +key). A device Windows already has paired is left alone: Windows encrypts with +the stored keys when a protected characteristic is first used. + +**A lost bond, seen from the host.** When the board has forgotten the keys, +Windows' encryption fails and the link drops two milliseconds later (the +board's log: `GET_SECRET` misses, `ENCRYPTION_UPDATE` with encrypted 0, +disconnect). After a board resets, Windows also hands out links the board +never sees, for a few seconds. From the host the two look the same, so +`connect_and_set_up` backs off between attempts and, when every attempt +drops after encrypting with stored keys, says both. + ## The checks +**Pairing, without a radio**, on both interpreters: `tests/bledev_pairing.py` +serves the file service over the fake the way CircuitPython does (encrypted, +no `AUTH`) and the way a passkey board does, and checks that clients pair when +they must, refuse what they must, and move 20 KB intact; plus the NVS blob +codec. `tests/test_bledev_pairing.py` runs four plants, each of which must fail. + **The file-transfer protocol**, on both interpreters: `tests/bledev_filetransfer.py` wires the client to a `FileServer` through a loopback that splits both directions at the MTU (23, 185 and 247, windows @@ -417,6 +495,8 @@ The serving scripts have a `PLANT` switch, and a planted run must fail: | `hid_peripheral.py` + `hid_central.py` | HID: the device types `hid_script.py`'s text, chords, media keys and gamepad moves; the host's events must equal `hid_script.expected()` and spell the text. Then key-press round trips. `PLANT` drops one Shift. The host also runs on a laptop, where it refuses to pair | | `bless_peripheral.py` + `bless_probe.py` | The laptop as a peripheral, through bless, probed by a board | +| `pair_server.py` + `pair_client.py` (laptop) | Pairing. `pair`: a passkey read off the board's console, then the REPL and 20 KB through the file service on the paired link. `reconnect`: from the bond, nobody asked for a passkey. `wrong`: a passkey one off must fail pairing and leave Windows unpaired. `unpaired`: every protected characteristic refuses reads and writes. Plants on the server: `forget`, `justworks`, `open`. `uart_say.py` reads the board's state without mpftp's soft reset | +| `pair_server.py` + `files_pair_client.py` (board) | Board to board: the client pairs on its own, 20 KB both ways byte for byte, then reconnects from the bond. `plant="flip"` and `plant="nopair"` must FAIL | `gatt_peripheral.py` is also the peripheral to point a host backend at: it advertises as `bledev-radio`, and a central steers it by writing commands. @@ -608,3 +688,30 @@ windows. Up got slower from run to run at Windows' defaults (25.8, 17.4, 14.5 KB/s), overwriting the same file each time. The planted bit flip on the client failed the round-trip check. mpftp's `ble://` transport, and its gate, are in [PyDevices/mpftp](https://github.com/PyDevices/mpftp) `docs/plans/ble.md`. + +### Pairing + +The laptop (Windows 11, bleak 3.0.2, WinRT custom pairing) and the LCD-7 +(ESP32-S3, MicroPython 1.29, bledev as `.mpy`) one desk apart, 2026-09-24, +with `pair_server.start("passkey")`: files and the REPL, the passkey the only +lock. Every step below is `pair_client.py`. + +| Step | Result | +|---|---| +| `pair`, three fresh pairings | the passkey read off the board, the link authenticated on both sides (board: encrypted, authenticated, bonded, 16-byte key), `123 * 456` over the REPL and 20 KB through the file service byte for byte. Connect to prompt 8.1-11.1 s, most of it the passkey round trip | +| `reconnect` after a hard reset of the board and a new laptop process, 10 runs | 10 of 10, no passkey asked, board and laptop both report the bonded, authenticated link. Connect to prompt median 2.1 s (1.43-6.28 s; the slow ones retried links Windows gave out that never reached the board). First command 0.05-0.30 s | +| the same, 15 earlier runs before the retry backoff | 12 of 15. One failure the board's log settled: no connection ever reached it (a dead link after its reset). Two were before logging, one after with the bond intact | +| `wrong`, three runs | pairing refused (`FAILED`) in 2.7-4.0 s; no bond on either side | +| `unpaired` | the REPL's RX, the file transfer (read, write) and `AUTH` (read, write) each refused with ATT 0x05; no REPL output; version readable; Windows didn't pair on its own | +| `forget` plant, then `reconnect` | fails as it must (the board's log: key lookup misses, encryption fails, link drops). Unpairing on the laptop and pairing again restores it | +| `justworks` plant, then `wrong` | the wrong passkey gets in: FAIL, as it must | +| `open` plant, then `unpaired` | every protected characteristic allowed: FAIL, as it must | + +The passkey on the panel, read back from the framebuffer: "Bluetooth passkey" +over the six digits at 8x the 8-pixel font, drawn in 77 ms, present in both of +the dot-clock panel's buffers while shown and gone from both after. + +mpftp's `ble://` (PyDevices/mpftp `ble-pairing`) used the bond with no password +set: `exec` and 20 KB `put`/`get` byte for byte. Against a just-works board +with a password it paired by itself; against a passkey board with the laptop +unpaired it said to pair once and left no pairing behind. diff --git a/docs/bledev.md b/docs/bledev.md index 5994f285..174b1dae 100644 --- a/docs/bledev.md +++ b/docs/bledev.md @@ -192,7 +192,7 @@ Any terminal that speaks Nordic UART works too: send an empty line, and the board answers `Password: `. A wrong password gets `Access denied` and a disconnect, and nothing sent with it reaches the REPL. The link isn't encrypted, as with WebREPL, so someone nearby with a sniffer can read the -password; pairing is the later fix. +password, unless you [pair](#pairing-and-bonding). While it runs, the REPL owns the radio: it registers its own service and advertises whenever nobody is connected. Call `bledev.repl.stop()` to give the @@ -227,7 +227,8 @@ await files.delete("/data") # a directory and everything in it so it never receives more than it can hold. It's locked the way the REPL is. CircuitPython uses pairing; bledev uses the -REPL's password, sent to one extra characteristic, and until it's right +REPL's password by default (or [pairing](#pairing-and-bonding)), sent to one +extra characteristic, and until it's right every command is refused. One attempt per connection, and a wrong one is hung up on. Logging in to the REPL over the same connection unlocks files too, which is what [mpftp](https://github.com/PyDevices/mpftp) does. The protocol @@ -242,6 +243,71 @@ How fast: 20 KB from the laptop to the T-Embed in about 0.8 s and back in 0.3 s, against 16 s and 11 s through the raw REPL. The numbers are in [the internals](bledev-internals.md#file-transfer). +## Pairing and bonding + +Pairing encrypts the link and, with a passkey, proves who's on the other end. +It's off unless you ask; the password alone stays the default. On a board with +a display, this is the one to use: + +```python +import bledev.filetransfer +bledev.filetransfer.start(password=False, name="rack", pairing="passkey") +``` + +The first time a computer connects, the board draws a six-digit passkey on its +screen (and prints it to the console) and the computer asks for it. After that +the two are bonded: they reconnect encrypted, with no passkey, until one of +them forgets. `password=False` means the passkey is the only lock; keep a +password as well if you like. + +From a laptop or another board, pair when you connect, and pass the passkey, +or a function that asks for it: + +```python +link = await bledev.repl.connect(ble, name="rack", pair=True, passkey=lambda: input("Passkey: ")) +files = await bledev.filetransfer.connect(ble, name="rack", passkey=ask) # pairs when the board asks it to +``` + +On Windows that's WinRT's own pairing, with no system dialog. To pair a +computer once for tools that don't pair themselves (mpftp), +`python -m bledev.bleak pair rack` asks for the passkey in the terminal, and +`python -m bledev.bleak unpair rack` undoes it. Windows Settings, Add device, +works too. + +The other modes, for `bledev.repl.start()` and `bledev.filetransfer.start()`: + +| `pairing=` | What the board does | Password | +|---|---|---| +| `None` (the default) | no pairing | required | +| `"passkey"` | shows a passkey the host types in | optional | +| `"justworks"` | pairs with no passkey; encrypted, but anyone can pair | required | +| `"numeric"` | shows a number both sides confirm; `confirm(number)` answers for the board (a button) | optional | +| `"auto"` | `"passkey"` with a display, `"justworks"` without | as above | + +Just works on its own would let anyone in range pair and get a REPL, so it +needs the password too; what it adds is that nobody sniffing can read the +password or the session. A board draws the passkey on `board_config.display_drv`; +pass `show(passkey)` and `hide()` to put it somewhere else. + +**Where the keys live.** On an ESP32 they're in NVS, not the filesystem, so +reformatting the filesystem or deleting every file keeps them. A full chip +erase (`mpftp firmware flash --erase`) loses them, and so does +`bledev.security.forget()`. On other boards they're in `ble_secrets.json`. + +**When the board has lost its keys** and the computer still has its half, the +computer's reconnect fails: it tries the old keys, the board doesn't know them, +and the link drops. bledev says so (`PairingError`, "unpair it on this host"). +The fix is on the computer: unpair the board (`python -m bledev.bleak unpair +rack`, or Settings > Bluetooth > Remove device), then pair again. A phone is the +same: forget the device, then pair. + +A board's name, and the services it serves, follow from what you start: the +same `console` and `files` give the same table every time, whatever the lock, +because hosts cache a paired device's table. Serving something else on the same +board (HID, say) changes it under the same address, and MicroPython doesn't tell +bonded hosts, so a host that kept the old table may need the board unpaired and +paired again. + ## Wi-Fi setup with Improv `bledev.improv` speaks [Improv](https://www.improv-wifi.com/ble/), the BLE @@ -339,9 +405,10 @@ an `(x, y)` tuple with y up. A keyboard that wants an encrypted link gets one: the first read it refuses pairs the link ("just works", no passkey) and tries again. On a board, call `ble.enable_bonding()` once before connecting so the keys are kept, and the -next connect doesn't pair from scratch. The keys live in `ble_secrets.json` -on the board's filesystem, so erasing the board forgets them, and a keyboard -that remembers the board may then need putting back into pairing mode. +next connect doesn't pair from scratch. The keys live in NVS on an ESP32 +(see [where the keys live](#pairing-and-bonding)), so a full chip erase +forgets them, and a keyboard that remembers the board may then need putting +back into pairing mode. **A board can be the keyboard, too.** It advertises as a keyboard, media remote and gamepad (or any one or two of them) and sends what you tell it: From c60bd8c92365c7b2095428c1a9c5507295236e31 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:39:51 -0500 Subject: [PATCH 12/14] docs: pairing counts from the run log --- docs/bledev-internals.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/bledev-internals.md b/docs/bledev-internals.md index f119a38a..b53b47ca 100644 --- a/docs/bledev-internals.md +++ b/docs/bledev-internals.md @@ -700,8 +700,8 @@ lock. Every step below is `pair_client.py`. |---|---| | `pair`, three fresh pairings | the passkey read off the board, the link authenticated on both sides (board: encrypted, authenticated, bonded, 16-byte key), `123 * 456` over the REPL and 20 KB through the file service byte for byte. Connect to prompt 8.1-11.1 s, most of it the passkey round trip | | `reconnect` after a hard reset of the board and a new laptop process, 10 runs | 10 of 10, no passkey asked, board and laptop both report the bonded, authenticated link. Connect to prompt median 2.1 s (1.43-6.28 s; the slow ones retried links Windows gave out that never reached the board). First command 0.05-0.30 s | -| the same, 15 earlier runs before the retry backoff | 12 of 15. One failure the board's log settled: no connection ever reached it (a dead link after its reset). Two were before logging, one after with the bond intact | -| `wrong`, three runs | pairing refused (`FAILED`) in 2.7-4.0 s; no bond on either side | +| the same, 21 earlier runs, before the retry backoff | 17 of 21, each failure a link dropped during discovery with the bond intact (the next run passed on the same keys). The one the board's log covered: no connection ever reached the board, so Windows' three attempts all went to a dead link | +| `wrong`, six runs | pairing refused (`FAILED`) in 2.7-4.9 s; no bond on either side | | `unpaired` | the REPL's RX, the file transfer (read, write) and `AUTH` (read, write) each refused with ATT 0x05; no REPL output; version readable; Windows didn't pair on its own | | `forget` plant, then `reconnect` | fails as it must (the board's log: key lookup misses, encryption fails, link drops). Unpairing on the laptop and pairing again restores it | | `justworks` plant, then `wrong` | the wrong passkey gets in: FAIL, as it must | From 64eb22500adcbb16195358de123fb771e96b378d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:43:42 -0500 Subject: [PATCH 13/14] bledev.repl: the GAP name is the advertised name A paired Windows listed the LCD-7 as 'MPY ESP32', MicroPython's default GAP name, not the name it advertises, so two paired boards looked alike. Read back over the air: the Device Name is now the name passed to start(). --- docs/bledev.md | 2 ++ lib/bledev/repl.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/docs/bledev.md b/docs/bledev.md index 174b1dae..8cd97e2a 100644 --- a/docs/bledev.md +++ b/docs/bledev.md @@ -302,6 +302,8 @@ rack`, or Settings > Bluetooth > Remove device), then pair again. A phone is the same: forget the device, then pair. A board's name, and the services it serves, follow from what you start: the +name you pass is both the advertised name and the one a paired computer lists +(the GAP name, which was MicroPython's "MPY ESP32" before), and the same `console` and `files` give the same table every time, whatever the lock, because hosts cache a paired device's table. Serving something else on the same board (HID, say) changes it under the same address, and MicroPython doesn't tell diff --git a/lib/bledev/repl.py b/lib/bledev/repl.py index 5a0160a7..e0a27745 100644 --- a/lib/bledev/repl.py +++ b/lib/bledev/repl.py @@ -313,6 +313,12 @@ def start(self): ble.config(mtu=nus.MTU) except Exception: pass + try: + # The name hosts show for a paired board (Windows' Settings list) + # is the GAP name, not the advertised one: make them the same. + ble.config(gap_name=self.name) + except Exception: + pass # With pairing, the stack itself refuses the protected characteristics # to a link that isn't encrypted (or, for a passkey, authenticated), # before anything here sees the request. From 7af0d07a390c1f70611c0253ef9916d7c6216733 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:13:42 -0500 Subject: [PATCH 14/14] docs: the board-to-board pairing gate, T-Embed to LCD-7 --- docs/bledev-internals.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/bledev-internals.md b/docs/bledev-internals.md index b53b47ca..2f634d26 100644 --- a/docs/bledev-internals.md +++ b/docs/bledev-internals.md @@ -707,6 +707,18 @@ lock. Every step below is `pair_client.py`. | `justworks` plant, then `wrong` | the wrong passkey gets in: FAIL, as it must | | `open` plant, then `unpaired` | every protected characteristic allowed: FAIL, as it must | +**Board to board** (`files_pair_client.py` on the T-Embed against +`pair_server.start("justworks", password="gate")` on the LCD-7, one desk +apart, both with no bonds to start): the client paired by itself because the +board refused the file service to an unpaired host, and the link was +encrypted and bonded (16-byte key, not authenticated, as just works is). Connect, +pair and log in 3.8 s; 20 KB written in 3.0 s (6.7 KB/s) and read back in +1.0 s (20.1 KB/s), byte for byte, and the LCD-7's own copy had the same +SHA-256. A second connection encrypted from the bond in 2.1 s. Plants: +`nopair` was refused ("the board wants pairing") and `flip` failed the +byte-for-byte check. Not run on hardware: a board as central typing in a +passkey another board shows (the fake covers it). + The passkey on the panel, read back from the framebuffer: "Bluetooth passkey" over the six digits at 8x the 8-pixel font, drawn in 77 ms, present in both of the dot-clock panel's buffers while shown and gone from both after.