From c2c715cfd470719d950c2413fcd70af08ad31cfe Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:10:21 -0500 Subject: [PATCH] Reach a board over Bluetooth: ble:// devices through bledev A bledev.repl link dressed as a pyserial port for mpremote's SerialTransport, over bleak in the sidecar's Python. Files go through the board's BLE file-transfer service (bledev.filetransfer, CircuitPython's protocol) when it serves one: 20 KB up in 0.8 s and down in 0.3 s, against 16 s and 11 s over the raw REPL. put/get keep their SHA-256 check, and MPFTP_BLE_PLANT proves it fails on a flipped bit. The password comes the way the WebREPL one does. Soft reset refuses over BLE (it turns Bluetooth off); mount stays serial-only. --- CHANGELOG.md | 12 + README.md | 1 + cli/src/mpftp/ble.py | 529 +++++++++++++++++++++++++++++++++++++++ cli/src/mpftp/boards.py | 14 +- cli/src/mpftp/cli.py | 32 ++- cli/src/mpftp/config.py | 3 + cli/src/mpftp/mcp.py | 3 +- cli/src/mpftp/sidecar.py | 72 ++++-- cli/tests/test_ble.py | 231 +++++++++++++++++ docs/agent-guide.md | 32 +++ docs/plans/ble.md | 134 ++++++++++ docs/user-guide.md | 10 + tools/ble_bench.py | 85 +++++++ tools/ble_gate.py | 88 +++++++ 14 files changed, 1216 insertions(+), 30 deletions(-) create mode 100644 cli/src/mpftp/ble.py create mode 100644 cli/tests/test_ble.py create mode 100644 docs/plans/ble.md create mode 100644 tools/ble_bench.py create mode 100644 tools/ble_gate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 518536c..14e2b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +- Boards can be reached over Bluetooth from the CLI and agents: every board + command takes `-d ble://NAME`, a board running pydevices' `bledev.repl` or + `bledev.filetransfer`. exec, run, the REPL, interrupt, ls, put and get all + work, with no serial port involved. Files use the board's BLE file-transfer + service (CircuitPython's protocol) when it has one, which is 19 to 35 times + faster than the raw REPL. The password comes from `MPFTP_BLE_PASSWORD`, + `blePassword`, `mpftp wifi password ble://NAME`, or the WebREPL one. The + sidecar's Python needs bleak. How it works and the measurements: + [docs/plans/ble.md](docs/plans/ble.md). + ## v0.0.7 (2026-09-24) - `interrupt` over Wi-Fi no longer calls a live board busy. Bytes left over diff --git a/README.md b/README.md index 88cd611..acd8ddb 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - **Embedded Serial REPL**: The interactive REPL opens directly inside your editor's **Terminal panel**, allowing you to test and inspect code live alongside your editor tabs and AI coding agents. - **Wi-Fi Fast MIP Transfers**: Transferring `wifi.py` and creating `secrets.py` on Wi-Fi enabled boards enables on-board `mip` package installation directly over the network, which is significantly faster than serial file transfers. - **Connect over Wi-Fi**: Once a board is set up over USB (mpftp shows you the `boot.py` change first), pick it by name from the Wi-Fi list and use the REPL and file transfer through WebREPL. See [mpftp over Wi-Fi](docs/plans/wifi-webrepl.md). +- **Connect over Bluetooth** (CLI and agents): `-d ble://NAME` reaches a board running pydevices' `bledev`, REPL and files, with no cable. See [mpftp over Bluetooth](docs/plans/ble.md). - **Visual Firmware Builder**: A GUI companion for firmware workspaces (a MicroPython checkout with user C modules beside it), allowing you to download official releases or build and flash custom firmware from the UI. - **Completely Optional**: If you are already comfortable with command-line tools (`mpremote`, `esptool`, `circup`) or standalone IDEs, you can continue using them. `mpftp` is provided as an all-in-one in-editor workbench. - **No Editor Required**: `pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ pydevices-mpftp && python -m mpftp` opens a local installable PWA — file transfer and REPL in a browser tab, no VS Code needed. diff --git a/cli/src/mpftp/ble.py b/cli/src/mpftp/ble.py new file mode 100644 index 0000000..c35d3a4 --- /dev/null +++ b/cli/src/mpftp/ble.py @@ -0,0 +1,529 @@ +"""Reach a board over Bluetooth Low Energy: ``-d ble://NAME``. + +The board runs ``bledev.repl`` (pydevices' ``bledev`` package), which serves +the REPL over the Nordic UART service behind a password, the way WebREPL does +over Wi-Fi. :class:`BleSerial` dresses that link up as a pyserial port and +:func:`open_transport` hands it to mpremote's ``SerialTransport``, so exec, +raw REPL, interrupt and every ``fs_*`` operation work unchanged. + +When the board also serves files (``bledev.filetransfer``, CircuitPython's BLE +file-transfer protocol), uploads and downloads go through that instead of the +REPL: it's 19 to 35 times faster (measured in docs/plans/ble.md). A board +without it still works, over the REPL alone. + +bleak is the only dependency beyond the standard library, and only for BLE: +``python -m pip install bleak`` into the Python the sidecar runs on (on WSL, +the Windows one, because Windows owns the radio). +""" + +from __future__ import annotations + +import asyncio +import os +import struct +import threading +import time +from typing import Any, Optional + +SCHEME = "ble://" + +NUS_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e" +NUS_RX = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" # the central writes +NUS_TX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" # the board notifies + +FT_SERVICE = "0000febb-0000-1000-8000-00805f9b34fb" +FT_TRANSFER = "adaf0200-4669-6c65-5472-616e73666572" +FT_AUTH = "adaf0300-4669-6c65-5472-616e73666572" + +#: ``bledev.repl`` refuses shorter passwords and buffers at most 64 bytes. +MIN_PASSWORD_LEN = 4 +MAX_PASSWORD_LEN = 64 + +_PASSWORD_PROMPT = b"Password: " +_CONNECTED = b"bledev REPL connected" +_DENIED = b"Access denied" + +#: As for WebREPL: a blocking read gives up after this long, because a link +#: that went quiet may never say so. +STALL_TIMEOUT = 20.0 + +#: How much of a file one READ asks for. The board streams it from flash, so +#: bigger only means fewer round trips. +READ_CHUNK = 64 * 1024 + +# The file-transfer protocol's commands and statuses (bledev.filetransfer). +_READ, _READ_DATA, _READ_PACING = 0x10, 0x11, 0x12 +_WRITE, _WRITE_PACING, _WRITE_DATA = 0x20, 0x21, 0x22 +_OK = 0x01 + +#: Test hook, to prove the SHA-256 check behind put/get can fail: +#: ``MPFTP_BLE_PLANT=put`` flips one bit in one uploaded chunk, +#: ``MPFTP_BLE_PLANT=get`` one bit in one downloaded chunk. +PLANT_ENV = "MPFTP_BLE_PLANT" + +#: ``MPFTP_BLE_FILES=repl`` moves files over the raw REPL even when the board +#: serves file transfer (for comparing the two). +FILES_ENV = "MPFTP_BLE_FILES" + + +class BleError(OSError): + """The BLE link failed or dropped. Says "port is closed" when the link is + gone, so the sidecar treats it the way it treats a pulled USB cable.""" + + +class BleAuthError(RuntimeError): + """The board refused the password. Never retried.""" + + +def is_ble_device(device: Optional[str]) -> bool: + return bool(device) and device.strip().lower().startswith(SCHEME) + + +def parse_device(device: str) -> str: + """``ble://NAME`` (the advertised name) or ``ble://AA:BB:CC:DD:EE:FF`` -> the part after ``ble://``.""" + target = device.strip()[len(SCHEME) :].strip("/") + if not target: + raise ValueError(f"{device}: expected ble://NAME, the name the board advertises") + if "@" in target: + raise ValueError( + f"{device}: don't put the password in the address; set MPFTP_BLE_PASSWORD " + "or blePassword in ~/.mpftp/config.json" + ) + return target + + +def check_password(password: Optional[str], device: str) -> str: + if not password: + raise BleAuthError( + f"{device}: no BLE REPL password. Set MPFTP_BLE_PASSWORD, or blePassword in " + "~/.mpftp/config.json, or save one with `mpftp wifi password ble://NAME`" + ) + if not MIN_PASSWORD_LEN <= len(password) <= MAX_PASSWORD_LEN or "\r" in password or "\n" in password: + raise BleAuthError( + f"{device}: a bledev.repl password is {MIN_PASSWORD_LEN} to {MAX_PASSWORD_LEN} " + "characters with no line breaks" + ) + return password + + +def _need_bleak() -> Any: + try: + import bleak + except ImportError as e: + import sys + + raise BleError( + "ble:// needs bleak in the Python the sidecar runs on: " + f"{sys.executable} -m pip install --user bleak" + ) from e + return bleak + + +class BleSerial: + """The pyserial surface mpremote's ``SerialTransport`` uses, over bledev.repl. + + ``open()`` finds the board by its advertised name, connects, and logs in, + so a wrong password or a board that isn't advertising fails there, with a + message. bleak runs on its own event loop in a background thread; the + board's notifications land in a buffer that ``read`` takes from. + """ + + def __init__( + self, + device: str, + password: Optional[str], + *, + timeout: Optional[float] = None, + scan_timeout: float = 10.0, + login_timeout: float = 8.0, + throughput: bool = True, + ) -> None: + self.port = device + self.target = parse_device(device) + self._password = check_password(password, device) + self.timeout = timeout + self.write_timeout: Optional[float] = None + self.scan_timeout = scan_timeout + self.login_timeout = login_timeout + self.throughput = throughput + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + self._client: Any = None + self._cond = threading.Condition() + self._rx = bytearray() # the REPL stream + self._frx = bytearray() # file-transfer answers + self._closed_reason: Optional[str] = None + self._wlock = threading.Lock() + #: REPL bytes received so far, as WebSocketSerial counts them, so the + #: sidecar can tell whether the board answered an interrupt. + self.rx_total = 0 + self.mtu = 23 + self.has_files = False + self._plant = os.environ.get(PLANT_ENV, "").strip().lower() + self._planted = False + self.dtr = True + self.rts = True + + # --- pyserial compatibility ------------------------------------------- + + @property + def is_open(self) -> bool: + return self._client is not None and self._closed_reason is None + + def isOpen(self) -> bool: # noqa: N802 (pyserial spelling) + return self.is_open + + def setDTR(self, value: bool = True) -> None: # noqa: N802 + self.dtr = value + + def setRTS(self, value: bool = True) -> None: # noqa: N802 + self.rts = value + + def flush(self) -> None: + pass + + def reset_input_buffer(self) -> None: + with self._cond: + self._rx.clear() + + flushInput = reset_input_buffer # noqa: N815 + + @property + def in_waiting(self) -> int: + return self.inWaiting() + + def inWaiting(self) -> int: # noqa: N802 + with self._cond: + if not self._rx and self._closed_reason: + raise BleError(self._closed_message()) + return len(self._rx) + + def read(self, size: int = 1) -> bytes: + limit = STALL_TIMEOUT if self.timeout is None else self.timeout + deadline = time.monotonic() + limit + with self._cond: + while len(self._rx) < size: + if self._closed_reason: + if self._rx: + break + raise BleError(self._closed_message()) + left = deadline - time.monotonic() + if left <= 0: + if self.timeout is None: + raise BleError( + f"{self.port}: no data for {STALL_TIMEOUT:.0f} s; " + "port is closed (the BLE link stalled)" + ) + break + self._cond.wait(min(left, 0.5)) + out = bytes(self._rx[:size]) + del self._rx[:size] + return out + + def write(self, data: bytes) -> int: + data = bytes(data) + if data: + self._write_char(NUS_RX, data) + return len(data) + + def close(self) -> None: + loop, client = self._loop, self._client + if loop is None: + return + self._closed_reason = self._closed_reason or "closed by mpftp" + if client is not None: + try: + asyncio.run_coroutine_threadsafe(client.disconnect(), loop).result(5) + except Exception: + pass + loop.call_soon_threadsafe(loop.stop) + if self._thread is not None: + self._thread.join(timeout=5) + self._client = None + self._loop = None + + # --- connection ------------------------------------------------------- + + def open(self) -> None: + _need_bleak() + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._loop.run_forever, name="mpftp-ble", daemon=True) + self._thread.start() + try: + self._run(self._connect(), self.scan_timeout + 30) + self._login() + except BaseException: + self._closed_reason = self._closed_reason or "open failed" + self.close() + raise + + def _run(self, coro: Any, timeout: float) -> Any: + if self._loop is None: + raise BleError(self._closed_message()) + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + try: + return future.result(timeout) + except TimeoutError as e: + future.cancel() + raise BleError(f"{self.port}: the BLE operation timed out after {timeout:.0f} s; port is closed") from e + + async def _connect(self) -> None: + from bleak import BleakClient, BleakScanner + + target = self.target + wanted = target.upper() + + def match(device: Any, adv: Any) -> bool: + return (adv.local_name or device.name or "") == target or (device.address or "").upper() == wanted + + device = await BleakScanner.find_device_by_filter(match, timeout=self.scan_timeout) + if device is None: + raise BleError( + f"{self.port}: nothing is advertising as {target!r} within {self.scan_timeout:.0f} s. " + "Is bledev.repl.start() running on the board (from main.py), and is nobody " + "else connected to it? It takes one client at a time." + ) + + def dropped(_client: Any) -> None: + with self._cond: + self._closed_reason = self._closed_reason or "the board disconnected" + self._cond.notify_all() + + client = BleakClient(device, disconnected_callback=dropped) + try: + await client.connect() + except Exception as e: + raise BleError(f"{self.port}: could not connect to {target!r}: {e}") from e + self._client = client + if self.throughput: + self._request_throughput(client) + # Windows exchanges the MTU itself; give it a moment to move off 23. + for _ in range(50): + if int(getattr(client, "mtu_size", 23) or 23) > 23: + break + await asyncio.sleep(0.02) + self.mtu = int(getattr(client, "mtu_size", 23) or 23) + services = client.services + if services.get_characteristic(NUS_RX) is None or services.get_characteristic(NUS_TX) is None: + await client.disconnect() + raise BleError(f"{self.port}: {target!r} doesn't serve the REPL (no Nordic UART service)") + await client.start_notify(NUS_TX, self._on_repl) + if services.get_characteristic(FT_TRANSFER) is not None: + await client.start_notify(FT_TRANSFER, self._on_files) + self.has_files = True + + @staticmethod + def _request_throughput(client: Any) -> bool: + """Ask Windows for a short connection interval (WinRT only). + + It roughly doubles what the board can send us (measured in docs/plans/ble.md). + bleak doesn't expose it, so this reaches into its WinRT backend. + """ + requester = getattr(getattr(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 _on_repl(self, _sender: Any, data: bytearray) -> None: + with self._cond: + self._rx += data + self.rx_total += len(data) + self._cond.notify_all() + + def _on_files(self, _sender: Any, data: bytearray) -> None: + with self._cond: + self._frx += data + self._cond.notify_all() + + def _write_char(self, uuid: str, data: bytes, response: bool = False) -> None: + if self._closed_reason or self._client is None: + raise BleError(self._closed_message()) + size = max(20, self.mtu - 3) + limit = self.write_timeout or STALL_TIMEOUT + + async def send() -> None: + for i in range(0, len(data), size): + await self._client.write_gatt_char(uuid, data[i : i + size], response=response) + + with self._wlock: + try: + self._run(send(), limit + len(data) / 2000) + except BleError: + raise + except Exception as e: + if self._closed_reason: + raise BleError(self._closed_message()) from e + raise BleError(f"{self.port}: BLE write failed: {e}") from e + + def _login(self) -> None: + self.write(b"\r") # asks for the prompt again if we subscribed after it went out + self._read_until_any((_PASSWORD_PROMPT,), self.login_timeout, "the password prompt") + self.write(self._password.encode("utf-8") + b"\r") + seen = self._read_until_any((_CONNECTED, _DENIED), self.login_timeout, "the login reply") + if _DENIED in seen: + self._closed_reason = "password rejected" + raise BleAuthError( + f"{self.port}: the board rejected the BLE REPL password " + "(check MPFTP_BLE_PASSWORD / blePassword)" + ) + # The REPL login unlocks files on the same connection too. + self._read_until_any((b">>> ",), self.login_timeout, "the prompt after login") + + def _read_until_any(self, needles: tuple[bytes, ...], limit: float, what: str) -> bytes: + deadline = time.monotonic() + limit + with self._cond: + while True: + data = bytes(self._rx) + for n in needles: + at = data.find(n) + if at >= 0: + del self._rx[: at + len(n)] + return data[: at + len(n)] + if self._closed_reason: + raise BleError(f"{self.port}: the board closed the link before {what} (it said {data[-80:]!r})") + left = deadline - time.monotonic() + if left <= 0: + raise BleError(f"{self.port}: no {what} within {limit:.0f} s (got {data[-80:]!r})") + self._cond.wait(min(left, 0.5)) + + def _closed_message(self) -> str: + return f"{self.port}: port is closed ({self._closed_reason or 'not open'})" + + # --- file transfer ---------------------------------------------------- + + def _frecv(self, n: int, timeout: float = STALL_TIMEOUT) -> bytes: + deadline = time.monotonic() + timeout + with self._cond: + while len(self._frx) < n: + if self._closed_reason: + raise BleError(self._closed_message()) + left = deadline - time.monotonic() + if left <= 0: + raise BleError(f"{self.port}: file transfer stalled for {timeout:.0f} s; port is closed") + self._cond.wait(min(left, 0.5)) + out = bytes(self._frx[:n]) + del self._frx[:n] + return out + + def _fsend(self, data: bytes) -> None: + self._write_char(FT_TRANSFER, data) + + def _flip(self, data: bytes, which: str) -> bytes: + if self._plant == which and not self._planted and len(data) > 100: + self._planted = True + return data[:-1] + bytes((data[-1] ^ 0x10,)) + return data + + def files_read(self, path: str) -> bytes: + """A whole file, over the file-transfer service. Raises OSError on a board error.""" + raw = path.encode("utf-8") + with self._cond: + self._frx.clear() + self._fsend(struct.pack("= total or not size: + return bytes(out) + self._fsend(struct.pack(" None: + """Write a whole file over the file-transfer service. Raises OSError on a board error.""" + raw = path.encode("utf-8") + total = len(data) + with self._cond: + self._frx.clear() + self._fsend(struct.pack(" Any: + """Build the ``SerialTransport`` subclass once mpremote is importable.""" + global _TRANSPORT_CLASS + if _TRANSPORT_CLASS is not None: + return _TRANSPORT_CLASS + + from mpremote.transport_serial import SerialTransport + + class BleTransport(SerialTransport): + """mpremote's serial transport with a bledev.repl link as its port. + + Commands use raw-paste, whose flow control keeps the board's input + buffer from overflowing. Files go over the file-transfer service when + the board has one, and over the REPL otherwise. + """ + + def __init__(self, link: BleSerial) -> None: # pyserial is not used + self.in_raw_repl = False + self.use_raw_paste = True + self.device_name = link.port + self.mounted = False + self.serial = link + + def _files(self) -> bool: + return self.serial.has_files and os.environ.get(FILES_ENV, "").lower() != "repl" + + def fs_writefile(self, dest, data, chunk_size=256, progress_callback=None): + if not self._files(): + return super().fs_writefile(dest, data, chunk_size, progress_callback) + try: + self.serial.files_write(dest, bytes(data)) + except OSError as e: + if isinstance(e, BleError): + raise + # Let the REPL say what's wrong, in mpremote's own words. + return super().fs_writefile(dest, data, chunk_size, progress_callback) + if progress_callback: + progress_callback(len(data), len(data)) + + def fs_readfile(self, src, chunk_size=256, progress_callback=None): + if not self._files(): + return super().fs_readfile(src, chunk_size, progress_callback) + try: + data = self.serial.files_read(src) + except OSError as e: + if isinstance(e, BleError): + raise + return super().fs_readfile(src, chunk_size, progress_callback) + if progress_callback: + progress_callback(len(data), len(data)) + return bytearray(data) + + _TRANSPORT_CLASS = BleTransport + return BleTransport + + +def open_transport(device: str, password: Optional[str], **kwargs: Any) -> Any: + """An mpremote ``SerialTransport`` whose "serial port" is a BLE REPL.""" + cls = _transport_class() + link = BleSerial(device, password, **kwargs) + link.open() + return cls(link) diff --git a/cli/src/mpftp/boards.py b/cli/src/mpftp/boards.py index 85b52f2..7994005 100644 --- a/cli/src/mpftp/boards.py +++ b/cli/src/mpftp/boards.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import Any, Optional -from . import config, webrepl +from . import ble, config, webrepl PASSWORDS_NAME = "webrepl-passwords.json" KEY = config.WIFI_BOARDS_KEY @@ -151,7 +151,10 @@ def listing() -> list[dict[str, Any]]: def password_key(device_or_uid: str) -> str: - """A board's key in the password file: its uid, else ``host:
``.""" + """A board's key in the password file: its uid, else ``host:
``, + or ``ble:`` for a board reached over Bluetooth.""" + if ble.is_ble_device(device_or_uid): + return "ble:" + ble.parse_device(device_or_uid).lower() if webrepl.is_network_device(device_or_uid): uid = uid_for_device(device_or_uid) if uid: @@ -196,6 +199,8 @@ def get_password(device_or_uid: str) -> Optional[str]: key = password_key(device_or_uid) if key in stored: return stored[key] + if ble.is_ble_device(device_or_uid): + return config.resolve("blePassword") or config.resolve("webreplPassword") or None if webrepl.is_network_device(device_or_uid): host, port, _path = webrepl.parse_device(device_or_uid) by_host = stored.get(f"host:{host.lower()}:{port}") @@ -206,7 +211,10 @@ def get_password(device_or_uid: str) -> Optional[str]: def set_password(device_or_uid: str, password: str) -> str: """Store a board's password. Returns the key it went under.""" - webrepl.check_password(password, device_or_uid) + if ble.is_ble_device(device_or_uid): + ble.check_password(password, device_or_uid) + else: + webrepl.check_password(password, device_or_uid) data = passwords() key = password_key(device_or_uid) data[key] = password diff --git a/cli/src/mpftp/cli.py b/cli/src/mpftp/cli.py index 27f7a6f..5462fbc 100755 --- a/cli/src/mpftp/cli.py +++ b/cli/src/mpftp/cli.py @@ -41,7 +41,7 @@ from pathlib import Path from typing import Any, Callable, Optional -from . import boards, config, webrepl, wifiboard +from . import ble, boards, config, webrepl, wifiboard def _linux_home() -> Path: @@ -400,6 +400,9 @@ def _is_windows_python(python: str) -> bool: _WSLENV_FORWARD = ( ("MICROPYPATH", "l"), ("PYTHONPATH", "p"), + # ble:// test switches (see ble.py); plain values. + (ble.PLANT_ENV, ""), + (ble.FILES_ENV, ""), ) @@ -458,7 +461,7 @@ def _wslenv_forwarded_env(python: str) -> Optional[dict]: to_forward = [(v, flag) for v, flag in _WSLENV_FORWARD if os.environ.get(v) is not None] existing = [e.strip() for e in os.environ.get("WSLENV", "").split(":") if e.strip()] already = {e.split("/")[0] for e in existing} - additions = [f"{v}/{flag}" for v, flag in to_forward if v not in already] + additions = [f"{v}/{flag}" if flag else v for v, flag in to_forward if v not in already] if not additions and interop is None: return None env = dict(os.environ) @@ -834,6 +837,13 @@ def connect_params(device: str, baud: int) -> dict[str, Any]: resolved here so it comes from this user's environment or config file (a Windows sidecar spawned from WSL sees neither).""" params: dict[str, Any] = {"device": device, "baud": baud} + if ble.is_ble_device(device): + # ~/.mpftp/webrepl-passwords.json under ble:, else + # MPFTP_BLE_PASSWORD / blePassword, else the WebREPL one. + password = boards.get_password(device) + if password: + params["password"] = password + return params if webrepl.is_network_device(device): # This board's own password (~/.mpftp/webrepl-passwords.json), else # MPFTP_WEBREPL_PASSWORD / webreplPassword. @@ -1182,7 +1192,7 @@ def _wait_and_reconnect( for _ in range(max(1, attempts)): time.sleep(delay) try: - if not webrepl.is_network_device(device): + if not (webrepl.is_network_device(device) or ble.is_ble_device(device)): ports = client.call("list_ports") if not any((p or {}).get("device") == device for p in ports or []): continue @@ -1612,10 +1622,11 @@ def cmd_wifi_password(ns: argparse.Namespace) -> None: if ns.forget: print("forgotten" if boards.forget_password(target) else "no password was saved") return - password = ns.password or getpass.getpass(f"WebREPL password for {ns.board}: ") + kind = "BLE REPL" if ble.is_ble_device(target) else "WebREPL" + password = ns.password or getpass.getpass(f"{kind} password for {ns.board}: ") try: key = boards.set_password(target, password) - except webrepl.WebReplAuthError as e: + except (webrepl.WebReplAuthError, ble.BleAuthError) as e: raise SystemExit(f"mpftp: {e}") from None print(f"saved for {key} in {boards._passwords_path()} (plaintext, mode 0600)") @@ -2071,9 +2082,10 @@ def build_parser() -> argparse.ArgumentParser: "-d", dest="device", default=None, - help="Serial port (COM4, /dev/ttyACM0) or WebREPL address (ws://HOST[:8266]); " - "a ws:// device reads its password from MPFTP_WEBREPL_PASSWORD or " - "webreplPassword in ~/.mpftp/config.json", + help="Serial port (COM4, /dev/ttyACM0), WebREPL address (ws://HOST[:8266]) or " + "bledev.repl board (ble://NAME); a ws:// device reads its password from " + "MPFTP_WEBREPL_PASSWORD or webreplPassword in ~/.mpftp/config.json, a ble:// " + "one from MPFTP_BLE_PASSWORD or blePassword", ) device_opts.add_argument("--baud", type=int, default=config.resolve("defaultBaud")) @@ -2085,7 +2097,7 @@ def build_parser() -> argparse.ArgumentParser: c = sub.add_parser("connect", parents=[device_opts], help="Connect to device") c.add_argument( - "device_pos", metavar="DEVICE", help="e.g. COM4, /dev/ttyACM0 or ws://192.168.1.50:8266" + "device_pos", metavar="DEVICE", help="e.g. COM4, /dev/ttyACM0, ws://192.168.1.50:8266 or ble://rack" ) c.set_defaults(func=cmd_connect) @@ -2385,7 +2397,7 @@ def build_parser() -> argparse.ArgumentParser: "password", help="Save a board's WebREPL password in ~/.mpftp/webrepl-passwords.json (plaintext, 0600)", ) - wp.add_argument("board", help="remembered name or uid, or ws://HOST") + wp.add_argument("board", help="remembered name or uid, ws://HOST, or ble://NAME") wp.add_argument("--password", help="default: ask without echo") wp.add_argument("--forget", action="store_true") wp.set_defaults(func=cmd_wifi_password) diff --git a/cli/src/mpftp/config.py b/cli/src/mpftp/config.py index 793f392..84af829 100644 --- a/cli/src/mpftp/config.py +++ b/cli/src/mpftp/config.py @@ -37,6 +37,9 @@ # WebREPL password for ws:// devices. Keep it here or in the environment, # never in a repository. "webreplPassword": (str, "", "MPFTP_WEBREPL_PASSWORD"), + # bledev.repl password for ble:// devices. Unset, the WebREPL one is + # tried, since bledev.repl defaults to webrepl_cfg.PASS. + "blePassword": (str, "", "MPFTP_BLE_PASSWORD"), "verifyTransfers": (bool, True, None), "compileOnUpload": (bool, False, None), "mpyExcludeFiles": (list, ["boot.py", "main.py"], None), diff --git a/cli/src/mpftp/mcp.py b/cli/src/mpftp/mcp.py index cb9bb21..ddf666b 100644 --- a/cli/src/mpftp/mcp.py +++ b/cli/src/mpftp/mcp.py @@ -36,7 +36,8 @@ DEVICE_PROPS: dict[str, Any] = { "device": { "type": "string", - "description": "Serial device to connect first (e.g. COM4, /dev/ttyACM0). " + "description": "Device to connect first: a serial port (COM4, /dev/ttyACM0), " + "ws://HOST for WebREPL, or ble://NAME for a board running bledev. " "Omit to use the board a session is already connected to.", }, "baud": {"type": "integer", "description": "Baud rate.", "default": 115200}, diff --git a/cli/src/mpftp/sidecar.py b/cli/src/mpftp/sidecar.py index 576e6a7..903d482 100644 --- a/cli/src/mpftp/sidecar.py +++ b/cli/src/mpftp/sidecar.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import Any, Callable, Optional -from . import config, mdns, webrepl, wifiboard +from . import ble, config, mdns, webrepl, wifiboard def split_fs_path(path: str) -> tuple[bool, str]: @@ -620,10 +620,26 @@ def is_network(self) -> bool: """True when the session's board is reached over WebREPL (ws://).""" return webrepl.is_network_device(self.device or self.last_device) + @property + def is_ble(self) -> bool: + """True when the session's board is reached over bledev.repl (ble://).""" + return ble.is_ble_device(self.device or self.last_device) + + @property + def is_remote(self) -> bool: + """Over the air (Wi-Fi or BLE): no serial port, no modem lines, and a + soft reset ends the link.""" + return self.is_network or self.is_ble + def _open_transport(self, device: str, baud: int) -> Any: - """mpremote transport for ``device``: a serial port, or ws:// WebREPL.""" + """mpremote transport for ``device``: a serial port, ws:// WebREPL or ble://.""" import time + if ble.is_ble_device(device): + password = self._webrepl_password or config.resolve("blePassword") or config.resolve( + "webreplPassword" + ) + return ble.open_transport(device, password) if webrepl.is_network_device(device): password = self._webrepl_password or config.resolve("webreplPassword") # WebREPL takes one client and hangs up on a second before the @@ -653,10 +669,13 @@ def connect( from mpremote.transport import TransportError - network = webrepl.is_network_device(device) + network = webrepl.is_network_device(device) or ble.is_ble_device(device) if network: try: - webrepl.parse_device(device) + if ble.is_ble_device(device): + ble.parse_device(device) + else: + webrepl.parse_device(device) except ValueError as e: raise RuntimeError(str(e)) from e if password: @@ -682,9 +701,12 @@ def connect( try: self.transport = self._open_transport(device, baud) _bound_write_timeout(self.transport) - except webrepl.WebReplAuthError as e: + except (webrepl.WebReplAuthError, ble.BleAuthError) as e: # Retrying a wrong password only repeats the refusal. raise RuntimeError(str(e)) from e + except ble.BleError as e: + # Already scanned for the board; say what it said. + raise RuntimeError(str(e)) from e except webrepl.WebReplError as e: # Already waited out its own timeout; a retry triples it. raise RuntimeError(self._network_open_error(device, e)) from e @@ -857,8 +879,8 @@ def _reset_esp_to_app(self, serial: Any) -> None: """ import time - if isinstance(serial, webrepl.WebSocketSerial): - return # no EN/IO0 lines over Wi-Fi + if isinstance(serial, (webrepl.WebSocketSerial, ble.BleSerial)): + return # no EN/IO0 lines over the air try: serial.dtr = False # IO0 released (not held for download) @@ -1156,9 +1178,11 @@ def _finish_clean_after_raw( # MicroPython: raw soft-reset skips main.py (unless corrupt FS). if saw_fs_corrupt: return - if webrepl.is_network_device(getattr(t, "device_name", None)): - # A soft reset frees every socket, WebREPL's included, so over - # Wi-Fi "clean" stops at the interrupt: the VM keeps its state. + device_name = getattr(t, "device_name", None) + if webrepl.is_network_device(device_name) or ble.is_ble_device(device_name): + # A soft reset frees every socket, WebREPL's included, and turns + # Bluetooth off, so over the air "clean" stops at the interrupt: + # the VM keeps its state. return try: if t.in_raw_repl: @@ -1375,8 +1399,8 @@ def _take_control_resilient( return self._require() except Exception as e: if not (is_dead_serial_error(e) or "serial handle dead" in str(e).lower()): - if self.is_network: - # The socket is fine but the REPL won't come back: a loop + if self.is_remote: + # The link is fine but the REPL won't come back: a loop # that never yields keeps WebREPL from reading Ctrl-C. raise RuntimeError( f"{self.device or self.last_device}: {wifiboard.STUCK_MESSAGE}" @@ -2169,7 +2193,7 @@ def interrupt(self) -> dict[str, Any]: serial = getattr(t, "serial", None) if serial is None: raise RuntimeError("transport has no serial port") - if isinstance(serial, webrepl.WebSocketSerial): + if isinstance(serial, (webrepl.WebSocketSerial, ble.BleSerial)): return self._network_interrupt(serial) serial.write(b"\r\x03") return {"ok": True} @@ -2226,6 +2250,13 @@ def soft_reset(self) -> dict[str, Any]: MicroPython: raw soft-reset (does not run main.py). CircuitPython: friendly↔raw toggle (Ctrl-D would run code.py). """ + if self.is_ble: + raise RuntimeError( + "soft-reset over BLE would end the session for good: a soft reset turns " + "Bluetooth off, and a raw soft reset skips the main.py that starts " + "bledev.repl. Use soft-reboot (main.py runs, and brings BLE back if it " + "starts bledev.repl), or hard-reset." + ) if self.is_network: return self._network_soft_reset(run_main=False) with self._lock: @@ -2255,7 +2286,7 @@ def soft_reboot(self) -> dict[str, Any]: """ import time - if self.is_network: + if self.is_remote: return self._network_soft_reset(run_main=True) with self._lock: t = self._require() @@ -2345,7 +2376,7 @@ def _network_soft_reset(self, *, run_main: bool) -> dict[str, Any]: "runs_main": True, "reconnected": False, "note": ( - "soft-reboot over WebREPL ends the session; main.py is running. " + "soft-reboot over the air ends the session; main.py is running. " "`resume` reconnects (and interrupts main.py)" ), } @@ -3178,6 +3209,11 @@ def op(t): def mount(self, path: str, unsafe_links: bool = False) -> dict[str, Any]: self._require_micropython("mount") + if self.is_ble: + raise RuntimeError( + "mount works over serial only. Over BLE every file the board opens from " + "/remote would cross the REPL a byte at a time. Use put/get instead." + ) if self.is_network: raise RuntimeError( "mount works over serial only. Over WebREPL every file the board " @@ -3620,7 +3656,11 @@ def repl_write(self, data_b64: str) -> dict[str, Any]: self._release_dead_transport(str(e)) raise RuntimeError(f"repl_write failed: {e}") from e raise - if b"\x03" in data and isinstance(serial, webrepl.WebSocketSerial) and before is not None: + if ( + b"\x03" in data + and isinstance(serial, (webrepl.WebSocketSerial, ble.BleSerial)) + and before is not None + ): self._watch_repl_interrupt(serial, before) return {"bytes": len(data)} diff --git a/cli/tests/test_ble.py b/cli/tests/test_ble.py new file mode 100644 index 0000000..4833b6a --- /dev/null +++ b/cli/tests/test_ble.py @@ -0,0 +1,231 @@ +"""ble:// devices: addresses, passwords, the bledev.repl login and file transfer. + +No radio and no bleak: the "board" answers what BleSerial writes, the way +bledev.repl and bledev.filetransfer do on a real one. The protocol side here +is written from Adafruit's file-transfer spec, not from ble.py. +""" + +from __future__ import annotations + +import os +import struct +import unittest +from unittest import mock + +from mpftp import ble, boards, cli, config + +PASSWORD = "correct-horse" + + +class FakeBoard: + """bledev.repl's login and a minimal file-transfer server, in memory.""" + + def __init__(self, link: ble.BleSerial, *, password: str = PASSWORD, window: int = 512) -> None: + self.link = link + self.password = password + self.window = window + self.files: dict[str, bytes] = {} + self.cmd = bytearray() + self.writing: dict | None = None + self.link._write_char = self.receive # type: ignore[method-assign] + self.link._client = object() + + def receive(self, uuid: str, data: bytes, response: bool = False) -> None: + if uuid == ble.NUS_RX: + self.repl(data) + elif uuid == ble.FT_TRANSFER: + self.cmd += data + self.serve() + + def repl(self, data: bytes) -> None: + line = data.rstrip(b"\r") + if not line: + self.link._on_repl(None, bytearray(b"Password: ")) + elif line == self.password.encode(): + self.link._on_repl(None, bytearray(b"\r\nbledev REPL connected\r\n>>> ")) + else: + self.link._on_repl(None, bytearray(b"\r\nAccess denied\r\n")) + + def answer(self, data: bytes) -> None: + # Notifications of MTU - 3 bytes, as the board sends them. + for i in range(0, len(data), 244): + self.link._on_files(None, bytearray(data[i : i + 244])) + + def serve(self) -> None: + b = self.cmd + while b: + if b[0] == 0x10 and len(b) >= 12: + _, _, n, offset, chunk = struct.unpack_from("= 20: + _, _, n, offset, _t, total = struct.unpack_from("= 12: + _, _, _, offset, size = struct.unpack_from(" ble.BleSerial: + serial = ble.BleSerial("ble://rack", password) + serial.mtu = 247 + serial.has_files = True + return serial + + +class Addresses(unittest.TestCase): + def test_scheme(self) -> None: + self.assertTrue(ble.is_ble_device("ble://rack")) + self.assertTrue(ble.is_ble_device(" BLE://rack")) + self.assertFalse(ble.is_ble_device("ws://rack")) + self.assertFalse(ble.is_ble_device("COM5")) + self.assertEqual(ble.parse_device("ble://bledev-files"), "bledev-files") + self.assertEqual(ble.parse_device("ble://AA:BB:CC:DD:EE:FF/"), "AA:BB:CC:DD:EE:FF") + + def test_bad_addresses(self) -> None: + with self.assertRaises(ValueError): + ble.parse_device("ble://") + with self.assertRaisesRegex(ValueError, "password"): + ble.parse_device("ble://pw@rack") + + def test_password_rules(self) -> None: + with self.assertRaisesRegex(ble.BleAuthError, "MPFTP_BLE_PASSWORD"): + ble.check_password(None, "ble://rack") + with self.assertRaises(ble.BleAuthError): + ble.check_password("abc", "ble://rack") + with self.assertRaises(ble.BleAuthError): + ble.check_password("x" * 65, "ble://rack") + self.assertEqual(ble.check_password("a-long-password-past-webrepls-nine", "ble://rack")[:6], "a-long") + + +class Passwords(unittest.TestCase): + def setUp(self) -> None: + import tempfile + from pathlib import Path + + self.home = Path(tempfile.mkdtemp()) + patches = [ + mock.patch.object(config, "CONFIG_DIR", self.home), + mock.patch.object(config, "CONFIG_PATH", self.home / "config.json"), + mock.patch.object(boards, "_passwords_path", lambda: self.home / "webrepl-passwords.json"), + mock.patch.dict(os.environ, {"MPFTP_BLE_PASSWORD": "", "MPFTP_WEBREPL_PASSWORD": ""}), + ] + for p in patches: + p.start() + self.addCleanup(p.stop) + + def test_key(self) -> None: + self.assertEqual(boards.password_key("ble://Rack"), "ble:rack") + + def test_falls_back_to_the_webrepl_password(self) -> None: + # bledev.repl defaults to webrepl_cfg.PASS. + self.assertIsNone(boards.get_password("ble://rack")) + with mock.patch.dict(os.environ, {"MPFTP_WEBREPL_PASSWORD": "webpw1"}): + self.assertEqual(boards.get_password("ble://rack"), "webpw1") + with mock.patch.dict(os.environ, {"MPFTP_BLE_PASSWORD": "blepw12"}): + self.assertEqual(boards.get_password("ble://rack"), "blepw12") + self.assertEqual(cli.connect_params("ble://rack", 115200)["password"], "blepw12") + + def test_saved_password_wins(self) -> None: + key = boards.set_password("ble://rack", "a-long-ble-password") + self.assertEqual(key, "ble:rack") + with mock.patch.dict(os.environ, {"MPFTP_BLE_PASSWORD": "blepw12"}): + self.assertEqual(boards.get_password("ble://rack"), "a-long-ble-password") + with self.assertRaises(ble.BleAuthError): + boards.set_password("ble://rack", "abc") + + +class Login(unittest.TestCase): + def test_right_password(self) -> None: + serial = link() + FakeBoard(serial) + serial._login() + self.assertTrue(serial.is_open) + + def test_wrong_password(self) -> None: + serial = link("not-the-one") + FakeBoard(serial) + with self.assertRaisesRegex(ble.BleAuthError, "rejected"): + serial._login() + + +class Files(unittest.TestCase): + def test_round_trip_in_windows(self) -> None: + serial = link() + board = FakeBoard(serial, window=512) + data = os.urandom(20 * 1024 + 7) + serial.files_write("/big.bin", data) + self.assertEqual(board.files["/big.bin"], data) + self.assertEqual(serial.files_read("/big.bin"), data) + + def test_empty_file(self) -> None: + serial = link() + board = FakeBoard(serial) + serial.files_write("/empty", b"") + self.assertEqual(board.files["/empty"], b"") + self.assertEqual(serial.files_read("/empty"), b"") + + def test_missing_file_is_oserror(self) -> None: + serial = link() + FakeBoard(serial) + with self.assertRaises(OSError) as caught: + serial.files_read("/nope") + self.assertNotIsInstance(caught.exception, ble.BleError) # a board answer, not a dead link + + def test_planted_faults_change_the_bytes(self) -> None: + # What mpftp's SHA-256 check after put/get has to catch. + data = os.urandom(4096) + with mock.patch.dict(os.environ, {ble.PLANT_ENV: "put"}): + serial = link() + board = FakeBoard(serial) + serial.files_write("/p.bin", data) + self.assertNotEqual(board.files["/p.bin"], data) + self.assertEqual(len(board.files["/p.bin"]), len(data)) + with mock.patch.dict(os.environ, {ble.PLANT_ENV: "get"}): + serial = link() + board = FakeBoard(serial) + board.files["/g.bin"] = data + self.assertNotEqual(serial.files_read("/g.bin"), data) + + +class Wslenv(unittest.TestCase): + def test_plain_values_forwarded_without_a_flag(self) -> None: + env = {"WSL_DISTRO_NAME": "Ubuntu", "WSLENV": "", ble.PLANT_ENV: "put"} + with mock.patch.dict(os.environ, env, clear=False): + os.environ.pop("MICROPYPATH", None) + os.environ.pop("PYTHONPATH", None) + os.environ.pop(ble.FILES_ENV, None) + out = cli._wslenv_forwarded_env("python.exe") + self.assertIsNotNone(out) + self.assertIn(ble.PLANT_ENV, out["WSLENV"].split(":")) + self.assertNotIn(ble.PLANT_ENV + "/", out["WSLENV"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 11be7f5..e7c50b0 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -130,6 +130,38 @@ Limits worth knowing before you rely on it: (unless Bluetooth is active). WSL's LAN path can flap: judge a board unreachable from the Windows-side Python, not WSL alone. +### Over Bluetooth (bledev) + +Every board command also takes `-d ble://NAME`, a board running pydevices' +`bledev.repl` or `bledev.filetransfer` from `main.py`. The reason an agent +would choose it: **the S3's one USB PHY is busy** (usbif hosting a device +takes the USB console away), or you want serial free for `monitor`, as with +Wi-Fi. + +```bash +export MPFTP_BLE_PASSWORD=... # the board's bledev password; never print it +./scripts/mpftp exec -d ble://bledev-files "print(1+1)" +./scripts/mpftp put -d ble://bledev-files main.py /main.py +./scripts/mpftp interrupt -d ble://bledev-files +``` + +- **Set it up over serial first:** copy `lib/bledev` to `/lib/bledev`, and + make `main.py` call `bledev.filetransfer.start(password=..., name=...)`. + A serial connect soft-resets the board, and a soft reset turns Bluetooth + off, so after setup `hard-reset` it and stay off the serial port. +- Files go over the board's file-transfer service when it has one: 20 KB up + in about 0.8 s and down in 0.3 s. Over the raw REPL alone (a board running + only `bledev.repl`) the same file takes 11-16 s up and 6-11 s down. +- `soft-reset` refuses over BLE, since it would switch Bluetooth off and skip + the `main.py` that starts it. Use `soft-reboot` or `hard-reset`. `mount` is + serial-only. +- The board takes one client at a time. The laptop's radio may be shared with + other agents, so give the board a name nobody else advertises. +- The sidecar's Windows Python needs bleak (`python.exe -m pip install --user + bleak`). + +More, including the measurements: [docs/plans/ble.md](plans/ble.md). + ### Board filesystem & REPL Treat the board like a small filesystem. Prefer verified transfers for anything diff --git a/docs/plans/ble.md b/docs/plans/ble.md new file mode 100644 index 0000000..d6d50a0 --- /dev/null +++ b/docs/plans/ble.md @@ -0,0 +1,134 @@ +# mpftp over Bluetooth (bledev) + +Status: the CLI and agents can use it. The VS Code extension's Connect list +and the PWA don't offer BLE boards yet. + +## What you can do + +Any board command takes `-d ble://NAME`, where `NAME` is what the board +advertises. You can use exec, eval, run, ls, put and get, the REPL, and +interrupt, and USB serial isn't involved at all: + +```bash +export MPFTP_BLE_PASSWORD=... # or blePassword in ~/.mpftp/config.json +mpftp exec -d ble://rack "print('hello')" +mpftp put -d ble://rack main.py /main.py +mpftp get -d ble://rack /data.bin ./data.bin +mpftp interrupt -d ble://rack +``` + +The board has to be serving. That takes pydevices' `bledev` package in +`/lib` and one line in `main.py`: + +```python +import bledev.filetransfer +bledev.filetransfer.start(password="...", name="rack") # the REPL and files +``` + +`bledev.repl.start(...)` serves the REPL alone, and mpftp works with that too, +only more slowly for files (see [the numbers](#why-files-dont-go-through-the-repl)). +Nothing starts either one at boot unless `main.py` does, because a board that +advertises a REPL should be something you chose. + +## Where the password comes from + +It's handled the same way as the WebREPL password +([mpftp#43](https://github.com/PyDevices/mpftp/issues/43)). mpftp looks in +three places, in order: + +1. `~/.mpftp/webrepl-passwords.json` under `ble:NAME`. Save it with + `mpftp wifi password ble://NAME`, which stores it as plaintext with mode + 0600. +2. `MPFTP_BLE_PASSWORD`, or `blePassword` in `~/.mpftp/config.json`. +3. The WebREPL password (`MPFTP_WEBREPL_PASSWORD` / `webreplPassword`), + because `bledev.repl` uses `webrepl_cfg.PASS` when it isn't given one. + +A BLE password is 4 to 64 characters. The link isn't encrypted, the same as +WebREPL, so anyone nearby with a sniffer can read it. Pairing is bledev's +later fix. + +## How it works + +Windows owns the laptop's radio, so the sidecar reaches the board through +bleak in the Windows Python, the same one it uses for COM ports. On WSL, +install it there: `python.exe -m pip install --user bleak`. + +`mpftp/ble.py` dresses the link up as a pyserial port, the way `webrepl.py` +does for Wi-Fi, and hands it to mpremote's `SerialTransport`: + +- **The REPL.** mpftp finds the board by name, connects, subscribes to the + Nordic UART service, and logs in the way WebREPL does. It sends an empty + line, the board answers `Password: `, and mpftp sends the password. A wrong + password gets `Access denied` and a hang-up, and mpftp never retries it. + Commands go in raw-paste mode, whose flow control keeps the board's input + buffer from overflowing. +- **Files.** When the board also serves `bledev.filetransfer`, which is + CircuitPython's BLE file-transfer protocol on service `0xFEBB`, `put` and + `get` go through that instead. Logging in to the REPL unlocks it on the + same connection. If the board answers a file command with an error, mpftp + repeats the operation over the REPL, so you get mpremote's usual error + message. +- **Checking.** `put` and `get` compare SHA-256 with the board afterwards, + the same check they run over serial. The file-transfer protocol has no + checksum of its own, so this check is the one that counts. +- **Connection parameters.** mpftp asks Windows for its throughput-optimized + connection parameters. That roughly doubles every rate below. +- **Resets.** A soft reset turns Bluetooth off. `soft-reset` over BLE refuses + and says why. `soft-reboot` runs `main.py`, which brings the link back if it + starts bledev. `mount` is serial-only. + +## Why files don't go through the REPL + +These are 20 KB transfers between the laptop (Windows 11, bleak 3.0.2, Intel +radio) and the LilyGo T-Embed (ESP32-S3, MicroPython 1.29), one desk apart, +with the board's Wi-Fi off, on 2026-09-24. Each figure is the median of three +runs, and every run was checked byte for byte and against the board's +SHA-256. The script is [tools/ble_bench.py](../../tools/ble_bench.py). + +| Path | Up | Down | +|---|---|---| +| File transfer, throughput parameters | 0.83 s (24.0 KB/s) | 0.30 s (67.5 KB/s) | +| File transfer, Windows' defaults | 1.37 s (14.6 KB/s) | 0.49 s (40.8 KB/s) | +| Raw REPL (mpremote, 256-byte chunks), throughput parameters | 15.6 s (1.3 KB/s) | 10.6 s (1.9 KB/s) | +| Raw REPL, 4 KB chunks, throughput parameters | 11.8 s (1.7 KB/s) | 6.3 s (3.2 KB/s) | +| Raw REPL (256-byte chunks), Windows' defaults | 43.6 s (0.5 KB/s) | 26.6 s (0.8 KB/s) | + +File transfer is 19 times faster up and 35 times faster down than +mpremote's own raw-REPL transfers. Bigger chunks don't close the gap. +Raw-paste waits a round trip for every window of input the board grants, +and each exec's output crosses the same link as text. Plain raw REPL, which +is what the Wi-Fi transport uses, isn't safe here: `bledev.repl` keeps 4 KB +of unread input and drops anything beyond that. + +The upload rate is set by the board, which writes flash between windows. A +larger window than `bledev.filetransfer`'s 4 KB doesn't help much, because +five round trips already cover 20 KB. + +## The gate (2026-09-24) + +This was run on the T-Embed over BLE, with USB serial used only to copy +`/lib/bledev` and `main.py` onto the board beforehand: + +- `mpftp ls`, `put` (verified by SHA-256), `get` (compared byte for byte with + `cmp`), and `exec` all pass. So does `interrupt` of a `while True` loop that + `run` started (the board answered in 61 ms), and the REPL answers + afterwards. +- [tools/ble_gate.py](../../tools/ble_gate.py) does the same in one sidecar + session, the way the extension would. Connecting and logging in took 6 s, + a 20 KB put took 1.1 s and a get 0.25 s. Both an interrupt and a later + command stop a running loop. +- Planted faults: `MPFTP_BLE_PLANT=put` flips one bit in one uploaded chunk, + and `put` exits 1 with a hash mismatch. `MPFTP_BLE_PLANT=get` flips one bit + in a downloaded chunk, and `get` reports a hash mismatch. +- A wrong password gets "the board rejected the BLE REPL password". A name + nothing is advertising gets "nothing is advertising as ... within 10 s". + +## Not done + +- The extension and the PWA don't list BLE boards. There's no discovery + (`mpftp ble find`) and no remembered-board list. +- Only the laptop's Windows radio has been tried. bleak runs on Linux and + macOS too, but the throughput request is WinRT-only and no other host has + been tested. +- The board takes one client at a time. While mpftp holds the link, nothing + else (a phone, Workbench) can connect. diff --git a/docs/user-guide.md b/docs/user-guide.md index 8d6792e..4855469 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -70,6 +70,16 @@ lists the boards mpftp remembers, and `mpftp wifi find NAME` looks one up by its `.local` name. What each piece does, where passwords live, and what's serial-only: [mpftp over Wi-Fi](plans/wifi-webrepl.md). +### Over Bluetooth + +A board running pydevices' `bledev` can be reached over BLE, with no cable to +this machine: any CLI board command takes `-d ble://NAME`, where NAME is the +name the board advertises. The board serves it from `main.py`, e.g. +`bledev.filetransfer.start(password="...", name="rack")`, and the password +comes from `MPFTP_BLE_PASSWORD` (or `mpftp wifi password ble://NAME`). The +extension's Connect list doesn't offer BLE boards yet. Details and speeds: +[mpftp over Bluetooth](plans/ble.md). + ### Soft reset and packages | Interpreter | Soft Reset | Soft Reboot | Install Package | diff --git a/tools/ble_bench.py b/tools/ble_bench.py new file mode 100644 index 0000000..c2cdaf9 --- /dev/null +++ b/tools/ble_bench.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Time file transfers to a board over BLE: file transfer against raw REPL. + +Opens one ``ble://`` transport (the sidecar's own code path), then times a +20 KB upload and download, byte for byte checked, once over the board's +file-transfer service and once over the raw REPL (mpremote's +``fs_writefile``/``fs_readfile``, raw-paste). Connecting is timed apart. +Needs bleak, so on WSL run it with the Windows Python: + + PYTHONPATH="$(wslpath -w cli/src)" MPFTP_BLE_PASSWORD=... \\ + python.exe "$(wslpath -w tools/ble_bench.py)" ble://bledev-files --runs 3 + +Prints one line per transfer and a summary. The numbers in docs/plans/ble.md +came from this script. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import statistics +import sys +import time + +from mpftp import ble + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("device", help="ble://NAME") + ap.add_argument("--runs", type=int, default=3) + ap.add_argument("--size", type=int, default=20 * 1024) + ap.add_argument("--no-throughput", action="store_true", help="keep Windows' default connection parameters") + ap.add_argument("--only", choices=("files", "repl"), help="time one path only") + ap.add_argument("--repl-chunk", type=int, default=256, help="mpremote's chunk size for the REPL path (its default: 256)") + ns = ap.parse_args() + + password = os.environ.get("MPFTP_BLE_PASSWORD") + t0 = time.monotonic() + link = ble.BleSerial(ns.device, password, throughput=not ns.no_throughput) + link.open() + t = ble._transport_class()(link) + t.enter_raw_repl(soft_reset=False) + print(f"connected and logged in in {time.monotonic() - t0:.1f} s; mtu {link.mtu}; files served: {link.has_files}") + + data = os.urandom(ns.size) + path = "/ble_bench.bin" + results: dict[str, dict[str, list[float]]] = {} + paths = [p for p in ("files", "repl") if ns.only in (None, p)] + ok = True + for run in range(ns.runs): + for how in paths: + os.environ[ble.FILES_ENV] = "repl" if how == "repl" else "" + chunk = ns.repl_chunk + a = time.monotonic() + t.fs_writefile(path, data, chunk_size=chunk) + b = time.monotonic() + back = bytes(t.fs_readfile(path, chunk_size=chunk)) + c = time.monotonic() + board = t.fs_hashfile(path, "sha256") + board = board.hex() if isinstance(board, bytes) else str(board) + good = back == data and board == hashlib.sha256(data).hexdigest() + ok = ok and good + up, down = b - a, c - b + results.setdefault(how, {"up": [], "down": []}) + results[how]["up"].append(up) + results[how]["down"].append(down) + print( + f"run {run + 1} {how:5}: up {up:6.2f} s ({ns.size / up / 1024:5.1f} KB/s), " + f"down {down:6.2f} s ({ns.size / down / 1024:5.1f} KB/s), " + f"{'byte for byte' if good else 'MISMATCH'}" + ) + t.exec(f"import os\nos.remove({path!r})") + t.exit_raw_repl() + link.close() + print("median of", ns.runs) + for how, r in results.items(): + up, down = statistics.median(r["up"]), statistics.median(r["down"]) + print(f" {how:5}: up {up:.2f} s ({ns.size / up / 1024:.1f} KB/s), down {down:.2f} s ({ns.size / down / 1024:.1f} KB/s)") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ble_gate.py b/tools/ble_gate.py new file mode 100644 index 0000000..e799419 --- /dev/null +++ b/tools/ble_gate.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""The ble:// gate, in one sidecar session, as the VS Code extension drives it. + +Against a board running ``bledev.filetransfer.start()`` (or +``bledev.repl.start()``) from main.py, with no serial port involved: + +1. connect and log in; list ``/`` +2. put 20 KB, get it back, byte for byte, and the board's SHA-256 agrees +3. exec prints what it should +4. a running ``while True`` loop is interrupted, and the REPL still answers + +On WSL, run it with the Windows Python, which has bleak and the radio: + + PYTHONPATH=cli/src MPFTP_BLE_PASSWORD=... WSLENV=MPFTP_BLE_PASSWORD:PYTHONPATH/p \\ + python.exe "$(wslpath -w tools/ble_gate.py)" ble://bledev-files + +The planted faults are the CLI's (``MPFTP_BLE_PLANT=put mpftp put ...`` must +fail its hash check); see docs/plans/ble.md. Ends with ``RESULT PASS`` or +``RESULT FAIL``. +""" + +from __future__ import annotations + +import base64 +import hashlib +import os +import sys +import time + +from mpftp import sidecar + +failures: list[str] = [] + + +def check(ok: bool, what: str, detail: str = "") -> None: + print("PASS" if ok else "FAIL", what, detail) + if not ok: + failures.append(what) + + +def main() -> None: + device = sys.argv[1] if len(sys.argv) > 1 else "ble://bledev-files" + s = sidecar.Session() + t0 = time.monotonic() + res = s.connect(device, password=os.environ.get("MPFTP_BLE_PASSWORD")) + check(res.get("interpreter") == "micropython", "connect and log in", f"{time.monotonic() - t0:.1f} s") + + names = [e.get("name") for e in s.fs_listdir("/")] + check("lib" in names, "ls /", repr(names)) + + data = os.urandom(20 * 1024) + path = "/ble_gate.bin" + a = time.monotonic() + put = s.fs_write(path, base64.b64encode(data).decode("ascii"), verify=True) + b = time.monotonic() + back = base64.b64decode(s.fs_read(path)["data_b64"]) + c = time.monotonic() + board = s.fs_hash(path)["hash"] + check(put.get("verified") == hashlib.sha256(data).hexdigest(), "put 20 KB, verified", f"{b - a:.2f} s") + check(back == data, "get 20 KB byte for byte", f"{c - b:.2f} s") + check(board == hashlib.sha256(data).hexdigest(), "the board's SHA-256 matches") + s.fs_rm(path) + + out = s.exec("print(6 * 7)")["output"] + check(out.strip() == "42", "exec", repr(out)) + + s.run_script("n = 0\nwhile True:\n n += 1\n", follow=False) + time.sleep(2.0) + busy = s.exec("print('ran', n > 1000)")["output"] # connect-style: Ctrl-C first + check("ran True" in busy, "a later command interrupts the loop first", repr(busy)) + + s.run_script("n = 0\nwhile True:\n n += 1\n", follow=False) + time.sleep(2.0) + r = s.interrupt() + check(bool(r.get("ok")), "interrupt stops a running loop", repr(r)) + out = s.exec("print('after', n > 1000)")["output"] + check("after True" in out, "the REPL answers after the interrupt", repr(out)) + + s.disconnect() + + +try: + main() +except Exception as e: + failures.append("exception") + print("EXCEPTION", type(e).__name__, e) +print("RESULT", f"FAIL {failures}" if failures else "PASS") +sys.exit(1 if failures else 0)