Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
## Unreleased

- `mpftp --version` prints the version (mpftp#52).
- VS Code and the PWA connect to Bluetooth boards. Their Connect lists have a
**Look for Bluetooth boards…** entry that scans for boards advertising
bledev's REPL (the sidecar's new `ble_scan`), lists them strongest first,
and takes a typed name too. A bledev password is asked for when the board
wants one and kept under `ble:NAME`, the key the CLI uses (mpftp#49).
- A board's WebREPL or bledev password now works from every front end. VS Code
reads `~/.mpftp/webrepl-passwords.json`, where the CLI, the PWA and agents
keep theirs, and a password you type in VS Code can be saved there too: it
asks once per board, and `mpftp.sharePasswords` (`ask`, `always`, `never`)
answers for all of them. Enable Wi-Fi Access says where the password ends up
(mpftp#43).

## v0.0.8 (2026-09-25)

Expand Down
33 changes: 33 additions & 0 deletions cli/src/mpftp/ble.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,39 @@ def _need_bleak() -> Any:
return bleak


def scan(timeout: float = 5.0) -> list[dict[str, Any]]:
"""Boards advertising the REPL (the Nordic UART service), strongest first.

Each row: ``name``, ``address``, ``rssi``, ``files`` (it advertises the
file-transfer service too) and ``device``, the ``ble://`` address to
connect with: the advertised name, else the Bluetooth address.
"""
_need_bleak()
from bleak import BleakScanner

async def discover() -> dict[str, Any]:
return await BleakScanner.discover(timeout=timeout, return_adv=True)

found = asyncio.run(discover())
rows: list[dict[str, Any]] = []
for address, (device, adv) in found.items():
uuids = {str(u).lower() for u in (getattr(adv, "service_uuids", None) or [])}
if NUS_SERVICE not in uuids:
continue
name = getattr(adv, "local_name", None) or getattr(device, "name", None) or ""
rows.append(
{
"name": name,
"address": address,
"rssi": getattr(adv, "rssi", None),
"files": FT_SERVICE in uuids,
"device": SCHEME + (name or address),
}
)
rows.sort(key=lambda r: -(r["rssi"] if isinstance(r["rssi"], int) else -999))
return rows


class BleSerial:
"""The pyserial surface mpremote's ``SerialTransport`` uses, over bledev.repl.

Expand Down
5 changes: 3 additions & 2 deletions cli/src/mpftp/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
Passwords are kept apart, in ``~/.mpftp/webrepl-passwords.json``, one per
board. The file is created with mode 0600 where the operating system has
POSIX modes. It is plaintext on disk: anyone who can read your home directory
can read it. The VS Code extension doesn't use this file; it keeps passwords in
VS Code's SecretStorage instead.
can read it. The VS Code extension keeps its own copy in VS Code's
SecretStorage, reads this file as well, and writes a password here when the
user lets it (``mpftp.sharePasswords``), with the same keys (mpftp#43).

This module runs on the side of the user's frontend (CLI or PWA server), not
in the sidecar, because a Windows sidecar spawned from WSL has a different
Expand Down
28 changes: 23 additions & 5 deletions cli/src/mpftp/pwa.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,25 @@ def _local_method(method: str, params: dict[str, Any]) -> Any:


def _prepare_connect(params: dict[str, Any]) -> dict[str, Any]:
"""Fill in a ws:// connect from the password store. Returns what to keep
for after the reply (the password that was used, and whether to save it)."""
from . import boards, webrepl
"""Fill in a ws:// or ble:// connect from the password store. Returns what
to keep for after the reply (the password that was used, and whether to
save it)."""
from . import ble, boards, webrepl

device = str(params.get("device") or "")
keep: dict[str, Any] = {"device": device, "remember": bool(params.pop("remember", False))}
if ble.is_ble_device(device):
if params.get("password"):
keep["typed"] = True
else:
try:
password = boards.get_password(device)
except Exception:
password = None
if password:
params["password"] = password
keep["password"] = params.get("password")
return keep
if not webrepl.is_network_device(device):
return keep
if not params.get("password"):
Expand All @@ -210,10 +223,15 @@ def _prepare_connect(params: dict[str, Any]) -> dict[str, Any]:

def _after_reply(method: str, keep: dict[str, Any], result: Any) -> None:
"""Remember what a connect or Wi-Fi setup found (see mpftp.boards)."""
from . import boards
from . import ble, boards

try:
if method == "connect":
if method == "connect" and ble.is_ble_device(keep["device"]):
# A bledev password is kept by the name the board advertises.
if keep.get("typed") and keep.get("remember") and keep.get("password"):
boards.set_password(keep["device"], keep["password"])
boards.record_connect(keep["device"], result)
elif method == "connect":
boards.record_connect(
keep["device"],
result,
Expand Down
1 change: 1 addition & 0 deletions cli/src/mpftp/sidecar.py
Original file line number Diff line number Diff line change
Expand Up @@ -3904,6 +3904,7 @@ def _debug_tee_loop(self, log_path: Path) -> None:
p["action"], p.get("password"), p.get("expect_sha256"), bool(p.get("now", False))
),
"mdns_resolve": lambda p: SESSION.mdns_resolve(p["name"], p.get("timeout", 1.5)),
"ble_scan": lambda p: {"boards": ble.scan(float(p.get("timeout", 5.0)))},
"repl_start": lambda _p: SESSION.repl_start(),
"repl_stop": lambda _p: SESSION.repl_stop(),
"repl_write": lambda p: SESSION.repl_write(p["data_b64"]),
Expand Down
2 changes: 1 addition & 1 deletion cli/src/mpftp/webui/app.css

Large diffs are not rendered by default.

64 changes: 32 additions & 32 deletions cli/src/mpftp/webui/app.js

Large diffs are not rendered by default.

63 changes: 63 additions & 0 deletions cli/tests/test_ble.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,66 @@ def test_plain_values_forwarded_without_a_flag(self) -> None:

if __name__ == "__main__":
unittest.main()


class Scan(unittest.TestCase):
"""ble.scan: what the Connect lists (VS Code, the PWA) offer (mpftp#49)."""

def run_scan(self, found: dict) -> list:
import sys
import types

async def discover(timeout: float, return_adv: bool): # noqa: ARG001
self.assertTrue(return_adv)
return found

fake = types.ModuleType("bleak")
fake.BleakScanner = types.SimpleNamespace(discover=discover) # type: ignore[attr-defined]
with mock.patch.dict(sys.modules, {"bleak": fake}):
return ble.scan(0.1)

@staticmethod
def adv(name: str, rssi: int, *uuids: str):
import types

dev = types.SimpleNamespace(name=None)
return dev, types.SimpleNamespace(local_name=name, rssi=rssi, service_uuids=list(uuids))

def test_lists_repl_boards_strongest_first(self) -> None:
rows = self.run_scan(
{
"AA:01": self.adv("far", -80, ble.NUS_SERVICE),
"AA:02": self.adv("near", -40, ble.NUS_SERVICE.upper(), ble.FT_SERVICE),
"AA:03": self.adv("headphones", -30, "0000110b-0000-1000-8000-00805f9b34fb"),
"AA:04": self.adv("", -60, ble.NUS_SERVICE),
}
)
self.assertEqual([r["device"] for r in rows], ["ble://near", "ble://AA:04", "ble://far"])
self.assertEqual([r["files"] for r in rows], [True, False, False])

def test_nothing_found(self) -> None:
self.assertEqual(self.run_scan({}), [])


class PwaConnect(Passwords):
"""The PWA server fills a ble:// connect from the store and keeps a typed
password when asked to (mpftp#49)."""

def test_saved_password_goes_in(self) -> None:
from mpftp import pwa

boards.set_password("ble://rack", "a-long-ble-password")
params = {"device": "ble://rack"}
keep = pwa._prepare_connect(params)
self.assertEqual(params["password"], "a-long-ble-password")
self.assertNotIn("known", params)
self.assertFalse(keep.get("typed"))

def test_typed_password_is_kept_when_remembered(self) -> None:
from mpftp import pwa

for remember, expect in ((False, None), (True, "typed-pw-1")):
params = {"device": "ble://Rack", "password": "typed-pw-1", "remember": remember}
keep = pwa._prepare_connect(params)
pwa._after_reply("connect", keep, {"board": {"uid": "abc"}})
self.assertEqual(boards.passwords().get("ble:rack"), expect)
4 changes: 4 additions & 0 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ WebREPL; `wifi disable` removes it byte for byte. Both show the change first.
The password comes from `~/.mpftp/webrepl-passwords.json` for that board
(`wifi password`), else `MPFTP_WEBREPL_PASSWORD`; it is at most 9 characters,
and WebREPL is unencrypted, so treat it as a LAN courtesy lock. Never print it.
A board Brad set up in VS Code is in that file only if he let VS Code share
it (`mpftp.sharePasswords`); if the connect says there's no password, ask him
to connect to it once in VS Code and share it, or to run `mpftp wifi password
BOARD`. Don't read it out of the board's `boot.py`.

Limits worth knowing before you rely on it:

Expand Down
5 changes: 3 additions & 2 deletions docs/plans/ble.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# 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.
Status: the CLI and agents can use it, and so can VS Code and the PWA: their
Connect lists have a Bluetooth entry that looks for boards advertising
bledev's REPL and connects to the one you pick (mpftp#49).

## What you can do

Expand Down
5 changes: 5 additions & 0 deletions docs/plans/wifi-webrepl.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ What phase 1 left open, as Brad decided it, and what each became.
4 to 9 characters, upstream `webrepl_setup`'s rule. The phase-1
`MPFTP_WEBREPL_PASSWORD` / `webreplPassword` still works as a fallback. No
password reaches a log or an RPC reply.
Since mpftp#43 the extension reads that file as well, after its own
SecretStorage, and writes a password there when you let it
(`mpftp.sharePasswords`: ask once per board, always, or never). Before that
a board set up in VS Code was out of reach of the CLI, the PWA and agents
until its password was typed a second time.
2. **boot.py: offered, with your OK.** Over a serial connection, Enable puts a
block between `# >>> mpftp wifi-access >>>` and `# <<< mpftp wifi-access
<<<` at the top of `boot.py`. It imports the board's `wifi` helper, calls
Expand Down
14 changes: 13 additions & 1 deletion docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ joins your network at every reset, and the Connect list offers it under
**Wi-Fi** by name. The first connect asks for its WebREPL password (at most 9
characters) and remembers it for that board.

The command line, the PWA and agents keep passwords in
`~/.mpftp/webrepl-passwords.json` (plaintext, readable only by you). VS Code
keeps its own in its secret storage and reads that file too. When you type a
password in VS Code, it asks once whether the others may have it; say yes and
an agent can reach the board you just set up. The setting
`mpftp.sharePasswords` (`ask`, `always`, `never`) answers for every board.

From the CLI, any board command takes `-d ws://BOARD-IP`. `mpftp wifi boards`
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
Expand All @@ -81,7 +88,12 @@ board started with `pairing="passkey"` needs this computer paired once: run
`python -m bledev.bleak pair NAME` and type in the passkey the board shows
(or use Windows Settings, Add device); after that mpftp uses the bond, with no
password if the board has none. A board with `pairing="justworks"` is paired
by mpftp itself. The extension's Connect list doesn't offer BLE boards yet. Details and speeds:
by mpftp itself.

In VS Code and the PWA, pick **Look for Bluetooth boards…** in the Connect
list. It lists what's advertising bledev's REPL nearby, strongest signal
first; pick one, or type the name. The first connect asks for the password if
the board wants one, and mpftp keeps it for that board. Details and speeds:
[mpftp over Bluetooth](plans/ble.md).

### Soft reset and packages
Expand Down
15 changes: 15 additions & 0 deletions extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,21 @@
"type": "string",
"default": "",
"description": "Override the esptool command used to flash esp32 (e.g. a Windows python.exe on WSL so it can see COM ports). Leave empty to auto-detect."
},
"mpftp.sharePasswords": {
"type": "string",
"enum": [
"ask",
"always",
"never"
],
"enumDescriptions": [
"Ask once per board whether the CLI, the PWA and agents may use its password",
"Also save every board's password in ~/.mpftp/webrepl-passwords.json",
"Keep passwords in VS Code's secret storage only"
],
"default": "ask",
"markdownDescription": "Where a WebREPL or bledev password you type in VS Code is kept. It always goes in VS Code's secret storage. The mpftp command line, the PWA and agents read `~/.mpftp/webrepl-passwords.json` instead (plaintext, readable only by you), so a password shared there works from all of them. VS Code reads that file too, so a password saved by the CLI or the PWA works here without asking."
}
}
},
Expand Down
29 changes: 27 additions & 2 deletions extension/src/bridge/SidecarBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ export function isWifiDevice(device: string | undefined): boolean {
return !!device && /^wss?:\/\//i.test(device.trim());
}

/** A bledev board over Bluetooth (ble://NAME) rather than a serial port. */
export function isBleDevice(device: string | undefined): boolean {
return !!device && /^ble:\/\//i.test(device.trim());
}

/** Reached over the air (Wi-Fi or Bluetooth): a password may be needed, and there is no COM port. */
export function isRemoteDevice(device: string | undefined): boolean {
return isWifiDevice(device) || isBleDevice(device);
}

/** One board a Bluetooth scan found (sidecar ble_scan). */
export type BleBoard = {
name: string;
address: string;
rssi?: number | null;
files?: boolean;
device: string;
};

type Pending = {
resolve: (v: unknown) => void;
reject: (e: Error) => void;
Expand Down Expand Up @@ -400,6 +419,12 @@ export class SidecarBridge extends EventEmitter {
return this.request<PortInfo[]>("list_ports");
}

/** Boards advertising bledev's REPL nearby, strongest signal first. */
async scanBle(timeout = 5): Promise<BleBoard[]> {
const res = await this.request<{ boards?: BleBoard[] }>("ble_scan", { timeout });
return res.boards || [];
}

async connect(
device: string,
baud?: number,
Expand All @@ -409,7 +434,7 @@ export class SidecarBridge extends EventEmitter {
const params: Record<string, unknown> = { device, baud: baud ?? cfg.defaultBaud };
const wifi = isWifiDevice(device);
let password = opts?.password;
if (wifi && !password && this.passwordFor) {
if (isRemoteDevice(device) && !password && this.passwordFor) {
password = await this.passwordFor(device);
}
if (password) {
Expand Down Expand Up @@ -518,7 +543,7 @@ export class SidecarBridge extends EventEmitter {
return false;
}
try {
if (!isWifiDevice(device)) {
if (!isRemoteDevice(device)) {
const ports = await this.listPorts();
if (!ports.some((p) => p.device === device)) {
continue;
Expand Down
Loading
Loading