From 70d0653a0031f40a058f67279f6b6b397bd491fd Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:36:57 -0500 Subject: [PATCH 1/2] bledev: the bleak backend, the BLE REPL and Improv Wi-Fi setup bledev.bleak is the central-only CPython backend. bledev.repl serves the MicroPython REPL over nus behind a WebREPL-style password, from the BLE IRQ so it keeps working at the prompt. bledev.improv is both sides of the Improv Wi-Fi standard. Advertisements can carry service data, which Improv needs. Two mpble fixes the laptop found: notifications that arrive before subscribe() are no longer queued, and an MTU exchanged before MicroPython reports the connection (Windows does this) is no longer lost, which had held every notification to a Windows central at 20 bytes. gatt_central.py and nus_client.py now run on a laptop too. --- lib/bledev/__init__.py | 50 ++- lib/bledev/bleak.py | 449 ++++++++++++++++++++ lib/bledev/fake.py | 5 +- lib/bledev/improv.py | 628 ++++++++++++++++++++++++++++ lib/bledev/mpble.py | 48 ++- lib/bledev/repl.py | 542 ++++++++++++++++++++++++ tests/bledev_board/gatt_central.py | 85 +++- tests/bledev_board/improv_client.py | 88 ++++ tests/bledev_board/improv_server.py | 59 +++ tests/bledev_board/nus_client.py | 72 +++- tests/bledev_board/repl_client.py | 126 ++++++ tests/bledev_board/repl_server.py | 21 + tests/bledev_contract.py | 173 +++++++- 13 files changed, 2295 insertions(+), 51 deletions(-) create mode 100644 lib/bledev/bleak.py create mode 100644 lib/bledev/improv.py create mode 100644 lib/bledev/repl.py create mode 100644 tests/bledev_board/improv_client.py create mode 100644 tests/bledev_board/improv_server.py create mode 100644 tests/bledev_board/repl_client.py create mode 100644 tests/bledev_board/repl_server.py diff --git a/lib/bledev/__init__.py b/lib/bledev/__init__.py index 3fed80bc..49d81a62 100644 --- a/lib/bledev/__init__.py +++ b/lib/bledev/__init__.py @@ -223,29 +223,34 @@ def __hash__(self): ADV_TYPE_UUID128_COMPLETE = 0x07 ADV_TYPE_SHORT_NAME = 0x08 ADV_TYPE_NAME = 0x09 +ADV_TYPE_SERVICE_DATA16 = 0x16 ADV_TYPE_APPEARANCE = 0x19 +ADV_TYPE_SERVICE_DATA32 = 0x20 +ADV_TYPE_SERVICE_DATA128 = 0x21 ADV_TYPE_MANUFACTURER = 0xFF ADV_PAYLOAD_MAX = 31 -def pack_advertisement(name=None, services=None, appearance=0, manufacturer=None): - """Build ``(adv_data, resp_data)`` exactly the way aioble packs them. +def pack_advertisement(name=None, services=None, appearance=0, manufacturer=None, service_data=None): + """Build ``(adv_data, resp_data)``, in aioble's field order. Fields go into the 31-byte advertisement in this order (flags, services, - name, appearance, manufacturer) and overflow into the scan response, which - only an *active* scan sees. That is why a long name can be invisible to a - passive scan: the fake backend uses this so tests see the same thing. - ``manufacturer`` is ``(company_id, data)``. + service data, name, appearance, manufacturer) and overflow into the scan + response, which only an *active* scan sees. That is why a long name can be + invisible to a passive scan. The fake backend and mpble both advertise + what this returns, so tests see what a board sends. + ``manufacturer`` is ``(company_id, data)``; ``service_data`` is a list of + ``(uuid, data)``. """ adv = bytearray() resp = bytearray() def append(adv_type, value): field = bytes((len(value) + 1, adv_type)) + bytes(value) - if len(adv) + len(field) < ADV_PAYLOAD_MAX: + if len(adv) + len(field) <= ADV_PAYLOAD_MAX: adv.extend(field) - elif len(resp) + len(field) < ADV_PAYLOAD_MAX: + elif len(resp) + len(field) <= ADV_PAYLOAD_MAX: resp.extend(field) else: raise ValueError("advertising payload too long") @@ -257,6 +262,11 @@ def append(adv_type, value): group = b"".join(u for u in uuids if len(u) == size) if group: append(code, group) + if service_data: + codes = {2: ADV_TYPE_SERVICE_DATA16, 4: ADV_TYPE_SERVICE_DATA32, 16: ADV_TYPE_SERVICE_DATA128} + for uuid, data in service_data: + raw = UUID(uuid).to_bytes() + append(codes[len(raw)], raw + bytes(data)) if name: append(ADV_TYPE_NAME, name.encode() if isinstance(name, str) else name) if appearance: @@ -312,6 +322,17 @@ def decode_advertisement(*payloads): return name, services, appearance, manufacturer +def decode_service_data(*payloads): + """``[(UUID, data), ...]`` from the service-data fields of raw payloads.""" + sizes = {ADV_TYPE_SERVICE_DATA16: 2, ADV_TYPE_SERVICE_DATA32: 4, ADV_TYPE_SERVICE_DATA128: 16} + found = [] + for adv_type, value in decode_fields(*payloads): + size = sizes.get(adv_type) + if size and len(value) >= size: + found.append((UUID(value[:size]), value[size:])) + return found + + # ---------------------------------------------------------------- the adapter @@ -378,9 +399,11 @@ async def advertise( manufacturer=None, connectable=True, timeout_ms=None, + service_data=None, ): """Advertise until a central connects, and return that :class:`Connection`. + ``service_data`` is a list of ``(uuid, data)`` (Improv uses it). Raises :class:`BLETimeoutError` if nobody connects within ``timeout_ms``. Cancelling the task stops advertising. Advertising stops when a central connects; call again to accept another. @@ -587,13 +610,16 @@ class ScanResult: aioble. ``services()`` returns a list of :class:`UUID`. """ - def __init__(self, device, rssi=None, name=None, services=(), appearance=0, manufacturer=(), connectable=True): + def __init__( + self, device, rssi=None, name=None, services=(), appearance=0, manufacturer=(), connectable=True, service_data=() + ): self.device = device self.rssi = rssi self._name = name self._services = [UUID(s) for s in services] self.appearance = appearance self._manufacturer = list(manufacturer) + self._service_data = [(UUID(u), bytes(d)) for u, d in service_data] self.connectable = connectable def name(self): @@ -605,6 +631,12 @@ def services(self): def manufacturer(self, filter=None): return [(m, d) for m, d in self._manufacturer if filter is None or m == filter] + def service_data(self, uuid=None): + """``[(UUID, data), ...]``, optionally only for ``uuid``.""" + if uuid is not None: + uuid = UUID(uuid) + return [(u, d) for u, d in self._service_data if uuid is None or u == uuid] + def __repr__(self): return "ScanResult({!r}, rssi={})".format(self.device, self.rssi) diff --git a/lib/bledev/bleak.py b/lib/bledev/bleak.py new file mode 100644 index 00000000..df106544 --- /dev/null +++ b/lib/bledev/bleak.py @@ -0,0 +1,449 @@ +"""CPython backend over bleak: Windows, Linux and macOS. Central only. + + from bledev.bleak import BleakBLE + ble = BleakBLE() + +Needs bleak (``pip install "pydevices[ble]"``). A laptop can scan and connect +but can't advertise, so ``advertise()`` and ``register_services()`` raise +:class:`bledev.UnsupportedError`; ``capabilities()`` says so up front. + +Where bleak and the operating system differ from the contract, this backend +translates: + +* bleak hands every notification to a callback. Here each subscribed + characteristic gets a queue of ``queue_limit`` entries, and an overflow + raises from the next ``notified()`` rather than dropping data. +* The OS negotiates the MTU on its own. ``exchange_mtu()`` waits briefly for + that negotiation to finish and returns the result; it can't ask for a + particular value. BlueZ always reports 23 through bleak, so on Linux the MTU + comes from the characteristics' write-without-response size instead. +* A write longer than ``mtu - 3`` raises :class:`bledev.BLEError` before it + reaches the OS, as on every backend, even though a write with response + could go out as a long write. +* bleak can't tell a notification from an indication. Data goes to + ``indicated()`` when you subscribed with ``indicate=True`` alone, and to + ``notified()`` otherwise. +* On Windows, ``connect(priority="throughput")`` (or asking for a connection + interval of 15 ms or less, as ``nus.connect()`` does on a board) asks WinRT + for its throughput-optimized parameters, which roughly quarters the time a + GATT round trip takes. Other hosts ignore it. +""" + +import asyncio +import sys + +from bleak import BleakClient, BleakScanner +from bleak.exc import BleakError + +try: + from bleak.exc import BleakGATTProtocolError +except ImportError: # bleak < 3 + BleakGATTProtocolError = None + +from . import ( + BLE, + BLEError, + ClientCharacteristic, + ClientService, + Connection, + DEFAULT_MTU, + Device, + DisconnectedError, + FLAG_INDICATE, + FLAG_NOTIFY, + FLAG_READ, + FLAG_WRITE, + FLAG_WRITE_NO_RESPONSE, + GattError, + MAX_MTU, + ScanResult, + Scanner, + UnsupportedError, + UUID, + wait_ms, +) + +_PROPERTIES = { + "read": FLAG_READ, + "write-without-response": FLAG_WRITE_NO_RESPONSE, + "write": FLAG_WRITE, + "notify": FLAG_NOTIFY, + "indicate": FLAG_INDICATE, +} + +# How long exchange_mtu() waits for the OS to finish its own negotiation. +_MTU_SETTLE_MS = 1000 + + +def _flags(properties): + flags = 0 + for name in properties: + flags |= _PROPERTIES.get(name, 0) + return flags + + +class _Inbox: + """An ordered queue that raises instead of dropping data.""" + + def __init__(self, limit, what): + self.items = [] + self.event = asyncio.Event() + self.limit = limit + self.what = what + self.overrun = 0 + self.closed = False + + def put(self, item): + if len(self.items) >= self.limit: + self.overrun += 1 + else: + self.items.append(item) + self.event.set() + + def close(self): + self.closed = True + self.event.set() + + async def get(self, timeout_ms): + while True: + if self.overrun: + raise BLEError( + "{} queue overran: {} item(s) dropped because the reader fell " + "{} behind".format(self.what, self.overrun, self.limit) + ) + if self.items: + return self.items.pop(0) + if self.closed: + raise DisconnectedError("{}: disconnected".format(self.what)) + self.event.clear() + await wait_ms(self.event.wait(), timeout_ms, self.what) + + +class BleakBLE(BLE): + """The host's Bluetooth adapter, through bleak. Central only. + + ``queue_limit`` bounds each notification queue. ``mtu`` is recorded for + ``config("mtu")``; the OS picks the real value. Extra keyword arguments in + ``scanner_args`` and ``client_args`` go to ``BleakScanner`` and + ``BleakClient`` (for example ``{"bluez": {"adapter": "hci1"}}``). + """ + + backend = "bleak" + + def __init__(self, *, queue_limit=1024, mtu=MAX_MTU, scanner_args=None, client_args=None): + self.queue_limit = queue_limit + self._mtu = mtu + self._scanner_args = dict(scanner_args or {}) + self._client_args = dict(client_args or {}) + self._connections = [] + + def capabilities(self): + caps = BLE.capabilities(self) + caps.update(central=True, peripheral=False, mtu=self._mtu, max_mtu=MAX_MTU, indicate=True) + return caps + + def config(self, *names, **settings): + for key, value in settings.items(): + if key == "mtu": + if not DEFAULT_MTU <= value <= MAX_MTU: + raise ValueError("mtu must be {}..{}".format(DEFAULT_MTU, MAX_MTU)) + self._mtu = value + else: + raise UnsupportedError("bleak has no setting {!r}".format(key)) + if names: + if len(names) != 1: + raise ValueError("read one setting at a time") + if names[0] == "mtu": + return self._mtu + raise UnsupportedError("bleak has no setting {!r}".format(names[0])) + return None + + def close(self): + """Drop every connection. The OS disconnects in the background; ``aclose()`` waits for it.""" + for connection in list(self._connections): + connection._drop() + try: + asyncio.get_running_loop().create_task(connection._client.disconnect()) + except RuntimeError: + pass + self._connections = [] + + async def aclose(self): + """``close()``, waiting until the OS has let go of every connection.""" + connections = list(self._connections) + self.close() + for connection in connections: + try: + await connection._client.disconnect() + except Exception: + pass + + # -- central + + def scan(self, duration_ms, *, active=False, interval_us=None, window_us=None): + return _BleakScanner(self, duration_ms, active, interval_us, window_us) + + async def _connect(self, device, timeout_ms, **options): + target = device.native if device.native is not None else device.address + connection = None + + def on_disconnect(client): + if connection is not None: + connection._drop() + + args = dict(self._client_args) + if timeout_ms is not None: + args.setdefault("timeout", timeout_ms / 1000) + client = BleakClient(target, disconnected_callback=on_disconnect, **args) + try: + await wait_ms(client.connect(), timeout_ms, "connect to {}".format(device.address)) + except BleakError as e: + raise BLEError("connect to {}: {}".format(device.address, e)) + connection = _BleakConnection(self, device, client) + self._connections.append(connection) + if _wants_throughput(options): + connection.request_throughput() + return connection + + +def _wants_throughput(options): + if options.get("priority") == "throughput": + return True + interval = options.get("max_conn_interval_us") or options.get("min_conn_interval_us") + return interval is not None and interval <= 15000 + + +class _BleakScanner(Scanner): + async def _start(self): + mode = "active" if self.active or sys.platform == "darwin" else "passive" + self._scanner = BleakScanner(self._detected, scanning_mode=mode, **self._ble._scanner_args) + try: + await self._scanner.start() + except BleakError as e: + raise BLEError("scan: {}".format(e)) + self._timer = asyncio.get_running_loop().create_task(self._run_out()) + + async def _run_out(self): + await asyncio.sleep(self.duration_ms / 1000) + self._timer = None + await self.cancel() + + async def _stop(self): + timer = getattr(self, "_timer", None) + if timer is not None and timer is not asyncio.current_task(): + timer.cancel() + self._timer = None + scanner = getattr(self, "_scanner", None) + if scanner is not None: + self._scanner = None + try: + await scanner.stop() + except BleakError: + pass + + def _detected(self, native, adv): + if self._done: + return + name = adv.local_name or None + self._push( + ScanResult( + Device(self._ble, native.address, name=name or native.name, native=native), + rssi=adv.rssi, + name=name, + services=[UUID(u) for u in adv.service_uuids], + manufacturer=[(k, bytes(v)) for k, v in adv.manufacturer_data.items()], + service_data=[(UUID(k), bytes(v)) for k, v in adv.service_data.items()], + ) + ) + + +def _translate(e, connection, what): + """A bleak or OS exception, as the bledev error it means.""" + if isinstance(e, BLEError): + return e + if BleakGATTProtocolError is not None and isinstance(e, BleakGATTProtocolError): + return GattError(int(e.args[0]), "{}: {}".format(what, e)) + if not connection.is_connected(): + return DisconnectedError("{}: disconnected".format(what)) + return BLEError("{}: {}".format(what, e)) + + +class _BleakConnection(Connection): + def __init__(self, ble, device, client): + Connection.__init__(self, ble, device, "central") + self._client = client + self._dropped = asyncio.Event() + self._inboxes = [] + self._wwr = 0 # the largest write-without-response size seen in discovery + + def _drop(self): + if self._dropped.is_set(): + return + self._dropped.set() + for inbox in self._inboxes: + inbox.close() + if self in self._ble._connections: + self._ble._connections.remove(self) + + @property + def mtu(self): + mtu = DEFAULT_MTU + try: + mtu = max(mtu, int(self._client.mtu_size)) + except Exception: + pass + if self._wwr: + mtu = max(mtu, self._wwr + 3) + return mtu + + def is_connected(self): + if self._dropped.is_set(): + return False + try: + return bool(self._client.is_connected) + except Exception: + return False + + async def disconnect(self, timeout_ms=2000): + try: + await wait_ms(self._client.disconnect(), timeout_ms, "disconnect") + except BleakError: + pass + finally: + self._drop() + + async def disconnected(self, timeout_ms=None): + if self.is_connected(): + await wait_ms(self._dropped.wait(), timeout_ms, "disconnected") + + 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") + if mtu: + self._ble.config(mtu=mtu) + waited = 0 + limit = min(timeout_ms if timeout_ms is not None else _MTU_SETTLE_MS, _MTU_SETTLE_MS) + while self.mtu == DEFAULT_MTU and waited < limit and self.is_connected(): + await asyncio.sleep(0.02) + waited += 20 + return self.mtu + + def request_throughput(self): + """Ask the OS for a short connection interval. Windows 11 only; returns whether it asked.""" + requester = getattr(getattr(self._client, "_backend", None), "_requester", None) + if requester is None: + return False + try: + from winrt.windows.devices.bluetooth import BluetoothLEPreferredConnectionParameters + + requester.request_preferred_connection_parameters( + BluetoothLEPreferredConnectionParameters.throughput_optimized + ) + return True + except Exception: + return False + + def _live(self, what): + if not self.is_connected(): + raise DisconnectedError("{}: not connected".format(what)) + + async def _discover_services(self, uuid, timeout_ms): + self._live("service discovery") + found = [] + for service in sorted(self._client.services, key=lambda s: s.handle): + wrapped = _BleakClientService(self, service) + if uuid is None or wrapped.uuid == uuid: + found.append(wrapped) + return found + + +class _BleakClientService(ClientService): + def __init__(self, connection, native): + ClientService.__init__(self, connection, native.uuid) + self._native = native + + async def _discover_characteristics(self, uuid, timeout_ms): + self.connection._live("characteristic discovery") + found = [] + for native in sorted(self._native.characteristics, key=lambda c: c.handle): + try: + wwr = native.max_write_without_response_size + except Exception: + wwr = 0 + if wwr > self.connection._wwr: + self.connection._wwr = wwr + wrapped = _BleakClientCharacteristic(self, native) + if uuid is None or wrapped.uuid == uuid: + found.append(wrapped) + return found + + +class _BleakClientCharacteristic(ClientCharacteristic): + def __init__(self, service, native): + ClientCharacteristic.__init__(self, service, native.uuid, _flags(native.properties)) + self._native = native + self._client = service.connection._client + limit = service.connection._ble.queue_limit + self._notify_inbox = _Inbox(limit, "notify {}".format(self.uuid)) + self._indicate_inbox = _Inbox(limit, "indicate {}".format(self.uuid)) + service.connection._inboxes.extend((self._notify_inbox, self._indicate_inbox)) + self._route = None + self._subscribed = False + + def _live(self, what): + self.connection._live(what) + + async def read(self, timeout_ms=1000): + self._check(FLAG_READ, "read") + self._live("read") + try: + return bytes(await wait_ms(self._client.read_gatt_char(self._native), timeout_ms, "read")) + except (BleakError, OSError) as e: + raise _translate(e, self.connection, "read") + + async def write(self, data, response=None, timeout_ms=1000): + data = bytes(data) + response = self._want_response(response) + self._live("write") + mtu = self.connection.mtu + if len(data) > mtu - 3: + raise BLEError( + "write of {} bytes exceeds the ATT payload of {} (mtu {} - 3)".format(len(data), mtu - 3, mtu) + ) + try: + await wait_ms(self._client.write_gatt_char(self._native, data, response=response), timeout_ms, "write") + except (BleakError, OSError) as e: + raise _translate(e, self.connection, "write") + + def _received(self, sender, data): + inbox = self._indicate_inbox if self._route == "indicate" else self._notify_inbox + inbox.put(bytes(data)) + + async def subscribe(self, notify=True, indicate=False): + if notify: + self._check(FLAG_NOTIFY, "notify") + if indicate: + self._check(FLAG_INDICATE, "indicate") + self._live("subscribe") + try: + if self._subscribed: + await self._client.stop_notify(self._native) + self._subscribed = False + if not notify and not indicate: + return + self._route = "indicate" if indicate and not notify else "notify" + kwargs = {} + if self._route == "indicate" and sys.platform == "win32": + kwargs["force_indicate"] = True + await self._client.start_notify(self._native, self._received, **kwargs) + self._subscribed = True + except (BleakError, OSError) as e: + raise _translate(e, self.connection, "subscribe") + + async def notified(self, timeout_ms=None): + self._check(FLAG_NOTIFY, "notify") + return await self._notify_inbox.get(timeout_ms) + + async def indicated(self, timeout_ms=None): + self._check(FLAG_INDICATE, "indicate") + return await self._indicate_inbox.get(timeout_ms) diff --git a/lib/bledev/fake.py b/lib/bledev/fake.py index 49ab8a89..71eb66f5 100644 --- a/lib/bledev/fake.py +++ b/lib/bledev/fake.py @@ -52,6 +52,7 @@ Scanner, UnsupportedError, decode_advertisement, + decode_service_data, pack_advertisement, sleep_ms, wait_ms, @@ -227,10 +228,11 @@ async def advertise( manufacturer=None, connectable=True, timeout_ms=None, + service_data=None, ): if not self._peripheral: raise UnsupportedError("this fake adapter is central only; it cannot advertise") - adv_data, resp_data = pack_advertisement(name, services, appearance, manufacturer) + adv_data, resp_data = pack_advertisement(name, services, appearance, manufacturer, service_data) advert = _Advert(self, adv_data, resp_data, connectable) self._air.adverts.append(advert) self._adverts.append(advert) @@ -304,6 +306,7 @@ async def _run(self): appearance=appearance, manufacturer=manufacturer, connectable=advert.connectable, + service_data=decode_service_data(*payloads), ) ) await sleep_ms(SCAN_TICK_MS) diff --git a/lib/bledev/improv.py b/lib/bledev/improv.py new file mode 100644 index 00000000..15f15bbc --- /dev/null +++ b/lib/bledev/improv.py @@ -0,0 +1,628 @@ +"""Wi-Fi setup over BLE with the Improv standard (https://www.improv-wifi.com/ble/). + +A board with no Wi-Fi credentials advertises the Improv service; a phone, a +laptop or another board sends it a network name and password; the board joins +and answers with a URL. ESPHome, WLED and Home Assistant speak the same +protocol, so their tools can provision a bledev board too. + +On the board (``serve()`` returns once it has joined):: + + import bledev.mpble, bledev.improv as improv + url = await improv.serve(bledev.mpble.get(), name="kitchen") + +From a laptop or another board:: + + import bledev.improv as improv + url = await improv.provision(ble, ssid, password, name="kitchen") + +A network that can't be joined raises :class:`ImprovError` with +``code == ERROR_UNABLE_TO_CONNECT``, which the board also reports in its error +characteristic. + +The credentials cross the air unencrypted; that is the Improv standard, and +the reason a board should only serve it until it's set up. ``serve()`` never +logs them. Saving them for the next boot is the app's job (``on_join``), so +nothing here writes a credential to the filesystem. +""" + +try: + import asyncio +except ImportError: # pragma: no cover + import uasyncio as asyncio + +import sys + +from . import ( + BLEError, + BLETimeoutError, + BusyError, + Characteristic, + DisconnectedError, + MAX_MTU, + Service, + UUID, + sleep_ms, + wait_ms, +) + +SERVICE = UUID("00467768-6228-2272-4663-277478268000") +STATE = UUID("00467768-6228-2272-4663-277478268001") +ERROR = UUID("00467768-6228-2272-4663-277478268002") +RPC = UUID("00467768-6228-2272-4663-277478268003") +RESULT = UUID("00467768-6228-2272-4663-277478268004") +CAPABILITIES = UUID("00467768-6228-2272-4663-277478268005") +#: The 16-bit UUID the advertisement's service data is filed under. +SERVICE_DATA = UUID(0x4677) + +STATE_AUTHORIZATION_REQUIRED = 0x01 +STATE_AUTHORIZED = 0x02 +STATE_PROVISIONING = 0x03 +STATE_PROVISIONED = 0x04 + +ERROR_NONE = 0x00 +ERROR_INVALID_RPC = 0x01 +ERROR_UNKNOWN_RPC = 0x02 +ERROR_UNABLE_TO_CONNECT = 0x03 +ERROR_NOT_AUTHORIZED = 0x04 +ERROR_BAD_HOSTNAME = 0x05 +ERROR_UNKNOWN = 0xFF + +CMD_WIFI_SETTINGS = 0x01 +CMD_IDENTIFY = 0x02 +CMD_DEVICE_INFO = 0x03 +CMD_WIFI_SCAN = 0x04 +CMD_HOSTNAME = 0x05 +CMD_DEVICE_NAME = 0x06 + +CAP_IDENTIFY = 0x01 +CAP_DEVICE_INFO = 0x02 +CAP_WIFI_SCAN = 0x04 +CAP_HOSTNAME = 0x08 + +#: How long an authorization given with ``authorize()`` lasts (the standard +#: suggests about a minute). +AUTHORIZATION_MS = 60000 + +_ERROR_NAMES = { + ERROR_INVALID_RPC: "invalid RPC packet", + ERROR_UNKNOWN_RPC: "unknown RPC command", + ERROR_UNABLE_TO_CONNECT: "unable to connect", + ERROR_NOT_AUTHORIZED: "not authorized", + ERROR_BAD_HOSTNAME: "bad hostname", + ERROR_UNKNOWN: "unknown error", +} + + +class ImprovError(BLEError): + """The device reported an Improv error. ``code`` is one of ``ERROR_*``.""" + + def __init__(self, code, message=None): + super().__init__(message or "Improv error 0x{:02x}: {}".format(code, _ERROR_NAMES.get(code, "?"))) + self.code = code + + +# ---------------------------------------------------------------- packets + + +def checksum(data): + """The standard's checksum: the sum of every byte, keeping the low byte.""" + return sum(data) & 0xFF + + +def _encode(s): + return s.encode() if isinstance(s, str) else bytes(s) + + +def pack(command, *strings): + """An RPC command or result: ``command, length, (len, bytes)..., checksum``.""" + body = bytearray() + for s in strings: + s = _encode(s) + if len(s) > 255: + raise ValueError("an Improv string is at most 255 bytes") + body.append(len(s)) + body.extend(s) + if len(body) > 255: + raise ValueError("an Improv packet carries at most 255 bytes") + packet = bytes((command, len(body))) + bytes(body) + return packet + bytes((checksum(packet),)) + + +def packet_length(buffer): + """The full length of the packet that starts ``buffer``, or ``None`` until two bytes are in.""" + if len(buffer) < 2: + return None + return buffer[1] + 3 + + +def unpack(packet): + """``(command, [bytes, ...])`` from one whole packet. Raises :class:`ImprovError`.""" + packet = bytes(packet) + if len(packet) < 3 or len(packet) != packet[1] + 3: + raise ImprovError(ERROR_INVALID_RPC, "Improv packet has the wrong length") + if checksum(packet[:-1]) != packet[-1]: + raise ImprovError(ERROR_INVALID_RPC, "Improv packet checksum mismatch") + body = packet[2:-1] + strings = [] + i = 0 + while i < len(body): + n = body[i] + if i + 1 + n > len(body): + raise ImprovError(ERROR_INVALID_RPC, "Improv string runs past the packet") + strings.append(body[i + 1 : i + 1 + n]) + i += 1 + n + return packet[0], strings + + +def service_data(state, capabilities): + """The six bytes the advertisement carries under :data:`SERVICE_DATA`.""" + return bytes((state, capabilities, 0, 0, 0, 0)) + + +# ---------------------------------------------------------------- joining Wi-Fi on a board + +# network.WLAN statuses that mean the attempt failed rather than is under way. +_FAILED = (200, 201, 202, 203, 204) # beacon timeout, no AP, wrong password, assoc fail, handshake timeout +_FAIL_AFTER_MS = 5000 + + +async def wifi_connect(ssid, password, timeout_ms=20000): + """Join ``ssid`` with MicroPython's ``network.WLAN``; the IP address, or ``None``. + + A status that means failure (no such network, wrong password) ends the + attempt after it has held for five seconds, since the ESP32 retries a + few times on its own; otherwise it waits up to ``timeout_ms``. + """ + import network + + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + try: + wlan.disconnect() + except OSError: + pass + wlan.connect(ssid, password) + waited = 0 + failing = 0 + while waited < timeout_ms: + if wlan.isconnected(): + ip = wlan.ifconfig()[0] + if ip and ip != "0.0.0.0": + return ip + status = wlan.status() + failing = failing + 250 if status in _FAILED or status == getattr(network, "STAT_IDLE", -1) else 0 + if failing >= _FAIL_AFTER_MS: + break + await sleep_ms(250) + waited += 250 + try: + wlan.disconnect() + except OSError: + pass + return None + + +def wifi_scan(): + """``[(ssid, rssi, secured), ...]`` from ``network.WLAN.scan()``, strongest first.""" + import network + + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + found = {} + for ssid, _bssid, _channel, rssi, security, _hidden in wlan.scan(): + ssid = ssid.decode("utf-8", "replace") if isinstance(ssid, bytes) else ssid + if ssid and (ssid not in found or found[ssid][1] < rssi): + found[ssid] = (ssid, rssi, security != 0) + return sorted(found.values(), key=lambda n: -n[1]) + + +def _default_device_info(name): + impl = sys.implementation + version = ".".join(str(v) for v in impl.version[:3]) + chip = sys.platform + try: + import os + + chip = os.uname().machine + except (ImportError, AttributeError): + pass + return (impl.name, version, chip, name) + + +# ---------------------------------------------------------------- the device side + + +class Server: + """The Improv service on a peripheral adapter. + + ``join(ssid, password)`` is an async callable returning the IP address + as a string, or ``None`` if the network couldn't be joined; the default + is :func:`wifi_connect`, on MicroPython. ``redirect`` is the URL the client + is sent to afterwards, with ``{ip}`` filled in; ``None`` sends none. + ``on_join(ssid, password, ip)`` runs after a successful join, so the app + can store the credentials for the next boot. + + ``require_authorization=True`` starts in "authorization required" until + :meth:`authorize` (from a button, say). ``identify`` is a callable that + makes the device noticeable (blink, beep); ``scan`` returns + ``[(ssid, rssi, secured), ...]``. Each one given turns on its capability. + """ + + def __init__( + self, + ble, + name="improv", + *, + join=None, + redirect="http://{ip}/", + on_join=None, + require_authorization=False, + identify=None, + scan=None, + device_info=None, + ): + self.ble = ble + self.name = name + self._join = join or wifi_connect + self.redirect = redirect + self._on_join = on_join + self._identify = identify + if scan is None and sys.implementation.name == "micropython": + scan = wifi_scan + self._scan = scan + self._device_info = tuple(device_info or _default_device_info(name)) + caps = CAP_DEVICE_INFO + if identify is not None: + caps |= CAP_IDENTIFY + if scan is not None: + caps |= CAP_WIFI_SCAN + self.capabilities = caps + self.state = STATE_AUTHORIZATION_REQUIRED if require_authorization else STATE_AUTHORIZED + self.error = ERROR_NONE + self.url = None + self.ip = None + self._require_authorization = require_authorization + self._authorized_until = None + self._connection = None + + self.service = Service(SERVICE) + self._state = Characteristic(self.service, STATE, read=True, notify=True, initial=bytes((self.state,))) + self._error = Characteristic(self.service, ERROR, read=True, notify=True, initial=b"\x00") + self._rpc = Characteristic( + self.service, RPC, write=True, write_no_response=True, capture=True, max_len=MAX_MTU - 3 + ) + self._result = Characteristic(self.service, RESULT, read=True, notify=True, max_len=260) + self._caps = Characteristic(self.service, CAPABILITIES, read=True, initial=bytes((caps,))) + self._registered = False + + # -- state + + def authorize(self): + """Mark the device authorized for :data:`AUTHORIZATION_MS` (from a button press).""" + if self.state == STATE_AUTHORIZATION_REQUIRED: + self._authorized_until = AUTHORIZATION_MS + self.state = STATE_AUTHORIZED + self._state.write(bytes((STATE_AUTHORIZED,))) + if self._connection is not None: + asyncio.create_task(self._notify(self._state, bytes((STATE_AUTHORIZED,)))) + + async def _set_state(self, state): + self.state = state + self._state.write(bytes((state,))) + await self._notify(self._state, bytes((state,))) + + async def _set_error(self, error): + self.error = error + self._error.write(bytes((error,))) + await self._notify(self._error, bytes((error,))) + + async def _notify(self, characteristic, data): + connection = self._connection + if connection is None: + return + size = connection.mtu - 3 + for i in range(0, len(data), size): + while True: + try: + characteristic.notify(connection, data[i : i + size]) + break + except BusyError: + await sleep_ms(2) + except DisconnectedError: + return + + async def _send_result(self, command, *strings): + packet = pack(command, *strings) + self._result.write(packet) + await self._notify(self._result, packet) + + # -- serving + + def _register(self): + if not self._registered: + self.ble.register_services(self.service) + self._registered = True + + async def serve(self, timeout_ms=None): + """Advertise until a client provisions the device; return the redirect URL (or IP). + + ``timeout_ms`` bounds each wait for a client to connect. + """ + self._register() + while True: + connection = await self.ble.advertise( + name=self.name, + services=[SERVICE], + service_data=[(SERVICE_DATA, service_data(self.state, self.capabilities))], + timeout_ms=timeout_ms, + ) + if await self.session(connection): + return self.url if self.url is not None else self.ip + + async def session(self, connection): + """Serve one connected client. True once the device is provisioned.""" + self._connection = connection + buffer = bytearray() + clock = 0 + try: + while connection.is_connected(): + try: + writer, data = await self._rpc.written(timeout_ms=250) + except BLETimeoutError: + clock += 250 + await self._tick(250) + if self.state == STATE_PROVISIONED and clock > 3000: + break # the client has had time to read the result + continue + if writer is not connection and writer != connection: + continue + buffer.extend(data) + while True: + size = packet_length(buffer) + if size is None or len(buffer) < size: + break + packet, buffer = bytes(buffer[:size]), buffer[size:] + clock = 0 + await self._handle(packet) + finally: + self._connection = None + if connection.is_connected() and self.state == STATE_PROVISIONED: + try: + await connection.disconnect() + except BLEError: + pass + return self.state == STATE_PROVISIONED + + async def _tick(self, ms): + if self._authorized_until is not None: + self._authorized_until -= ms + if self._authorized_until <= 0: + self._authorized_until = None + if self.state == STATE_AUTHORIZED: + await self._set_state(STATE_AUTHORIZATION_REQUIRED) + + async def _handle(self, packet): + try: + command, strings = unpack(packet) + except ImprovError as e: + await self._set_error(e.code) + return + if command == CMD_WIFI_SETTINGS: + await self._wifi(strings) + elif command == CMD_IDENTIFY and self._identify is not None: + await self._set_error(ERROR_NONE) + self._identify() + elif command == CMD_DEVICE_INFO: + await self._set_error(ERROR_NONE) + await self._send_result(CMD_DEVICE_INFO, *self._device_info) + elif command == CMD_WIFI_SCAN and self._scan is not None: + await self._set_error(ERROR_NONE) + for ssid, rssi, secured in self._scan(): + await self._send_result(CMD_WIFI_SCAN, ssid, str(rssi), "YES" if secured else "NO") + await self._send_result(CMD_WIFI_SCAN) + else: + await self._set_error(ERROR_UNKNOWN_RPC) + + async def _wifi(self, strings): + if len(strings) != 2: + await self._set_error(ERROR_INVALID_RPC) + return + if self.state == STATE_AUTHORIZATION_REQUIRED: + await self._set_error(ERROR_NOT_AUTHORIZED) + return + ssid = strings[0].decode() + password = strings[1].decode() + await self._set_error(ERROR_NONE) + await self._set_state(STATE_PROVISIONING) + try: + ip = await self._join(ssid, password) + except Exception: + ip = None + if not ip: + await self._set_error(ERROR_UNABLE_TO_CONNECT) + await self._set_state(STATE_AUTHORIZATION_REQUIRED if self._require_authorization and self._authorized_until is None else STATE_AUTHORIZED) + return + self.ip = ip + self.url = self.redirect.format(ip=ip) if self.redirect else None + if self._on_join is not None: + self._on_join(ssid, password, ip) + await self._set_state(STATE_PROVISIONED) + if self.url: + await self._send_result(CMD_WIFI_SETTINGS, self.url) + else: + await self._send_result(CMD_WIFI_SETTINGS) + + +async def serve(ble, name="improv", *, timeout_ms=None, **options): + """Serve Improv on ``ble`` until provisioned; return the redirect URL (or the IP). + + ``options`` go to :class:`Server`. + """ + return await Server(ble, name, **options).serve(timeout_ms) + + +# ---------------------------------------------------------------- the client side + + +class Client: + """A connection to an Improv device. Use :meth:`connect`, or :func:`provision`.""" + + def __init__(self, connection, chars): + self.connection = connection + self._state, self._error, self._rpc, self._result, self._caps = chars + self._events = [] + self._event = asyncio.Event() + self._tasks = [] + self.capabilities = 0 + + @classmethod + async def connect(cls, ble, name=None, *, device=None, timeout_ms=10000): + """Find the device (by ``name``, or the first advertising Improv), connect, subscribe.""" + if device is None: + device = await ble.find(name=name, service=SERVICE, timeout_ms=timeout_ms) + connection = await device.connect(timeout_ms=timeout_ms) + try: + try: + await connection.exchange_mtu(MAX_MTU) + except BLEError: + pass + service = await connection.service(SERVICE) + if service is None: + raise BLEError("{!r} has no Improv service".format(device)) + chars = [] + for uuid in (STATE, ERROR, RPC, RESULT, CAPABILITIES): + c = await service.characteristic(uuid) + if c is None: + raise BLEError("{!r}: the Improv service lacks {}".format(device, uuid)) + chars.append(c) + client = cls(connection, chars) + await client._start() + except BaseException: + if connection.is_connected(): + await connection.disconnect() + raise + return client + + async def _start(self): + for kind, c in (("state", self._state), ("error", self._error), ("result", self._result)): + await c.subscribe(notify=True) + self._tasks.append(asyncio.create_task(self._watch(kind, c))) + caps = await self._caps.read() + self.capabilities = caps[0] if caps else 0 + + async def _watch(self, kind, characteristic): + try: + while True: + # Await first: ``self._events`` is replaced between commands. + data = await characteristic.notified() + self._events.append((kind, data)) + self._event.set() + except BLEError as e: + self._events.append(("gone", e)) + self._event.set() + + async def _next_event(self, timeout_ms): + while not self._events: + self._event.clear() + await wait_ms(self._event.wait(), timeout_ms, "Improv reply") + return self._events.pop(0) + + async def state(self): + """The device's current ``STATE_*``.""" + return (await self._state.read())[0] + + async def error(self): + """The device's current ``ERROR_*``.""" + return (await self._error.read())[0] + + async def close(self): + for task in self._tasks: + task.cancel() + self._tasks = [] + if self.connection.is_connected(): + await self.connection.disconnect() + + async def _command(self, packet): + size = self.connection.mtu - 3 + for i in range(0, len(packet), size): + await self._rpc.write(packet[i : i + size]) + + async def _results(self, command, timeout_ms): + """Collect RPC results for ``command``; raise on a device error.""" + buffer = bytearray() + while True: + kind, data = await self._next_event(timeout_ms) + if kind == "gone": + raise data + if kind == "error" and data and data[0] != ERROR_NONE: + raise ImprovError(data[0]) + if kind == "state": + continue + if kind == "result": + buffer.extend(data) + size = packet_length(buffer) + if size is not None and len(buffer) >= size: + packet, buffer = bytes(buffer[:size]), buffer[size:] + got_command, strings = unpack(packet) + if got_command == command: + return strings + + async def send_wifi(self, ssid, password, timeout_ms=60000): + """Send credentials; return the redirect URL (``""`` if the device sends none). + + Raises :class:`ImprovError` (``ERROR_UNABLE_TO_CONNECT`` for a network + the device couldn't join). + """ + state = await self.state() + if state == STATE_AUTHORIZATION_REQUIRED: + state = await self._await_state(STATE_AUTHORIZED, timeout_ms) + self._events = [] + await self._command(pack(CMD_WIFI_SETTINGS, ssid, password)) + strings = await self._results(CMD_WIFI_SETTINGS, timeout_ms) + state = await self.state() + if state != STATE_PROVISIONED: + raise ImprovError(ERROR_UNKNOWN, "the device sent a result but reports state {}".format(state)) + return strings[0].decode() if strings else "" + + async def _await_state(self, want, timeout_ms): + while True: + try: + kind, data = await self._next_event(timeout_ms) + except BLETimeoutError: + raise ImprovError(ERROR_NOT_AUTHORIZED, "the device was not authorized in time") + if kind == "gone": + raise data + if kind == "state" and data and data[0] == want: + return want + + async def device_info(self, timeout_ms=5000): + """``[firmware, version, chip, device name, ...]`` as strings.""" + self._events = [] + await self._command(pack(CMD_DEVICE_INFO)) + return [s.decode() for s in await self._results(CMD_DEVICE_INFO, timeout_ms)] + + async def identify(self): + await self._command(pack(CMD_IDENTIFY)) + + async def scan(self, timeout_ms=20000): + """``[(ssid, rssi, secured), ...]`` as the device sees them.""" + self._events = [] + await self._command(pack(CMD_WIFI_SCAN)) + found = [] + while True: + strings = await self._results(CMD_WIFI_SCAN, timeout_ms) + if not strings: + return found + found.append((strings[0].decode(), int(strings[1]), strings[2] == b"YES")) + + +async def provision(ble, ssid, password, name=None, *, device=None, timeout_ms=60000): + """Connect to an Improv device, send it ``ssid`` and ``password``, and return its redirect URL. + + Raises :class:`ImprovError` if it can't join. + """ + client = await Client.connect(ble, name, device=device, timeout_ms=min(timeout_ms, 20000)) + try: + return await client.send_wifi(ssid, password, timeout_ms) + finally: + await client.close() diff --git a/lib/bledev/mpble.py b/lib/bledev/mpble.py index c9eca2ea..7dc3f282 100644 --- a/lib/bledev/mpble.py +++ b/lib/bledev/mpble.py @@ -51,10 +51,15 @@ Scanner, UnsupportedError, UUID, + decode_service_data, + pack_advertisement, sleep_ms, ) +_IRQ_CENTRAL_CONNECT = 1 +_IRQ_CENTRAL_DISCONNECT = 2 _IRQ_GATTS_WRITE = 3 +_IRQ_MTU_EXCHANGED = 21 # errno values NimBLE uses when its buffers are full (ENOMEM, EAGAIN, EBUSY, # ENOBUFS). A send that sees one of these waits and tries again. @@ -90,18 +95,25 @@ def _errno(e): class _Queue: """Stands in for aioble's one-slot deque: same three operations, but it - keeps ``limit`` entries and turns an overflow into an error.""" + keeps ``limit`` entries and turns an overflow into an error. + + It ignores everything until ``subscribe()`` opens it. NimBLE's server + notifies whether or not the central subscribed, and the contract (like + bleak and browsers) delivers nothing before a subscription.""" def __init__(self, limit, what): self._items = [] self._limit = limit self._what = what self.overrun = 0 + self.accepting = False def __len__(self): return len(self._items) def append(self, item): + if not self.accepting: + return if len(self._items) >= self._limit: self.overrun += 1 else: @@ -143,6 +155,8 @@ def __exit__(self, exc_type, exc, tb): # Captured characteristics by value handle, for the IRQ handler. Module-level # because there is one radio however many MPBLE objects wrap it. _captures = {} +# MTUs exchanged before their connection existed, by connection handle. +_early_mtu = {} _irq_registered = False _shared = None @@ -159,6 +173,23 @@ def _irq(event, data): _AioConnection._connected.get(conn_handle), bytes(_aioble_core.ble.gatts_read(value_handle)), ) + elif event == _IRQ_MTU_EXCHANGED: + # When a central (Windows, for one) exchanges the MTU at once, + # MicroPython delivers the exchange *before* the connect event, and + # aioble, with no connection to give it to, drops it. The link then + # runs at the negotiated MTU while this side believes 23, so every + # notification carries 20 bytes. Keep it for the connect below. + conn_handle, mtu = data + if conn_handle not in _AioConnection._connected: + _early_mtu[conn_handle] = mtu + elif event == _IRQ_CENTRAL_CONNECT: + conn_handle = data[0] + mtu = _early_mtu.pop(conn_handle, None) + aconn = _AioConnection._connected.get(conn_handle) + if mtu and aconn is not None and not aconn.mtu: + aconn.mtu = mtu + elif event == _IRQ_CENTRAL_DISCONNECT: + _early_mtu.pop(data[0], None) return None @@ -294,14 +325,16 @@ async def advertise( manufacturer=None, connectable=True, timeout_ms=None, + service_data=None, ): + # Packed here rather than by aioble, so the fake and the board send + # the same bytes, and so service data (which aioble can't pack) fits. + adv_data, resp_data = pack_advertisement(name, services, appearance, manufacturer, service_data) with _Translate("advertise"): aconn = await aioble.advertise( interval_us, - name=name.encode() if isinstance(name, str) else name, - services=[_buuid(s) for s in services] if services else None, - appearance=appearance, - manufacturer=manufacturer, + adv_data=adv_data, + resp_data=resp_data or None, connectable=connectable, timeout_ms=timeout_ms, ) @@ -368,6 +401,7 @@ async def __anext__(self): services=[_uuid(u) for u in r.services()], manufacturer=list(r.manufacturer()), connectable=r.connectable, + service_data=decode_service_data(r.adv_data, r.resp_data), ) @@ -480,6 +514,10 @@ async def subscribe(self, notify=True, indicate=False): if indicate: self._check(FLAG_INDICATE, "indicate") self._live("subscribe") + a = self._achar + for queue, on in ((getattr(a, "_notify_queue", None), notify), (getattr(a, "_indicate_queue", None), indicate)): + if isinstance(queue, _Queue): + queue.accepting = bool(on) with _Translate("subscribe"): try: await self._achar.subscribe(notify, indicate) diff --git a/lib/bledev/repl.py b/lib/bledev/repl.py new file mode 100644 index 00000000..b07f6b94 --- /dev/null +++ b/lib/bledev/repl.py @@ -0,0 +1,542 @@ +"""The MicroPython REPL over Bluetooth, behind a password. + +On the board, opt in from ``main.py`` (nothing starts it for you):: + + import bledev.repl + bledev.repl.start(password="correct horse") # or webrepl_cfg.PASS + +The board then advertises the Nordic UART service as ``name`` while the normal +REPL carries on. A client connects, sends an empty line to get +``Password: ``, sends the password and a newline, and gets the prompt. From a +laptop or another board:: + + import bledev.repl + link = await bledev.repl.connect(ble, "correct horse", name="mpy-repl") + await link.write(b"1 + 1\\r") + +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). + +**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 +from interrupts rather than asyncio, so it keeps serving at the ``>>>`` +prompt and while your program runs, and Ctrl-C interrupts a running program +as it does over USB. ``stop()`` gives the radio back. It's MicroPython only; +``connect()`` runs anywhere bledev does. +""" + +import sys + +from . import BLEError, pack_advertisement, wait_ms +from . import nus + +PROMPT = b"Password: " +BANNER = b"\r\nbledev REPL connected\r\n>>> " +DENIED = b"\r\nAccess denied\r\n" + +#: Longest password line the board will buffer before refusing. +MAX_PASSWORD = 64 + + +class AuthError(BLEError): + """The board refused the password.""" + + +def _check_password(given, want): + """Constant-time comparison of two byte strings.""" + diff = len(given) ^ len(want) + for i in range(len(given)): + diff |= given[i] ^ want[i % len(want)] if want else 1 + return diff == 0 + + +class Login: + """The password exchange, without a radio, so it can be tested anywhere. + + Feed it what the client writes. It answers with bytes to send back and a + verdict: ``None`` while waiting, ``True`` with any bytes that followed the + password (they go to the REPL), or ``False`` for a refusal. + """ + + def __init__(self, password): + self._password = password.encode() if isinstance(password, str) else bytes(password) + self._line = bytearray() + self.verdict = None + + def feed(self, data): + """``(reply, verdict, rest)`` for a chunk the client wrote.""" + if self.verdict is not None: + return b"", self.verdict, b"" + reply = b"" + for i in range(len(data)): + b = data[i] + if b in (10, 13): + if not self._line: + reply = PROMPT # an empty line asks for the prompt; no attempt used + continue + self.verdict = _check_password(bytes(self._line), self._password) + self._line = bytearray() + rest = bytes(data[i + 1 :]) + if self.verdict: + # Drop the other half of a CRLF so the REPL doesn't see a blank line. + if b == 13 and rest[:1] == b"\n": + rest = rest[1:] + return BANNER, True, rest + return DENIED, False, b"" + if len(self._line) >= MAX_PASSWORD: + self.verdict = False + return DENIED, False, b"" + self._line.append(b) + return reply, None, b"" + + +# ---------------------------------------------------------------- the board side + +_server = None + + +def start(password=None, name="mpy-repl", *, interval_us=100000, dupterm_index=0): + """Serve the REPL over BLE until :func:`stop` or a reset. MicroPython only. + + ``password`` defaults to ``webrepl_cfg.PASS`` when that file exists; + it must be at least 4 characters. Returns at once. + """ + global _server + if sys.implementation.name != "micropython": + raise BLEError("bledev.repl serves only on MicroPython; use connect() on this host") + 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: + raise ValueError("the password must be at least 4 characters") + stop() + _server = _Server(password, name, interval_us, dupterm_index) + _server.start() + return _server + + +def stop(): + """Stop serving: drop the client, give the REPL back, stop advertising.""" + global _server + if _server is not None: + _server.stop() + _server = None + + +def is_running(): + return _server is not None + + +try: + import io + + _IOBase = io.IOBase +except (ImportError, AttributeError): # CPython without the MicroPython stream protocol + _IOBase = object + + +class _Stream(_IOBase): + """What ``os.dupterm`` reads from and writes to.""" + + def __init__(self, server): + self._s = server + + def readinto(self, buf): + s = self._s + n = len(s.rx) - s.rx_pos + if n <= 0: + return None # nothing yet; 0 would mean end of stream and detach us + n = min(n, len(buf)) + buf[:n] = s.rx[s.rx_pos : s.rx_pos + n] + s.rx_pos += n + if s.rx_pos >= len(s.rx): + s.rx = bytearray() + s.rx_pos = 0 + return n + + def write(self, buf): + self._s.send(buf) + return len(buf) + + def ioctl(self, op, arg): + if op == 3: # MP_STREAM_POLL + return 1 if (arg & 1) and len(self._s.rx) > self._s.rx_pos else 0 + return 0 + + +class _Server: + # Output buffered beyond this waits for the link before print() returns. + TX_CAP = 4096 + # Input buffered beyond this, while nothing reads stdin, is dropped. + RX_CAP = 4096 + + def __init__(self, password, name, interval_us, dupterm_index): + self.password = password + self.name = name + self.interval_us = interval_us + self.dupterm_index = dupterm_index + self.running = False + self.conn = None + self.login = None + self.authed = False + self.mtu = 23 + self.rx = bytearray() + self.rx_pos = 0 + self.tx = bytearray() + self.dropped_out = 0 + self.prev_term = None + self.stream = _Stream(self) + self.timer = None + self.early_mtu = None + + # -- setup + + def start(self): + import bluetooth + import machine + + try: + from aioble import core + + self.ble = core.ble + core.ensure_active() + _arm_aioble() + if not getattr(core, "_bledev_repl_irq", False): + core.register_irq_handler(_irq, None) + core._bledev_repl_irq = True + self._aioble = True + except ImportError: + self.ble = bluetooth.BLE() + self.ble.active(True) + self.ble.irq(_irq) + self._aioble = False + ble = self.ble + try: + ble.config(mtu=nus.MTU) + except Exception: + pass + ((self.tx_handle, self.rx_handle),) = ble.gatts_register_services( + ( + ( + bluetooth.UUID(str(nus.SERVICE)), + ( + (bluetooth.UUID(str(nus.TX)), 0x0010), # notify + (bluetooth.UUID(str(nus.RX)), 0x0008 | 0x0004), # write, write without response + ), + ), + ) + ) + # Appending: several writes between two IRQs arrive together. + ble.gatts_set_buffer(self.rx_handle, 1024, True) + self._forget_other_services() + self.timer = machine.Timer(-1) + self.one_shot = machine.Timer.ONE_SHOT + self.adv = pack_advertisement(self.name, [nus.SERVICE]) + self.running = True + self.advertise() + + def _forget_other_services(self): + # The registration above replaced every service aioble and mpble knew, + # so their handle tables now point at our handles. Clear them. + mods = sys.modules + server = mods.get("aioble.server") + if server is not None: + server._registered_characteristics.clear() + mpble = mods.get("bledev.mpble") + if mpble is not None: + mpble._captures.clear() + shared = getattr(mpble, "_shared", None) + if shared is not None and hasattr(shared, "_nus_server"): + del shared._nus_server + + def advertise(self): + if self.running and self.conn is None: + adv, resp = self.adv + try: + self.ble.gap_advertise(self.interval_us, adv_data=adv, resp_data=resp or None) + except OSError: + pass + + def stop(self): + self.running = False + try: + self.ble.gap_advertise(None) + except Exception: + pass + if self.conn is not None: + try: + self.ble.gap_disconnect(self.conn) + except Exception: + pass + self._detach() + if self.timer is not None: + self.timer.deinit() + + # -- connection events (from the BLE IRQ) + + def on_connect(self, conn): + if self.conn is not None: + self.ble.gap_disconnect(conn) # one client at a time + return + self.conn = conn + self.mtu = 23 + # MicroPython can report the MTU exchange before the connection. + 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.authed = False + self.rx = bytearray() + self.rx_pos = 0 + self.tx = bytearray() + # 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) + + def on_disconnect(self, conn): + if conn != self.conn: + return + self._detach() + self.conn = None + self.login = None + self.authed = False + self.rx = bytearray() + self.rx_pos = 0 + self.tx = bytearray() + self.advertise() + + def on_write(self, conn, data): + if conn != self.conn: + return + if not self.authed: + reply, verdict, rest = self.login.feed(data) + if verdict is False: + self.notify_now(reply) + self.login.refused = True + # Let "Access denied" reach the client, then hang up. + self.timer.init(mode=self.one_shot, period=300, callback=self._hang_up) + return + if verdict is None: + if reply: + self.notify_now(reply) + return + self._attach() + self.send(reply) + data = rest + if not data: + return + self.feed_repl(data) + + def on_mtu(self, conn, mtu): + if conn == self.conn: + self.mtu = mtu + else: + self.early_mtu = (conn, mtu) + + # -- the REPL + + def _attach(self): + import os + + self.authed = True + self.prev_term = os.dupterm(self.stream, self.dupterm_index) + + def _detach(self): + if not self.authed: + return + import os + + self.authed = False + try: + os.dupterm(self.prev_term, self.dupterm_index) + except Exception: + pass + self.prev_term = None + + def feed_repl(self, data): + if len(self.rx) - self.rx_pos + len(data) > self.RX_CAP: + return + self.rx.extend(data) + if 3 in data: + # Ctrl-C: have dupterm read it now, so a running program is + # interrupted even though nothing is reading stdin. + import os + + os.dupterm_notify(None) + + # -- sending + + def _hang_up(self, _timer=None): + if self.conn is not None and not self.authed: + try: + self.ble.gap_disconnect(self.conn) + except OSError: + pass + + def notify_now(self, data): + """Send while unauthenticated (small replies); failures are ignored.""" + if self.conn is None: + return + size = self.mtu - 3 + for i in range(0, len(data), size): + try: + self.ble.gatts_notify(self.conn, self.tx_handle, data[i : i + size]) + except OSError: + return + + def send(self, data): + if self.conn is None or not self.authed: + return + self.tx.extend(data) + self.flush() + if len(self.tx) > self.TX_CAP: + import time + + # A program printing faster than the link carries: wait for it, + # but never forever, because print() can't fail. + deadline = time.ticks_add(time.ticks_ms(), 2000) + while len(self.tx) > self.TX_CAP and self.conn is not None: + if time.ticks_diff(deadline, time.ticks_ms()) <= 0: + self.dropped_out += len(self.tx) - self.TX_CAP + self.tx = self.tx[: self.TX_CAP] + break + time.sleep_ms(2) + self.flush() + if self.tx: + self.timer.init(mode=self.one_shot, period=5, callback=self._flush_later) + + def _flush_later(self, _timer=None): + self.flush() + if self.tx and self.conn is not None: + self.timer.init(mode=self.one_shot, period=5, callback=self._flush_later) + + def flush(self): + size = self.mtu - 3 + while self.tx and self.conn is not None: + chunk = self.tx[:size] + try: + self.ble.gatts_notify(self.conn, self.tx_handle, chunk) + except OSError: + return # the controller's buffers are full; the timer retries + self.tx = self.tx[len(chunk) :] + + +class _Gate(Login): + refused = False + + def feed(self, data): + if self.refused: + return b"", False, b"" + return Login.feed(self, data) + + +def _irq(event, data): + s = _server + if s is None or not s.running: + return None + if event == 1: # _IRQ_CENTRAL_CONNECT + s.on_connect(data[0]) + elif event == 2: # _IRQ_CENTRAL_DISCONNECT + s.on_disconnect(data[0]) + _tidy_aioble(data[0]) + elif event == 3: # _IRQ_GATTS_WRITE + conn, handle = data + if handle == s.rx_handle: + s.on_write(conn, bytes(s.ble.gatts_read(handle))) + elif event == 21: # _IRQ_MTU_EXCHANGED + s.on_mtu(data[0], data[1]) + return None + + +def _arm_aioble(): + # aioble's peripheral IRQ signals its advertise() on every incoming + # connection and raises if advertise() never ran, which would stop the + # IRQ reaching us. Give it something to signal. + try: + import asyncio + from aioble import peripheral + except ImportError: + return + if peripheral._connect_event is None: + peripheral._connect_event = asyncio.ThreadSafeFlag() + + +def _tidy_aioble(conn): + # aioble's peripheral IRQ records every incoming connection, expecting its + # own advertise() to start a task that forgets it on disconnect. Ours + # never went through advertise(), so forget it here. + try: + from aioble import peripheral + from aioble.device import DeviceConnection + except ImportError: + return + connection = DeviceConnection._connected.pop(conn, None) + if connection is not None: + connection._conn_handle = None + connection.device._connection = None + if peripheral._incoming_connection is connection: + peripheral._incoming_connection = None + if peripheral._connect_event is not None: + try: + peripheral._connect_event.clear() + except AttributeError: + pass + + +# ---------------------------------------------------------------- the client side + + +async def _expect(link, markers, buffer, timeout_ms): + """Read until one of ``markers`` appears; return (marker, everything read).""" + while True: + for marker in markers: + if marker in buffer: + return marker, buffer + chunk = await wait_ms(link.read(), timeout_ms, "waiting for {!r}".format(markers[0])) + if not chunk: + return None, buffer + buffer.extend(chunk) + + +async def connect(ble, password, name="mpy-repl", *, device=None, timeout_ms=10000, **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. + """ + link = await nus.connect(ble, name=name, device=device, timeout_ms=timeout_ms, **connect_options) + try: + buffer = bytearray() + await link.write(b"\r") + marker, buffer = await _expect(link, (PROMPT,), 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") + except BaseException: + await link.close() + raise + return link + + +async def run(link, code, timeout_ms=10000): + """Type ``code`` at the prompt and return what it printed, up to the next ``>>>``.""" + line = code.encode() if isinstance(code, str) else bytes(code) + await link.write(line + b"\r") + marker, buffer = await _expect(link, (b">>> ",), bytearray(), timeout_ms) + if marker is None: + raise BLEError("the link closed while running {!r}".format(code)) + text = bytes(buffer[: buffer.rfind(b">>> ")]).decode("utf-8", "replace") + # Drop the echoed command. + first = text.find("\n") + return text[first + 1 :] if first >= 0 else "" diff --git a/tests/bledev_board/gatt_central.py b/tests/bledev_board/gatt_central.py index 539859d8..b85e5208 100644 --- a/tests/bledev_board/gatt_central.py +++ b/tests/bledev_board/gatt_central.py @@ -1,10 +1,16 @@ # SPDX-FileCopyrightText: 2026 Brad Barnett # # SPDX-License-Identifier: MIT -"""Board side of the over-the-air contract check: the central. +"""The over-the-air contract check: the central, on a board or a laptop. -Needs ``gatt_peripheral.py`` running on another board. Writes one line per -check to /gatt_central.log and ends with ``RESULT PASS`` or ``RESULT FAIL``. +Needs ``gatt_peripheral.py`` running on a board. On MicroPython it uses +mpble and writes one line per check to /gatt_central.log. On CPython it uses +``bledev.bleak`` and prints; run it with the Windows Python from WSL:: + + PYTHONPATH="$(wslpath -w lib)" python.exe "$(wslpath -w tests/bledev_board/gatt_central.py)" + +It ends with ``RESULT PASS`` or ``RESULT FAIL``. Start the peripheral with +its ``PLANT = True`` (it skips notification 7) and this must fail. """ import asyncio @@ -12,22 +18,43 @@ import sys import bledev -import bledev.mpble -LOG = "/gatt_central.log" +MICROPYTHON = sys.implementation.name == "micropython" +LOG = "/gatt_central.log" if MICROPYTHON else None SVC = bledev.UUID("12345678-1234-5678-1234-56789abcdef0") CHR_RW = bledev.UUID("12345678-1234-5678-1234-56789abcdef1") CHR_STREAM = bledev.UUID("12345678-1234-5678-1234-56789abcdef2") CHR_IND = bledev.UUID("12345678-1234-5678-1234-56789abcdef3") + +def adapter(): + if MICROPYTHON: + import bledev.mpble + + return bledev.mpble.get() + import bledev.bleak + + return bledev.bleak.BleakBLE() + + +def print_exception(e): + if MICROPYTHON: + sys.print_exception(e) + else: + import traceback + + traceback.print_exception(type(e), e, e.__traceback__) + + failures = [] def log(*parts): line = " ".join(str(p) for p in parts) print(line) - with open(LOG, "a") as f: - f.write(line + "\n") + if LOG: + with open(LOG, "a") as f: + f.write(line + "\n") def check(ok, what, detail=""): @@ -37,10 +64,30 @@ def check(ok, what, detail=""): async def main(): - ble = bledev.mpble.get() + ble = adapter() caps = ble.capabilities() - check(caps["central"] and caps["peripheral"], "capabilities: both roles", caps) + if MICROPYTHON: + check(caps["central"] and caps["peripheral"], "capabilities: both roles", caps) + else: + check(caps["central"] and not caps["peripheral"], "capabilities: central only", caps) + for what, call in ( + ("advertise", lambda: ble.advertise(name="x", timeout_ms=10)), + ("nus.serve", lambda: __import__("bledev.nus").nus.serve(ble, name="x", timeout_ms=10)), + ): + try: + await call() + check(False, "a central-only host refuses " + what) + except bledev.UnsupportedError: + check(True, "a central-only host refuses " + what) + + try: + await ble.find(name="bledev-nobody", timeout_ms=1500) + check(False, "find() times out with BLETimeoutError") + except bledev.BLETimeoutError: + check(True, "find() times out with BLETimeoutError") + named = await ble.find(name="bledev-radio", timeout_ms=15000) + check(named is not None and named.name == "bledev-radio", "find by name", repr(named)) device = await ble.find(service=SVC, timeout_ms=15000) # The name may arrive in a later scan response than the service UUID, so # a find by service can return before the name is known. @@ -63,7 +110,16 @@ async def main(): check(await rw.read() == b"hi", "read the initial value") + # The peripheral sends these before we subscribe; none may arrive. + await rw.write(b"notify 3", response=True) + await bledev.sleep_ms(500) await stream.subscribe(notify=True) + try: + early = await stream.notified(timeout_ms=300) + check(False, "nothing arrives before subscribe()", early) + except bledev.BLETimeoutError: + check(True, "nothing arrives before subscribe()") + await rw.write(b"notify 200", response=True) seqs = [] for _ in range(200): @@ -77,7 +133,7 @@ async def main(): for i in range(100): await stream.write(struct.pack("= 4 and info[3] == NAME, "device info", info) + state = await client.state() + check(state == improv.STATE_AUTHORIZED, "the board starts authorized", state) + if WRONG: + try: + await client.send_wifi("bledev-no-such-network", "not-a-real-password", timeout_ms=60000) + check(False, "a network that doesn't exist is refused") + except improv.ImprovError as e: + check(e.code == improv.ERROR_UNABLE_TO_CONNECT, "the board reports unable to connect", e) + error = await client.error() + check(error == improv.ERROR_UNABLE_TO_CONNECT, "the error characteristic says so", error) + state = await client.state() + check(state == improv.STATE_AUTHORIZED, "and the board is back to authorized", state) + else: + import secrets + + ssid = getattr(secrets, "WIFI_SSID", None) or getattr(secrets, "ssid", None) + password = getattr(secrets, "WIFI_PASSWORD", None) or getattr(secrets, "password", None) or "" + url = await client.send_wifi(ssid, password, timeout_ms=60000) + del ssid, password + check(url.startswith("http://"), "the board joined and sent a redirect", url) + state = await client.state() + check(state == improv.STATE_PROVISIONED, "the board reports provisioned", state) + finally: + await client.close() + + +async def guarded(): + try: + await main() + except Exception as e: + failures.append("exception") + print("EXCEPTION", type(e).__name__, e) + print("RESULT", "FAIL {}".format(failures) if failures else "PASS") + + +asyncio.run(guarded()) +if not MICROPYTHON: + sys.exit(1 if failures else 0) diff --git a/tests/bledev_board/improv_server.py b/tests/bledev_board/improv_server.py new file mode 100644 index 00000000..2f38ee48 --- /dev/null +++ b/tests/bledev_board/improv_server.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The Improv gate, board side: serve Improv as ``bledev-improv`` until provisioned. + +It first takes the board off Wi-Fi, so a join it reports afterwards is one the +Improv client caused. It never prints the credentials it receives, only the +state changes and the address it got. Logs to /improv_server.log. +""" +import asyncio + +import network + +import bledev.improv as improv +import bledev.mpble + +LOG = "/improv_server.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(): + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + try: + wlan.disconnect() + except OSError: + pass + await asyncio.sleep_ms(500) + log("wifi before: connected", wlan.isconnected()) + ble = bledev.mpble.get() + server = improv.Server(ble, "bledev-improv") + state_names = {1: "authorization required", 2: "authorized", 3: "provisioning", 4: "provisioned"} + set_state = server._set_state + + async def logged_state(state): + log("state", state_names.get(state, state)) + await set_state(state) + + set_error = server._set_error + + async def logged_error(error): + log("error", error) + await set_error(error) + + server._set_state = logged_state + server._set_error = logged_error + log("serving Improv as bledev-improv, capabilities", server.capabilities) + url = await server.serve() + log("provisioned: redirect", url, "wifi connected", wlan.isconnected(), "ip", wlan.ifconfig()[0]) + + +open(LOG, "w").close() +asyncio.run(main()) diff --git a/tests/bledev_board/nus_client.py b/tests/bledev_board/nus_client.py index 0caddfc5..1ad2e4c8 100644 --- a/tests/bledev_board/nus_client.py +++ b/tests/bledev_board/nus_client.py @@ -3,27 +3,48 @@ # SPDX-License-Identifier: MIT """The nus byte-stream gate, the central: 16 KB up, down and echoed, verified. -Needs ``nus_server.py`` on another board. Every byte is checked against the +Needs ``nus_server.py`` on a board. It runs on another board (mpble) or on a +laptop (``bledev.bleak``, printing instead of logging); see gatt_central.py +for the command line. Every byte is checked against the pattern, lengths must match, and throughput is timed per phase. Ends with ``RESULT PASS`` or ``RESULT FAIL`` in /gate_client.log. ``PLANT = True`` flips one bit at offset 5000 of the UP stream, which the server must catch. ``CONN`` passes connection intervals to ``device.connect()``. """ -import asyncio, time, gc -import bledev.mpble +import asyncio, time, gc, sys import bledev.nus as nus +MICROPYTHON = sys.implementation.name == "micropython" +if MICROPYTHON: + import bledev.mpble + + ticks_ms, ticks_diff = time.ticks_ms, time.ticks_diff +else: + import bledev.bleak + + def ticks_ms(): + return int(time.monotonic() * 1000) + + def ticks_diff(a, b): + return a - b + N = 16384 PLANT = False # the planted run sets this: flip one bit at offset 5000 of the UP stream CONN = {} # e.g. min_conn_interval_us / max_conn_interval_us -LOG = "/gate_client.log" +if not MICROPYTHON: + # On a laptop: --fast asks the OS for a short connection interval, --plant plants. + if "--fast" in sys.argv: + CONN = {"priority": "throughput"} + PLANT = PLANT or "--plant" in sys.argv +LOG = "/gate_client.log" if MICROPYTHON else None def log(*parts): line = " ".join(str(p) for p in parts) print(line) - with open(LOG, "a") as f: - f.write(line + "\n") + if LOG: + with open(LOG, "a") as f: + f.write(line + "\n") def pattern(n, seed): @@ -47,10 +68,10 @@ def kbps(n, ms): async def main(): gc.collect() - ble = bledev.mpble.get() - t0 = time.ticks_ms() + ble = bledev.mpble.get() if MICROPYTHON else bledev.bleak.BleakBLE() + t0 = ticks_ms() link = await nus.connect(ble, name="bledev-gate", timeout_ms=20000, **CONN) - log("connected in", time.ticks_diff(time.ticks_ms(), t0), "ms, mtu", link.connection.mtu, "conn", CONN, "plant", PLANT) + log("connected in", ticks_diff(ticks_ms(), t0), "ms, mtu", link.connection.mtu, "conn", CONN, "plant", PLANT) ok = True # UP: we send, the server checks. @@ -60,9 +81,9 @@ async def main(): data[5000] ^= 0x01 data = bytes(data) await link.write("UP {}\n".format(N).encode()) - t0 = time.ticks_ms() + t0 = ticks_ms() await link.write(data) - sent_ms = time.ticks_diff(time.ticks_ms(), t0) + sent_ms = ticks_diff(ticks_ms(), t0) reply = (await link.readline()).decode().split() ms = int(reply[2]) log("UP", reply[1], N, "bytes: server received in", ms, "ms =", kbps(N, ms), "(our send", sent_ms, "ms) mismatches", reply[3], "first", reply[4]) @@ -70,13 +91,13 @@ async def main(): # DOWN: the server sends, we check. await link.write("DOWN {}\n".format(N).encode()) - t0 = time.ticks_ms() + t0 = ticks_ms() try: - got = await asyncio.wait_for_ms(link.readexactly(N), 15000) + got = await asyncio.wait_for(link.readexactly(N), 15) except asyncio.TimeoutError: log("DOWN stalled: buffered", len(link._buf), "bytes_in", link.bytes_in, "connected", link.connection.is_connected()) raise - ms = time.ticks_diff(time.ticks_ms(), t0) + ms = ticks_diff(ticks_ms(), t0) bad, first = compare(got, pattern(N, 2)) verdict = "OK" if bad == 0 and len(got) == N else "BAD" log("DOWN", verdict, len(got), "bytes in", ms, "ms =", kbps(N, ms), "mismatches", bad, "first", first) @@ -85,10 +106,10 @@ async def main(): # ECHO: round trip through the server. await link.write("ECHO {}\n".format(N).encode()) data = pattern(N, 3) - t0 = time.ticks_ms() + t0 = ticks_ms() sender = asyncio.create_task(link.write(data)) got = await link.readexactly(N) - ms = time.ticks_diff(time.ticks_ms(), t0) + ms = ticks_diff(ticks_ms(), t0) await sender bad, first = compare(got, data) verdict = "OK" if bad == 0 and len(got) == N else "BAD" @@ -96,7 +117,7 @@ async def main(): ok = ok and verdict == "OK" await link.write(b"BYE\n") - await asyncio.sleep_ms(300) + await asyncio.sleep(0.3) await link.close() log("RESULT", "PASS" if ok else "FAIL") @@ -105,13 +126,20 @@ async def guarded(): try: await main() except BaseException as e: - import sys, io - buf = io.StringIO() - sys.print_exception(e, buf) - log("EXCEPTION", buf.getvalue()) + if MICROPYTHON: + import io + + buf = io.StringIO() + sys.print_exception(e, buf) + log("EXCEPTION", buf.getvalue()) + else: + import traceback + + log("EXCEPTION", "".join(traceback.format_exception(type(e), e, e.__traceback__))) log("RESULT", "FAIL") -open(LOG, "w").close() +if LOG: + open(LOG, "w").close() log("start") asyncio.run(guarded()) diff --git a/tests/bledev_board/repl_client.py b/tests/bledev_board/repl_client.py new file mode 100644 index 00000000..c285e272 --- /dev/null +++ b/tests/bledev_board/repl_client.py @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The REPL gate, client side: log in over BLE, run code, interrupt a loop. + +Needs ``repl_server.py`` started on a board. Runs on a laptop (bleak) or a +board (mpble); see gatt_central.py for the command line. Checks, in order: + +1. The right password gets a prompt, and ``123 * 456`` evaluates to 56088. +2. A wrong password, sent together with a line of code that would create + ``/pwned``, gets ``Access denied`` and a disconnect, and never a prompt. +3. Logged in again, ``/pwned`` does not exist: the code never ran. +4. ``while True`` runs until Ctrl-C, which interrupts it, and the REPL still + answers afterwards. + +Ends with ``RESULT PASS`` or ``RESULT FAIL``. +""" +import asyncio +import sys + +import bledev +import bledev.nus as nus +import bledev.repl as repl + +MICROPYTHON = sys.implementation.name == "micropython" +PASSWORD = "bledev-gate-pw" +NAME = "bledev-repl" + +failures = [] + + +def check(ok, what, detail=""): + print("PASS" if ok else "FAIL", what, detail) + if not ok: + failures.append(what) + + +def adapter(): + if MICROPYTHON: + import bledev.mpble + + return bledev.mpble.get() + import bledev.bleak + + return bledev.bleak.BleakBLE() + + +async def drain(link, ms): + """Everything that arrives within ``ms``.""" + got = bytearray() + try: + while True: + chunk = await bledev.wait_ms(link.read(), ms, "drain") + if not chunk: + break + got.extend(chunk) + except bledev.BLETimeoutError: + pass + return bytes(got) + + +async def main(): + ble = adapter() + + link = await repl.connect(ble, PASSWORD, name=NAME, timeout_ms=15000) + out = await repl.run(link, "123 * 456") + check(out.strip() == "56088", "the right password gets a working prompt", repr(out)) + await repl.run(link, "import os") + await repl.run(link, "exec(\"try:\\n os.remove('/pwned')\\nexcept OSError:\\n pass\")") + out = await repl.run(link, "'pwned' in os.listdir('/')") + check(out.strip() == "False", "no /pwned before the attack", repr(out)) + await link.close() + await bledev.sleep_ms(1500) + + link = await nus.connect(ble, name=NAME, timeout_ms=15000) + await link.write(b"\r") + got = await drain(link, 1500) + check(repl.PROMPT in got, "the board asks for a password", repr(got)) + await link.write(b"not-the-password\rimport os; open('/pwned', 'w').write('1')\r") + got = await drain(link, 3000) + check(b"Access denied" in got, "a wrong password is refused", repr(got)) + check(b">>>" not in got, "a wrong password never gets a prompt", repr(got)) + try: + await link.connection.disconnected(timeout_ms=3000) + check(True, "the board hangs up after a wrong password") + except bledev.BLETimeoutError: + check(False, "the board hangs up after a wrong password") + await link.close() + await bledev.sleep_ms(1500) + + try: + wrong = await repl.connect(ble, "wrong-again", name=NAME, timeout_ms=15000) + check(False, "repl.connect() raises AuthError for a wrong password") + await wrong.close() + except repl.AuthError: + check(True, "repl.connect() raises AuthError for a wrong password") + await bledev.sleep_ms(1500) + + link = await repl.connect(ble, PASSWORD, name=NAME, timeout_ms=15000) + out = await repl.run(link, "'pwned' in os.listdir('/')") + check(out.strip() == "False", "the code sent with the wrong password never ran", repr(out)) + + await repl.run(link, "n = 0") + await link.write(b"while True: n += 1\r\r") + got = await drain(link, 1500) + check(b">>> " not in got, "the loop is running", repr(got)) + await link.write(b"\x03") + got = await drain(link, 2000) + check(b"KeyboardInterrupt" in got and got.rstrip().endswith(b">>>"), "Ctrl-C interrupts it", repr(got[-80:])) + out = await repl.run(link, "n > 1000") + check(out.strip() == "True", "the loop ran, and the REPL answers after the interrupt", repr(out)) + await link.close() + + +async def guarded(): + try: + await main() + except Exception as e: + failures.append("exception") + print("EXCEPTION", type(e).__name__, e) + print("RESULT", "FAIL {}".format(failures) if failures else "PASS") + + +asyncio.run(guarded()) +if not MICROPYTHON: + sys.exit(1 if failures else 0) diff --git a/tests/bledev_board/repl_server.py b/tests/bledev_board/repl_server.py new file mode 100644 index 00000000..7088ea3e --- /dev/null +++ b/tests/bledev_board/repl_server.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The REPL gate, board side: start ``bledev.repl`` as ``bledev-repl`` and return. + +Run it with ``mpftp exec`` (or ``run``) and leave the board at its REPL; the +REPL keeps being served over BLE. ``repl_client.py`` then checks it from a +laptop or another board. The password is a test value, not a secret. + +``PLANT = True`` makes the board accept any password, which the client must +catch. +""" +import bledev.repl + +PASSWORD = "bledev-gate-pw" +PLANT = False + +if PLANT: + bledev.repl._check_password = lambda given, want: True +bledev.repl.start(password=PASSWORD, name="bledev-repl") +print("bledev.repl serving as bledev-repl, plant", PLANT) diff --git a/tests/bledev_contract.py b/tests/bledev_contract.py index a57f1164..b671c219 100644 --- a/tests/bledev_contract.py +++ b/tests/bledev_contract.py @@ -46,7 +46,7 @@ UnsupportedError, UUID, ) -from bledev import fake, nus +from bledev import fake, improv, nus, repl from bledev.fake import Air, FakeBLE MICROPYTHON = sys.implementation.name == "micropython" @@ -187,6 +187,26 @@ async def advertisement_packing(air): equal(appearance, 0x03C1, "appearance") equal(manufacturer, [(0xFFFF, b"\x01\x02")], "manufacturer") raises_sync(ValueError, bledev.pack_advertisement, "x" * 40, [nus.SERVICE]) + # Improv's layout: a 128-bit service and 16-bit service data fill the + # advertisement to exactly 31 bytes, and the name goes to the response. + adv, resp = bledev.pack_advertisement("imp", [nus.SERVICE], service_data=[(0x4677, b"\x02\x01\0\0\0\0")]) + equal(len(adv), 31, "a full 31-byte advertisement") + equal(bledev.decode_service_data(adv), [(UUID(0x4677), b"\x02\x01\0\0\0\0")], "service data") + equal(bledev.decode_advertisement(resp)[0], "imp", "the name overflowed to the response") + + +@check +async def scan_reports_service_data(air): + board = FakeBLE(air=air) + laptop = FakeBLE(air=air, peripheral=False) + adv = asyncio.create_task(board.advertise(name="imp", services=[SVC], service_data=[(0x4677, b"\x01")])) + await asyncio.sleep(0) + async with laptop.scan(100, active=False) as scanner: + async for result in scanner: + equal(result.service_data(0x4677), [(UUID(0x4677), b"\x01")], "service_data()") + equal(result.service_data(0x180F), [], "service_data(other)") + break + adv.cancel() # ---------------------------------------------------------------- roles @@ -582,6 +602,157 @@ async def nus_readexactly_short_stream(air): await raises(DisconnectedError, client.readexactly(10)) +# ---------------------------------------------------------------- repl login + + +@check +async def repl_login_right_password(air): + login = repl.Login("hunter22") + reply, verdict, rest = login.feed(b"\r") + equal((reply, verdict), (repl.PROMPT, None), "an empty line asks for the prompt") + login.feed(b"hunt") + reply, verdict, rest = login.feed(b"er22\r\n1 + 1\r") + equal((reply, verdict, rest), (repl.BANNER, True, b"1 + 1\r"), "split password, then code") + + +@check +async def repl_login_wrong_password(air): + login = repl.Login("hunter22") + reply, verdict, rest = login.feed(b"hunter2\rimport os; os.remove('main.py')\r") + equal((reply, verdict, rest), (repl.DENIED, False, b""), "refused, and the code after it dropped") + equal(login.feed(b"hunter22\r"), (b"", False, b""), "no second attempt") + equal(repl.Login("hunter22").feed(b"x" * 100)[1], False, "an overlong line is refused") + expect(not repl._check_password(b"hunter2", b"hunter22"), "prefix") + expect(not repl._check_password(b"hunter222", b"hunter22"), "longer") + expect(repl._check_password(b"hunter22", b"hunter22"), "equal") + + +# ---------------------------------------------------------------- improv + + +SSID = "a-network-name-that-is-32-bytes!" +PASSWORD = "a-password-long-enough-to-cross-several-23-byte-mtu-packets-63" + + +async def improv_pair(air, **server_options): + board = FakeBLE(air=air) + laptop = FakeBLE(air=air, peripheral=False) + joined = [] + + async def join(ssid, password): + joined.append((ssid, password)) + await asyncio.sleep(0) + return "10.0.0.5" if (ssid, password) == (SSID, PASSWORD) else None + + server = improv.Server(board, "imp", join=join, **server_options) + task = asyncio.create_task(server.serve(timeout_ms=3000)) + return board, laptop, server, task, joined + + +@check +async def improv_packets(air): + packet = improv.pack(improv.CMD_WIFI_SETTINGS, "ab", "cd") + equal(packet[:-1], b"\x01\x06\x02ab\x02cd", "layout") + equal(packet[-1], sum(packet[:-1]) & 0xFF, "checksum") + equal(improv.unpack(packet), (1, [b"ab", b"cd"]), "unpack") + bad = packet[:-1] + bytes(((packet[-1] + 1) & 0xFF,)) + e = raises_sync(improv.ImprovError, improv.unpack, bad) + equal(e.code, improv.ERROR_INVALID_RPC, "bad checksum code") + + +@check +async def improv_advertises_its_state(air): + board, laptop, server, task, joined = await improv_pair(air) + async with laptop.scan(200, active=False) as scanner: + async for result in scanner: + expect(improv.SERVICE in result.services(), "service UUID in the advertisement") + equal(result.service_data(improv.SERVICE_DATA), + [(improv.SERVICE_DATA, bytes((improv.STATE_AUTHORIZED, server.capabilities, 0, 0, 0, 0)))], + "state and capabilities in the service data") + break + task.cancel() + + +@check +async def improv_provisions(air): + board, laptop, server, task, joined = await improv_pair(air) + client = await improv.Client.connect(laptop, "imp", timeout_ms=2000) + equal(client.connection.mtu, 23, "a small MTU, so the RPC crosses several writes") + info = await client.device_info(timeout_ms=2000) + equal(len(info), 4, "device info has four strings") + equal(info[3], "imp", "device name") + url = await client.send_wifi(SSID, PASSWORD, timeout_ms=2000) + equal(url, "http://10.0.0.5/", "redirect URL") + equal(joined, [(SSID, PASSWORD)], "the device got the credentials intact") + equal(await client.state(), improv.STATE_PROVISIONED, "state") + await client.close() + equal(await wait_task(task), "http://10.0.0.5/", "serve() returns the URL") + + +async def wait_task(task): + return await asyncio.wait_for(task, 3) + + +@check +async def improv_wrong_network(air): + board, laptop, server, task, joined = await improv_pair(air) + client = await improv.Client.connect(laptop, "imp", timeout_ms=2000) + e = await raises(improv.ImprovError, client.send_wifi("nope", "wrong", timeout_ms=2000)) + equal(e.code, improv.ERROR_UNABLE_TO_CONNECT, "error code") + equal(await client.error(), improv.ERROR_UNABLE_TO_CONNECT, "the error characteristic") + equal(await client.state(), improv.STATE_AUTHORIZED, "back to authorized") + url = await client.send_wifi(SSID, PASSWORD, timeout_ms=2000) + equal(url, "http://10.0.0.5/", "a second, right attempt works") + await client.close() + task.cancel() + + +@check +async def improv_needs_authorization(air): + board, laptop, server, task, joined = await improv_pair(air, require_authorization=True) + client = await improv.Client.connect(laptop, "imp", timeout_ms=2000) + equal(await client.state(), improv.STATE_AUTHORIZATION_REQUIRED, "starts locked") + e = await raises(improv.ImprovError, client.send_wifi(SSID, PASSWORD, timeout_ms=100)) + equal(e.code, improv.ERROR_NOT_AUTHORIZED, "not authorized in time") + equal(joined, [], "nothing was joined") + + async def press(): + await asyncio.sleep(0.05) + server.authorize() + + asyncio.create_task(press()) + equal(await client.send_wifi(SSID, PASSWORD, timeout_ms=2000), "http://10.0.0.5/", "after a button press") + await client.close() + task.cancel() + + +@check +async def improv_rejects_a_bad_packet(air): + board, laptop, server, task, joined = await improv_pair(air) + client = await improv.Client.connect(laptop, "imp", timeout_ms=2000) + await client._command(b"\x01\x00\x00") # checksum should be 0x01 + for _ in range(20): + await asyncio.sleep(0) + equal(await client.error(), improv.ERROR_INVALID_RPC, "invalid RPC") + await client._command(improv.pack(0x7E)) + for _ in range(20): + await asyncio.sleep(0) + equal(await client.error(), improv.ERROR_UNKNOWN_RPC, "unknown RPC") + await client.close() + task.cancel() + + +@check +async def improv_scan(air): + nets = [("one", -40, True), ("two", -70, False)] + board, laptop, server, task, joined = await improv_pair(air, scan=lambda: nets) + client = await improv.Client.connect(laptop, "imp", timeout_ms=2000) + expect(client.capabilities & improv.CAP_WIFI_SCAN, "scan capability") + equal(await client.scan(timeout_ms=2000), nets, "scan results") + await client.close() + task.cancel() + + # ---------------------------------------------------------------- auto From df5738f971399a95cfae08ddb6d957f45d2c1215 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:02:43 -0500 Subject: [PATCH 2/2] bledev: board configs hand out a bledev adapter; the laptop and coexistence runs The 19 board_peripherals ble() factories return bledev.mpble.get(), falling back to the raw radio without bledev. MPBLE answers every bluetooth.BLE method, and irq() runs beside aioble's handler; raw advertising no longer trips aioble's peripheral IRQ or leaks its connection record. coex_server.py and tcp_pull.py measure Wi-Fi and BLE on one S3; the numbers and the bleak, REPL and Improv notes are in docs/bledev*.md. --- .../i80/bpi-centi-s3/board_peripherals.py | 13 +- .../i80/t-display-s3/board_peripherals.py | 13 +- .../busdisplay/i80/t-hmi/board_peripherals.py | 11 +- .../i80/wt32sc01-plus/board_peripherals.py | 13 +- .../spi/m5stack-cores3/board_peripherals.py | 13 +- .../spi/t-display-s3-pro/board_peripherals.py | 13 +- .../spi/t-dongle-s3/board_peripherals.py | 13 +- .../spi/t-embed/board_peripherals.py | 11 +- .../spi/t-qt-pro/board_peripherals.py | 13 +- .../board_peripherals.py | 11 +- .../board_peripherals.py | 13 +- .../esp32-s3-touch-lcd-7/board_peripherals.py | 13 +- .../board_peripherals.py | 13 +- .../m5stack_tab5_st7123/board_peripherals.py | 13 +- .../board_peripherals.py | 13 +- .../board_peripherals.py | 13 +- .../qualia_tl040hds20/board_peripherals.py | 12 +- .../board_peripherals.py | 13 +- .../fbdisplay/t-rgb_480/board_peripherals.py | 11 +- docs/bledev-internals.md | 148 +++++++++++++++++- docs/bledev.md | 90 ++++++++++- lib/bledev/mpble.py | 60 ++++++- mip-split.toml | 6 +- pydevices-desktop.toml | 3 + tests/bledev_board/coex_server.py | 85 ++++++++++ tests/bledev_board/nus_server.py | 8 +- tests/bledev_board/tcp_pull.py | 31 ++++ 27 files changed, 605 insertions(+), 64 deletions(-) create mode 100644 tests/bledev_board/coex_server.py create mode 100644 tests/bledev_board/tcp_pull.py diff --git a/board_configs/busdisplay/i80/bpi-centi-s3/board_peripherals.py b/board_configs/busdisplay/i80/bpi-centi-s3/board_peripherals.py index 04ca6ba9..74e9b6f1 100644 --- a/board_configs/busdisplay/i80/bpi-centi-s3/board_peripherals.py +++ b/board_configs/busdisplay/i80/bpi-centi-s3/board_peripherals.py @@ -16,6 +16,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/busdisplay/i80/t-display-s3/board_peripherals.py b/board_configs/busdisplay/i80/t-display-s3/board_peripherals.py index f2d66f79..c776265e 100644 --- a/board_configs/busdisplay/i80/t-display-s3/board_peripherals.py +++ b/board_configs/busdisplay/i80/t-display-s3/board_peripherals.py @@ -22,6 +22,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/busdisplay/i80/t-hmi/board_peripherals.py b/board_configs/busdisplay/i80/t-hmi/board_peripherals.py index 796a8b63..abf83e94 100644 --- a/board_configs/busdisplay/i80/t-hmi/board_peripherals.py +++ b/board_configs/busdisplay/i80/t-hmi/board_peripherals.py @@ -38,6 +38,13 @@ def wlan(): def ble(): - import bluetooth + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth - return bluetooth.BLE() + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/busdisplay/i80/wt32sc01-plus/board_peripherals.py b/board_configs/busdisplay/i80/wt32sc01-plus/board_peripherals.py index 1901b8c5..4b40c246 100644 --- a/board_configs/busdisplay/i80/wt32sc01-plus/board_peripherals.py +++ b/board_configs/busdisplay/i80/wt32sc01-plus/board_peripherals.py @@ -26,6 +26,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/busdisplay/spi/m5stack-cores3/board_peripherals.py b/board_configs/busdisplay/spi/m5stack-cores3/board_peripherals.py index 008003fb..c17adea8 100644 --- a/board_configs/busdisplay/spi/m5stack-cores3/board_peripherals.py +++ b/board_configs/busdisplay/spi/m5stack-cores3/board_peripherals.py @@ -287,6 +287,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/busdisplay/spi/t-display-s3-pro/board_peripherals.py b/board_configs/busdisplay/spi/t-display-s3-pro/board_peripherals.py index 98121ca9..73f39ce6 100644 --- a/board_configs/busdisplay/spi/t-display-s3-pro/board_peripherals.py +++ b/board_configs/busdisplay/spi/t-display-s3-pro/board_peripherals.py @@ -32,6 +32,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/busdisplay/spi/t-dongle-s3/board_peripherals.py b/board_configs/busdisplay/spi/t-dongle-s3/board_peripherals.py index a609a540..153f806b 100644 --- a/board_configs/busdisplay/spi/t-dongle-s3/board_peripherals.py +++ b/board_configs/busdisplay/spi/t-dongle-s3/board_peripherals.py @@ -26,6 +26,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/busdisplay/spi/t-embed/board_peripherals.py b/board_configs/busdisplay/spi/t-embed/board_peripherals.py index 960cb398..de8f4201 100644 --- a/board_configs/busdisplay/spi/t-embed/board_peripherals.py +++ b/board_configs/busdisplay/spi/t-embed/board_peripherals.py @@ -392,6 +392,13 @@ def wlan(): def ble(): - import bluetooth + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth - return bluetooth.BLE() + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/busdisplay/spi/t-qt-pro/board_peripherals.py b/board_configs/busdisplay/spi/t-qt-pro/board_peripherals.py index 0f57a967..7f9fee74 100644 --- a/board_configs/busdisplay/spi/t-qt-pro/board_peripherals.py +++ b/board_configs/busdisplay/spi/t-qt-pro/board_peripherals.py @@ -22,6 +22,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/fbdisplay/esp32-p4-wifi6-touch-lcd-4b/board_peripherals.py b/board_configs/fbdisplay/esp32-p4-wifi6-touch-lcd-4b/board_peripherals.py index 63073e5d..30b584a7 100644 --- a/board_configs/fbdisplay/esp32-p4-wifi6-touch-lcd-4b/board_peripherals.py +++ b/board_configs/fbdisplay/esp32-p4-wifi6-touch-lcd-4b/board_peripherals.py @@ -461,9 +461,16 @@ def wlan(): def ble(): - import bluetooth + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth - return bluetooth.BLE() + return bluetooth.BLE() + return bledev.mpble.get() def usb_device(): diff --git a/board_configs/fbdisplay/esp32-s3-touch-lcd-4_3/board_peripherals.py b/board_configs/fbdisplay/esp32-s3-touch-lcd-4_3/board_peripherals.py index 4b80b790..b865affd 100644 --- a/board_configs/fbdisplay/esp32-s3-touch-lcd-4_3/board_peripherals.py +++ b/board_configs/fbdisplay/esp32-s3-touch-lcd-4_3/board_peripherals.py @@ -84,6 +84,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/fbdisplay/esp32-s3-touch-lcd-7/board_peripherals.py b/board_configs/fbdisplay/esp32-s3-touch-lcd-7/board_peripherals.py index 27f15113..e7025973 100644 --- a/board_configs/fbdisplay/esp32-s3-touch-lcd-7/board_peripherals.py +++ b/board_configs/fbdisplay/esp32-s3-touch-lcd-7/board_peripherals.py @@ -81,6 +81,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/fbdisplay/m5stack_tab5_ili9881c/board_peripherals.py b/board_configs/fbdisplay/m5stack_tab5_ili9881c/board_peripherals.py index be39cf9d..f67c720c 100644 --- a/board_configs/fbdisplay/m5stack_tab5_ili9881c/board_peripherals.py +++ b/board_configs/fbdisplay/m5stack_tab5_ili9881c/board_peripherals.py @@ -286,6 +286,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/fbdisplay/m5stack_tab5_st7123/board_peripherals.py b/board_configs/fbdisplay/m5stack_tab5_st7123/board_peripherals.py index 2b33a606..b6749742 100644 --- a/board_configs/fbdisplay/m5stack_tab5_st7123/board_peripherals.py +++ b/board_configs/fbdisplay/m5stack_tab5_st7123/board_peripherals.py @@ -286,6 +286,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/fbdisplay/matrixportal_s3_64x64/board_peripherals.py b/board_configs/fbdisplay/matrixportal_s3_64x64/board_peripherals.py index 0fcee4a6..ac48b0ce 100644 --- a/board_configs/fbdisplay/matrixportal_s3_64x64/board_peripherals.py +++ b/board_configs/fbdisplay/matrixportal_s3_64x64/board_peripherals.py @@ -41,6 +41,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/fbdisplay/pico2w_dvi_sock_640x480/board_peripherals.py b/board_configs/fbdisplay/pico2w_dvi_sock_640x480/board_peripherals.py index dd7f328c..3042859d 100644 --- a/board_configs/fbdisplay/pico2w_dvi_sock_640x480/board_peripherals.py +++ b/board_configs/fbdisplay/pico2w_dvi_sock_640x480/board_peripherals.py @@ -16,6 +16,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/fbdisplay/qualia_tl040hds20/board_peripherals.py b/board_configs/fbdisplay/qualia_tl040hds20/board_peripherals.py index 7a5626b9..78440d1c 100644 --- a/board_configs/fbdisplay/qualia_tl040hds20/board_peripherals.py +++ b/board_configs/fbdisplay/qualia_tl040hds20/board_peripherals.py @@ -12,6 +12,14 @@ def wlan(): return network.WLAN(network.STA_IF) def ble(): - import bluetooth - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/fbdisplay/sparkfun_iot_redboard_rp2350_hstx_640x480/board_peripherals.py b/board_configs/fbdisplay/sparkfun_iot_redboard_rp2350_hstx_640x480/board_peripherals.py index 457bc844..a496f86d 100644 --- a/board_configs/fbdisplay/sparkfun_iot_redboard_rp2350_hstx_640x480/board_peripherals.py +++ b/board_configs/fbdisplay/sparkfun_iot_redboard_rp2350_hstx_640x480/board_peripherals.py @@ -54,6 +54,13 @@ def wlan(): def ble(): - import bluetooth - - return bluetooth.BLE() + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth + + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/board_configs/fbdisplay/t-rgb_480/board_peripherals.py b/board_configs/fbdisplay/t-rgb_480/board_peripherals.py index 95d2984c..158f3a04 100644 --- a/board_configs/fbdisplay/t-rgb_480/board_peripherals.py +++ b/board_configs/fbdisplay/t-rgb_480/board_peripherals.py @@ -54,6 +54,13 @@ def wlan(): def ble(): - import bluetooth + # A bledev adapter (docs/ble.md §2). It also answers every bluetooth.BLE + # method, so code written for the raw radio keeps working. Without bledev + # (or aioble) installed, the raw radio, as before. + try: + import bledev.mpble + except ImportError: + import bluetooth - return bluetooth.BLE() + return bluetooth.BLE() + return bledev.mpble.get() diff --git a/docs/bledev-internals.md b/docs/bledev-internals.md index 0f206880..c80723e5 100644 --- a/docs/bledev-internals.md +++ b/docs/bledev-internals.md @@ -15,7 +15,10 @@ standalone module per backend, and an optional selector. | `__init__.py` | The contract: `UUID`, the errors, `FLAG_*`, advertising pack/unpack, and the base classes below. Imports no backend. | | `fake.py` | In-process loopback. The reference implementation of every hook. | | `mpble.py` | MicroPython, over aioble. | +| `bleak.py` | CPython on Windows, Linux and macOS, over bleak. 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. | | `auto.py` | Picks a backend. Nothing imports it, and backends must not. | A backend module imports `bledev` and whatever its host provides, and nothing @@ -123,6 +126,89 @@ returns `None` when cancelled, and mpble turns that back into aioble owns the one `bluetooth.BLE()` and its IRQ, so `mpble.get()` shares one adapter, and the capture handler is registered once per boot. +The laptop found two more (2026-09-24): + +- **Notifications before a subscription.** NimBLE's server notifies whether + or not the central wrote the CCCD, and mpble registered its queue at + discovery, so a board central received what a laptop never would. mpble's + queues now ignore everything until `subscribe()` opens them. The over-the-air + check "nothing arrives before subscribe()" failed on the old code. +- **An MTU exchange before the connection.** Windows exchanges the MTU the + moment it connects, and MicroPython 1.29 on the S3 delivers + `_IRQ_MTU_EXCHANGED` *before* `_IRQ_CENTRAL_CONNECT` (seen every time, + instrumented in the IRQ). aioble has no connection to give it to and drops + it, so the board believed the MTU was 23 while the link ran at 247, and + every notification to a Windows central carried 20 bytes. mpble's IRQ keeps + the early value and applies it on the connect. Laptop downstream went from + 19 to 49 KB/s. Upstream's own order is worth an issue; the board-to-board + case never showed it because the central there asks for the MTU after + connecting. + +**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. +`irq(handler)` is the exception: it runs the handler from mpble's IRQ rather +than replacing aioble's. Raw advertising trips aioble in two places, both +handled in mpble. aioble's peripheral IRQ calls `_connect_event.set()` on every +incoming connection and raised `AttributeError` when `aioble.advertise()` had +never run, which stopped the IRQ reaching anyone after it; mpble gives it a +flag at startup. And aioble records that connection but never forgets it, +because the task that would is started by its own `advertise()`; mpble forgets +it on the disconnect. Checked on the T-Embed: raw service, raw advertising, +a user IRQ that saw the connect and disconnect, a laptop read, then `nus` on +the same adapter. + +## How bleak gets there + +bleak hands each notification to a callback on the event loop. The backend +queues it per characteristic (`queue_limit`, an overflow raises) in +`_BleakClientCharacteristic._received`, and routes it to `indicated()` only +when you subscribed with `indicate=True` alone, because bleak can't tell the +two apart. On Windows that subscription passes `force_indicate`. + +The OS negotiates the MTU itself. `exchange_mtu()` waits up to a second for +the value to move off 23 and returns it; against `gatt_peripheral.py`, which +asks for 185, Windows settles on 185. BlueZ always reports 23 through bleak, +so the backend also takes the largest write-without-response size it saw in +discovery, plus three. + +`connect(priority="throughput")`, or an interval of 15 ms or less (what +`nus.connect()` passes on a board), calls WinRT's +`RequestPreferredConnectionParameters(ThroughputOptimized)` through bleak's +private `_backend._requester`, as the P4 measurements did. On the S3 it made +no difference to nus throughput. + +After a disconnect bleak sets `client.services` to `None`, so every client call +checks the connection first and raises `DisconnectedError`. + +## How the REPL gets there + +The REPL has to work when nothing runs asyncio, at the `>>>` prompt, so the +board side doesn't use bledev's adapter at all. It registers the Nordic UART +service with the raw API (the RX buffer in append mode, 1 KB), advertises +with `gap_advertise`, and does everything else from an IRQ handler it adds to +aioble's dispatcher (or sets itself, with no aioble). `os.dupterm` reads a +`_Stream` whose `readinto` returns `None` when empty, because 0 means end of +stream and detaches it. Output goes out as notifications of `mtu - 3` bytes, +and what the controller can't take yet is retried from a one-shot soft timer. +A program printing faster than the link carries waits in `write()` up to two +seconds, then drops the excess, because `print()` can't fail. + +Ctrl-C works because the IRQ calls `os.dupterm_notify()` whenever a write +contains 0x03; dupterm then reads it and raises `KeyboardInterrupt`, even in +a loop that never reads stdin. It only does that for 0x03: notifying on every +write would push pasted input through the 260-byte stdin ring, which drops what +doesn't fit. + +The password stage is `repl.Login`, pure and checked on both interpreters. +Nothing reaches dupterm until the password line matches, and anything sent +after a wrong one is dropped. The prompt goes out at connect, but a client +that subscribes after connecting misses it, so an empty line asks for it again +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. + ## The checks **Against the fake**, on both interpreters: `tests/bledev_contract.py`. @@ -134,14 +220,22 @@ must fail the contract; if you change the fake, keep that true. **Over a real radio**, `tests/bledev_board/`. Run the serving script with `mpftp run` on one board and the other with `mpftp probe --capture` on a -second. `gatt_peripheral.py`, `nus_server.py` and `nus_client.py` each have a -`PLANT` switch, and a planted run must fail: +second, or run the client on the laptop with the Windows Python: + +```bash +PYTHONPATH="$(wslpath -w lib)" python.exe "$(wslpath -w tests/bledev_board/gatt_central.py)" +``` + +The serving scripts have a `PLANT` switch, and a planted run must fail: | Scripts | What they check | |---|---| | `gatt_peripheral.py` + `gatt_central.py` | The contract over the air: find, MTU exchange, discovery order, read, 200 notifications in order, 100 captured writes in order, the MTU refusal, an indication, a disconnect from the far side | | `gatt_peripheral.py` + `gatt_latency.py` | Per-operation latency of reads and writes-with-response, with the stall count | -| `nus_server.py` + `nus_client.py` | 16 KB up, down and echoed over nus, byte for byte, timed | +| `nus_server.py` + `nus_client.py` | 16 KB up, down and echoed over nus, byte for byte, timed. On a laptop, `--fast` asks for throughput parameters and `--plant` flips a bit | +| `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 | `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. @@ -181,3 +275,51 @@ off, 2026-09-24. | the same, as .py source | none | 98.8 KB | | registering nus, then advertising | 0.2 KB | 1.8 KB | | `active(False)` | all returned | | + +### The laptop to an S3 + +Windows 11, bleak 3.0.2, the laptop's Intel radio, to the LCD-7 running +`nus_server.py`, 16 KB per phase, every byte checked, 2026-09-24: + +| Run | Up (writes) | Down (notifications) | Echo, each way | +|---|---|---|---| +| before the MTU fix (the board sent 20-byte notifications) | 100.6-134.5 KB/s | 16.4-20.4 KB/s | 8.8-16.1 KB/s | +| after it, Windows' default parameters | 126.0 KB/s | 48.6 KB/s | 33.9 KB/s | +| after it, `priority="throughput"` | 133.3 KB/s | 47.1 KB/s | 29.7 KB/s | +| planted bit flips on both sides | FAIL, 1 mismatch at 5000 | FAIL, same | FAIL, same | + +The over-the-air contract (`gatt_central.py`) passed from the laptop, and +failed with the peripheral's planted skip ("got 199, first gap 7"). + +### Wi-Fi and BLE on one S3 + +The LCD-7 served nus with `coex_server.py`, the T-Embed ran `nus_client.py` +at 64 KB per phase (default interval, BLE at about -50 dBm), and the laptop +read the LCD-7's TCP source with `tcp_pull.py`. The LCD-7's Wi-Fi was weak, +-74 to -82 dBm, and that matters below. Three runs each: + +| LCD-7's Wi-Fi | Up (writes) | Down (notifications) | Echo, each way | +|---|---|---|---| +| off | 76.5, 81.3, 83.9 KB/s | 34.0, 33.6, 31.7 | 26.6, 26.3, 25.8 | +| connected, idle | 36.1, 61.8, 36.0 | 23.7, 26.0, 28.5 | 22.5, 22.3, 24.2 | +| connected, the laptop streaming from it | 21.8, 39.2, 31.9 | 23.1, 27.0, 26.7 | 20.2, 18.5, 21.5 | + +Every byte arrived intact in every run. What the board receives (up) suffers +most: an idle association alone halves it about two runs in three. + +**Wi-Fi throughput with BLE on is not measured, in the sense that matters.** +The TCP stream from the board to the laptop, 10 s per run: + +| BLE on the LCD-7 | KB/s | +|---|---| +| off (never activated) | 155.0, 49.2, 13.5, 86.1 | +| active, not advertising | 43.5, 35.9, 36.8, 120.4 | +| advertising every 100 ms | 39.7, (connection timed out at -82 dBm), 130.8, 112.5 | +| carrying the nus gate (averaged over 26-29 s that include advertising) | 46.2, 43.4, 40.7 | + +With BLE off entirely the rate ran from 13.5 to 155 KB/s, so at this signal +strength the run-to-run spread is wider than any difference BLE could make. +One early run with BLE advertising read 8.1 KB/s, which looked like a finding +until the repeats. Measuring BLE's cost to Wi-Fi needs the board at -60 dBm +or better. + diff --git a/docs/bledev.md b/docs/bledev.md index f0053c16..daa301f4 100644 --- a/docs/bledev.md +++ b/docs/bledev.md @@ -35,7 +35,7 @@ transport can talk to it too. | `bledev.webble` | Browsers: PyScript, the Workbench simulator | yes | no | | `bledev.fake` | Anywhere: tests | yes | yes | -`bledev.bleak` and `bledev.webble` are being written; the table is the plan. +`bledev.webble` is being written; the other three 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 @@ -61,13 +61,31 @@ adds bleak, which the laptop backend needs. ## Getting an adapter -The adapter is one radio. On a board: +The adapter is one radio. On a board with a PyDevices board config, it's the +`ble` role: + +```python +from board_config import ble # a bledev adapter +``` + +It still answers every `bluetooth.BLE` method (`active()`, `gap_advertise()`, +`gatts_notify()`), so code written for the raw radio keeps working. The one +difference is `ble.irq(handler)`: your handler runs beside aioble's instead of +replacing it. A board without bledev installed gets the raw `bluetooth.BLE()`, +as before. Without a board config: ```python import bledev.mpble ble = bledev.mpble.get() # the board has one radio, so get() shares one adapter ``` +On a laptop: + +```python +import bledev.bleak +ble = bledev.bleak.BleakBLE() # central only +``` + Anywhere, if you don't want to name the backend: ```python @@ -146,6 +164,65 @@ max_conn_interval_us=15000)` asks for a short connection interval, which nearly doubles a round trip's throughput (the numbers are in [the measurements](#measured-on-two-esp32-s3s)). +From a laptop, `nus.connect(ble, name=..., priority="throughput")` asks +Windows for its short-interval parameters. Board to laptop, it made little +difference (see [the measurements](#measured-on-two-esp32-s3s)). + +## A REPL over Bluetooth + +A board can offer its REPL over nus, behind a password, the way WebREPL does +over Wi-Fi. It's opt-in; put this in `main.py`: + +```python +import bledev.repl +bledev.repl.start(password="correct horse", name="rack") # or webrepl_cfg.PASS +``` + +`start()` returns at once, and the REPL keeps being served at the `>>>` +prompt and while your program runs; Ctrl-C over Bluetooth interrupts a running +program. From a laptop or another board: + +```python +import bledev.repl +link = await bledev.repl.connect(ble, "correct horse", name="rack") +print(await bledev.repl.run(link, "1 + 1")) # "2" +``` + +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. + +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 +radio back to your app. + +## Wi-Fi setup with Improv + +`bledev.improv` speaks [Improv](https://www.improv-wifi.com/ble/), the BLE +Wi-Fi setup standard ESPHome, WLED and Home Assistant use. A board without +credentials serves it until someone provisions it: + +```python +import bledev.improv as improv +url = await improv.serve(ble, name="kitchen") # returns once the board has joined +``` + +Anything that speaks Improv can then set it up: Home Assistant, the Improv web +page, or another bledev host: + +```python +url = await improv.provision(ble, ssid, password, name="kitchen") +``` + +A network the board can't join raises `improv.ImprovError` with +`code == improv.ERROR_UNABLE_TO_CONNECT`. Saving the credentials for the next +boot is up to you: pass `on_join=lambda ssid, password, ip: ...` to `serve()`. +`require_authorization=True` makes the board wait for `server.authorize()` +(from a button, say) before it accepts credentials. The credentials cross the +air unencrypted; that's the standard. + ## The rules every backend keeps These hold on every backend. The fake enforces the strict version of each, so @@ -218,6 +295,15 @@ Board to board over nus (16 KB each way, verified byte for byte), with Wi-Fi off | Peripheral to central (notifications) | 31-41 KB/s | 37-39 KB/s | | Echo round trip, each way | 24-30 KB/s | 44 KB/s | +A laptop (Windows, bleak) to an S3 over the same gate: 100-134 KB/s up, +47-49 KB/s down and 30-34 KB/s echoed each way. Asking Windows for its +throughput parameters changed nothing measurable. + +With the board also on Wi-Fi, BLE slows: idle-connected Wi-Fi roughly halves +the upstream writes (36-62 KB/s instead of 77-84) and trims the rest by a +fifth, and a Wi-Fi stream running at the same time takes upstream to 22-39 +KB/s. The runs are in [bledev-internals.md](bledev-internals.md#wi-fi-and-ble-on-one-s3). + A single GATT read or write takes two connection intervals: a median of 100 ms at the default interval, and 30 ms at 7.5-15 ms, with nothing over 100 ms in two minutes. Turning the radio on costs about 64 KB of the S3's internal RAM; diff --git a/lib/bledev/mpble.py b/lib/bledev/mpble.py index 7dc3f282..a0df68dc 100644 --- a/lib/bledev/mpble.py +++ b/lib/bledev/mpble.py @@ -7,8 +7,12 @@ ``mip.install("aioble")``, or install ``bledev`` from the PyDevices index, which brings aioble with it. -aioble owns the one ``bluetooth.BLE()`` object and its IRQ handler, so don't -set ``ble.irq()`` yourself while this backend is in use. +aioble owns the one ``bluetooth.BLE()`` object and its IRQ handler. An +:class:`MPBLE` also answers every ``bluetooth.BLE`` method (``active()``, +``gap_advertise()``, ``gatts_notify()`` and the rest go straight to the radio), +so code written for the raw object keeps working when a board config hands +out an :class:`MPBLE` instead. ``irq(handler)`` is the one it changes: the +handler runs beside aioble's rather than replacing it. Where aioble would lose data, this backend doesn't: @@ -161,7 +165,14 @@ def __exit__(self, exc_type, exc, tb): _shared = None +_user_irq = None + + def _irq(event, data): + if _user_irq is not None: + result = _user_irq(event, data) + if result is not None: + return result # Runs beside aioble's own handlers. A captured write is copied here, in # the IRQ, before the next write can overwrite the value buffer, and tied # to its connection while that connection still exists. @@ -189,10 +200,32 @@ def _irq(event, data): if mtu and aconn is not None and not aconn.mtu: aconn.mtu = mtu elif event == _IRQ_CENTRAL_DISCONNECT: - _early_mtu.pop(data[0], None) + conn_handle = data[0] + _early_mtu.pop(conn_handle, None) + aconn = _AioConnection._connected.get(conn_handle) + if aconn is not None and aconn._task is None: + _forget(conn_handle, aconn) return None +def _forget(conn_handle, aconn): + # A central connected to something advertised with the raw API + # (ble.gap_advertise), not aioble.advertise(). aioble recorded it anyway + # and left its advertise() signal set, and nothing of aioble's will clean + # up, so do it here. + from aioble import peripheral + + _AioConnection._connected.pop(conn_handle, None) + aconn._conn_handle = None + aconn.device._connection = None + if peripheral._incoming_connection is aconn: + peripheral._incoming_connection = None + try: + peripheral._connect_event.clear() + except AttributeError: + pass + + def get(ble=None): """The shared :class:`MPBLE` for this board's radio, made on first use.""" global _shared @@ -221,6 +254,13 @@ def __init__(self, ble=None, *, queue_limit=128, mtu=None): if not _irq_registered: _aioble_core.register_irq_handler(_irq, None) _irq_registered = True + # aioble's peripheral IRQ signals its advertise() on every incoming + # connection and raises when advertise() never ran, which happens + # when code uses the raw API (ble.gap_advertise). Give it a flag. + from aioble import peripheral + + if peripheral._connect_event is None: + peripheral._connect_event = asyncio.ThreadSafeFlag() if mtu: self.config(mtu=mtu) @@ -255,6 +295,20 @@ def close(self): aioble.stop() self._connections = {} + # -- the raw bluetooth.BLE, for code written against it + + def __getattr__(self, name): + # Only reached for names MPBLE doesn't define. + return getattr(_aioble_core.ble, name) + + def irq(self, handler): + """Like ``bluetooth.BLE.irq``, but beside aioble's handler rather than instead of it. + + A later call replaces the earlier handler; ``irq(None)`` removes it. + """ + global _user_irq + _user_irq = handler + # -- connections, one wrapper per aioble connection def _wrap(self, aconn, role, device=None): diff --git a/mip-split.toml b/mip-split.toml index 80633a62..10bcb5d8 100644 --- a/mip-split.toml +++ b/mip-split.toml @@ -80,9 +80,9 @@ host-only = [ own-package = true requires = ["aioble"] # The central-only host backends go in pydevices-desktop, as for the other -# packages here. List `bleak` and `webble` when those modules land; a name -# listed before its file exists fails the sync. -host-only = [] +# packages here. List `webble` when that module lands; a name listed before +# its file exists fails the sync. +host-only = ["bleak"] # On PyPI bledev stays inside the pydevices wheel. bleak is optional, behind # `pip install "pydevices[ble]"`. pypi-extras = { ble = ["bleak>=1.0"] } diff --git a/pydevices-desktop.toml b/pydevices-desktop.toml index 1f3d50ac..a1f8be06 100644 --- a/pydevices-desktop.toml +++ b/pydevices-desktop.toml @@ -22,9 +22,12 @@ "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/audiodev/win_audio.py" = "/lib/audiodev/win_audio.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/__init__.py" = "/lib/bledev/__init__.py" "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/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" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/bledev/repl.py" = "/lib/bledev/repl.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/coex_server.py b/tests/bledev_board/coex_server.py new file mode 100644 index 00000000..daa3299f --- /dev/null +++ b/tests/bledev_board/coex_server.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Wi-Fi and BLE coexistence, board side: a TCP source on Wi-Fi beside the nus gate. + +Joins Wi-Fi with this board's own ``secrets.py`` (never printed), then serves +a TCP stream of zeros on port 5001 to anyone who connects (a laptop measures +Wi-Fi throughput by reading it, see ``tcp_pull.py``). With ``BLE = True`` it +then runs ``nus_server.py``'s gate (copy that file to the board's root +first) over and over, so ``nus_client.py`` on another board +measures BLE while the laptop does or doesn't stream over Wi-Fi. + +``WIFI = False`` skips Wi-Fi, for the baseline. Logs to /coex_server.log. +""" +import asyncio +import time + +import network + +WIFI = True +BLE = True +PORT = 5001 +LOG = "/coex_server.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 join(): + import secrets + + ssid = getattr(secrets, "WIFI_SSID", None) or getattr(secrets, "ssid", None) + password = getattr(secrets, "WIFI_PASSWORD", None) or getattr(secrets, "password", None) or "" + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + if not wlan.isconnected(): + wlan.connect(ssid, password) + del ssid, password + for _ in range(120): + if wlan.isconnected(): + break + await asyncio.sleep_ms(250) + log("wifi connected", wlan.isconnected(), "ip", wlan.ifconfig()[0], "rssi", wlan.status("rssi")) + + +async def source(reader, writer): + block = bytes(4096) + sent = 0 + t0 = time.ticks_ms() + try: + while True: + writer.write(block) + await writer.drain() + sent += len(block) + except Exception: + pass + ms = time.ticks_diff(time.ticks_ms(), t0) + log("tcp source sent", sent, "bytes in", ms, "ms") + try: + writer.close() + except Exception: + pass + + +async def main(): + open(LOG, "w").close() + if WIFI: + await join() + await asyncio.start_server(source, "0.0.0.0", PORT) + log("tcp source on port", PORT) + if BLE: + import nus_server # copied to the board beside this script + + while True: + await nus_server.gate(log) + else: + while True: + await asyncio.sleep(1) + + +asyncio.run(main()) diff --git a/tests/bledev_board/nus_server.py b/tests/bledev_board/nus_server.py index b600821a..e7320163 100644 --- a/tests/bledev_board/nus_server.py +++ b/tests/bledev_board/nus_server.py @@ -49,6 +49,11 @@ def compare(got, want): async def main(): open(LOG, "w").close() + await gate(log) + + +async def gate(log): + """Serve one gate run; ``coex_server.py`` imports this.""" ble = bledev.mpble.get() log("serving as bledev-gate, plant", PLANT) link = await nus.serve(ble, name="bledev-gate", timeout_ms=120000) @@ -91,4 +96,5 @@ async def main(): await link.close() -asyncio.run(main()) +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/bledev_board/tcp_pull.py b/tests/bledev_board/tcp_pull.py new file mode 100644 index 00000000..261dd19d --- /dev/null +++ b/tests/bledev_board/tcp_pull.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Wi-Fi throughput from a laptop: read ``coex_server.py``'s TCP source. + + python tcp_pull.py BOARD_IP [SECONDS] + +Prints KB/s for each second and the average. +""" +import socket +import sys +import time + +ip = sys.argv[1] +seconds = float(sys.argv[2]) if len(sys.argv) > 2 else 10 +s = socket.create_connection((ip, 5001), timeout=5) +total = 0 +t0 = time.monotonic() +mark, marked = t0, 0 +while time.monotonic() - t0 < seconds: + data = s.recv(65536) + if not data: + break + total += len(data) + now = time.monotonic() + if now - mark >= 1: + print(" {:.1f} s: {:.1f} KB/s".format(now - t0, (total - marked) / 1024 / (now - mark)), flush=True) + mark, marked = now, total +elapsed = time.monotonic() - t0 +s.close() +print("TCP {} bytes in {:.1f} s = {:.1f} KB/s".format(total, elapsed, total / 1024 / elapsed))