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
78 changes: 78 additions & 0 deletions docs/bledev-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ standalone module per backend, and an optional selector.
| `mpble.py` | MicroPython, over aioble. |
| `bleak.py` | CPython on Windows, Linux and macOS, over bleak. Central only. |
| `cpble.py` | CircuitPython, over `_bleio`. Both roles. |
| `webble.py` | Web Bluetooth in a browser, central only. |
| `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. |
Expand Down Expand Up @@ -522,6 +523,61 @@ 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.

## webble

`webble` reaches the page the way `audiodev.web_audio` does, through the `js`
module, which Pyodide and MicroPython's WebAssembly build both have. The
runtimes disagree on how Python values cross into JavaScript, so everything
goes through a few helpers made once with `js.Function.new()`: strings,
numbers and opaque objects cross, and bytes cross as hex. JS errors come back
as each runtime's `JsException`, and `_translate()` maps them by name.

What it takes to meet the contract in a browser, all found on hardware:

- **Chrome on Android never matches a `name` filter against a scan
response.** A board advertising nus's 128-bit UUID has its name pushed
there, so `find(name=, service=)` filters on the service alone and checks
the pick's name afterwards. The check caught two wrong picks while the S3s
were also advertising nus.
- **The MTU is invisible.** Chrome on Android asks for 517 right after
connecting (logcat: `configureMTU() mtu: 517`, answered with 247 by a
board), and Windows negotiates on its own, but neither tells the page.
Writes are sized to `WebBLE(mtu=)`, default 23. A write longer than the
link carries is cut short on Android without an error, which is what the
mock models and `test_bledev_web.py` plants.
- **First connects fail.** Android gives status 133 / HCI 0x3E ("failed to be
established") on about half the first attempts to the P4, so `_connect()`
tries three times. One gate run needed two retries.
- **One GATT operation at a time.** Chrome rejects a second while one is in
flight, so each connection serialises them behind a lock.
- **Discovery is slow the first time.** The browser walks the whole GATT
table on the first request, which took longer than nus's 2 s from Windows,
so discovery timeouts have a 10 s floor.
- **MicroPython's asyncio can't `wait_for()` a JS promise**, only a
coroutine, so promises with a timeout are wrapped in one.

**The gate** is `tests/bledev_web/`: `gate.py` runs nus's three 16 KB phases
against `nus_gate_server.py` on a board, on three pages (`pyodide.html`,
`mpy.html`, `wasm.html`) served by `serve.py`, which also collects what the
pages log. `?mock=1` swaps in `mock_bluetooth.js`, and
`tests/test_bledev_web.py` runs that in headless Chromium with two planted
faults. Against a board, the chooser is the only human step, and both ends
of it can be automated:

- **Android:** `adb reverse tcp:8765 tcp:8765` makes the page `localhost` on
the phone, `adb shell input tap` presses the page's button (a real user
gesture), and `uiautomator dump` finds the device's row and the Pair
button in the chooser. Chrome needs the Nearby devices permission, and
Android's `DeviceAccess` events never fire.
- **Desktop Chrome:** start it with `--remote-debugging-port` and its own
`--user-data-dir`, click the button with `Runtime.evaluate(userGesture=true)`,
and answer `DeviceAccess.deviceRequestPrompted` with
`DeviceAccess.selectPrompt`. No person is needed at all.

Restart the board's server with a hard reset: after a Ctrl-C, a new
`nus.serve()` on the P4 twice said it was advertising while nothing was on
the air.

## The checks

**Pairing, without a radio**, on both interpreters: `tests/bledev_pairing.py`
Expand Down Expand Up @@ -569,6 +625,8 @@ The serving scripts have a `PLANT` switch, and a planted run must fail:
| `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 |
| `nus_server.py` + `nus_client.py` | 16 KB up, down and echoed over nus, byte for byte, timed |
| `tests/bledev_web/nus_gate_server.py` + a gate page | The same, from a browser through webble |

`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.
Expand Down Expand Up @@ -852,3 +910,23 @@ 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.
**webble to the P4** (through its C6), nus, 16 KB per phase, every byte
checked, 2026-09-24. Chrome 153 on a Galaxy S21 about a metre away, and on
Windows 11 through the laptop's Intel radio. KB/s:

| Browser, runtime, page MTU | Up (writes) | Down (notifications) | Echo, each way |
|---|---|---|---|
| S21, Pyodide, 247 | 22.8 | 15.8 | 8.6 |
| S21, PyScript MicroPython, 247 | 15.6 | 19.9 | 9.2 |
| S21, MicroPython WebAssembly, 247 | 22.3 | 8.5 | 9.8 |
| S21, Pyodide, 23 | 3.3-4.4 | 10.7-14.1 | 3.9 |
| S21, Pyodide, 247, planted bit flips | FAIL, 1 at 5000 | FAIL, 1 at 5000 | FAIL, 1 at 5000 |
| Windows, Pyodide and PyScript MicroPython, 247 | 37-61 | 4-7 | 3.7-4.8 |
| Windows, MicroPython WebAssembly, 247 | 50.6 | 3.6 | 3.6 |

From Windows the P4 sends 20-byte notifications, because it never learns the
MTU Windows negotiated ([#80](https://github.com/PyDevices/pydevices/issues/80)).
Two of three ECHO runs on the phone at MTU 23 (none of five at 247), and
two of eleven from Windows, lost writes the P4 never received, always while it was notifying
back ([#79](https://github.com/PyDevices/pydevices/issues/79)). The gate
reports those as a stall, not a pass.
34 changes: 32 additions & 2 deletions docs/bledev.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ transport can talk to it too.
| `bledev.webble` | Browsers: PyScript, the Workbench simulator | yes | no |
| `bledev.fake` | Anywhere: tests | yes | yes |

`bledev.webble` is being written; the other four are here.

Roles aren't symmetric. A laptop or a browser can only scan and connect, so
when a board talks to one, the board advertises. Only board-to-board links put
a board in the central role. `ble.capabilities()` tells you which roles you
Expand Down Expand Up @@ -154,6 +152,38 @@ async with ble.scan(5000, active=True) as scanner:
A device can come back more than once as its advertisement and scan response
arrive, and `device.name` is whatever the scan had seen by then.

## In a browser

`bledev.webble` runs the same code in Chrome or Edge, on desktop or Android,
under PyScript (Pyodide or MicroPython) or the MicroPython WebAssembly build
Workbench uses. The page must be served from `https://` or `http://localhost`.

```python
from bledev.webble import WebBLE
import bledev.nus as nus

ble = WebBLE(mtu=247) # see below for the MTU
link = await nus.connect(ble, name="rack") # call this from a click or tap
```

Three things differ from a board:

- **The browser's chooser is the scan.** `find()` and `nus.connect()` open
it, and the person picks the device. It only opens during a click or tap,
so start from one. `scan()` raises `UnsupportedError`.
- **Only services you name are reachable.** The one in `find()`'s filter is;
list any others as `WebBLE(services=[...])`.
- **The browser won't tell you the MTU.** `connection.mtu` is 23 unless you
pass `WebBLE(mtu=...)`, and writes are sized to it. Chrome asks for a large
MTU on every connect (a board running nus ends up at 247), but a write
longer than the link carries can be cut short without an error, so only
raise it when you know. At 23 everything still works, about five times
slower.

A Web Bluetooth page can be tested without a person: Chrome's DevTools
protocol can answer the chooser. How the gate was run on a phone and a
laptop is in [bledev-internals.md](bledev-internals.md#webble).

## Byte streams with nus

`bledev.nus` gives you a `Link` from either end, and it reads like an asyncio
Expand Down
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
Loading
Loading