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/__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..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: @@ -51,10 +55,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 +99,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,11 +159,20 @@ 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 +_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. @@ -159,9 +184,48 @@ 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: + 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 @@ -190,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) @@ -224,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): @@ -294,14 +379,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 +455,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 +568,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/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/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/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/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_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)) 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