Skip to content

bledev v1, first half: contract, fake, mpble, auto, nus - #78

Merged
bdbarnett merged 6 commits into
mainfrom
bledev-core
Sep 25, 2026
Merged

bdbarnett merged 6 commits into
mainfrom
bledev-core

Conversation

@bdbarnett

@bdbarnett bdbarnett commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

The first half of bledev v1 (workspace docs/ble.md §3 and §9): the contract, an in-process fake, the MicroPython backend over aioble, auto, and the Nordic UART byte stream. bledev.bleak, bledev.repl, bledev.improv and bledev.webble come from other agents, built on this.

Start with docs/bledev.md. Backend authors want docs/bledev-internals.md.

Proven

  • The contract checks (tests/bledev_contract.py, 31 checks) pass on CPython, on unix MicroPython, and on an ESP32-S3 itself. tests/test_bledev.py runs the first two, then five planted faults (drop, corrupt, reorder, truncate, overwrite), and each one fails the checks.
  • Board to board on two ESP32-S3s over real radio: 16 KB up, down and echoed through nus, byte for byte. Up (writes) 80-87 KB/s, down (notifications) 31-41 KB/s, echo 24-30 KB/s each way, 44 KB/s at a 7.5-15 ms interval. A planted one-bit flip fails every phase at offset 5000.
  • The GATT contract over the air (tests/bledev_board/gatt_*) passes, and a planted missing notification fails it. Latency: 3966 operations in 120 s, max 80 ms, none over 500 ms.
  • The full suite passes (544 tests).

What this changes about aioble's behaviour. mpble closes three places where aioble loses data. The client notification queue is one slot deep. Captured writes share a ten-entry deque. notified() strands the last item of a longer queue. mpble also scans at full duty: aioble's default scan found nothing in six seconds a few inches from the board. The details are in the internals page.

Packaging. mip-split.toml declares bledev its own MIP package, requiring aioble, and adds pydevices[ble] for bleak. The publisher learns those keys in PyDevices/.github#52. Until that is released and this repo's pin moves past publishing-v11, the current publisher ignores the new keys and ships bledev inside pydevices, so merging this first is safe. I built the MIP index locally with that change and installed bledev from it onto an ESP32-S3; one install brought bledev and aioble, and both imported.

Not in this PR: board_config.ble still returns a raw bluetooth.BLE() in the 19 board_peripherals files. nus.serve() wraps one for you (mpble.get()), but the switch §2 describes hasn't been made.

bledev/__init__ is the portable BLE contract (UUID, errors, capabilities,
adapter, services and characteristics on both sides), async and
aioble-shaped. bledev.fake is an in-process loopback that is stricter than
a radio: over-MTU packets, over-max_len writes, unsubscribed notifications,
transmit bursts and receive overruns all raise instead of truncating or
dropping. bledev.nus is the Nordic UART byte stream on top of it, and
bledev.auto picks a backend.

tests/bledev_contract.py runs on CPython and unix MicroPython; test_bledev
runs it both ways and proves five planted faults each fail it.
Both roles, on the one bluetooth.BLE(). Three places aioble loses data
are closed here: a client characteristic keeps a queue instead of one
notification slot (and notified() waits only on an empty queue, since
aioble's wait-when-one-left logic strands the last item of a longer
queue); captured writes are copied in the IRQ into a per-characteristic
queue instead of a shared ten-entry deque; and payloads over mtu - 3 are
refused instead of truncated. Scans default to full duty: aioble's 1 %
default found a board a few inches away once in six seconds.

Proven board to board on two ESP32-S3s: 16 KB each way and echoed,
byte-for-byte, and a planted one-bit flip fails every phase.
tests/bledev_board holds the over-the-air checks run on two ESP32-S3s:
gatt_peripheral/gatt_central (the contract over a real radio), gatt_latency
(per-operation latency) and nus_server/nus_client (the byte-stream gate).
Each has a PLANT switch whose run must fail.

They found three things in mpble: aioble returns discovered services and
characteristics in IRQ-race order (now sorted by handle), a
BufferedCharacteristic loses its initial value when aioble sizes the
buffer after writing it (now written again), and an operation on a dead
connection raised TypeError from inside aioble (now DisconnectedError).
Declares the packaging from docs/ble.md section 3: bledev leaves the
pydevices MIP package so a board without a radio doesn't carry it, one
mip.install("bledev") brings aioble, and on PyPI bledev stays in the
pydevices wheel with bleak behind pydevices[ble]. The publisher learns
these keys in PyDevices/.github (branch bledev-packaging); until that
lands, the current publisher reads only host-only and ships bledev
inside pydevices as before.
bledev.md says what you can do (a nus stream first, then serving and
connecting), where each backend runs, how to install and test, and the
rules every backend keeps. bledev-internals.md is the next agents' brief:
the hooks to fill in, what to translate, how mpble works around aioble,
the checks to run, and the S3 measurements.
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