Skip to content

bledev: pairing and bonding for the REPL and file services - #88

Merged
bdbarnett merged 15 commits into
mainfrom
bledev-pairing
Sep 25, 2026
Merged

bdbarnett merged 15 commits into
mainfrom
bledev-pairing

Conversation

@bdbarnett

@bdbarnett bdbarnett commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Pairing and bonding for bledev.repl and bledev.filetransfer (docs/ble.md §3 "pairing later", §4, §7 "Bonding"). The password alone stays the default.

Stacking. Based on bledev-filetransfer (#86), with bledev-hid (#83) merged in, because this builds on #83's Connection.pair(), encrypted characteristics and bond store. Until #83 merges, its commits show in this diff too. Merge order: #78, #81, #84, #85, #86, #83, then this.

What you get

  • bledev.repl.start(..., pairing="passkey") (or bledev.filetransfer.start): the board draws a six-digit passkey on board_config.display_drv (and prints it), the host types it in, and the two are bonded. password=False makes the passkey the only lock. Also "justworks" (password still required: anyone can pair), "numeric" (with confirm=), and "auto" (passkey with a display, just works without).
  • Clients: repl.connect(..., pair=True, passkey=...); filetransfer.connect() pairs by itself when the board asks, which a CircuitPython board always does (no AUTH characteristic, transfer encrypted).
  • bledev.bleak pairs through WinRT's custom pairing (no system dialog, passkey supplied behind a deferral), and python -m bledev.bleak pair|unpair NAME pairs a computer once for tools like mpftp.
  • The bond store is NVS on an ESP32 (bledev.security): a filesystem reformat keeps it, a chip erase loses it. A host with a stale bond is told to unpair and pair again.

Fixed on the way, all from the LCD-7: aioble saves keys through micropython.schedule and raises when the queue is full, which failed the stack's key store mid-pairing (the host thought it had paired). The passkey step, which aioble leaves unhandled, is answered from the IRQ. The passkey is drawn into both buffers of the double-buffered panel.

Gates (laptop and LCD-7; each planted fault turned its gate red):

  • Passkey pairing, then REPL and 20 KB through files on the paired link: PASS, three fresh pairings. After a hard reset of the board and a new laptop process, reconnect from the bond with no passkey: 10 of 10, median 2.1 s to a prompt. Plant (board forgets its keys): FAIL, and unpair plus pair again recovers.

  • Wrong passkey refused, nothing bonded: PASS, six runs. Plant (board pairs just works): FAIL.

  • Unpaired host: RX, transfer and AUTH reads and writes all refused (ATT 0x05): PASS. Plant (unprotected): FAIL.

  • Fake-backed checks, CPython and MicroPython: tests/bledev_pairing.py, four plants.

  • Board to board, T-Embed to the LCD-7 (just works plus password): the file client paired by itself, 20 KB both ways byte for byte (the LCD-7's copy had the same SHA-256), and a second connection encrypted from the bond: PASS. Plants nopair (refused) and flip (byte check): FAIL.

Not run on hardware: a board as central typing in a passkey another board shows. The fake covers it.

mpftp's side: PyDevices/mpftp#48, stacked on mpftp#47. Numbers and method: docs/bledev-internals.md, "How pairing gets there" and "Pairing".

… 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.
…ss 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.
# Conflicts:
#	docs/bledev-internals.md
#	docs/bledev.md
#	pydevices-desktop.toml
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.
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.
…essage; 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.
…le-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.
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().
@bdbarnett

Copy link
Copy Markdown
Contributor Author

This isn't proven on hardware yet. A board entering a passkey that another board displays fails on stock MicroPython, on every board.

What happened. The LCD-7 ran pair_server.start("passkey") and the P4 connected as central with files_pair_client.run(passkey="console"). The passkey was carried with no shortcut: passkey_gate.py (#89) read it from the LCD-7's UART console (bledev: Bluetooth passkey 129 763) and was ready to type it into the P4's UART, where the script waits on input() after printing PASSKEY?. The LCD-7 showed its passkey 6.7 s in. The P4 never asked. With every BLE IRQ logged on both boards, the LCD-7 had _IRQ_PASSKEY_ACTION (display) and the P4 had none. About 29 s later both sides saw the encryption fail and the link dropped. That's NimBLE's pairing timeout. Neither board kept a bond, but that doesn't count as the wrong-passkey gate, because nothing was ever typed.

The cause. In extmod/nimble/modbluetooth_nimble.c, peripheral_gap_event_cb handles the connections a board makes, and it drops BLE_GAP_EVENT_PASSKEY_ACTION (and REPEAT_PAIRING). Only central_gap_event_cb handles them, and that one covers the connections a board accepts. So on any NimBLE port, a board acting as central is never asked for the passkey. It's the same on an S3. The fake radio delivers the event, which is why the fake-radio check passed. Upstream's draft micropython/micropython#19470 moves the event into the common handler.

Two fixes, neither on a board yet:

  1. bledev workaround (bledev: the P4 HID-host gate, and a board typing in a passkey #89, first commit). If the stack hasn't asked within 1.5 s, pair(passkey=...) asks the provider itself. It then keeps offering the passkey with gap_passkey() until NimBLE takes it. NimBLE answers EINVAL until the pairing reaches its confirm step, and by then a person has read the screen. This works on stock firmware. The fake-radio suites pass.
  2. Firmware. I built the ci: bump the actions group across 1 directory with 3 updates #12 P4 image plus an 8-line patch that routes the two events from peripheral_gap_event_cb to central_gap_event_cb. It built clean under the build lock, and the shared checkout and the ci: bump the actions group across 1 directory with 3 updates #12 build dir were restored byte for byte. It wasn't flashed.

Both stopped at the same point: the session lost permission to flash the P4 and then to drive the boards, before the rerun. What's owed is passkey_gate.py right wrong on the P4 and LCD-7 with #89's bledev, plus --plant justworks wrong, which must fail.

One other thing, seen once: after a 30 s failed pairing, printing a long list of logged IRQ tuples at the P4's REPL gave a Guru Meditation (load access fault, core 1). It didn't happen again in the rerun, which logged fewer events, and I didn't chase it.

Both boards are left with no bonds (NVS namespace bledev erased) and bledev from #89 as .mpy.

@bdbarnett

bdbarnett commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

A board typing in a passkey another board shows now works on hardware. It passes with #89's bledev workaround alone, on the stock #12 P4 image, with no firmware patch; the patched P4 image was never flashed.

The LCD-7 served files with pair_server.start("passkey") and no password. The P4 connected as central with files_pair_client.run(passkey="console"). Each step started from hard resets of both boards, with the NVS bonds erased and checked at zero. passkey_gate.py read the passkey off the LCD-7's UART console and typed it into the P4's UART, where the script waits on input() after printing PASSKEY?. The display showed the same number. Nothing else passed between the boards.

Step Runs Result
Right passkey 3 all PASS. The link was encrypted, authenticated and bonded (16-byte key), and the passkey was asked for exactly once. 20 KB was written (8.0-9.1 KB/s) and read back (17.7-26.1 KB/s) byte for byte, and the LCD-7's copy had the same SHA-256. The P4 reconnected from the bond in 2.2-2.4 s, authenticated, with no second passkey. After hard resets each board held 1 bond.
Wrong passkey (one off) 3 all refused (PairingError), and after hard resets each board held 0 bonds
--plant justworks, wrong passkey 1 FAIL, as it must. No passkey was shown or asked for, the link was encrypted but not authenticated, and each board kept a bond.

The LCD-7 printed the passkey 3.3-3.9 s after the start, and the P4 asked for it 1.2 s later. That's the workaround's 1.5 s wait running out, which confirms the stack never asks on its own. No run was disturbed.

Both boards are left idle at the REPL with no bonds, and the LCD-7's /main.py (the Spotify speaker demo) is restored.

Base automatically changed from bledev-filetransfer to main September 25, 2026 05:38
@bdbarnett
bdbarnett merged commit f591f2d into main Sep 25, 2026
3 checks passed
@bdbarnett
bdbarnett deleted the bledev-pairing branch September 25, 2026 05:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant