Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion lib/bledev/mpble.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@
_IRQ_GATTS_WRITE = 3
_IRQ_PERIPHERAL_DISCONNECT = 8
_IRQ_MTU_EXCHANGED = 21
_PASSKEY_INPUT = 2 # gap_passkey's action for typing in the peer's passkey
_EINVAL = 22
# As central with a passkey to type: how long to wait for the stack to ask
# before asking anyway (see _MPConnection._type_passkey).
_ASK_WITHIN_MS = 1500

_FLAG_READ_ENCRYPTED = 0x0200
_FLAG_WRITE_ENCRYPTED = 0x1000
Expand Down Expand Up @@ -575,21 +580,54 @@ def on_change(conn, encrypted, authenticated, bonded, key_size):
try:
with _Translate("pair"):
_aioble_core.ble.gap_pair(handle)
deadline = time.ticks_add(time.ticks_ms(), timeout_ms)
started = time.ticks_ms()
deadline = time.ticks_add(started, timeout_ms)
typed = passkey is None
# 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")
if not typed and handle not in security._asked and time.ticks_diff(time.ticks_ms(), started) > _ASK_WITHIN_MS:
typed = True
await self._type_passkey(handle, deadline, updates)
await sleep_ms(20)
finally:
security.remove_listener(on_change)
security.expect_passkey(handle, None)
security._asked.discard(handle)
if not security.state(handle)[0]:
raise PairingError("pair: refused (wrong passkey, or the peer lost its keys)")

async def _type_passkey(self, handle, deadline, updates):
# MicroPython's NimBLE bindings raise _IRQ_PASSKEY_ACTION only on
# connections the board accepted, never on one it made
# (peripheral_gap_event_cb in extmod/nimble/modbluetooth_nimble.c
# doesn't handle it; micropython/micropython#19470 moves it), so a
# board as central is never asked for the passkey and pairing stalls
# until the stack's 30 s timeout. When the stack hasn't asked by now,
# ask for the passkey here and hand it to the stack ourselves: NimBLE
# takes it once its pairing reaches the confirm step and answers
# EINVAL before that, so keep offering it until it's taken.
from . import security

given = security._inputs.get(handle)
if callable(given):
given = given()
given = int(given)
# If the stack does ask after all, it answers from this, not by asking twice.
security._inputs[handle] = given
while not updates and handle not in security._asked and self.is_connected():
try:
_aioble_core.ble.gap_passkey(handle, _PASSKEY_INPUT, given)
return
except OSError as e:
if e.errno != _EINVAL or time.ticks_diff(deadline, time.ticks_ms()) <= 0:
return # taken already (EALREADY), or the pairing is over
await sleep_ms(50)

async def unpair(self):
from . import security

Expand Down
8 changes: 7 additions & 1 deletion lib/bledev/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ def const(x):
# 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 = {}
# Connections the stack has asked, as central, for the peer's passkey. Stock
# MicroPython's NimBLE never asks on a connection it made (see mpble's pair()).
_asked = set()
# The server's side: what to do when the stack wants a passkey shown or a
# number confirmed. Set by configure().
_show = None
Expand Down Expand Up @@ -438,7 +441,9 @@ def _irq(event, data):
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)):
if action == _ACTION_INPUT:
_asked.add(conn)
if 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.
Expand All @@ -451,6 +456,7 @@ def _irq(event, data):
conn = data[0]
had = _links.pop(conn, None)
_inputs.pop(conn, None)
_asked.discard(conn)
if had is None and _hide is not None:
_defer(_call_hide, None)
return None
Expand Down
61 changes: 61 additions & 0 deletions tests/bledev_board/connect_loop_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: 2026 Brad Barnett
#
# SPDX-License-Identifier: MIT
"""Connect reliability, the central: find ``connect_loop_server.py`` and
connect to it ``N`` times, counting connects that time out.

Each attempt scans for the board (up to ``FIND_MS``), then connects with a
``CONNECT_MS`` timeout at the default connection interval (``FAST`` asks
for 7.5-15 ms), holds the link 300 ms and hangs up. Ends with a ``CONNECTS``
line: attempts, connected, timed out, other errors, not found, and the
connect times in ms.
"""
import asyncio
import time

import bledev
import bledev.mpble

NAME = "bledev-conn-loop"
N = 10
FIND_MS = 20000
CONNECT_MS = 10000
FAST = False


async def main():
ble = bledev.mpble.get()
conn_kw = {"min_conn_interval_us": 7500, "max_conn_interval_us": 15000} if FAST else {}
ok = timeouts = other = missing = 0
times = []
for i in range(N):
try:
device = await ble.find(name=NAME, timeout_ms=FIND_MS)
except bledev.BLETimeoutError:
missing += 1
print("attempt", i + 1, "not found")
continue
t = time.ticks_ms()
try:
conn = await device.connect(timeout_ms=CONNECT_MS, **conn_kw)
except bledev.BLETimeoutError as e:
timeouts += 1
print("attempt", i + 1, "connect timed out after", time.ticks_diff(time.ticks_ms(), t), "ms:", e)
await asyncio.sleep_ms(500)
continue
except bledev.BLEError as e:
other += 1
print("attempt", i + 1, "connect failed:", repr(e))
await asyncio.sleep_ms(500)
continue
dt = time.ticks_diff(time.ticks_ms(), t)
ok += 1
times.append(dt)
print("attempt", i + 1, "connected in", dt, "ms")
await asyncio.sleep_ms(300)
await conn.disconnect()
await asyncio.sleep_ms(1000)
print("CONNECTS", N, "ok", ok, "timeouts", timeouts, "other", other, "not found", missing, "times", sorted(times))


asyncio.run(main())
31 changes: 31 additions & 0 deletions tests/bledev_board/connect_loop_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: 2026 Brad Barnett
#
# SPDX-License-Identifier: MIT
"""Connect reliability, the peripheral: advertise, take a connection, wait for
it to end, and advertise again, forever. Pair with ``connect_loop_client.py``.

Prints ``accepted N`` for each connection.
"""
import asyncio

import bledev
import bledev.mpble

NAME = "bledev-conn-loop"


async def main():
ble = bledev.mpble.get()
n = 0
while True:
conn = await ble.advertise(name=NAME)
n += 1
print("accepted", n)
try:
await conn.disconnected(30000)
except bledev.BLEError as e:
print("still connected after 30 s:", e)
await conn.disconnect()


asyncio.run(main())
33 changes: 29 additions & 4 deletions tests/bledev_board/files_pair_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
``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).

``run(passkey="console")`` is for a serving board started with
``pair_server.start("passkey")``: when the other board shows its passkey, this
board prints ``PASSKEY?`` and reads the digits from its own console, the way a
person would type what they see. The link must then also be authenticated, and
the reconnect must not ask again. ``passkey_gate.py`` carries the digits from
one board's console to the other's.
"""
import asyncio
import hashlib
Expand Down Expand Up @@ -61,7 +68,17 @@ def pattern(n):
return bytes(out)


async def _main(plant):
asked = []


def _typed():
# Called when the peer shows a passkey: read it off this board's console.
asked.append(time.ticks_ms())
print("PASSKEY?")
return int(input().strip())


async def _main(plant, passkey):
ble = bledev.mpble.get()
ble.enable_bonding()
log("store", security.store().kind, "bonds before", security.bonds())
Expand All @@ -79,14 +96,18 @@ def feed(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)
client = await ft.connect(ble, PASSWORD, name=NAME, pair=False if plant == "nopair" else None, timeout_ms=15000,
passkey=_typed if passkey == "console" else None)
except bledev.BLEError as e:
check(False, "connect and pair", repr(e))
log("passkey asked", len(asked), "bonds after", security.bonds())
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)
if passkey:
check(enc[1] and len(asked) == 1, "the link is authenticated by the passkey", (enc, len(asked)))
data = pattern(20 * 1024)
t = time.ticks_ms()
await client.write(PATH, data)
Expand All @@ -107,17 +128,21 @@ def feed(self, data):
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)
if passkey:
check(enc[1] and len(asked) == 1, "the second link is authenticated with no passkey asked", (enc, len(asked)))
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()
log("bonds after", security.bonds())


def run(plant=None):
def run(plant=None, passkey=None):
_lines.clear()
_failures.clear()
asked.clear()
log("PLANTED", plant) if plant else None
try:
asyncio.run(_main(plant))
asyncio.run(_main(plant, passkey))
except Exception as e:
check(False, "no exception", repr(e))
log("RESULT", "FAIL " + "; ".join(_failures) if _failures else "PASS")
Expand Down
Loading
Loading