From 88603eba730dba869f49b0a5095d331bec808418 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 20:42:23 -0500 Subject: [PATCH 01/19] SDK: connection 'usb' prefers the pod's USB network adapter (fw 3.9.0+) Auto-detected USB pods: read the console status, and when its usb line says net on + link up and :8080 accepts a TCP connect within 1.5 s, return a TcpTransport to it (usb_console = the console's device path). Otherwise keep the SerialTransport as before. An explicit device path is never upgraded. VID 0x1209 (1209:0001, fw 3.9.0) ranks as a pod port; parse_text_status turns the usb line into the JSON status shape. Docs + changelogs + unit tests. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-openhtf/CHANGELOG.md | 5 + packages/embeddedci-openhtf/README.md | 12 +- packages/embeddedci/CHANGELOG.md | 15 ++ packages/embeddedci/README.md | 42 +++-- packages/embeddedci/examples/README.md | 6 +- .../src/embeddedci/benchpod/connection.py | 7 +- .../embeddedci/benchpod/transport/__init__.py | 90 ++++++++- .../embeddedci/benchpod/transport/serial.py | 46 ++++- .../src/embeddedci/benchpod/transport/tcp.py | 4 + packages/embeddedci/tests/e2e/test_e2e_usb.py | 3 + .../tests/test_serial_autodetect.py | 12 ++ packages/embeddedci/tests/test_usb_net.py | 173 ++++++++++++++++++ 12 files changed, 379 insertions(+), 36 deletions(-) create mode 100644 packages/embeddedci/tests/test_usb_net.py diff --git a/packages/embeddedci-openhtf/CHANGELOG.md b/packages/embeddedci-openhtf/CHANGELOG.md index dd05808..dfaf719 100644 --- a/packages/embeddedci-openhtf/CHANGELOG.md +++ b/packages/embeddedci-openhtf/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog — `embeddedci-openhtf` +## Unreleased + +- Docs: connection `usb` reaches the pod's USB network adapter (full API) on firmware 3.9.0 and + later, through the `embeddedci` SDK of the same release; a serial device path stays the console. + ## 2.2.0 - Persistent plugs (`benchpod_plug(..., persistent=True)`) are pooled by their connection diff --git a/packages/embeddedci-openhtf/README.md b/packages/embeddedci-openhtf/README.md index 7939f2f..86a43b6 100644 --- a/packages/embeddedci-openhtf/README.md +++ b/packages/embeddedci-openhtf/README.md @@ -53,7 +53,8 @@ Connection forms (all **direct**, never cloud): | Form | Example | | --- | --- | | TCP `host[:port]` | `benchpod_plug("192.168.1.50:8080")` | -| Serial device path | `benchpod_plug("/dev/ttyACM0")` / `benchpod_plug("COM5")` | +| USB, auto-detected | `benchpod_plug("usb")` (the pod's USB network adapter on firmware 3.9.0+, else its console) | +| Serial device path | `benchpod_plug("/dev/ttyACM0")` / `benchpod_plug("COM5")` (the console only) | | `BENCHPOD_CONNECTION` env | `benchpod_plug()` (unbound) | | OpenHTF config | `htf.conf.load(benchpod_connection="...")` then `@htf.plug(bench=BenchPodPlug)` | @@ -94,7 +95,7 @@ unset for analog-only tests that don't touch the LA bank. | Key | Default | Meaning | | --- | --- | --- | -| `benchpod_connection` | `None` → `BENCHPOD_CONNECTION` | `host[:port]`, a serial device path, or `serial` | +| `benchpod_connection` | `None` → `BENCHPOD_CONNECTION` | `host[:port]`, a serial device path, `usb`, or `serial` | | `benchpod_timeout` | `30.0` | Transport timeout in seconds | | `benchpod_la_voltage` | `None` → `BENCHPOD_LA_VOLTAGE` | LA I/O-bank voltage in volts (`1.8` or `3.3`) | @@ -236,9 +237,10 @@ Notes: `current_out_phase` switches them off, and a voltage waveform also moves the loop. `signal_generate_phase` and `dac_replay_phase` take `dac_path="current_out"` for a waveform in amps. Needs firmware 3.4.0. -- Use a TCP connection. The STM32 pod's USB serial console is a text shell without a JSON - mode, so over serial only `power_phase`, status and the LA voltage work; flashing, UART, - analog and replay phases need TCP (or the cloud). +- Use a TCP connection, or `usb` on firmware 3.9.0 and later (it reaches the pod's USB network + adapter, which carries the full API, unless Wi-Fi is configured on the pod). The STM32 pod's + USB serial console is a text shell without a JSON mode, so over the console only `power_phase`, + status and the LA voltage work; flashing, UART, analog and replay phases need TCP (or the cloud). The low-level helpers take a connected `BenchPod` or the injected plug: `signal_generate`, `signal_stop`, `analog_path`, `dac_output`, `current_out`, `adc_read`, diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 87ee51b..0c782c9 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog — `embeddedci` +## Unreleased + +- Connection `usb` (and `serial`) uses the pod's USB network adapter on firmware 3.9.0 and later: + the SDK finds the pod's USB console as before, reads the pod's address on the USB link + (`10.237.x.y`) from its `status`, and when the link is up and port 8080 answers it returns a + `TcpTransport` to that address, so the full API works over USB. The transport's new + `usb_console` attribute names the console's device path. It falls back to the console (status, + ping, LA voltage, power) on older firmware, when Wi-Fi is configured on the pod (that turns USB + networking off), when the link is down, or when the computer has not configured the adapter. An + explicit device path (`/dev/ttyACM0`, `COM3`) always stays the console. +- USB auto-detect ranks the new USB id `1209:0001` (firmware 3.9.0) with the older pod ids; a pod + is still identified by probing its console. +- The console's text `status` parses the `usb` line into the JSON status's shape: + `{"net": "on"|"off", "link": bool, "ip": ..., "host_ip": ...}`. + ## 2.11.0 - `flash` can flash a build uploaded to embeddedci.com (the `upload-artifact` action or the diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index 04b302b..d455209 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -188,24 +188,38 @@ explicitly. The pytest plugin does **not** fall back to it (see [pytest plugin]( |---|---| | `192.168.1.213`, `host:8080`, `[fe80::1]:8080` | network (JSON over TCP, port 8080 by default) | | `/dev/ttyACM0`, `COM3`, `\\.\COM10` | USB console, explicit device path | -| `usb` | USB console, auto-detected by probing the serial ports (`serial` also accepted) | +| `usb` | USB pod, auto-detected by probing the serial ports (`serial` also accepted): its USB network adapter on firmware 3.9.0 and later (full API), else its USB console | | `discover` (or `mdns`, `auto`) | find exactly one pod on the LAN via mDNS (needs `[discovery]`); errors on zero or several | | `embeddedci:` | a named device through embeddedci.com (needs `[cloud]`; an API key or a user token anywhere, or GitHub Actions OIDC) | | `saved` | the default connection `benchpod-cli` saved (`benchpod set-connection`); errors when none is saved | -**Use the network (or the cloud) for testing.** The STM32 pod's USB console is a text shell for -setup and diagnostics with no JSON mode, so over USB the SDK can only read `status()`, `ping()`, -select the LA voltage and switch target power (without `delay`). Everything else — flashing, UART, -analog, captures, I2C-sensor emulation, CAN — raises a `TransportError` right away that points at -the network connection. (On firmware whose console does have a JSON mode, the SDK uses it -automatically.) - -| | TCP | USB (STM32 pod) | Cloud | -|---|---|---|---| -| `status`, `ping`, LA voltage, target power on/off | yes | yes | yes | -| Scheduled power changes (`delay=`), flashing, UART, analog, captures, I2C sensor, CAN, control loop | yes | no | yes | -| `replay()` / client-side `replay_waveform()` (streams the waveform to the pod) | yes | no | yes | -| Device lease | — | — | yes | +**USB network (firmware 3.9.0 and later).** The STM32 pod's USB port is a composite device +(USB id `1209:0001`; older firmware uses `0483:5740`): the console plus a USB network adapter +(CDC-NCM) that carries the same JSON API as the LAN. The link is a point-to-point `/30` in +`10.237.0.0/16` (the pod is the first address, your computer the second, handed out by the pod's +DHCP server). With connection `usb` the SDK finds the pod's console, reads the pod's address on +that link from its `status`, and when the link is up and port 8080 answers within 1.5 s it talks +to `:8080` over TCP: everything works as on the LAN. The transport is then a +`TcpTransport` whose `usb_console` attribute names the console's device path +(`bp.transport.usb_console`). The SDK falls back to the console, as on older firmware, when the +pod's firmware is older than 3.9.0, when Wi-Fi is configured on the pod (that turns USB networking +off), when the link is down, or when the computer has not configured the adapter (for example +macOS under a locked screen); it logs which one it picked. An explicit device path such as +`/dev/ttyACM0` is always the console. + +**Otherwise use the network (or the cloud) for testing.** The STM32 pod's USB console is a text +shell for setup and diagnostics with no JSON mode, so over the console the SDK can only read +`status()`, `ping()`, select the LA voltage and switch target power (without `delay`). Everything +else (flashing, UART, analog, captures, I2C-sensor emulation, CAN) raises a `TransportError` right +away that points at the network connection. (On firmware whose console does have a JSON mode, the +SDK uses it automatically.) + +| | TCP | USB network (`usb`, firmware 3.9.0+) | USB console (STM32 pod) | Cloud | +|---|---|---|---|---| +| `status`, `ping`, LA voltage, target power on/off | yes | yes | yes | yes | +| Scheduled power changes (`delay=`), flashing, UART, analog, captures, I2C sensor, CAN, control loop | yes | yes | no | yes | +| `replay()` / client-side `replay_waveform()` (streams the waveform to the pod) | yes | yes | no | yes | +| Device lease | — | — | — | yes | ### Environment variables diff --git a/packages/embeddedci/examples/README.md b/packages/embeddedci/examples/README.md index d0d2f30..be41ece 100644 --- a/packages/embeddedci/examples/README.md +++ b/packages/embeddedci/examples/README.md @@ -26,7 +26,8 @@ pytest examples/test_bmp280.py \ --benchpod-firmware=path/to/your_app.elf ``` -`--benchpod-connection` also accepts `usb` (auto-detect the USB console), a serial device +`--benchpod-connection` also accepts `usb` (auto-detect the USB pod: its USB network adapter on +firmware 3.9.0 and later, else its console), a serial device path such as `/dev/ttyACM0`, `discover` (mDNS), or `embeddedci:` for a pod reached through embeddedci.com (see the package README for cloud authentication). @@ -72,7 +73,8 @@ referenced to 3V3, so the pod will not engage them while the LA bank is at 1.8 V ### Notes -- **USB vs network.** Flashing works over every transport, but over the USB console each +- **USB vs network.** Over the USB network adapter (connection `usb`, firmware 3.9.0 and later) + flashing runs over TCP, as on the LAN. Over the USB console each CMSIS-DAP packet round-trips the serial link, so a large image is noticeably slower than over TCP. `verify=False` keeps flashing snappy either way (programming is reliable; the read-back verify is the slow part). diff --git a/packages/embeddedci/src/embeddedci/benchpod/connection.py b/packages/embeddedci/src/embeddedci/benchpod/connection.py index fefc9f5..82b6a08 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/connection.py +++ b/packages/embeddedci/src/embeddedci/benchpod/connection.py @@ -4,8 +4,11 @@ * ``host`` or ``host:port`` -> TCP/wifi (default port 8080) * ``/dev/tty*`` / ``COM3`` / ``\\\\.\\COM3`` -> serial device path -* ``usb`` -> USB console, auto-detected by probing the ports - (``serial`` is accepted as a legacy spelling) +* ``usb`` -> USB pod, auto-detected by probing the ports + (``serial`` is accepted as a legacy spelling); on + firmware 3.9.0+ the transport is the pod's USB network + adapter when it answers, else the console (see + :func:`~embeddedci.benchpod.transport.open_transport`) * ``discover`` / ``mdns`` / ``auto`` -> find a single pod on the LAN via mDNS * ``embeddedci:`` -> drive a named device through embeddedci.com (an API key anywhere, or GitHub Actions OIDC) diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py b/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py index d17e5cd..6b7c223 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py @@ -3,13 +3,22 @@ :class:`TcpTransport` talks to a pod over the network; :class:`SerialTransport` over its USB serial console. Use :func:`open_transport` to build the right one from a resolved :class:`~embeddedci.benchpod.connection.ConnSpec`. + +Connection ``usb`` (an auto-detected USB pod) prefers the pod's USB network adapter +(firmware 3.9.0+): the console's ``status`` names the pod's address on that link, and when +the link is up and the API port answers, the result is a :class:`TcpTransport` to it (with +``usb_console`` set to the console's device path). Otherwise it is the console, as before. +An explicit device path always stays the console. """ from __future__ import annotations -from typing import Callable +import ipaddress +import logging +import socket +from typing import Any, Callable, Optional -from ..connection import ConnSpec +from ..connection import DEFAULT_PORT, ConnSpec from ..errors import ConnectionConfigError from .base import RawLink, Transport @@ -43,9 +52,7 @@ def open_transport( return TcpTransport(spec.addr, timeout=timeout) if spec.is_serial(): - from .serial import SerialTransport - - return SerialTransport(spec.device, timeout=timeout) + return _open_usb(spec.device, timeout=timeout) if spec.is_cloud(): from ..cloud_auth import DEFAULT_API_BASE, DEFAULT_AUDIENCE from .cloud import CloudTransport @@ -62,6 +69,79 @@ def open_transport( raise ConnectionConfigError(f"unknown connection kind {spec.kind!r}") +_log = logging.getLogger("embeddedci.benchpod") + +#: Budget for the TCP connect that checks the pod's API answers on its USB network address. +USB_NET_PROBE_TIMEOUT = 1.5 +#: Budget for reading the console's ``status`` while choosing between USB network and console. +_USB_STATUS_TIMEOUT = 5.0 + + +def usb_net_address(status: Any) -> Optional[str]: + """The pod's IPv4 address on its USB network link, from a ``status`` reply (JSON, or the + console's text status parsed by :func:`~.serial.parse_text_status`), or None when USB + networking is off, its link is down, or the firmware has no USB network (before 3.9.0).""" + usb = status.get("usb") if isinstance(status, dict) else None + if not isinstance(usb, dict) or usb.get("net") != "on" or usb.get("link") is not True: + return None + try: + ip = ipaddress.IPv4Address(str(usb.get("ip", ""))) + except ValueError: + return None + return None if ip.is_unspecified else str(ip) + + +def _tcp_reachable(host: str, port: int, timeout: float = USB_NET_PROBE_TIMEOUT) -> bool: + """Whether a TCP connect to ``host:port`` succeeds within ``timeout`` (then closed at once).""" + try: + socket.create_connection((host, port), timeout=timeout).close() + return True + except OSError: + return False + + +def _open_usb(device: str, *, timeout: float) -> Transport: + """Open a USB pod: the console at ``device``, or for ``""`` (connection ``usb``) the + auto-detected pod, reached over its USB network adapter when that works, else its console. + + The console's ``status`` names the pod's USB network address; the network is used when it is + on, its link is up and the API port answers a TCP connect within + :data:`USB_NET_PROBE_TIMEOUT`. Anything else (firmware before 3.9.0, Wi-Fi configured on the + pod, link down, the host has not configured the interface) keeps the console. + """ + from .serial import SerialTransport + + console = SerialTransport(device, timeout=timeout) + if device: + return console # an explicit device path is the console, never upgraded + try: + console.timeout = min(timeout, _USB_STATUS_TIMEOUT) + try: + ip = usb_net_address(console.status()) + finally: + console.timeout = timeout + except Exception as exc: # noqa: BLE001 - any failure keeps the console, as before + _log.info("usb: could not read the pod's USB network address on %s (%s); using the " + "USB console", console.device, exc) + return console + if ip is None: + _log.info("usb: the pod's USB network is off, down or absent; using the USB console %s", + console.device) + return console + if not _tcp_reachable(ip, DEFAULT_PORT): + _log.info("usb: the pod's USB network address %s:%d does not answer (is the host's " + "interface configured?); using the USB console %s", ip, DEFAULT_PORT, + console.device) + return console + from .tcp import TcpTransport + + tcp = TcpTransport(f"{ip}:{DEFAULT_PORT}", timeout=timeout) + tcp.usb_console = console.device + console.close() + _log.info("usb: using the pod's USB network at %s (console %s)", tcp.addr, console.device) + return tcp + + def __getattr__(name: str): # Lazy re-exports so importing this package does not pull in pyserial. if name == "TcpTransport": diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py index 5731f6e..43cad37 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py @@ -29,14 +29,17 @@ from .base import RawLink, Transport, request_timeout # USB vendor ids a bench-pod console enumerates under: 0x2E8A (Raspberry Pi, -# the RP2350 pod) and 0x0483 (STMicroelectronics, the STM32H563 pod, which uses -# ST's stock CDC VID/PID 0483:5740 so the host binds its in-box driver). +# the RP2350 pod), 0x0483 (STMicroelectronics, the STM32H563 pod up to firmware +# 3.8, which uses ST's stock CDC VID/PID 0483:5740 so the host binds its in-box +# driver) and 0x1209 (the STM32H563 pod from firmware 3.9.0: a composite device +# with the CDC-ACM console plus a CDC-NCM network adapter, for now under the +# pid.codes test VID/PID 1209:0001). # -# Neither id is exclusive to a bench pod — 0x2E8A is shared with the CMSIS-DAP -# probe's CDC and 0483:5740 with every ST Virtual COM Port on the bench — so -# these only ORDER the ports to probe. Identification is by probing for -# BENCHPOD_MARKER, never by vendor id alone. -POD_VIDS = (0x2E8A, 0x0483) +# None of these ids is exclusive to a bench pod (0x2E8A is shared with the +# CMSIS-DAP probe's CDC, 0483:5740 with every ST Virtual COM Port on the bench +# and 1209:0001 is a shared test id), so they only ORDER the ports to probe. +# Identification is by probing for BENCHPOD_MARKER, never by vendor id alone. +POD_VIDS = (0x2E8A, 0x0483, 0x1209) RP_VID = 0x2E8A # deprecated alias, kept for callers that imported it # The substring the firmware's ``status`` prints to identify itself @@ -111,9 +114,34 @@ def parse_text_status(raw: str) -> Dict[str, Any]: out["safe_reason"] = safe[len("safe mode:"):].strip() if safe.startswith("safe mode:") else safe if "crash" in out: out["last_crash"] = out["crash"] + # The USB network adapter (firmware 3.9.0+), under the JSON status's "usb" shape: + # "usb : net on pod 10.237.8.85 host 10.237.8.86 link up rx 2946 (drop 0) tx ..." + usb = parse_usb_status_line(str(out.get("usb", ""))) + if usb is not None: + out["usb"] = usb return out +_IPV4 = r"(\d{1,3}(?:\.\d{1,3}){3})" + + +def parse_usb_status_line(value: str) -> Optional[Dict[str, Any]]: + """Parse the value of the console status's ``usb`` line into the JSON status's ``usb`` dict + (``{"net": "on"|"off", "link": bool, "ip": ..., "host_ip": ...}``), or None when the line + does not describe the USB network adapter (older firmware).""" + m = re.search(r"\bnet (on|off)\b", value) + if not m: + return None + usb: Dict[str, Any] = {"net": m.group(1)} + link = re.search(r"\blink (up|down)\b", value) + usb["link"] = bool(link and link.group(1) == "up") + if ip := re.search(r"\bpod " + _IPV4, value): + usb["ip"] = ip.group(1) + if host := re.search(r"\bhost " + _IPV4, value): + usb["host_ip"] = host.group(1) + return usb + + def _import_serial(): try: import serial # type: ignore @@ -390,7 +418,9 @@ def _probe_json(self) -> bool: def _unsupported(what: str) -> str: return (f"{what!r} is not available over this pod's USB console, which only answers " "text commands (status, ping, LA voltage, target power); connect over the network " - "(host[:port]) or the cloud (embeddedci:) for it") + "(host[:port]) or the cloud (embeddedci:) for it. On firmware 3.9.0 and " + "later, connection 'usb' reaches the full API over the pod's USB network adapter " + "(off while Wi-Fi is configured on the pod)") def _enter_json(self, cmd: Any = "json") -> None: if not self._probe_json(): diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py b/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py index 95f5e5c..c8ee7a7 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py @@ -87,6 +87,10 @@ def close(self) -> None: class TcpTransport(Transport): """Talks to the pod over JSON/TCP.""" + #: The USB console's device path when connection ``usb`` resolved to the pod's USB network + #: adapter (firmware 3.9.0+) instead of the console; ``""`` for a network connection. + usb_console: str = "" + def __init__(self, addr: str, *, timeout: float = 30.0, dial_timeout: float = DEFAULT_DIAL_TIMEOUT) -> None: if not addr: diff --git a/packages/embeddedci/tests/e2e/test_e2e_usb.py b/packages/embeddedci/tests/e2e/test_e2e_usb.py index 8cc17cd..823abaf 100644 --- a/packages/embeddedci/tests/e2e/test_e2e_usb.py +++ b/packages/embeddedci/tests/e2e/test_e2e_usb.py @@ -25,6 +25,9 @@ def usb(benchpod_la_voltage): if not device: pytest.skip("set BENCHPOD_E2E_USB to the pod's USB console (a device path or 'usb')") with BenchPod(device, la_voltage=benchpod_la_voltage, timeout=10) as pod: + if type(pod.transport).__name__ != "SerialTransport": + pytest.skip("'usb' reached the pod's USB network adapter (firmware 3.9.0+), not the " + "console; set BENCHPOD_E2E_USB to the console's device path") yield pod diff --git a/packages/embeddedci/tests/test_serial_autodetect.py b/packages/embeddedci/tests/test_serial_autodetect.py index 7bffc15..98ae138 100644 --- a/packages/embeddedci/tests/test_serial_autodetect.py +++ b/packages/embeddedci/tests/test_serial_autodetect.py @@ -82,6 +82,18 @@ def test_finds_stm32_pod_under_st_vendor_id(monkeypatch): assert serial_mod.autodetect_port() == "/dev/cu.usbmodem-stm" +def test_pid_codes_vendor_id_ranks_as_a_pod_port(monkeypatch): + # Firmware 3.9.0 enumerates as 1209:0001 (console + USB network adapter). It ranks with the + # other pod vendor ids, ahead of an unknown port, but is still identified by probing. + infos = [PortInfo("/dev/cu.aaa-other", vid=0x1234), PortInfo("/dev/cu.zzz-pod", vid=0x1209)] + opened = install(monkeypatch, infos, {"/dev/cu.aaa-other": "not a pod\n> ", + "/dev/cu.zzz-pod": POD_STATUS}) + assert 0x1209 in serial_mod.POD_VIDS + assert serial_mod._candidate_ports() == ["/dev/cu.zzz-pod", "/dev/cu.aaa-other"] + assert serial_mod.autodetect_port() == "/dev/cu.zzz-pod" + assert opened == ["/dev/cu.zzz-pod"] + + def test_finds_pod_even_with_unknown_vendor_id(monkeypatch): # Ranking is not filtering: a port no rule recognises is still probed. infos = [PortInfo("/dev/cu.mystery", vid=0x1234)] diff --git a/packages/embeddedci/tests/test_usb_net.py b/packages/embeddedci/tests/test_usb_net.py new file mode 100644 index 0000000..a2da651 --- /dev/null +++ b/packages/embeddedci/tests/test_usb_net.py @@ -0,0 +1,173 @@ +"""Connection 'usb' prefers the pod's USB network adapter (firmware 3.9.0+) and falls back to the +console; an explicit device path stays the console. No hardware: the console and the TCP probe are +faked.""" + +from __future__ import annotations + +import pytest + +from embeddedci.benchpod import transport as transport_mod +from embeddedci.benchpod.connection import ConnSpec, parse_connection +from embeddedci.benchpod.errors import TransportError +from embeddedci.benchpod.transport import open_transport, usb_net_address +from embeddedci.benchpod.transport import serial as serial_mod +from embeddedci.benchpod.transport.serial import parse_text_status, parse_usb_status_line +from embeddedci.benchpod.transport.tcp import TcpTransport + +USB_ON = "net on pod 10.237.8.85 host 10.237.8.86 link up rx 2946 (drop 0) tx 5759 (full 2357)" +USB_WIFI = "net off (Wi-Fi configured) pod 10.237.8.85 host 10.237.8.86 link down rx 0 (drop 0) tx 0 (full 0)" +USB_DOWN = "net on pod 10.237.8.85 host 10.237.8.86 link down rx 0 (drop 0) tx 0 (full 0)" + +STATUS = ( + " device : benchpod\r\n" + " board : stm32h563 fw v3.9.0 rev v3 nrst_pin=yes usb_cc=yes\r\n" + " adc : 16-bit x1, 4096 mV full-scale\r\n" +) + + +# -- parsing ------------------------------------------------------------------------------------ + +def test_parse_usb_line_net_on(): + s = parse_text_status("status\r\n" + STATUS + f" usb : {USB_ON}\r\n> ") + assert s["usb"] == {"net": "on", "link": True, "ip": "10.237.8.85", "host_ip": "10.237.8.86"} + + +def test_parse_usb_line_wifi_configured(): + assert parse_usb_status_line(USB_WIFI) == { + "net": "off", "link": False, "ip": "10.237.8.85", "host_ip": "10.237.8.86"} + + +def test_parse_usb_line_from_older_firmware_is_left_alone(): + assert parse_usb_status_line("cdc acm") is None + s = parse_text_status(STATUS + " usb : cdc acm\r\n") + assert s["usb"] == "cdc acm" + assert "usb" not in parse_text_status(STATUS) + + +@pytest.mark.parametrize("status, want", [ + ({"usb": {"net": "on", "link": True, "ip": "10.237.8.85", "host_ip": "10.237.8.86"}}, "10.237.8.85"), + ({"usb": {"net": "off", "link": False, "ip": "10.237.8.85"}}, None), + ({"usb": {"net": "on", "link": False, "ip": "10.237.8.85"}}, None), + ({"usb": {"net": "on", "link": True, "ip": "0.0.0.0"}}, None), + ({"usb": {"net": "on", "link": True, "ip": "garbage"}}, None), + ({"usb": "cdc acm"}, None), + ({"device": "benchpod"}, None), + ("not a dict", None), +]) +def test_usb_net_address(status, want): + assert usb_net_address(status) == want + + +# -- resolution --------------------------------------------------------------------------------- + +class FakeConsole: + """Stands in for SerialTransport: answers status() with a canned dict (or raises).""" + + instances: list = [] + + def __init__(self, device="", *, timeout=30.0, status=None, error=None): + self.device = device or "/dev/cu.usbmodemPOD" + self.timeout = timeout + self._status = status + self._error = error + self.closed = False + self.status_timeouts: list = [] + FakeConsole.instances.append(self) + + def status(self): + self.status_timeouts.append(self.timeout) + if self._error is not None: + raise self._error + return self._status + + def close(self): + self.closed = True + + +@pytest.fixture +def usb(monkeypatch): + """Install a fake console answering `status` and a fake TCP probe; returns a setter.""" + FakeConsole.instances = [] + probes: list = [] + cfg = {"status": None, "error": None, "reachable": True} + + def make_console(device="", *, timeout=30.0): + return FakeConsole(device, timeout=timeout, status=cfg["status"], error=cfg["error"]) + + def probe(host, port, timeout=transport_mod.USB_NET_PROBE_TIMEOUT): + probes.append((host, port, timeout)) + return cfg["reachable"] + + monkeypatch.setattr(serial_mod, "SerialTransport", make_console) + monkeypatch.setattr(transport_mod, "_tcp_reachable", probe) + cfg["probes"] = probes + return cfg + + +def _status(usb_line): + return parse_text_status(STATUS + (f" usb : {usb_line}\r\n" if usb_line else "")) + + +def test_usb_picks_the_network_when_link_up_and_port_answers(usb): + usb["status"] = _status(USB_ON) + t = open_transport(parse_connection("usb"), timeout=12) + assert isinstance(t, TcpTransport) + assert t.addr == "10.237.8.85:8080" and t.timeout == 12 + assert t.usb_console == "/dev/cu.usbmodemPOD" + console = FakeConsole.instances[0] + assert console.closed # the console is released once the network is chosen + assert console.status_timeouts == [5.0] and console.timeout == 12 # short status read, restored + assert usb["probes"] == [("10.237.8.85", 8080, 1.5)] + + +def test_serial_keyword_also_upgrades(usb): + usb["status"] = {"device": "benchpod", + "usb": {"net": "on", "link": True, "ip": "10.237.8.85", "host_ip": "10.237.8.86"}} + assert isinstance(open_transport(parse_connection("serial")), TcpTransport) + + +@pytest.mark.parametrize("line", [USB_WIFI, USB_DOWN, None]) +def test_usb_keeps_the_console_when_net_off_link_down_or_old_firmware(usb, line): + usb["status"] = _status(line) + t = open_transport(parse_connection("usb")) + assert isinstance(t, FakeConsole) and not t.closed + assert usb["probes"] == [] + + +def test_usb_keeps_the_console_when_the_port_does_not_answer(usb): + usb["status"] = _status(USB_ON) + usb["reachable"] = False + t = open_transport(parse_connection("usb")) + assert isinstance(t, FakeConsole) and not t.closed + assert len(usb["probes"]) == 1 + + +def test_usb_keeps_the_console_when_status_fails(usb): + usb["error"] = TransportError("timed out waiting for console prompt") + t = open_transport(parse_connection("usb"), timeout=20) + assert isinstance(t, FakeConsole) and not t.closed and t.timeout == 20 + + +def test_explicit_device_path_never_upgrades(usb): + usb["status"] = _status(USB_ON) + t = open_transport(ConnSpec(kind="serial", device="/dev/ttyACM0")) + assert isinstance(t, FakeConsole) and t.device == "/dev/ttyACM0" + assert t.status_timeouts == [] and usb["probes"] == [] + + +def test_tcp_reachable_against_a_real_listener(): + import socket + + srv = socket.socket() + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + port = srv.getsockname()[1] + try: + assert transport_mod._tcp_reachable("127.0.0.1", port, timeout=1.0) + finally: + srv.close() + assert not transport_mod._tcp_reachable("127.0.0.1", port, timeout=0.5) + + +def test_plain_network_transport_has_no_usb_console(): + assert TcpTransport("192.168.1.220:8080").usb_console == "" From 95c46650021c569fede9fd2dfbd4f77c3c0ff446 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 20:42:23 -0500 Subject: [PATCH 02/19] MCP: 'usb' reaches the full API over the USB network; kind usb_net Guide, connect description, README and changelog: on firmware 3.9.0+ 'usb' uses the pod's USB network adapter (off while Wi-Fi is configured); a serial device path is the console only. connect/status report kind 'usb_net' when the SDK picked the USB network. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 7 +++++++ packages/embeddedci-mcp/README.md | 7 +++++-- .../embeddedci-mcp/src/embeddedci_mcp/guide.py | 2 +- .../embeddedci-mcp/src/embeddedci_mcp/models.py | 4 +++- .../embeddedci-mcp/src/embeddedci_mcp/server.py | 4 +++- .../embeddedci-mcp/src/embeddedci_mcp/session.py | 14 ++++++++++++-- packages/embeddedci-mcp/tests/test_tools.py | 14 ++++++++++++++ packages/embeddedci-mcp/tests/tools_surface.json | 2 +- 8 files changed, 46 insertions(+), 8 deletions(-) diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index 8e449b4..710b229 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog — `embeddedci-mcp` +## Unreleased + +- Connection `usb` reaches the full API over the pod's USB network adapter on firmware 3.9.0 and + later (through the `embeddedci` SDK of the same release), unless Wi-Fi is configured on the pod; + otherwise it falls back to the USB console as before. `connect`/`status` report the new kind + `usb_net` when the USB network is in use. A serial device path stays the console. + ## 2.11.0 - `flash` can flash a build uploaded to embeddedci.com instead of a `file`: new `build` (id or diff --git a/packages/embeddedci-mcp/README.md b/packages/embeddedci-mcp/README.md index 9e4033b..785c69e 100644 --- a/packages/embeddedci-mcp/README.md +++ b/packages/embeddedci-mcp/README.md @@ -13,8 +13,11 @@ maps to SDK calls, so anything an agent discovers interactively can become a pyt ## Requirements - Python 3.10+ and [uv](https://docs.astral.sh/uv/) (for `uvx`), or `pip`. -- A BenchPod reachable over the network or the embeddedci.com cloud. (A USB connection works for - status, LA voltage and power only: the STM32 pod's USB console has no JSON mode.) +- A BenchPod reachable over the network, the embeddedci.com cloud, or USB. With firmware 3.9.0 + and later, connection `usb` reaches the full API over the pod's USB network adapter (unless + Wi-Fi is configured on the pod, which turns USB networking off); `status` then reports kind + `usb_net`. A serial device path, or `usb` on older firmware, is the USB console: status, LA + voltage and power only, since the STM32 pod's console has no JSON mode. - For `flash`: OpenOCD with the `cmsis_dap_tcp` backend (newer than 0.12.0 — e.g. `brew install --HEAD open-ocd`, or the xPack build) on the machine running the server. The firmware `file` is read from that machine too. Flashing a build uploaded to embeddedci.com diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py b/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py index 7cb1faf..591baf3 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py @@ -8,7 +8,7 @@ Drive an EmbeddedCI BenchPod: a hardware-in-the-loop tester wired to a real target board (the DUT). Start every session with: -1. connect — a host[:port] or 'embeddedci:'. Omit it to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`); with none configured, connect finds the one pod on the LAN over mDNS, and if it finds none or several its error lists them and what to pass instead (a host, 'usb', or a cloud pod from cloud_list_devices). A USB connection ('usb' or a serial device path) only supports status, LA voltage and power on an STM32 pod; everything else needs the network or cloud. +1. connect — a host[:port] or 'embeddedci:'. Omit it to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`); with none configured, connect finds the one pod on the LAN over mDNS, and if it finds none or several its error lists them and what to pass instead (a host, 'usb', or a cloud pod from cloud_list_devices). With firmware 3.9.0 and later, 'usb' reaches the full API over the pod's USB network adapter (unless Wi-Fi is configured on the pod, which turns USB networking off; status then reports kind 'usb_net' when it worked). A serial device path, or 'usb' that fell back to the console (kind 'serial'), only supports status, LA voltage and power on an STM32 pod; everything else needs the network, the USB network or cloud. 2. set_la_voltage (1.8 or 3.3, the DUT's I/O voltage) unless status already reports one. The pod refuses flash, UART, LA capture, pull resistors and I2C-sensor emulation until it is set. 3. status shows firmware, capabilities, the LA voltage and open sessions. diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py index 26670b5..c45f703 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py @@ -98,7 +98,9 @@ class SessionInfo(BaseModel): class StatusResult(BaseModel): connected: bool connection: Optional[str] = None - kind: Optional[str] = Field(None, description="tcp, serial, discover or embeddedci (cloud).") + kind: Optional[str] = Field(None, description=( + "tcp, serial (USB console), usb_net ('usb' reached the pod's USB network adapter), " + "discover or embeddedci (cloud).")) leased: bool = False la_voltage: Optional[float] = Field(None, description="Selected LA bank voltage; null = not set.") capabilities: Optional[CapabilitiesInfo] = None diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index baf9da7..a5c5007 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -332,7 +332,9 @@ def _status() -> m.StatusResult: @mcp.tool(annotations=_ann("Connect to a BenchPod", idempotent=True, cloud=True)) async def connect( connection: Annotated[Optional[str], Field(description=( - "host[:port] (TCP, default port 8080), a serial device path, 'usb' (auto-detect), " + "host[:port] (TCP, default port 8080), a serial device path (the USB console only), 'usb' " + "(auto-detect: the pod's USB network adapter with the full API on firmware 3.9.0+, else " + "its console), " "'discover' (mDNS) or 'embeddedci:' (cloud). Omit to use the server default " "(--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod " "set-connection`), else to find the one pod on the LAN over mDNS."))] = None, diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py index 27ed563..2191231 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py @@ -18,7 +18,7 @@ import os import threading import time -from typing import Callable, Optional, Tuple +from typing import Any, Callable, Optional, Tuple from embeddedci.benchpod import ( BenchPod, @@ -58,6 +58,15 @@ def connection_kind(connection: str) -> str: return "tcp" +def transport_kind(connection: str, pod: Any) -> str: + """:func:`connection_kind`, except ``"usb_net"`` when a USB connection (``usb``) resolved to + the pod's USB network adapter (firmware 3.9.0+) rather than its console.""" + kind = connection_kind(connection) + if kind == "serial" and getattr(getattr(pod, "transport", None), "usb_console", ""): + return "usb_net" + return kind + + def cloud_user_token(connection: str) -> Optional[Callable[[], str]]: """The `benchpod login` session, without BENCHPOD_API_KEY and outside GitHub Actions (which uses OIDC). For a cloud connection the SDK exchanges it for a device session token; for a LAN or USB @@ -166,7 +175,7 @@ def connect(self, connection: Optional[str] = None, *, la_voltage: Optional[floa la = self.default_la_voltage if la_voltage is None else la_voltage wait = self.lease_wait if lease_wait is None else lease_wait self._pod = self._open(conn, la, wait) - self.connection, self.kind = conn, connection_kind(conn) + self.connection, self.kind = conn, transport_kind(conn, self._pod) self._reconnect = (conn, la, wait) self._touch() return self._pod @@ -179,6 +188,7 @@ def require(self) -> BenchPod: raise NotConnectedError("not connected to a BenchPod — call the `connect` tool first") conn, la, wait = self._reconnect # type: ignore[misc] self._pod = self._open(conn, la, wait) + self.kind = transport_kind(conn, self._pod) self._idle_closed = False self._touch() return self._pod diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index d1f502f..65df54c 100644 --- a/packages/embeddedci-mcp/tests/test_tools.py +++ b/packages/embeddedci-mcp/tests/test_tools.py @@ -118,6 +118,20 @@ def test_connect_without_la_voltage_warns(fake_open): assert any("set_la_voltage" in w for w in result["warnings"]) +def test_connect_usb_over_the_usb_network_reports_usb_net(fake_open, fake_transport, monkeypatch): + # 'usb' that resolved to the pod's USB network adapter (firmware 3.9.0+): the SDK hands back a + # TCP transport whose usb_console names the console it found. + monkeypatch.setattr(fake_transport, "usb_console", "/dev/cu.usbmodemPOD", raising=False) + assert call("connect", connection="usb", la_voltage=3.3)["kind"] == "usb_net" + assert call("status")["kind"] == "usb_net" + # Without usb_console (the console, or a LAN pod) the kind is the connection string's. + from embeddedci_mcp.session import transport_kind + console = type("Console", (), {"transport": object()})() + assert transport_kind("/dev/ttyACM0", console) == "serial" + assert transport_kind("usb", console) == "serial" + assert transport_kind("192.168.1.50", console) == "tcp" + + def test_connect_rolls_back_when_the_pod_does_not_answer(fake_open, fake_transport, monkeypatch): from embeddedci.benchpod.errors import TransportError diff --git a/packages/embeddedci-mcp/tests/tools_surface.json b/packages/embeddedci-mcp/tests/tools_surface.json index 11964eb..82bfe0f 100644 --- a/packages/embeddedci-mcp/tests/tools_surface.json +++ b/packages/embeddedci-mcp/tests/tools_surface.json @@ -808,7 +808,7 @@ } ], "default": null, - "description": "host[:port] (TCP, default port 8080), a serial device path, 'usb' (auto-detect), 'discover' (mDNS) or 'embeddedci:' (cloud). Omit to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`), else to find the one pod on the LAN over mDNS.", + "description": "host[:port] (TCP, default port 8080), a serial device path (the USB console only), 'usb' (auto-detect: the pod's USB network adapter with the full API on firmware 3.9.0+, else its console), 'discover' (mDNS) or 'embeddedci:' (cloud). Omit to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`), else to find the one pod on the LAN over mDNS.", "title": "Connection" }, "la_voltage": { From 975f9deef07df28fcb42c4b6f97f9257cdea2170 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 20:55:20 -0500 Subject: [PATCH 03/19] examples smoke: wait for the DUT rail to settle before reading it Over the USB network a round trip is ~10 ms (LAN ~17 ms) and the rail needs ~20 ms to reach 4.9 V, so the immediate read caught it mid-ramp (2.25 V, 3.17 V). Poll up to 0.5 s. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/tests/examples/test_smoke.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/embeddedci/tests/examples/test_smoke.py b/packages/embeddedci/tests/examples/test_smoke.py index 0548437..191da26 100644 --- a/packages/embeddedci/tests/examples/test_smoke.py +++ b/packages/embeddedci/tests/examples/test_smoke.py @@ -19,6 +19,8 @@ import os +import time + import pytest from embeddedci import benchpod as bp @@ -60,7 +62,13 @@ def test_target_power_switches(benchpod): assert status.efuse(rail).enabled, status assert not status.efuse(rail).fault, "the eFuse tripped when the rail came up" - monitor = benchpod.power_status().rail(rail) + # The rail takes ~20 ms to come up; a fast link (the USB network) can read it sooner. + deadline = time.monotonic() + 0.5 + while True: + monitor = benchpod.power_status().rail(rail) + if not monitor.ok or 4.0 < monitor.bus_voltage or time.monotonic() > deadline: + break + time.sleep(0.02) if monitor.ok: assert 4.0 < monitor.bus_voltage < 5.5, f"rail reads {monitor.bus_voltage:.2f} V" finally: From f3db2888bf8f1348756508dd037947b6920b698e Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 20:55:30 -0500 Subject: [PATCH 04/19] examples smoke: import order Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/tests/examples/test_smoke.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/embeddedci/tests/examples/test_smoke.py b/packages/embeddedci/tests/examples/test_smoke.py index 191da26..009fced 100644 --- a/packages/embeddedci/tests/examples/test_smoke.py +++ b/packages/embeddedci/tests/examples/test_smoke.py @@ -18,7 +18,6 @@ """ import os - import time import pytest From 3d5ab978d03a0dd24aeb33ace25699a8778f4ec1 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:05:17 -0500 Subject: [PATCH 05/19] discovery: keep every address a pod answers from; prefer its USB link A pod advertises on each of its interfaces, and an update from a second interface replaced the first entry's addresses. Merge them, and make DiscoveredPod.addr prefer the USB network link (10.237.0.0/16). New on_usb. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/CHANGELOG.md | 3 ++ .../src/embeddedci/benchpod/discovery.py | 32 ++++++++++++++++--- packages/embeddedci/tests/test_usb_net.py | 13 ++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 0c782c9..f3c5b5b 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -14,6 +14,9 @@ is still identified by probing its console. - The console's text `status` parses the `usb` line into the JSON status's shape: `{"net": "on"|"off", "link": bool, "ip": ..., "host_ip": ...}`. +- mDNS discovery (`connection="discover"`, `discover()`) keeps every address a pod answers from + (it advertises on each of its interfaces) and `DiscoveredPod.addr` prefers the pod's USB network + link (`10.237.0.0/16`) over the LAN when this computer hears it there. New `DiscoveredPod.on_usb`. ## 2.11.0 diff --git a/packages/embeddedci/src/embeddedci/benchpod/discovery.py b/packages/embeddedci/src/embeddedci/benchpod/discovery.py index 1e27596..3f5a6b6 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/discovery.py +++ b/packages/embeddedci/src/embeddedci/benchpod/discovery.py @@ -16,6 +16,7 @@ from __future__ import annotations +import ipaddress import time from dataclasses import dataclass, field from typing import Dict, List @@ -42,12 +43,31 @@ class DiscoveredPod: def addr(self) -> str: """A ``host:port`` string ready for :func:`parse_connection`. - Prefers a numeric address (avoids depending on the OS mDNS resolver), - falling back to the ``.local`` hostname if no address was resolved. + Prefers a numeric address (avoids depending on the OS mDNS resolver), and of those the + pod's USB network link (firmware 3.9.0+) when this computer heard it there: a pod + advertises itself on each of its interfaces. Falls back to the ``.local`` hostname if no + address was resolved. """ - host = self.addresses[0] if self.addresses else self.hostname + usb = [a for a in self.addresses if _on_usb_link(a)] + host = (usb or self.addresses or [self.hostname])[0] return f"{host}:{self.port}" + @property + def on_usb(self) -> bool: + """True when this computer reaches the pod over its USB network link.""" + return any(_on_usb_link(a) for a in self.addresses) + + +#: Where a pod's USB network adapter lives: a /30 per pod, derived from its unique ID. +USB_LINK_NET = ipaddress.IPv4Network("10.237.0.0/16") + + +def _on_usb_link(addr: str) -> bool: + try: + return ipaddress.ip_address(addr) in USB_LINK_NET + except ValueError: + return False + def discover(timeout: float = DEFAULT_TIMEOUT) -> List[DiscoveredPod]: """Browse the LAN for ``timeout`` seconds; return all pods found. @@ -74,10 +94,14 @@ def _record(zeroconf, type_: str, name: str) -> None: k.decode(errors="replace"): (v or b"").decode(errors="replace") for k, v in (info.properties or {}).items() } + # An update may come from another interface: keep every address heard. + prev = found.get(name) + addresses = list(prev.addresses) if prev else [] + addresses += [a for a in info.parsed_addresses() if a not in addresses] found[name] = DiscoveredPod( name=name.removesuffix("." + SERVICE_TYPE), hostname=(info.server or "").rstrip("."), - addresses=list(info.parsed_addresses()), + addresses=addresses, port=info.port or 8080, pod_id=txt.get("id", ""), ) diff --git a/packages/embeddedci/tests/test_usb_net.py b/packages/embeddedci/tests/test_usb_net.py index a2da651..b46b580 100644 --- a/packages/embeddedci/tests/test_usb_net.py +++ b/packages/embeddedci/tests/test_usb_net.py @@ -171,3 +171,16 @@ def test_tcp_reachable_against_a_real_listener(): def test_plain_network_transport_has_no_usb_console(): assert TcpTransport("192.168.1.220:8080").usb_console == "" + + +def test_discovered_pod_prefers_the_usb_link(): + from embeddedci.benchpod.discovery import DiscoveredPod + + both = DiscoveredPod(name="BenchPod baea06", hostname="benchpod-baea06.local", + addresses=["192.168.1.221", "10.237.8.85"]) + assert both.addr == "10.237.8.85:8080" and both.on_usb + lan = DiscoveredPod(name="BenchPod baea06", hostname="benchpod-baea06.local", + addresses=["192.168.1.221"]) + assert lan.addr == "192.168.1.221:8080" and not lan.on_usb + none = DiscoveredPod(name="BenchPod baea06", hostname="benchpod-baea06.local") + assert none.addr == "benchpod-baea06.local:8080" From 1a84ceb97f995cc215c8f1dda561ee03611e6378 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:11:50 -0500 Subject: [PATCH 06/19] usb: picks one of several pods on USB; plain usb lists them Mirrors benchpod-cli: auto-detect reads each pod's identity short id from its console (`identity`). usb: (baea06, benchpod-baea06, any case, optional .local) opens that pod and upgrades to its USB network as before; plain usb keeps probing the pod-VID ports after the first pod and errors listing both when it finds two. One pod's macOS cu/tty twins count once. MCP classifies usb: as serial (usb_net when upgraded); docs updated. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 3 + packages/embeddedci-mcp/README.md | 6 +- .../src/embeddedci_mcp/__main__.py | 2 +- .../src/embeddedci_mcp/guide.py | 2 +- .../src/embeddedci_mcp/server.py | 2 +- .../src/embeddedci_mcp/session.py | 7 +- packages/embeddedci-mcp/tests/test_tools.py | 9 ++ .../embeddedci-mcp/tests/tools_surface.json | 2 +- packages/embeddedci/CHANGELOG.md | 9 ++ packages/embeddedci/README.md | 10 ++ .../src/embeddedci/benchpod/client.py | 2 +- .../src/embeddedci/benchpod/connection.py | 24 +++ .../src/embeddedci/benchpod/pytest_plugin.py | 2 +- .../embeddedci/benchpod/transport/__init__.py | 12 +- .../embeddedci/benchpod/transport/serial.py | 142 +++++++++++++++--- packages/embeddedci/tests/api_surface.json | 3 +- packages/embeddedci/tests/test_connection.py | 28 ++++ .../tests/test_serial_autodetect.py | 113 +++++++++++++- packages/embeddedci/tests/test_usb_net.py | 15 +- 19 files changed, 344 insertions(+), 49 deletions(-) diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index 710b229..aacb30e 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -6,6 +6,9 @@ later (through the `embeddedci` SDK of the same release), unless Wi-Fi is configured on the pod; otherwise it falls back to the USB console as before. `connect`/`status` report the new kind `usb_net` when the USB network is in use. A serial device path stays the console. +- New connection form `usb:` picks one of several pods plugged into one computer, by name + (`usb:baea06`, `usb:benchpod-baea06`). Plain `usb` with several pods errors and lists them. + `connect`'s `connection` description lists it. ## 2.11.0 diff --git a/packages/embeddedci-mcp/README.md b/packages/embeddedci-mcp/README.md index 785c69e..0e6a32c 100644 --- a/packages/embeddedci-mcp/README.md +++ b/packages/embeddedci-mcp/README.md @@ -17,7 +17,9 @@ maps to SDK calls, so anything an agent discovers interactively can become a pyt and later, connection `usb` reaches the full API over the pod's USB network adapter (unless Wi-Fi is configured on the pod, which turns USB networking off); `status` then reports kind `usb_net`. A serial device path, or `usb` on older firmware, is the USB console: status, LA - voltage and power only, since the STM32 pod's console has no JSON mode. + voltage and power only, since the STM32 pod's console has no JSON mode. With several pods + plugged into one computer, `usb` errors and lists them by name; pick one with `usb:` + (for example `usb:benchpod-baea06`). - For `flash`: OpenOCD with the `cmsis_dap_tcp` backend (newer than 0.12.0 — e.g. `brew install --HEAD open-ocd`, or the xPack build) on the machine running the server. The firmware `file` is read from that machine too. Flashing a build uploaded to embeddedci.com @@ -124,7 +126,7 @@ chat never blocks CI on that pod. | Flag | Environment | Default | | | --- | --- | --- | --- | -| `--connection` | `BENCHPOD_CONNECTION` | the CLI's saved connection, else mDNS | host[:port], serial device, `usb`, `discover`, `saved`, or `embeddedci:`; unset = `benchpod set-connection`'s saved one, else find the one pod on the LAN | +| `--connection` | `BENCHPOD_CONNECTION` | the CLI's saved connection, else mDNS | host[:port], serial device, `usb`, `usb:`, `discover`, `saved`, or `embeddedci:`; unset = `benchpod set-connection`'s saved one, else find the one pod on the LAN | | `--la-voltage` | `BENCHPOD_LA_VOLTAGE` | — | LA I/O voltage (1.8 or 3.3) applied on connect | | — | `BENCHPOD_API_KEY` | — | cloud pods and the waveform library (without it, cloud tools use the `benchpod login` session) | | — | `BENCHPOD_API_BASE` | `https://www.embeddedci.com` | another embeddedci server | diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/__main__.py b/packages/embeddedci-mcp/src/embeddedci_mcp/__main__.py index 21daab5..cc3de05 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/__main__.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/__main__.py @@ -38,7 +38,7 @@ def build_parser() -> argparse.ArgumentParser: help="'stdio' (default, launched by an MCP client) or 'http' (streamable HTTP).") parser.add_argument("--connection", default=None, help="Default connection for the connect tool: host[:port], a serial device, " - "'usb', or 'embeddedci:'. Falls back to BENCHPOD_CONNECTION.") + "'usb', 'usb:', or 'embeddedci:'. Falls back to BENCHPOD_CONNECTION.") parser.add_argument("--la-voltage", type=float, choices=[1.8, 3.3], default=None, help="LA I/O voltage applied on connect. Falls back to BENCHPOD_LA_VOLTAGE.") parser.add_argument("--lease-wait", type=float, default=30.0, diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py b/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py index 591baf3..170300d 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py @@ -8,7 +8,7 @@ Drive an EmbeddedCI BenchPod: a hardware-in-the-loop tester wired to a real target board (the DUT). Start every session with: -1. connect — a host[:port] or 'embeddedci:'. Omit it to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`); with none configured, connect finds the one pod on the LAN over mDNS, and if it finds none or several its error lists them and what to pass instead (a host, 'usb', or a cloud pod from cloud_list_devices). With firmware 3.9.0 and later, 'usb' reaches the full API over the pod's USB network adapter (unless Wi-Fi is configured on the pod, which turns USB networking off; status then reports kind 'usb_net' when it worked). A serial device path, or 'usb' that fell back to the console (kind 'serial'), only supports status, LA voltage and power on an STM32 pod; everything else needs the network, the USB network or cloud. +1. connect — a host[:port] or 'embeddedci:'. Omit it to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`); with none configured, connect finds the one pod on the LAN over mDNS, and if it finds none or several its error lists them and what to pass instead (a host, 'usb', or a cloud pod from cloud_list_devices). With firmware 3.9.0 and later, 'usb' reaches the full API over the pod's USB network adapter (unless Wi-Fi is configured on the pod, which turns USB networking off; status then reports kind 'usb_net' when it worked). With several pods plugged into one computer, 'usb' errors and lists them by name; pick one with 'usb:' (for example 'usb:benchpod-baea06'). A serial device path, or 'usb' that fell back to the console (kind 'serial'), only supports status, LA voltage and power on an STM32 pod; everything else needs the network, the USB network or cloud. 2. set_la_voltage (1.8 or 3.3, the DUT's I/O voltage) unless status already reports one. The pod refuses flash, UART, LA capture, pull resistors and I2C-sensor emulation until it is set. 3. status shows firmware, capabilities, the LA voltage and open sessions. diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index a5c5007..f8ac507 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -334,7 +334,7 @@ async def connect( connection: Annotated[Optional[str], Field(description=( "host[:port] (TCP, default port 8080), a serial device path (the USB console only), 'usb' " "(auto-detect: the pod's USB network adapter with the full API on firmware 3.9.0+, else " - "its console), " + "its console), 'usb:' (one of several pods on USB, by name: 'usb:benchpod-baea06'), " "'discover' (mDNS) or 'embeddedci:' (cloud). Omit to use the server default " "(--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod " "set-connection`), else to find the one pod on the LAN over mDNS."))] = None, diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py index 2191231..2c1ea87 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py @@ -51,7 +51,7 @@ def connection_kind(connection: str) -> str: s = connection.strip() if s.lower().startswith(CLOUD_PREFIX): return "embeddedci" - if s.lower() in ("usb", "serial") or _is_device_path(s): + if s.lower() in ("usb", "serial") or s.lower().startswith("usb:") or _is_device_path(s): return "serial" if s.lower() in DISCOVER_KEYWORDS: return "discover" @@ -59,8 +59,9 @@ def connection_kind(connection: str) -> str: def transport_kind(connection: str, pod: Any) -> str: - """:func:`connection_kind`, except ``"usb_net"`` when a USB connection (``usb``) resolved to - the pod's USB network adapter (firmware 3.9.0+) rather than its console.""" + """:func:`connection_kind`, except ``"usb_net"`` when a USB connection (``usb`` or + ``usb:``) resolved to the pod's USB network adapter (firmware 3.9.0+) rather than its + console.""" kind = connection_kind(connection) if kind == "serial" and getattr(getattr(pod, "transport", None), "usb_console", ""): return "usb_net" diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index 65df54c..15e4616 100644 --- a/packages/embeddedci-mcp/tests/test_tools.py +++ b/packages/embeddedci-mcp/tests/test_tools.py @@ -129,9 +129,18 @@ def test_connect_usb_over_the_usb_network_reports_usb_net(fake_open, fake_transp console = type("Console", (), {"transport": object()})() assert transport_kind("/dev/ttyACM0", console) == "serial" assert transport_kind("usb", console) == "serial" + assert transport_kind("usb:benchpod-baea06", console) == "serial" assert transport_kind("192.168.1.50", console) == "tcp" +def test_connect_usb_name_reports_usb_net(fake_open, fake_transport, monkeypatch): + # usb: picks one of several pods on USB; it upgrades to the USB network like 'usb'. + monkeypatch.setattr(fake_transport, "usb_console", "/dev/cu.usbmodemPOD", raising=False) + assert call("connect", connection="usb:benchpod-baea06", la_voltage=3.3)["kind"] == "usb_net" + from embeddedci_mcp.session import connection_kind + assert connection_kind("USB:baea06") == "serial" + + def test_connect_rolls_back_when_the_pod_does_not_answer(fake_open, fake_transport, monkeypatch): from embeddedci.benchpod.errors import TransportError diff --git a/packages/embeddedci-mcp/tests/tools_surface.json b/packages/embeddedci-mcp/tests/tools_surface.json index 82bfe0f..938cc87 100644 --- a/packages/embeddedci-mcp/tests/tools_surface.json +++ b/packages/embeddedci-mcp/tests/tools_surface.json @@ -808,7 +808,7 @@ } ], "default": null, - "description": "host[:port] (TCP, default port 8080), a serial device path (the USB console only), 'usb' (auto-detect: the pod's USB network adapter with the full API on firmware 3.9.0+, else its console), 'discover' (mDNS) or 'embeddedci:' (cloud). Omit to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`), else to find the one pod on the LAN over mDNS.", + "description": "host[:port] (TCP, default port 8080), a serial device path (the USB console only), 'usb' (auto-detect: the pod's USB network adapter with the full API on firmware 3.9.0+, else its console), 'usb:' (one of several pods on USB, by name: 'usb:benchpod-baea06'), 'discover' (mDNS) or 'embeddedci:' (cloud). Omit to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`), else to find the one pod on the LAN over mDNS.", "title": "Connection" }, "la_voltage": { diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index f3c5b5b..b64357e 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -10,6 +10,15 @@ ping, LA voltage, power) on older firmware, when Wi-Fi is configured on the pod (that turns USB networking off), when the link is down, or when the computer has not configured the adapter. An explicit device path (`/dev/ttyACM0`, `COM3`) always stays the console. +- New connection form `usb:` for one of several pods plugged into one computer: + `usb:baea06`, `usb:benchpod-baea06` or `usb:BenchPod baea06` (any case, optional `.local`) all + pick the pod whose identity short id is `baea06` (`ConnSpec.pod`). Auto-detect now reads each + pod's identity from its console (`identity` command; new `SerialTransport.identity()`). With + a name it opens that pod, else raises `ConnectionConfigError` listing the pods found. Without a + name, after the first pod it keeps probing the ports with a pod's USB vendor id: two different + pods raise `ConnectionConfigError` listing both, and the same pod seen on two ports (macOS + `/dev/cu.*` and `/dev/tty.*`) counts once. The selected pod then upgrades to its USB network + as with `usb`. `autodetect_port(name="")` takes the name. - USB auto-detect ranks the new USB id `1209:0001` (firmware 3.9.0) with the older pod ids; a pod is still identified by probing its console. - The console's text `status` parses the `usb` line into the JSON status's shape: diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index d455209..5fa4ad3 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -189,6 +189,7 @@ explicitly. The pytest plugin does **not** fall back to it (see [pytest plugin]( | `192.168.1.213`, `host:8080`, `[fe80::1]:8080` | network (JSON over TCP, port 8080 by default) | | `/dev/ttyACM0`, `COM3`, `\\.\COM10` | USB console, explicit device path | | `usb` | USB pod, auto-detected by probing the serial ports (`serial` also accepted): its USB network adapter on firmware 3.9.0 and later (full API), else its USB console | +| `usb:` | the USB pod with that name when several are plugged into one computer: `usb:baea06`, `usb:benchpod-baea06` (any case, optional `.local`); otherwise as `usb` | | `discover` (or `mdns`, `auto`) | find exactly one pod on the LAN via mDNS (needs `[discovery]`); errors on zero or several | | `embeddedci:` | a named device through embeddedci.com (needs `[cloud]`; an API key or a user token anywhere, or GitHub Actions OIDC) | | `saved` | the default connection `benchpod-cli` saved (`benchpod set-connection`); errors when none is saved | @@ -207,6 +208,15 @@ off), when the link is down, or when the computer has not configured the adapter macOS under a locked screen); it logs which one it picked. An explicit device path such as `/dev/ttyACM0` is always the console. +**Several pods on USB.** Auto-detect reads each pod's name (its identity short id, `baea06` for +`benchpod-baea06`) from the console's `identity` command. Connection `usb:` opens the pod +with that name and raises `ConnectionConfigError` listing the pods it found when none matches. +Plain `usb` with two pods plugged in raises `ConnectionConfigError` naming both, for example +`2 pods on USB (benchpod-aaaaaa on /dev/cu.usbmodemA, benchpod-bbbbbb on /dev/cu.usbmodemB)`; +with one pod it works as before. macOS lists each pod twice (`/dev/cu.*` and `/dev/tty.*`); the +same name on two ports counts as one pod. `SerialTransport.identity()` reads the name of the pod +on an open console. + **Otherwise use the network (or the cloud) for testing.** The STM32 pod's USB console is a text shell for setup and diagnostics with no JSON mode, so over the console the SDK can only read `status()`, `ping()`, select the LA voltage and switch target power (without `delay`). Everything diff --git a/packages/embeddedci/src/embeddedci/benchpod/client.py b/packages/embeddedci/src/embeddedci/benchpod/client.py index c4cac0d..b012c1b 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/client.py +++ b/packages/embeddedci/src/embeddedci/benchpod/client.py @@ -210,7 +210,7 @@ def __init__( """Open a BenchPod. ``connection`` is a host[:port], a serial device path, ``"usb"`` (auto-detect the USB - console), ``"discover"`` (find one pod on the LAN via mDNS), or ``"embeddedci:"`` + pod), ``"usb:"`` (one of several USB pods, by name), ``"discover"`` (find one pod on the LAN via mDNS), or ``"embeddedci:"`` to drive a named device through embeddedci.com. When omitted ``BENCHPOD_CONNECTION`` is used, else the default connection ``benchpod-cli`` saved (``benchpod set-connection``); ``"saved"`` names that one explicitly. Pass ``transport`` to inject a custom backend diff --git a/packages/embeddedci/src/embeddedci/benchpod/connection.py b/packages/embeddedci/src/embeddedci/benchpod/connection.py index 82b6a08..67870a2 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/connection.py +++ b/packages/embeddedci/src/embeddedci/benchpod/connection.py @@ -9,6 +9,8 @@ firmware 3.9.0+ the transport is the pod's USB network adapter when it answers, else the console (see :func:`~embeddedci.benchpod.transport.open_transport`) +* ``usb:`` -> the USB pod with that name, when several are plugged in + (``usb:baea06``, ``usb:benchpod-baea06``) * ``discover`` / ``mdns`` / ``auto`` -> find a single pod on the LAN via mDNS * ``embeddedci:`` -> drive a named device through embeddedci.com (an API key anywhere, or GitHub Actions OIDC) @@ -33,6 +35,7 @@ ENV_VAR = "BENCHPOD_CONNECTION" CLOUD_PREFIX = "embeddedci:" DISCOVER_KEYWORDS = ("discover", "mdns", "auto") +USB_PREFIX = "usb:" _COM_RE = re.compile(r"^COM[0-9]+$", re.IGNORECASE) @@ -45,6 +48,7 @@ class ConnSpec: addr: str = "" # "host:port" when kind == "tcp" device: str = "" # device path when kind == "serial"; "" means auto-detect device_name: str = "" # named device when kind == "embeddedci" + pod: str = "" # usb:: the pod's identity short id ("baea06") among several on USB def is_wifi(self) -> bool: return self.kind == "tcp" @@ -56,6 +60,19 @@ def is_cloud(self) -> bool: return self.kind == "embeddedci" +def normalize_pod_name(name: str) -> str: + """Turn the ways a pod is named into its identity short id: ``"baea06"``, + ``"benchpod-baea06"`` (its embeddedci.com and mDNS host name), ``"BenchPod baea06"`` (its mDNS + instance), any case, with an optional ``.local`` suffix, all give ``"baea06"``.""" + s = name.strip().lower() + for prefix in ("benchpod-", "benchpod "): + if s.startswith(prefix): + s = s[len(prefix):] + if s.endswith(".local"): + s = s[: -len(".local")] + return s.strip() + + def _is_device_path(s: str) -> bool: if s.startswith("/dev/"): return True @@ -101,6 +118,13 @@ def parse_connection(raw: str) -> ConnSpec: return parse_connection(saved) if s.lower() in ("usb", "serial"): return ConnSpec(kind="serial", device="") + if s.lower().startswith(USB_PREFIX): + pod = normalize_pod_name(s[len(USB_PREFIX):]) + if not pod: + raise ConnectionConfigError( + f"connection {s!r} needs the pod's name, e.g. usb:benchpod-a1b2c3" + ) + return ConnSpec(kind="serial", device="", pod=pod) if s.lower() in DISCOVER_KEYWORDS: return _discover_one() if _is_device_path(s): diff --git a/packages/embeddedci/src/embeddedci/benchpod/pytest_plugin.py b/packages/embeddedci/src/embeddedci/benchpod/pytest_plugin.py index 37dbe0e..5c6673d 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/pytest_plugin.py +++ b/packages/embeddedci/src/embeddedci/benchpod/pytest_plugin.py @@ -122,7 +122,7 @@ def pytest_addoption(parser: "pytest.Parser") -> None: default=None, dest="benchpod_connection", help="BenchPod connection: host[:port], a serial device path, 'usb' (auto-detect), " - "'discover' (mDNS), 'embeddedci:' to drive a named device through " + "'usb:' (one of several USB pods), 'discover' (mDNS), 'embeddedci:' to drive a named device through " "embeddedci.com, or 'saved' for the default connection benchpod-cli saved " f"(never used unless asked for). Falls back to the {ENV_VAR} env var.", ) diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py b/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py index 6b7c223..f7ba169 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py @@ -8,7 +8,8 @@ (firmware 3.9.0+): the console's ``status`` names the pod's address on that link, and when the link is up and the API port answers, the result is a :class:`TcpTransport` to it (with ``usb_console`` set to the console's device path). Otherwise it is the console, as before. -An explicit device path always stays the console. +An explicit device path always stays the console. Connection ``usb:`` does the same for the +pod with that name when several are plugged in (see :func:`~.serial.autodetect_port`). """ from __future__ import annotations @@ -52,7 +53,7 @@ def open_transport( return TcpTransport(spec.addr, timeout=timeout) if spec.is_serial(): - return _open_usb(spec.device, timeout=timeout) + return _open_usb(spec.device, timeout=timeout, pod=spec.pod) if spec.is_cloud(): from ..cloud_auth import DEFAULT_API_BASE, DEFAULT_AUDIENCE from .cloud import CloudTransport @@ -100,9 +101,10 @@ def _tcp_reachable(host: str, port: int, timeout: float = USB_NET_PROBE_TIMEOUT) return False -def _open_usb(device: str, *, timeout: float) -> Transport: +def _open_usb(device: str, *, timeout: float, pod: str = "") -> Transport: """Open a USB pod: the console at ``device``, or for ``""`` (connection ``usb``) the - auto-detected pod, reached over its USB network adapter when that works, else its console. + auto-detected pod (``pod``, from ``usb:``, picks one of several by name), reached over + its USB network adapter when that works, else its console. The console's ``status`` names the pod's USB network address; the network is used when it is on, its link is up and the API port answers a TCP connect within @@ -111,7 +113,7 @@ def _open_usb(device: str, *, timeout: float) -> Transport: """ from .serial import SerialTransport - console = SerialTransport(device, timeout=timeout) + console = SerialTransport(device, timeout=timeout, pod=pod) if device: return console # an explicit device path is the console, never upgraded try: diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py index 43cad37..2e5f35c 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py @@ -15,11 +15,13 @@ import re import time from contextlib import contextmanager -from typing import Any, Dict, Iterator, List, Optional +from typing import Any, Dict, Iterator, List, Optional, Tuple +from ..connection import normalize_pod_name from ..errors import ( BenchPodError, ConnectionClosedError, + ConnectionConfigError, FirmwareError, TransportError, TransportTimeout, @@ -155,10 +157,17 @@ def _import_serial(): def _candidate_ports() -> List[str]: - """USB serial ports to probe, most likely to be a pod first. + """USB serial ports to probe, most likely to be a pod first (see :func:`_candidate_ports_vid`).""" + return [device for device, _ in _candidate_ports_vid()] + + +def _candidate_ports_vid() -> List[Tuple[str, Optional[int]]]: + """USB serial ports to probe with each one's USB vendor id (None when the enumerator does not + list it, as for macOS's ``/dev/tty.*`` twin of a ``/dev/cu.*`` port), most likely to be a pod + first. Ordering, not filtering: a pod is identified by probing (see - :func:`_is_benchpod`), so an adapter this ranking puts last is still tried. + :func:`_probe_pod`), so an adapter this ranking puts last is still tried. Ranking by vendor id alone is not enough — both pod vendor ids are shared with other hardware — so a product/manufacturer string that names the pod outranks a bare vendor-id match. @@ -175,15 +184,41 @@ def rank(p: Any) -> int: ports = [p for p in list_ports.comports() if getattr(p, "device", None)] ports.sort(key=lambda p: (rank(p), p.device)) - return [p.device for p in ports] + return [(p.device, getattr(p, "vid", None)) for p in ports] -def _is_benchpod(device: str, timeout: float = PROBE_TIMEOUT) -> bool: - """Open ``device``, run ``status``, and report whether a pod answered. +_IDENTITY_RE = re.compile(r"\bidentity[ \t]+(\S+)") + + +def parse_identity(text: str) -> str: + """The pod's identity short id (``"baea06"``) from the console's ``identity`` reply + (``identity ``), or ``""`` when the pod has none + (``identity unknown ``) or the firmware has no ``identity`` command.""" + for line in text.replace("\r", "\n").split("\n"): + m = _IDENTITY_RE.search(line) + if m and m.group(1).lower() != "unknown": + return m.group(1).lower() + return "" + + +def _read_to_prompt(port: Any, deadline: float, buf: bytearray) -> bool: + """Read ``port`` into ``buf`` until the console prompt or ``deadline``; whether it came.""" + while time.monotonic() < deadline: + if buf.decode("utf-8", errors="replace").rstrip(" \t").endswith(">"): + return True + chunk = port.read(256) + if chunk: + buf.extend(chunk) + return buf.decode("utf-8", errors="replace").rstrip(" \t").endswith(">") - Anything that goes wrong — the port is held by another process, it is not a - console, it never prints a prompt — means "not a pod here", so every failure - is swallowed and the caller moves on to the next candidate. + +def _probe_pod(device: str, timeout: float = PROBE_TIMEOUT) -> Optional[str]: + """Open ``device``, run ``status`` and then ``identity``, and close it again. + + Returns None when no pod answered, else the pod's identity short id (``""`` when it reports + none). Anything that goes wrong (the port is held by another process, it is not a console, it + never prints a prompt) means "not a pod here", so every failure is swallowed and the caller + moves on to the next candidate. ``status`` and ``identity`` each get ``timeout``. """ serial_mod, _ = _import_serial() port = None @@ -195,15 +230,29 @@ def _is_benchpod(device: str, timeout: float = PROBE_TIMEOUT) -> bool: port.write(b"status\n") deadline = time.monotonic() + timeout buf = bytearray() + is_pod = False while time.monotonic() < deadline: chunk = port.read(256) if chunk: buf.extend(chunk) if BENCHPOD_MARKER in buf.decode("utf-8", errors="replace").lower(): - return True - return False + is_pod = True + break + if not is_pod: + return None + try: + _read_to_prompt(port, deadline, buf) # let the rest of the status report drain + port.write(b"\r") + time.sleep(0.03) + port.reset_input_buffer() + port.write(b"identity\n") + reply = bytearray() + _read_to_prompt(port, time.monotonic() + timeout, reply) + return parse_identity(reply.decode("utf-8", errors="replace")) + except Exception: + return "" # a pod, but its name could not be read except Exception: - return False + return None finally: if port is not None: try: @@ -212,27 +261,72 @@ def _is_benchpod(device: str, timeout: float = PROBE_TIMEOUT) -> bool: pass -def autodetect_port() -> str: - """Return the device path of the first port that answers as a BenchPod. +def _is_benchpod(device: str, timeout: float = PROBE_TIMEOUT) -> bool: + """Whether a pod answers ``status`` on ``device`` (see :func:`_probe_pod`).""" + return _probe_pod(device, timeout) is not None + + +def _pod_label(short_id: str) -> str: + return f"benchpod-{short_id}" if short_id else "a pod with no identity" + + +def _list_found(found: List[Tuple[str, str]]) -> str: + if not found: + return "none" + return ", ".join(f"{_pod_label(sid)} on {device}" for device, sid in found) + + +def autodetect_port(name: str = "") -> str: + """Return the device path of the pod's USB console. Every USB serial port is probed (best candidates first) rather than trusting a vendor-id match, because the pod's vendor ids are shared with other hardware and differ between pod generations — an STM32 pod enumerates under ST's generic CDC id, so a vendor-id filter would miss it entirely. + + Several pods may be plugged in. With ``name`` (``"baea06"``, ``"benchpod-baea06"``: connection + ``usb:``) the port of the pod with that identity is returned. Without, the only pod's; + once one pod is found, only the remaining ports with a pod's USB vendor id are probed for + another, and two different pods raise :class:`ConnectionConfigError` listing them. The same + identity on two ports is one pod (macOS lists each pod as ``/dev/cu.*`` and ``/dev/tty.*``). + Every probed console is closed again; the caller opens the returned path. """ - candidates = _candidate_ports() + want = normalize_pod_name(name) + candidates = _candidate_ports_vid() if not candidates: raise TransportError( "no USB serial ports found — is the BenchPod plugged in, and does the " "cable carry data (many USB cables are charge-only)? " "Pass an explicit device path to override." ) - for device in candidates: - if _is_benchpod(device): + found: List[Tuple[str, str]] = [] # (device, identity short id) + for device, vid in candidates: + # Once one pod is found, only the ports with a pod's USB vendor id can hold another. + if found and vid not in POD_VIDS: + continue + short_id = _probe_pod(device) + if short_id is None: + continue + if want and short_id == want: return device + if short_id and any(short_id == sid for _, sid in found): + continue # the same pod on its second device node + found.append((device, short_id)) + if want: + raise ConnectionConfigError( + f"no pod named {want} on USB (found: {_list_found(found)})" + ) + if len(found) == 1: + return found[0][0] + if len(found) > 1: + example = next((sid for _, sid in found if sid), "a1b2c3") + raise ConnectionConfigError( + f"{len(found)} pods on USB ({_list_found(found)}): pick one with connection " + f"usb:, e.g. usb:benchpod-{example}" + ) raise TransportError( "no BenchPod found among the USB serial ports probed (" - + ", ".join(candidates) + + ", ".join(device for device, _ in candidates) + "). Is the pod powered? A pod with no firmware never opens a console — " "flash it with `benchpod flash-self`. Pass an explicit device path to override." ) @@ -304,9 +398,10 @@ class SerialTransport(Transport): """Talks to the pod over the USB serial console.""" def __init__(self, device: str = "", *, timeout: float = 30.0, - port: Optional[Any] = None) -> None: + port: Optional[Any] = None, pod: str = "") -> None: # ``port`` is a test seam: pass a pyserial-like object to skip opening a - # real device (and pyserial autodetect). + # real device (and pyserial autodetect). ``pod`` picks the pod by name (its identity + # short id) when ``device`` is auto-detected and several pods are plugged in. self.timeout = timeout self._json_mode = False # console "json" mode active? #: Whether the firmware's console has a JSON mode at all (None = not probed yet). The @@ -318,7 +413,7 @@ def __init__(self, device: str = "", *, timeout: float = 30.0, return serial, _ = _import_serial() self._serial_mod = serial - self.device = device or autodetect_port() + self.device = device or autodetect_port(pod) self._port = serial.Serial(self.device, BAUD, timeout=0.25) # -- console plumbing --------------------------------------------------- @@ -561,6 +656,11 @@ def status(self) -> Any: def ping(self) -> Any: return self.command({"cmd": "ping"}) + def identity(self) -> str: + """The pod's identity short id (``"baea06"``, from the console's ``identity`` command), + or ``""`` when the pod has none or its firmware has no ``identity`` command.""" + return parse_identity(self._send_command("identity")) + # -- text-console equivalents (firmware without a JSON mode on USB) ------ def _text_status(self, req: dict) -> Dict[str, Any]: diff --git a/packages/embeddedci/tests/api_surface.json b/packages/embeddedci/tests/api_surface.json index d4b40ef..af6e08c 100644 --- a/packages/embeddedci/tests/api_surface.json +++ b/packages/embeddedci/tests/api_surface.json @@ -305,7 +305,8 @@ "kind", "addr", "device", - "device_name" + "device_name", + "pod" ], "is_cloud": "(self) -> 'bool'", "is_serial": "(self) -> 'bool'", diff --git a/packages/embeddedci/tests/test_connection.py b/packages/embeddedci/tests/test_connection.py index 2d5f262..a60b0af 100644 --- a/packages/embeddedci/tests/test_connection.py +++ b/packages/embeddedci/tests/test_connection.py @@ -24,6 +24,34 @@ def test_parse_connection(raw, kind, addr, device): assert spec.device == device +@pytest.mark.parametrize( + "raw", + ["usb:baea06", "usb:benchpod-baea06", "USB:BenchPod-BAEA06", "usb:BenchPod baea06", + "usb:benchpod-baea06.local", "usb: baea06 "], +) +def test_parse_usb_name(raw): + spec = parse_connection(raw) + assert spec.is_serial() and spec.device == "" and spec.pod == "baea06" + + +def test_plain_usb_has_no_pod(): + assert parse_connection("usb").pod == "" + + +@pytest.mark.parametrize("raw", ["usb:", "usb: ", "usb:benchpod-"]) +def test_usb_without_a_name_is_an_error(raw): + with pytest.raises(ConnectionConfigError, match="usb:benchpod-a1b2c3"): + parse_connection(raw) + + +def test_normalize_pod_name(): + from embeddedci.benchpod.connection import normalize_pod_name + + assert normalize_pod_name("BenchPod-BAEA06.local") == "baea06" + assert normalize_pod_name("benchpod baea06") == "baea06" + assert normalize_pod_name("baea06") == "baea06" + + def test_resolve_prefers_explicit_over_env(monkeypatch): monkeypatch.setenv(ENV_VAR, "/dev/ttyACM0") spec = resolve_connection("192.168.1.5") diff --git a/packages/embeddedci/tests/test_serial_autodetect.py b/packages/embeddedci/tests/test_serial_autodetect.py index 98ae138..fdf30f4 100644 --- a/packages/embeddedci/tests/test_serial_autodetect.py +++ b/packages/embeddedci/tests/test_serial_autodetect.py @@ -8,18 +8,22 @@ import pytest -from embeddedci.benchpod.errors import TransportError +from embeddedci.benchpod.errors import ConnectionConfigError, TransportError from embeddedci.benchpod.transport import serial as serial_mod class FakePort: - """Minimal pyserial stand-in: replies to `status` with `text`.""" + """Minimal pyserial stand-in: replies to `status` with `text`, to `identity` with `ident`.""" - def __init__(self, device, text=""): + instances: list = [] + + def __init__(self, device, text="", ident=None): self.device = device self._text = text.encode() + self._ident = ident self._sent = bytearray() self.closed = False + FakePort.instances.append(self) def write(self, data): self._sent.extend(data) @@ -32,6 +36,10 @@ def reset_input_buffer(self): pass def read(self, n): + if b"identity" in self._sent and self._ident is not None: + reply = f"identity\r\n{self._ident}\r\n> " + self._text, self._ident = reply.encode(), None + self._sent = bytearray(b"status") if b"status" not in self._sent: return b"" out, self._text = self._text[:n], self._text[n:] @@ -50,9 +58,12 @@ def __init__(self, device, vid=None, product=None, manufacturer=None): self.description = None -def install(monkeypatch, infos, replies): - """Wire _import_serial to fake pyserial modules built from `replies`.""" +def install(monkeypatch, infos, replies, idents=None): + """Wire _import_serial to fake pyserial modules built from `replies` (status text per port) + and `idents` (the `identity` reply line per port; none = firmware without the command).""" opened = [] + idents = idents or {} + FakePort.instances = [] class FakeSerialMod: @staticmethod @@ -60,7 +71,7 @@ def Serial(device, baud, timeout=None): # noqa: N802 - pyserial's name opened.append(device) if device not in replies: raise OSError(f"cannot open {device}") - return FakePort(device, replies[device]) + return FakePort(device, replies[device], idents.get(device, "unknown command: identity")) class FakeListPorts: @staticmethod @@ -110,8 +121,9 @@ def test_skips_impostor_sharing_the_pod_vendor_id(monkeypatch): ] opened = install(monkeypatch, infos, {"/dev/cu.st-vcp": "hello\n> ", "/dev/cu.pod": POD_STATUS}) assert serial_mod.autodetect_port() == "/dev/cu.pod" - # The port naming the pod is ranked first, so the impostor is never opened. - assert opened == ["/dev/cu.pod"] + # The port naming the pod is ranked first; the impostor shares a pod vendor id, so it is + # still probed for a second pod, but it is not one. + assert opened == ["/dev/cu.pod", "/dev/cu.st-vcp"] def test_unopenable_port_does_not_stop_the_search(monkeypatch): @@ -136,3 +148,88 @@ def test_ports_but_no_pod_points_at_firmware(monkeypatch): msg = str(exc.value) assert "/dev/cu.other" in msg # says what it looked at assert "flash-self" in msg # and the most likely fix + + +# -- several pods: usb: ---------------------------------------------------------------- + +def _two_pods(monkeypatch, *, same=False, tty_vid=None): + infos = [ + PortInfo("/dev/cu.usbmodemA", vid=0x1209), + PortInfo("/dev/tty.usbmodemA", vid=tty_vid), + PortInfo("/dev/cu.usbmodemB", vid=0x1209), + PortInfo("/dev/cu.other", vid=0x1234), + ] + replies = {"/dev/cu.usbmodemA": POD_STATUS, "/dev/tty.usbmodemA": POD_STATUS, + "/dev/cu.usbmodemB": POD_STATUS, "/dev/cu.other": "not a pod\n> "} + a, b = "identity aaaaaa AAAApubkey", "identity bbbbbb BBBBpubkey" + idents = {"/dev/cu.usbmodemA": a, "/dev/tty.usbmodemA": a, + "/dev/cu.usbmodemB": a if same else b} + return install(monkeypatch, infos, replies, idents) + + +def _all_closed(): + return all(p.closed for p in FakePort.instances) + + +def test_parse_identity(): + assert serial_mod.parse_identity("identity\r\nidentity baea06 Zm9vYmFy\r\n> ") == "baea06" + assert serial_mod.parse_identity("identity\r\nidentity unknown no key yet\r\n> ") == "" + assert serial_mod.parse_identity("identity\r\nunknown command\r\n> ") == "" + + +def test_identity_on_the_transport(): + port = FakePort("/dev/x", "", "identity BAEA06 Zm9vYmFy") + t = serial_mod.SerialTransport("/dev/x", port=port, timeout=1) + assert t.identity() == "baea06" + + +@pytest.mark.parametrize("name", ["bbbbbb", "benchpod-bbbbbb", "BenchPod BBBBBB", "benchpod-bbbbbb.local"]) +def test_name_selects_that_pod(monkeypatch, name): + _two_pods(monkeypatch) + assert serial_mod.autodetect_port(name) == "/dev/cu.usbmodemB" + assert _all_closed() # every probe console is closed; the caller opens the returned path + + +def test_name_selects_the_first_pod_without_probing_further(monkeypatch): + opened = _two_pods(monkeypatch) + assert serial_mod.autodetect_port("aaaaaa") == "/dev/cu.usbmodemA" + assert opened == ["/dev/cu.usbmodemA"] + + +def test_unknown_name_lists_the_pods_found(monkeypatch): + _two_pods(monkeypatch) + with pytest.raises(ConnectionConfigError) as exc: + serial_mod.autodetect_port("cccccc") + assert str(exc.value) == ("no pod named cccccc on USB (found: benchpod-aaaaaa on " + "/dev/cu.usbmodemA, benchpod-bbbbbb on /dev/cu.usbmodemB)") + assert _all_closed() + + +def test_two_pods_without_a_name_is_an_error_listing_both(monkeypatch): + opened = _two_pods(monkeypatch) + with pytest.raises(ConnectionConfigError) as exc: + serial_mod.autodetect_port() + assert str(exc.value) == ( + "2 pods on USB (benchpod-aaaaaa on /dev/cu.usbmodemA, benchpod-bbbbbb on " + "/dev/cu.usbmodemB): pick one with connection usb:, e.g. usb:benchpod-aaaaaa") + # The tty twin has no vendor id and the other port is not a pod's: neither is probed + # once a pod has been found. + assert opened == ["/dev/cu.usbmodemA", "/dev/cu.usbmodemB"] + assert _all_closed() + + +def test_one_pod_seen_on_two_ports_is_one_pod(monkeypatch): + # The tty twin enumerates with the pod's vendor id here, so it is probed: same identity. + opened = _two_pods(monkeypatch, same=True, tty_vid=0x1209) + assert serial_mod.autodetect_port() == "/dev/cu.usbmodemA" + assert opened == ["/dev/cu.usbmodemA", "/dev/cu.usbmodemB", "/dev/tty.usbmodemA"] + assert _all_closed() + + +def test_two_ports_without_identity_count_as_two(monkeypatch): + # Old firmware has no identity command: two pods cannot be told apart from twins, so the + # error asks for a name (or a device path) instead of guessing. + infos = [PortInfo("/dev/cu.a", vid=0x0483), PortInfo("/dev/cu.b", vid=0x0483)] + install(monkeypatch, infos, {"/dev/cu.a": POD_STATUS, "/dev/cu.b": POD_STATUS}) + with pytest.raises(ConnectionConfigError, match="a pod with no identity on /dev/cu.a"): + serial_mod.autodetect_port() diff --git a/packages/embeddedci/tests/test_usb_net.py b/packages/embeddedci/tests/test_usb_net.py index b46b580..de4b44f 100644 --- a/packages/embeddedci/tests/test_usb_net.py +++ b/packages/embeddedci/tests/test_usb_net.py @@ -65,8 +65,9 @@ class FakeConsole: instances: list = [] - def __init__(self, device="", *, timeout=30.0, status=None, error=None): + def __init__(self, device="", *, timeout=30.0, status=None, error=None, pod=""): self.device = device or "/dev/cu.usbmodemPOD" + self.pod = pod self.timeout = timeout self._status = status self._error = error @@ -91,8 +92,9 @@ def usb(monkeypatch): probes: list = [] cfg = {"status": None, "error": None, "reachable": True} - def make_console(device="", *, timeout=30.0): - return FakeConsole(device, timeout=timeout, status=cfg["status"], error=cfg["error"]) + def make_console(device="", *, timeout=30.0, pod=""): + return FakeConsole(device, timeout=timeout, status=cfg["status"], error=cfg["error"], + pod=pod) def probe(host, port, timeout=transport_mod.USB_NET_PROBE_TIMEOUT): probes.append((host, port, timeout)) @@ -120,6 +122,13 @@ def test_usb_picks_the_network_when_link_up_and_port_answers(usb): assert usb["probes"] == [("10.237.8.85", 8080, 1.5)] +def test_usb_name_selects_the_pod_and_upgrades(usb): + usb["status"] = _status(USB_ON) + t = open_transport(parse_connection("usb:BenchPod-BAEA06")) + assert isinstance(t, TcpTransport) and t.addr == "10.237.8.85:8080" + assert FakeConsole.instances[0].pod == "baea06" # the console was auto-detected by name + + def test_serial_keyword_also_upgrades(usb): usb["status"] = {"device": "benchpod", "usb": {"net": "on", "link": True, "ip": "10.237.8.85", "host_ip": "10.237.8.86"}} From 141d8e642e307361a820891a73f34fdc7b100316 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:14:14 -0500 Subject: [PATCH 07/19] MCP status: warn about a USB port with only default power Firmware 3.9.0 reports what the USB-C source advertises (status.usb.source). default (500 mA) may not carry the pod plus a DUT on the internal 5 V rail, so status says so. The MCP e2e ignores that one warning (bench pods sit on computer ports). Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 2 ++ packages/embeddedci-mcp/src/embeddedci_mcp/server.py | 5 +++++ packages/embeddedci-mcp/tests/test_e2e_mcp.py | 3 ++- packages/embeddedci-mcp/tests/test_refusals.py | 8 ++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index aacb30e..ff00f9a 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -9,6 +9,8 @@ - New connection form `usb:` picks one of several pods plugged into one computer, by name (`usb:baea06`, `usb:benchpod-baea06`). Plain `usb` with several pods errors and lists them. `connect`'s `connection` description lists it. +- `status` warns when the USB-C port powering the pod advertises only default USB power (500 mA, + firmware 3.9.0's `status.usb.source`): it may not carry the pod plus a DUT on the internal 5 V rail. ## 2.11.0 diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index f8ac507..ba39ebb 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -315,6 +315,11 @@ def _status() -> m.StatusResult: if isinstance(firmware, dict) and firmware.get("lan_policy") == "locked" and SESSION.kind == "tcp": warnings.append("the pod's LAN policy is locked: configuration and firmware commands need " "the cloud ('embeddedci:') or the USB console") + usb = firmware.get("usb") if isinstance(firmware, dict) else None + if isinstance(usb, dict) and usb.get("source") == "default": + warnings.append("the USB port powering the pod offers only default USB power (500 mA), which may " + "not carry the pod plus a DUT on the internal 5 V rail: use a USB-C port or " + "charger with 1.5 A or more, or power the DUT externally") try: wiring_source: Optional[str] = pod.wiring.source except (BenchPodError, ValueError, OSError): diff --git a/packages/embeddedci-mcp/tests/test_e2e_mcp.py b/packages/embeddedci-mcp/tests/test_e2e_mcp.py index 92867dd..abfdb5d 100644 --- a/packages/embeddedci-mcp/tests/test_e2e_mcp.py +++ b/packages/embeddedci-mcp/tests/test_e2e_mcp.py @@ -41,7 +41,8 @@ def connected(benchpod_connection): def test_status_and_typed_state(connected): assert connected["capabilities"]["board"] - assert connected["warnings"] == [] + # A bench pod on a computer's USB port is told about its 500 mA port; nothing else may warn. + assert [w for w in connected["warnings"] if "default USB power" not in w] == [] assert "internal" in call("power_status") channels = call("pull_status")["channels"] assert [c["direction"] for c in channels] == ["up"] * 6 + ["down"] * 2 diff --git a/packages/embeddedci-mcp/tests/test_refusals.py b/packages/embeddedci-mcp/tests/test_refusals.py index 51569cf..c391078 100644 --- a/packages/embeddedci-mcp/tests/test_refusals.py +++ b/packages/embeddedci-mcp/tests/test_refusals.py @@ -147,6 +147,14 @@ def test_status_warns_about_a_cloud_lease_and_a_locked_lan(connected): assert "LAN policy is locked" in warnings +def test_status_warns_about_a_weak_usb_port(connected): + real = connected.status + connected.status = lambda: dict(real(), usb={"net": "on", "link": True, "source": "default", "source_ma": 500}) + assert any("default USB power (500 mA)" in w for w in call("status")["warnings"]) + connected.status = lambda: dict(real(), usb={"net": "on", "link": True, "source": "3.0A", "source_ma": 3000}) + assert not any("USB power" in w for w in call("status")["warnings"]) + + def test_capabilities_report_the_policy_flags(fake_open, fake_transport): real = fake_transport.status fake_transport.status = lambda: dict(real(), lan_policy="open", ota_sig=True, From b9ad16fe35357d6b000d4dd26119ac574d2a4ee0 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:39:20 -0500 Subject: [PATCH 08/19] serial: name the way out when the console has no la-voltage/power Firmware 3.9.0 moves la-voltage and power from the USB console to the JSON API. Over an explicit console device the SDK then says to connect with usb (the USB network adapter) or the pod's network address instead of reporting an unexpected reply. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/CHANGELOG.md | 3 +++ .../src/embeddedci/benchpod/transport/serial.py | 13 ++++++++++++- .../embeddedci/tests/test_serial_text_console.py | 14 +++++++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index b64357e..5bc4dbb 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- On firmware 3.9.0 and later the USB console no longer has `la-voltage` or `power` (they are JSON + commands, reached over the USB network adapter): a serial device path then fails with an error + that says to connect with `usb` or the pod's network address. - Connection `usb` (and `serial`) uses the pod's USB network adapter on firmware 3.9.0 and later: the SDK finds the pod's USB console as before, reads the pod's address on the USB link (`10.237.x.y`) from its `status`, and when the link is up and port 8080 answers it returns a diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py index 2e5f35c..b0ca5e4 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py @@ -672,9 +672,18 @@ def _text_ping(self, req: dict) -> str: raise TransportError(f"the pod did not answer ping: {out.strip()!r}") return "pong" + def _moved_to_json(self, what: str) -> TransportError: + """Firmware 3.9.0 moved the instrument commands off the console to the JSON API.""" + return TransportError( + f"this pod's firmware (3.9.0 or later) has no {what} on its USB console: connect with " + f"'usb' (the pod's USB network adapter) or its network address instead of the console " + f"device {self.device or ''}".rstrip()) + def _text_la_voltage(self, req: dict) -> Dict[str, Any]: line = "la-voltage" + (f" {int(req['mv'])}" if req.get("mv") is not None else "") out = self._send_command(line) + if UNKNOWN_COMMAND in out: + raise self._moved_to_json("la-voltage") if "needs a v3 pod" in out or "usage:" in out: msg = next((ln.strip() for ln in out.replace("\r", "\n").split("\n") if "needs a v3 pod" in ln or "usage:" in ln), out.strip()) @@ -689,7 +698,9 @@ def target_power(self, efuse: int, on: bool, delay_ms: int = 0) -> None: raise TransportError("the pod's USB console cannot schedule a delayed power change; " "use delay=None, or a network/cloud connection") out = self._send_command(f"power {int(efuse)} {'on' if on else 'off'}") - if UNKNOWN_COMMAND in out or "bad eFuse" in out or "ERROR:" in out: + if UNKNOWN_COMMAND in out: + raise self._moved_to_json("power") + if "bad eFuse" in out or "ERROR:" in out: raise TransportError(f"firmware rejected power {efuse}: {out.strip()!r}") def _console_raw_handshake( diff --git a/packages/embeddedci/tests/test_serial_text_console.py b/packages/embeddedci/tests/test_serial_text_console.py index d8219ce..79e0dc1 100644 --- a/packages/embeddedci/tests/test_serial_text_console.py +++ b/packages/embeddedci/tests/test_serial_text_console.py @@ -24,12 +24,13 @@ class TextConsolePort: """Emulates the STM32 console: echo, text replies, '> ' prompt, no JSON mode.""" - def __init__(self, rev: str = "v2") -> None: + def __init__(self, rev: str = "v2", trimmed: bool = False) -> None: self._in = bytearray() self._line = bytearray() self.lines: list = [] self.la_mv = 0 self.rev = rev + self.trimmed = trimmed # firmware 3.9.0+: no la-voltage / power on the console self.timeout = 0.25 def write(self, data: bytes): @@ -67,6 +68,8 @@ def _process(self, s: str) -> None: self._reply(echo + STATUS) elif argv[0] == "ping": self._reply(echo + " PING ok version=0x1f\r\n") + elif self.trimmed and argv[0] in ("la-voltage", "power"): + self._reply(echo + f" unknown command '{argv[0]}' (try 'help')\r\n") elif argv[0] == "la-voltage": if len(argv) > 1: if argv[1] == "1800" and self.rev == "v2": @@ -148,3 +151,12 @@ def test_flash_and_uart_proxy_fail_fast_with_a_hint(): with pytest.raises(TransportError, match="network"): t.uart_proxy_start(5, 4, 115200) assert time.monotonic() - start < 3 + + +def test_trimmed_console_points_at_the_usb_network(): + """Firmware 3.9.0 moved la-voltage and power off the console to the JSON API.""" + bp = BenchPod(transport=_transport(trimmed=True), la_voltage=None) + with pytest.raises(TransportError, match="'usb'"): + bp.get_la_voltage() + with pytest.raises(TransportError, match="USB network"): + bp.transport.target_power(1, True) From 0a0a7a89d9a5bef5fe98eaa4782f2ec7fadb4905 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 21:47:22 -0500 Subject: [PATCH 09/19] serial: recognize the 3.9.0 console's moved line; README compatibility table Firmware 3.9.0 answers console commands it moved to the JSON API with a moved-to-the-network-API line; the serial transport raises the connect with usb error for that line only (an unknown command still means firmware too old, as before). README: SDK x firmware compatibility table, Windows 10 supported but not tested, with the UsbNcm Host Device fallback. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/CHANGELOG.md | 4 +++- packages/embeddedci/README.md | 12 ++++++++++++ .../src/embeddedci/benchpod/transport/serial.py | 8 +++++--- .../embeddedci/tests/test_serial_text_console.py | 4 +++- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 5bc4dbb..9cfc9ab 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -4,7 +4,9 @@ - On firmware 3.9.0 and later the USB console no longer has `la-voltage` or `power` (they are JSON commands, reached over the USB network adapter): a serial device path then fails with an error - that says to connect with `usb` or the pod's network address. + that says to connect with `usb` or the pod's network address (the console answers those with a + "moved to the network API" line, which the SDK recognizes). +- README: a compatibility table (SDK version x firmware generation) and the Windows 10 note. - Connection `usb` (and `serial`) uses the pod's USB network adapter on firmware 3.9.0 and later: the SDK finds the pod's USB console as before, reads the pod's address on the USB link (`10.237.x.y`) from its `status`, and when the link is up and port 8080 answers it returns a diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index 5fa4ad3..d4fcc39 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -208,6 +208,18 @@ off), when the link is down, or when the computer has not configured the adapter macOS under a locked screen); it logs which one it picked. An explicit device path such as `/dev/ttyACM0` is always the console. +The USB network adapter works with the in-box drivers of macOS, Linux and Windows 11 (tested). +Windows 10 (1903 or newer) is supported but not tested: if the adapter shows up without a driver, +update its driver in Device Manager and pick "UsbNcm Host Device". + +**Compatibility.** The SDK decides per pod, from what the pod reports (the `usb` line of its +console `status`), never from its own version: + +| | Pod firmware before 3.9.0 | Pod firmware 3.9.0 and later | +|---|---|---| +| **This SDK (2.12.0+)** | As before: `usb` is the console (status, ping, LA voltage, target power); the rest needs the network or the cloud. Console auto-detect still finds `0483:5740` and `2E8A`. | `usb` is the full API over the USB network adapter when it is up, the console otherwise. | +| **An older SDK (2.11.x)** | Unchanged. | `usb` is the console. Status and ping work; LA voltage and target power, which 3.9.0 moved to the JSON API, fail with the pod's "moved to the network API ... update" line. A network address or `embeddedci:` works fully. | + **Several pods on USB.** Auto-detect reads each pod's name (its identity short id, `baea06` for `benchpod-baea06`) from the console's `identity` command. Connection `usb:` opens the pod with that name and raises `ConnectionConfigError` listing the pods it found when none matches. diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py index b0ca5e4..ce45e47 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py @@ -61,6 +61,8 @@ _RAW_READ_TIMEOUT = 0.1 # poll interval while bridging raw CMSIS-DAP bytes JSON_PROBE_TIMEOUT = 3.0 # how long to wait for the console to answer the `json` mode switch UNKNOWN_COMMAND = "unknown command" +#: Firmware 3.9.0 answers a console command it moved to the JSON API with this marker instead. +MOVED_TO_NETWORK = "moved to the network API" #: JSON commands the text console can answer itself when the firmware has no JSON mode on USB #: (the STM32 pod's console is a text shell: status, ping, la-voltage, power, diagnostics). @@ -682,7 +684,7 @@ def _moved_to_json(self, what: str) -> TransportError: def _text_la_voltage(self, req: dict) -> Dict[str, Any]: line = "la-voltage" + (f" {int(req['mv'])}" if req.get("mv") is not None else "") out = self._send_command(line) - if UNKNOWN_COMMAND in out: + if MOVED_TO_NETWORK in out: raise self._moved_to_json("la-voltage") if "needs a v3 pod" in out or "usage:" in out: msg = next((ln.strip() for ln in out.replace("\r", "\n").split("\n") @@ -698,9 +700,9 @@ def target_power(self, efuse: int, on: bool, delay_ms: int = 0) -> None: raise TransportError("the pod's USB console cannot schedule a delayed power change; " "use delay=None, or a network/cloud connection") out = self._send_command(f"power {int(efuse)} {'on' if on else 'off'}") - if UNKNOWN_COMMAND in out: + if MOVED_TO_NETWORK in out: raise self._moved_to_json("power") - if "bad eFuse" in out or "ERROR:" in out: + if UNKNOWN_COMMAND in out or "bad eFuse" in out or "ERROR:" in out: raise TransportError(f"firmware rejected power {efuse}: {out.strip()!r}") def _console_raw_handshake( diff --git a/packages/embeddedci/tests/test_serial_text_console.py b/packages/embeddedci/tests/test_serial_text_console.py index 79e0dc1..5a8a8d3 100644 --- a/packages/embeddedci/tests/test_serial_text_console.py +++ b/packages/embeddedci/tests/test_serial_text_console.py @@ -69,7 +69,9 @@ def _process(self, s: str) -> None: elif argv[0] == "ping": self._reply(echo + " PING ok version=0x1f\r\n") elif self.trimmed and argv[0] in ("la-voltage", "power"): - self._reply(echo + f" unknown command '{argv[0]}' (try 'help')\r\n") + self._reply(echo + f"{argv[0]}: moved to the network API (JSON la_voltage) in firmware 3.9.0; " + "update the benchpod CLI to 0.6.0 or later (or embeddedci to 2.12.0), which " + "uses it over USB\r\n") elif argv[0] == "la-voltage": if len(argv) > 1: if argv[1] == "1800" and self.rev == "v2": From 41dc4391cae09d7840aa94848ebf66df8aee979f Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 22:26:03 -0500 Subject: [PATCH 10/19] usb: fall back to the pod's network address when its USB network is off On firmware 3.9.0+ with Wi-Fi configured (USB networking off) or the adapter down, the trimmed console has no LA voltage or power. Connection usb now uses the pod's Ethernet or Wi-Fi address from the console status when it answers on :8080, logs a warning naming it (the external LAN policy applies there; clear-wifi turns USB networking back on), and sets TcpTransport.usb_lan_fallback. Firmware before 3.9.0 keeps the console. MCP: kind tcp and a status warning for that case. Tests and README table. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 2 + .../src/embeddedci_mcp/server.py | 5 +++ .../src/embeddedci_mcp/session.py | 5 ++- packages/embeddedci-mcp/tests/test_tools.py | 9 ++++ packages/embeddedci/CHANGELOG.md | 4 ++ packages/embeddedci/README.md | 2 +- .../embeddedci/benchpod/transport/__init__.py | 43 ++++++++++++++----- .../src/embeddedci/benchpod/transport/tcp.py | 3 ++ packages/embeddedci/tests/test_usb_net.py | 39 +++++++++++++++-- 9 files changed, 96 insertions(+), 16 deletions(-) diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index ff00f9a..bf5a15f 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -11,6 +11,8 @@ `connect`'s `connection` description lists it. - `status` warns when the USB-C port powering the pod advertises only default USB power (500 mA, firmware 3.9.0's `status.usb.source`): it may not carry the pod plus a DUT on the internal 5 V rail. +- A `usb` connection that went to the pod's network address (3.9.0+ with USB networking off or + down) reports kind `tcp`, and `status` warns which way it went. ## 2.11.0 diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index ba39ebb..7b82405 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -315,6 +315,11 @@ def _status() -> m.StatusResult: if isinstance(firmware, dict) and firmware.get("lan_policy") == "locked" and SESSION.kind == "tcp": warnings.append("the pod's LAN policy is locked: configuration and firmware commands need " "the cloud ('embeddedci:') or the USB console") + fallback = getattr(pod.transport, "usb_lan_fallback", "") + if isinstance(fallback, str) and fallback: + warnings.append(f"connection 'usb' went to the pod's network address because {fallback}; the " + "pod's external LAN policy applies there (`benchpod clear-wifi` turns USB " + "networking back on)") usb = firmware.get("usb") if isinstance(firmware, dict) else None if isinstance(usb, dict) and usb.get("source") == "default": warnings.append("the USB port powering the pod offers only default USB power (500 mA), which may " diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py index 2c1ea87..da3e0fc 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py @@ -63,8 +63,11 @@ def transport_kind(connection: str, pod: Any) -> str: ``usb:``) resolved to the pod's USB network adapter (firmware 3.9.0+) rather than its console.""" kind = connection_kind(connection) - if kind == "serial" and getattr(getattr(pod, "transport", None), "usb_console", ""): + transport = getattr(pod, "transport", None) + if kind == "serial" and getattr(transport, "usb_console", ""): return "usb_net" + if kind == "serial" and getattr(transport, "usb_lan_fallback", ""): + return "tcp" # USB networking off or down: the pod's Ethernet or Wi-Fi address return kind diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index 15e4616..4cd7f62 100644 --- a/packages/embeddedci-mcp/tests/test_tools.py +++ b/packages/embeddedci-mcp/tests/test_tools.py @@ -141,6 +141,15 @@ def test_connect_usb_name_reports_usb_net(fake_open, fake_transport, monkeypatch assert connection_kind("USB:baea06") == "serial" +def test_connect_usb_falling_back_to_the_lan_reports_tcp_and_warns(fake_open, fake_transport, monkeypatch): + # 'usb' on firmware 3.9.0+ with USB networking off: the SDK went to the pod's network address. + monkeypatch.setattr(fake_transport, "usb_lan_fallback", + "the pod's USB network is off (Wi-Fi is configured) or down", raising=False) + assert call("connect", connection="usb", la_voltage=3.3)["kind"] == "tcp" + warnings = " | ".join(call("status")["warnings"]) + assert "network address" in warnings and "clear-wifi" in warnings + + def test_connect_rolls_back_when_the_pod_does_not_answer(fake_open, fake_transport, monkeypatch): from embeddedci.benchpod.errors import TransportError diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 9cfc9ab..12f2a82 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -7,6 +7,10 @@ that says to connect with `usb` or the pod's network address (the console answers those with a "moved to the network API" line, which the SDK recognizes). - README: a compatibility table (SDK version x firmware generation) and the Windows 10 note. +- Connection `usb` on firmware 3.9.0+ whose USB networking is off (Wi-Fi configured) or down now + uses the pod's Ethernet or Wi-Fi address from its console `status` when it answers on port 8080 + (its console no longer has LA voltage or power), with a warning that names the address; the new + `TcpTransport.usb_lan_fallback` says why. Firmware before 3.9.0 keeps the console. - Connection `usb` (and `serial`) uses the pod's USB network adapter on firmware 3.9.0 and later: the SDK finds the pod's USB console as before, reads the pod's address on the USB link (`10.237.x.y`) from its `status`, and when the link is up and port 8080 answers it returns a diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index d4fcc39..685e003 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -217,7 +217,7 @@ console `status`), never from its own version: | | Pod firmware before 3.9.0 | Pod firmware 3.9.0 and later | |---|---|---| -| **This SDK (2.12.0+)** | As before: `usb` is the console (status, ping, LA voltage, target power); the rest needs the network or the cloud. Console auto-detect still finds `0483:5740` and `2E8A`. | `usb` is the full API over the USB network adapter when it is up, the console otherwise. | +| **This SDK (2.12.0+)** | As before: `usb` is the console (status, ping, LA voltage, target power); the rest needs the network or the cloud. Console auto-detect still finds `0483:5740` and `2E8A`. | `usb` is the full API over the USB network adapter when it is up. When USB networking is off (Wi-Fi configured) or down, it is the full API over the pod's Ethernet or Wi-Fi address from its console status, when this computer reaches it (logged as a warning; the pod's external LAN policy applies there; `transport.usb_lan_fallback` says why); else the console. | | **An older SDK (2.11.x)** | Unchanged. | `usb` is the console. Status and ping work; LA voltage and target power, which 3.9.0 moved to the JSON API, fail with the pod's "moved to the network API ... update" line. A network address or `embeddedci:` works fully. | **Several pods on USB.** Auto-detect reads each pod's name (its identity short id, `baea06` for diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py b/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py index f7ba169..91e184d 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py @@ -92,6 +92,19 @@ def usb_net_address(status: Any) -> Optional[str]: return None if ip.is_unspecified else str(ip) +def usb_lan_fallback_address(status: Any) -> Optional[str]: + """The pod's Ethernet or Wi-Fi address from a ``status`` reply, for a 3.9.0+ pod (one that + reports a ``usb`` section) whose USB network is off or down; None otherwise. Firmware before + 3.9.0 keeps every command on its console, so it never needs this.""" + if not isinstance(status, dict) or not isinstance(status.get("usb"), dict): + return None + try: + ip = ipaddress.IPv4Address(str(status.get("ip", "")).strip()) + except ValueError: + return None + return None if ip.is_unspecified else str(ip) + + def _tcp_reachable(host: str, port: int, timeout: float = USB_NET_PROBE_TIMEOUT) -> bool: """Whether a TCP connect to ``host:port`` succeeds within ``timeout`` (then closed at once).""" try: @@ -119,24 +132,34 @@ def _open_usb(device: str, *, timeout: float, pod: str = "") -> Transport: try: console.timeout = min(timeout, _USB_STATUS_TIMEOUT) try: - ip = usb_net_address(console.status()) + status = console.status() + ip = usb_net_address(status) finally: console.timeout = timeout except Exception as exc: # noqa: BLE001 - any failure keeps the console, as before _log.info("usb: could not read the pod's USB network address on %s (%s); using the " "USB console", console.device, exc) return console - if ip is None: - _log.info("usb: the pod's USB network is off, down or absent; using the USB console %s", - console.device) - return console - if not _tcp_reachable(ip, DEFAULT_PORT): - _log.info("usb: the pod's USB network address %s:%d does not answer (is the host's " - "interface configured?); using the USB console %s", ip, DEFAULT_PORT, - console.device) - return console from .tcp import TcpTransport + if ip is None or not _tcp_reachable(ip, DEFAULT_PORT): + why = ("the pod's USB network is off (Wi-Fi is configured) or down" if ip is None else + f"the pod's USB network address {ip}:{DEFAULT_PORT} does not answer") + # Firmware 3.9.0+ keeps only the physical-presence commands on its console, so without the + # USB network the way in is the pod's Ethernet or Wi-Fi address when this computer reaches + # it (the pod's external LAN policy applies there). + lan = usb_lan_fallback_address(status) + if lan and _tcp_reachable(lan, DEFAULT_PORT): + tcp = TcpTransport(f"{lan}:{DEFAULT_PORT}", timeout=timeout) + tcp.usb_lan_fallback = why + console.close() + _log.warning("usb: %s; using the pod's network address %s (its external LAN policy " + "applies there; `benchpod clear-wifi` turns USB networking back on)", + why, tcp.addr) + return tcp + _log.info("usb: %s; using the USB console %s", why, console.device) + return console + tcp = TcpTransport(f"{ip}:{DEFAULT_PORT}", timeout=timeout) tcp.usb_console = console.device console.close() diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py b/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py index c8ee7a7..a0a886b 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py @@ -90,6 +90,9 @@ class TcpTransport(Transport): #: The USB console's device path when connection ``usb`` resolved to the pod's USB network #: adapter (firmware 3.9.0+) instead of the console; ``""`` for a network connection. usb_console: str = "" + #: Why connection ``usb`` went to the pod's Ethernet or Wi-Fi address instead (firmware 3.9.0+ + #: with its USB network off or down); ``""`` otherwise. + usb_lan_fallback: str = "" def __init__(self, addr: str, *, timeout: float = 30.0, dial_timeout: float = DEFAULT_DIAL_TIMEOUT) -> None: diff --git a/packages/embeddedci/tests/test_usb_net.py b/packages/embeddedci/tests/test_usb_net.py index de4b44f..7d9d454 100644 --- a/packages/embeddedci/tests/test_usb_net.py +++ b/packages/embeddedci/tests/test_usb_net.py @@ -20,6 +20,7 @@ STATUS = ( " device : benchpod\r\n" + " ip : 192.168.1.215\r\n" " board : stm32h563 fw v3.9.0 rev v3 nrst_pin=yes usb_cc=yes\r\n" " adc : 16-bit x1, 4096 mV full-scale\r\n" ) @@ -98,7 +99,8 @@ def make_console(device="", *, timeout=30.0, pod=""): def probe(host, port, timeout=transport_mod.USB_NET_PROBE_TIMEOUT): probes.append((host, port, timeout)) - return cfg["reachable"] + r = cfg["reachable"] + return host in r if isinstance(r, (set, frozenset)) else r monkeypatch.setattr(serial_mod, "SerialTransport", make_console) monkeypatch.setattr(transport_mod, "_tcp_reachable", probe) @@ -135,9 +137,31 @@ def test_serial_keyword_also_upgrades(usb): assert isinstance(open_transport(parse_connection("serial")), TcpTransport) -@pytest.mark.parametrize("line", [USB_WIFI, USB_DOWN, None]) -def test_usb_keeps_the_console_when_net_off_link_down_or_old_firmware(usb, line): +@pytest.mark.parametrize("line", [USB_WIFI, USB_DOWN]) +def test_usb_falls_back_to_the_pods_network_address_on_3_9(usb, line, caplog): + """3.9.0+ with USB networking off or down: its console has no la-voltage or power any more, + so the way in is the pod's Ethernet or Wi-Fi address from its status.""" usb["status"] = _status(line) + with caplog.at_level("WARNING", logger="embeddedci.benchpod"): + t = open_transport(parse_connection("usb")) + assert isinstance(t, TcpTransport) and t.addr == "192.168.1.215:8080" + assert t.usb_lan_fallback and not t.usb_console + assert FakeConsole.instances[0].closed + assert "192.168.1.215:8080" in caplog.text and "clear-wifi" in caplog.text + + +@pytest.mark.parametrize("line", [USB_WIFI, USB_DOWN]) +def test_usb_keeps_the_console_when_no_network_address_answers(usb, line): + usb["status"] = _status(line) + usb["reachable"] = False + t = open_transport(parse_connection("usb")) + assert isinstance(t, FakeConsole) and not t.closed + assert [p[0] for p in usb["probes"]] == ["192.168.1.215"] + + +def test_usb_keeps_the_console_on_firmware_before_3_9(usb): + """Older firmware has every command on its console: no network fallback, nothing probed.""" + usb["status"] = _status(None) t = open_transport(parse_connection("usb")) assert isinstance(t, FakeConsole) and not t.closed assert usb["probes"] == [] @@ -148,7 +172,14 @@ def test_usb_keeps_the_console_when_the_port_does_not_answer(usb): usb["reachable"] = False t = open_transport(parse_connection("usb")) assert isinstance(t, FakeConsole) and not t.closed - assert len(usb["probes"]) == 1 + assert [p[0] for p in usb["probes"]] == ["10.237.8.85", "192.168.1.215"] + + +def test_usb_network_not_answering_falls_back_to_the_lan(usb): + usb["status"] = _status(USB_ON) + usb["reachable"] = {"192.168.1.215"} + t = open_transport(parse_connection("usb")) + assert isinstance(t, TcpTransport) and t.addr == "192.168.1.215:8080" def test_usb_keeps_the_console_when_status_fails(usb): From 48250437e81c15e710b8061ebd41d79c3088278b Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 22:30:16 -0500 Subject: [PATCH 11/19] usb: ping before falling back to the pod's LAN address; clearer console error With the external LAN policy off the pod accepts a TCP connection and resets it, so the LAN fallback now checks a JSON ping answers, not just a connect. The 3.9.0 console error names clear-wifi and the external LAN policy. Co-Authored-By: Claude Opus 5.5 --- .../embeddedci/benchpod/transport/__init__.py | 20 +++++++++++++- .../embeddedci/benchpod/transport/serial.py | 8 +++--- packages/embeddedci/tests/test_usb_net.py | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py b/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py index 91e184d..d592fc8 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py @@ -114,6 +114,24 @@ def _tcp_reachable(host: str, port: int, timeout: float = USB_NET_PROBE_TIMEOUT) return False +def _api_answers(host: str, port: int, timeout: float = USB_NET_PROBE_TIMEOUT) -> bool: + """Whether the pod answers a JSON ``ping`` at ``host:port``. A TCP connect is not enough on the + pod's external LAN: with the LAN policy ``off`` it accepts the connection and resets it.""" + try: + with socket.create_connection((host, port), timeout=timeout) as s: + s.settimeout(timeout) + s.sendall(b'{"cmd":"ping"}\n') + buf = b"" + while b"\n" not in buf: + chunk = s.recv(256) + if not chunk: + return False + buf += chunk + return b'"pong"' in buf + except OSError: + return False + + def _open_usb(device: str, *, timeout: float, pod: str = "") -> Transport: """Open a USB pod: the console at ``device``, or for ``""`` (connection ``usb``) the auto-detected pod (``pod``, from ``usb:``, picks one of several by name), reached over @@ -149,7 +167,7 @@ def _open_usb(device: str, *, timeout: float, pod: str = "") -> Transport: # USB network the way in is the pod's Ethernet or Wi-Fi address when this computer reaches # it (the pod's external LAN policy applies there). lan = usb_lan_fallback_address(status) - if lan and _tcp_reachable(lan, DEFAULT_PORT): + if lan and _api_answers(lan, DEFAULT_PORT): tcp = TcpTransport(f"{lan}:{DEFAULT_PORT}", timeout=timeout) tcp.usb_lan_fallback = why console.close() diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py index ce45e47..3c115b5 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py @@ -677,9 +677,11 @@ def _text_ping(self, req: dict) -> str: def _moved_to_json(self, what: str) -> TransportError: """Firmware 3.9.0 moved the instrument commands off the console to the JSON API.""" return TransportError( - f"this pod's firmware (3.9.0 or later) has no {what} on its USB console: connect with " - f"'usb' (the pod's USB network adapter) or its network address instead of the console " - f"device {self.device or ''}".rstrip()) + f"this pod's firmware (3.9.0 or later) has no {what} on its USB console " + f"{self.device or ''}: it is a network command. Connect with 'usb', which uses the " + "pod's USB network adapter (Wi-Fi configured on the pod turns it off; `benchpod " + "clear-wifi` turns it back on) or else the pod's network address (refused while its " + "external LAN policy is off), or pass that address".replace(" ", " ")) def _text_la_voltage(self, req: dict) -> Dict[str, Any]: line = "la-voltage" + (f" {int(req['mv'])}" if req.get("mv") is not None else "") diff --git a/packages/embeddedci/tests/test_usb_net.py b/packages/embeddedci/tests/test_usb_net.py index 7d9d454..4d9dd7a 100644 --- a/packages/embeddedci/tests/test_usb_net.py +++ b/packages/embeddedci/tests/test_usb_net.py @@ -4,6 +4,8 @@ from __future__ import annotations +import socket + import pytest from embeddedci.benchpod import transport as transport_mod @@ -104,6 +106,7 @@ def probe(host, port, timeout=transport_mod.USB_NET_PROBE_TIMEOUT): monkeypatch.setattr(serial_mod, "SerialTransport", make_console) monkeypatch.setattr(transport_mod, "_tcp_reachable", probe) + monkeypatch.setattr(transport_mod, "_api_answers", probe) cfg["probes"] = probes return cfg @@ -224,3 +227,26 @@ def test_discovered_pod_prefers_the_usb_link(): assert lan.addr == "192.168.1.221:8080" and not lan.on_usb none = DiscoveredPod(name="BenchPod baea06", hostname="benchpod-baea06.local") assert none.addr == "benchpod-baea06.local:8080" + + +def test_api_answers_needs_a_pong(): + """The LAN fallback pings: a pod whose external LAN policy is off accepts and resets.""" + import threading + + def serve(reply): + srv = socket.socket() + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + + def run(): + c, _ = srv.accept() + c.recv(64) + if reply: + c.sendall(reply) + c.close() + srv.close() + threading.Thread(target=run, daemon=True).start() + return srv.getsockname()[1] + + assert transport_mod._api_answers("127.0.0.1", serve(b'{"status":"ok","data":"pong"}\n')) + assert not transport_mod._api_answers("127.0.0.1", serve(b"")) From e23fff9a4a872d812b96a7a78a5934dbca860072 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 22:36:54 -0500 Subject: [PATCH 12/19] embeddedci 2.12.0, embeddedci-mcp 2.12.0, embeddedci-openhtf 2.3.0; pin the USB SDK The MCP server's and the OpenHTF plug's docs now say 'usb' reaches the full API over the USB network adapter, which only embeddedci 2.12.0 does: both require it (were >=2.9 and >=2.4). The mcp-min-sdk CI job installs the minimum from PyPI; while that minimum is the version this repository is about to release, it tests the in-repo SDK instead. Release order: publish embeddedci before embeddedci-mcp and embeddedci-openhtf, then rebuild the benchpod-mcp plugin (uvx caches, .mcpb bundle). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 9 ++++++++- packages/embeddedci-mcp/CHANGELOG.md | 4 +++- packages/embeddedci-mcp/pyproject.toml | 4 ++-- packages/embeddedci-mcp/server.json | 4 ++-- packages/embeddedci-openhtf/CHANGELOG.md | 4 +++- packages/embeddedci-openhtf/pyproject.toml | 4 ++-- packages/embeddedci/CHANGELOG.md | 2 +- packages/embeddedci/pyproject.toml | 2 +- 8 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90ea22d..ceba8e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,14 @@ jobs: run: | MIN=$(python scripts/min_sdk_version.py packages/embeddedci-mcp/pyproject.toml) echo "minimum embeddedci: $MIN" - python -m pip install "embeddedci[cloud,discovery,analysis]==$MIN" + # A minimum this repository is about to release is not on PyPI yet: test the in-repo SDK, + # which is that version (the release publishes the SDK before the MCP server). + if ! python -m pip install "embeddedci[cloud,discovery,analysis]==$MIN"; then + LOCAL=$(python -c "import sys; sys.path.insert(0, 'scripts'); import check_release_tag as c; print(c.pyproject_version('embeddedci'))") + if [ "$LOCAL" != "$MIN" ]; then echo "embeddedci $MIN is neither on PyPI nor in this repository ($LOCAL)"; exit 1; fi + echo "embeddedci $MIN is not on PyPI yet: using the in-repo package" + python -m pip install -e "packages/embeddedci[cloud,discovery,analysis]" + fi python -m pip install -e "packages/embeddedci-mcp[dev]" python -c "import embeddedci, sys; v = embeddedci.__version__; print('testing against embeddedci', v, embeddedci.__file__); sys.exit(v != '$MIN')" - name: Run the MCP tests diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index bf5a15f..66b0b5e 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -1,7 +1,9 @@ # Changelog — `embeddedci-mcp` -## Unreleased +## 2.12.0 +- Needs `embeddedci` 2.12.0 or later (was 2.9): its `usb` connection is what reaches the USB + network adapter the instructions describe. - Connection `usb` reaches the full API over the pod's USB network adapter on firmware 3.9.0 and later (through the `embeddedci` SDK of the same release), unless Wi-Fi is configured on the pod; otherwise it falls back to the USB console as before. `connect`/`status` report the new kind diff --git a/packages/embeddedci-mcp/pyproject.toml b/packages/embeddedci-mcp/pyproject.toml index 4d767f3..d0300c3 100644 --- a/packages/embeddedci-mcp/pyproject.toml +++ b/packages/embeddedci-mcp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "embeddedci-mcp" -version = "2.11.0" +version = "2.12.0" description = "MCP server that lets AI agents drive an EmbeddedCI BenchPod hardware-in-the-loop bench" readme = "README.md" requires-python = ">=3.10" @@ -26,7 +26,7 @@ classifiers = [ dependencies = [ # cloud: the embeddedci: destination; discovery: connect("discover") over mDNS; # analysis: dominant frequency in capture summaries. - "embeddedci[cloud,discovery,analysis]>=2.9,<3", + "embeddedci[cloud,discovery,analysis]>=2.12.0,<3", "mcp>=1.12,<2", "anyio>=4", "pydantic>=2.7", diff --git a/packages/embeddedci-mcp/server.json b/packages/embeddedci-mcp/server.json index f6fdb97..8578aa9 100644 --- a/packages/embeddedci-mcp/server.json +++ b/packages/embeddedci-mcp/server.json @@ -3,7 +3,7 @@ "name": "io.github.embeddedci-com/embeddedci-mcp", "title": "EmbeddedCI BenchPod", "description": "Drive a BenchPod hardware-in-the-loop bench: power, flash, UART, I2C, CAN, analog and logic.", - "version": "2.11.0", + "version": "2.12.0", "websiteUrl": "https://www.embeddedci.com/docs/benchpod-mcp", "repository": { "url": "https://github.com/embeddedci-com/embeddedci-python", @@ -15,7 +15,7 @@ "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "embeddedci-mcp", - "version": "2.11.0", + "version": "2.12.0", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/packages/embeddedci-openhtf/CHANGELOG.md b/packages/embeddedci-openhtf/CHANGELOG.md index dfaf719..e7960cd 100644 --- a/packages/embeddedci-openhtf/CHANGELOG.md +++ b/packages/embeddedci-openhtf/CHANGELOG.md @@ -1,7 +1,9 @@ # Changelog — `embeddedci-openhtf` -## Unreleased +## 2.3.0 +- Needs `embeddedci` 2.12.0 or later (was 2.4), the SDK whose `usb` connection reaches the USB + network adapter these docs describe. - Docs: connection `usb` reaches the pod's USB network adapter (full API) on firmware 3.9.0 and later, through the `embeddedci` SDK of the same release; a serial device path stays the console. diff --git a/packages/embeddedci-openhtf/pyproject.toml b/packages/embeddedci-openhtf/pyproject.toml index c53024a..e9df329 100644 --- a/packages/embeddedci-openhtf/pyproject.toml +++ b/packages/embeddedci-openhtf/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "embeddedci-openhtf" -version = "2.2.0" +version = "2.3.0" description = "OpenHTF plug for driving an EmbeddedCI BenchPod directly over TCP or serial (no cloud)" readme = "README.md" requires-python = ">=3.10" @@ -23,7 +23,7 @@ classifiers = [ "Topic :: Software Development :: Testing", ] dependencies = [ - "embeddedci>=2.4,<3", + "embeddedci>=2.12.0,<3", "openhtf>=1.6", ] diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 12f2a82..3bedae6 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog — `embeddedci` -## Unreleased +## 2.12.0 - On firmware 3.9.0 and later the USB console no longer has `la-voltage` or `power` (they are JSON commands, reached over the USB network adapter): a serial device path then fails with an error diff --git a/packages/embeddedci/pyproject.toml b/packages/embeddedci/pyproject.toml index b766a83..a8a9e3b 100644 --- a/packages/embeddedci/pyproject.toml +++ b/packages/embeddedci/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "embeddedci" -version = "2.11.0" +version = "2.12.0" description = "BenchPod SDK and pytest plugin for EmbeddedCI hardware-in-the-loop testing" readme = "README.md" requires-python = ">=3.10" From 64f3a737a4fdd9203c46af4ee11b7de1dc7e854f Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 22:39:51 -0500 Subject: [PATCH 13/19] serial: drop the console JSON-mode probe and JSON-over-serial code The STM32 pod's console never had a json mode (that was the retired RP2350 firmware), yet every serial open could wait up to 3 s probing for it. JSON goes over the network, also over USB through the USB network adapter; the serial transport is now the text console only. Tests for the old mode go; the unplugged and silent console tests move to the text-console fixture, plus a test that no json switch is ever sent. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/CHANGELOG.md | 5 + packages/embeddedci/README.md | 4 +- .../embeddedci/benchpod/transport/serial.py | 201 ++---------------- packages/embeddedci/tests/test_serial_json.py | 179 ---------------- .../tests/test_serial_text_console.py | 31 +++ .../tests/test_transport_failures.py | 52 +---- 6 files changed, 61 insertions(+), 411 deletions(-) delete mode 100644 packages/embeddedci/tests/test_serial_json.py diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 3bedae6..ebe4809 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -6,6 +6,11 @@ commands, reached over the USB network adapter): a serial device path then fails with an error that says to connect with `usb` or the pod's network address (the console answers those with a "moved to the network API" line, which the SDK recognizes). +- The serial transport no longer probes the console for a `json` mode (up to 3 s per open) or + carries the JSON-over-serial code: the STM32 pod's console never had that mode (it was the retired + RP2350 firmware's). JSON goes over the network, also over USB through the USB network adapter. + `SerialTransport.json_supported` is always `False`; `samples` and `stream_chunks` over the + console raise a `TransportError` naming the network ways in. - README: a compatibility table (SDK version x firmware generation) and the Windows 10 note. - Connection `usb` on firmware 3.9.0+ whose USB networking is off (Wi-Fi configured) or down now uses the pod's Ethernet or Wi-Fi address from its console `status` when it answers on port 8080 diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index 685e003..6f2d5da 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -233,8 +233,8 @@ on an open console. shell for setup and diagnostics with no JSON mode, so over the console the SDK can only read `status()`, `ping()`, select the LA voltage and switch target power (without `delay`). Everything else (flashing, UART, analog, captures, I2C-sensor emulation, CAN) raises a `TransportError` right -away that points at the network connection. (On firmware whose console does have a JSON mode, the -SDK uses it automatically.) +away that points at the network connection. (On firmware 3.9.0 and later the LA voltage and target +power moved to the network too; see the compatibility table above.) | | TCP | USB network (`usb`, firmware 3.9.0+) | USB console (STM32 pod) | Cloud | |---|---|---|---|---| diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py index 3c115b5..75cad3d 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/serial.py @@ -11,7 +11,6 @@ from __future__ import annotations -import json import re import time from contextlib import contextmanager @@ -25,10 +24,8 @@ FirmwareError, TransportError, TransportTimeout, - firmware_error, ) -from ..protocol import encode_request, parse_reply, raise_for_status -from .base import RawLink, Transport, request_timeout +from .base import RawLink, Transport # USB vendor ids a bench-pod console enumerates under: 0x2E8A (Raspberry Pi, # the RP2350 pod), 0x0483 (STMicroelectronics, the STM32H563 pod up to firmware @@ -59,12 +56,11 @@ CTRL_RBRACKET = b"\x1d" # Ctrl-] — leaves the console UART proxy _CLEAR_LINE = b"\x08" * 128 # backspaces to clear any partial input line _RAW_READ_TIMEOUT = 0.1 # poll interval while bridging raw CMSIS-DAP bytes -JSON_PROBE_TIMEOUT = 3.0 # how long to wait for the console to answer the `json` mode switch UNKNOWN_COMMAND = "unknown command" #: Firmware 3.9.0 answers a console command it moved to the JSON API with this marker instead. MOVED_TO_NETWORK = "moved to the network API" -#: JSON commands the text console can answer itself when the firmware has no JSON mode on USB +#: JSON commands the text console answers through its text equivalents (it has no JSON mode) #: (the STM32 pod's console is a text shell: status, ping, la-voltage, power, diagnostics). TEXT_CONSOLE_COMMANDS = ("status", "ping", "la_voltage") @@ -405,10 +401,9 @@ def __init__(self, device: str = "", *, timeout: float = 30.0, # real device (and pyserial autodetect). ``pod`` picks the pod by name (its identity # short id) when ``device`` is auto-detected and several pods are plugged in. self.timeout = timeout - self._json_mode = False # console "json" mode active? - #: Whether the firmware's console has a JSON mode at all (None = not probed yet). The - #: STM32 pod's USB console does not: only the text commands work over USB there. - self.json_supported: Optional[bool] = None + #: The STM32 pod's USB console is a text shell: it never had a JSON mode (that was the + #: retired RP2350 firmware). JSON goes over the network, also over USB (firmware 3.9.0+). + self.json_supported = False if port is not None: self.device = device self._port = port @@ -450,67 +445,10 @@ def _read_until_prompt(self, deadline: float) -> str: def _send_command(self, line: str) -> str: with _port_errors(line.split()[0] if line else "console"): - self._ensure_text() deadline = time.monotonic() + self.timeout self._write_line(line) return self._read_until_prompt(deadline) - # -- console "json" mode (TCP-style JSON API over serial) --------------- - - def _read_json_reply(self, deadline: float): - """Read lines until a JSON object reply; skip echoed/debug log lines. - - In JSON mode the firmware interleaves ``[...]`` debug prints with the - JSON reply on the same stream, so anything not starting with ``{`` is - ignored. Yields each decoded ``Reply`` (caller decides when to stop). - """ - buf = bytearray() - while time.monotonic() < deadline: - chunk = self._port.read(256) - if chunk: - buf.extend(chunk) - while b"\n" in buf: - raw_line, _, rest = buf.partition(b"\n") - buf = bytearray(rest) - s = raw_line.decode("utf-8", errors="replace").strip() - if not s or not s.startswith("{"): - continue # echo, prompt, or [subsystem] debug line - yield parse_reply(s.encode("utf-8")) - raise TransportTimeout("timed out waiting for a JSON reply over serial") - - def _probe_json(self) -> bool: - """Ask the console to switch to JSON mode once and remember whether it can. - - A console without the mode answers ``unknown command 'json'`` straight away, so the probe - costs a round trip, not a timeout. - """ - if self.json_supported is not None: - return self.json_supported - self._write_line("json") - deadline = time.monotonic() + min(self.timeout, JSON_PROBE_TIMEOUT) - buf = bytearray() - supported = False - while time.monotonic() < deadline: - chunk = self._port.read(256) - if chunk: - buf.extend(chunk) - if UNKNOWN_COMMAND in buf.decode("utf-8", errors="replace"): - break - lines = buf.split(b"\n")[:-1] # complete lines only - replies = [ln.strip() for ln in lines if ln.strip().startswith(b"{")] - if replies: - raise_for_status(parse_reply(replies[0]), cmd="json") - supported = True - self._json_mode = True - break - if not supported: - try: - self._port.reset_input_buffer() - except Exception: - pass - self.json_supported = supported - return supported - @staticmethod def _unsupported(what: str) -> str: return (f"{what!r} is not available over this pod's USB console, which only answers " @@ -519,127 +457,21 @@ def _unsupported(what: str) -> str: "later, connection 'usb' reaches the full API over the pod's USB network adapter " "(off while Wi-Fi is configured on the pod)") - def _enter_json(self, cmd: Any = "json") -> None: - if not self._probe_json(): - raise TransportError(self._unsupported(cmd)) - if self._json_mode: - return - deadline = time.monotonic() + self.timeout - self._write_line("json") - for reply in self._read_json_reply(deadline): - raise_for_status(reply, cmd="json") - self._json_mode = True - return - - def _exit_json(self) -> None: - if not self._json_mode: - return - deadline = time.monotonic() + self.timeout - self._port.write(b'{"cmd":"json_exit"}\n') - self._port.flush() - try: - for _reply in self._read_json_reply(deadline): - break # got the exit ack - except TransportError: - pass - self._json_mode = False - try: - self._port.reset_input_buffer() - except Exception: - pass - - def _ensure_text(self) -> None: - """Leave JSON mode so a text console command can run.""" - self._exit_json() - def command(self, req: dict) -> Any: - """Send one JSON command over the console's JSON mode and return its data. - - When the firmware has no JSON mode on USB, the commands in :data:`TEXT_CONSOLE_COMMANDS` - are answered through their text-console equivalents; any other command raises a - :class:`TransportError` naming the network/cloud alternative. - """ + """Answer a JSON command through its text-console equivalent: the commands in + :data:`TEXT_CONSOLE_COMMANDS`. Any other command raises a :class:`TransportError` naming + the network, USB network and cloud alternatives.""" cmd = req.get("cmd") with _port_errors(str(cmd)): - if not self._probe_json(): - if cmd not in TEXT_CONSOLE_COMMANDS: - raise TransportError(self._unsupported(cmd)) - return getattr(self, f"_text_{cmd}")(req) - self._enter_json(cmd) - deadline = time.monotonic() + request_timeout(req, self.timeout) - self._send_json(req) - for reply in self._read_json_reply(deadline): - raise_for_status(reply, cmd=req.get("cmd")) - return reply.data - raise TransportError("no JSON reply over serial") - - def _send_json(self, req: dict) -> None: - """Write one JSON request, dropping whatever is still unread first: a reply to an earlier - request that timed out would otherwise be taken as this one's.""" - try: - self._port.reset_input_buffer() - except OSError: - raise - except Exception: - pass - self._port.write(encode_request(req)) - self._port.flush() + if cmd not in TEXT_CONSOLE_COMMANDS: + raise TransportError(self._unsupported(cmd)) + return getattr(self, f"_text_{cmd}")(req) def samples(self, req: dict) -> List[int]: - """Send a command whose reply is a chunked sample array (json mode).""" - with _port_errors(str(req.get("cmd"))): - self._enter_json(req.get("cmd")) - deadline = time.monotonic() + request_timeout(req, self.timeout) - self._send_json(req) - out: List[int] = [] - for reply in self._read_json_reply(deadline): - raise_for_status(reply, cmd=req.get("cmd")) - if isinstance(reply.data, list): - out.extend(reply.data) - if not reply.more: - return out - raise TransportError("incomplete chunked JSON reply over serial") - - def _read_json_objects(self, idle_timeout: float) -> Iterator[Dict[str, Any]]: - """Yield each JSON object line; the timeout restarts whenever bytes arrive, so a long - streamed capture is bounded by silence rather than by its total length.""" - buf = bytearray() - deadline = time.monotonic() + idle_timeout - while time.monotonic() < deadline: - chunk = self._port.read(256) - if chunk: - buf.extend(chunk) - deadline = time.monotonic() + idle_timeout - while b"\n" in buf: - raw_line, _, rest = buf.partition(b"\n") - buf = bytearray(rest) - s = raw_line.decode("utf-8", errors="replace").strip() - if not s.startswith("{"): - continue # echo, prompt, or [subsystem] debug line - try: - obj = json.loads(s) - except json.JSONDecodeError: - continue - if isinstance(obj, dict): - yield obj - raise TransportTimeout("timed out waiting for a streamed JSON reply over serial") + raise TransportError(self._unsupported(req.get("cmd"))) def stream_chunks(self, req: dict) -> Iterator[Dict[str, Any]]: - """Send a streaming command and yield each raw chunk object until ``more`` is false. - - The serial counterpart of :meth:`TcpTransport.stream_chunks`: callers see every chunk's - extra fields (achieved rates, the RLE LA frames), which :meth:`samples` flattens away. - """ - cmd = req.get("cmd") - with _port_errors(str(cmd)): - self._enter_json(cmd) - self._send_json(req) - for obj in self._read_json_objects(request_timeout(req, self.timeout)): - if obj.get("status") == "error": - raise firmware_error(obj.get("message") or "streamed command failed", cmd=cmd) - yield obj - if not bool(obj.get("more", False)): - return + raise TransportError(self._unsupported(req.get("cmd"))) @staticmethod def _clean(raw: str, cmd: str) -> str: @@ -663,7 +495,7 @@ def identity(self) -> str: or ``""`` when the pod has none or its firmware has no ``identity`` command.""" return parse_identity(self._send_command("identity")) - # -- text-console equivalents (firmware without a JSON mode on USB) ------ + # -- text-console equivalents --------------------------------------------- def _text_status(self, req: dict) -> Dict[str, Any]: return parse_text_status(self._send_command("status")) @@ -722,7 +554,6 @@ def _console_raw_handshake( return self._console_raw_handshake_inner(cmd, ready, quit_byte) def _console_raw_handshake_inner(self, cmd: str, ready: str, quit_byte: bytes) -> RawLink: - self._ensure_text() # Recover from a previous raw session that didn't cleanly exit (e.g. a # stalled flash whose Q got dropped on a flaky link): send the quit byte # so the pod leaves raw mode, then this command is parsed by the console @@ -786,10 +617,6 @@ def uart_proxy_start(self, rx: int, tx: int, baud: int) -> RawLink: ) def close(self) -> None: - try: - self._exit_json() - except Exception: - pass try: self._port.close() except Exception: diff --git a/packages/embeddedci/tests/test_serial_json.py b/packages/embeddedci/tests/test_serial_json.py deleted file mode 100644 index 914ae6a..0000000 --- a/packages/embeddedci/tests/test_serial_json.py +++ /dev/null @@ -1,179 +0,0 @@ -"""SerialTransport JSON-mode plumbing, against a fake console port (no hardware). - -The fake emulates the firmware's serial console: text commands echo + prompt; -`json` enters JSON mode where lines are dispatched as JSON and replies (plus a -`[debug]` line, to prove it's skipped) come back; `{"cmd":"json_exit"}` leaves. -""" - -import json - -import pytest - -from embeddedci.benchpod.errors import FirmwareError -from embeddedci.benchpod.transport.serial import SerialTransport - - -class FakeConsolePort: - def __init__(self): - self._in = bytearray() # bytes the transport will read - self._line = bytearray() # accumulates bytes written by the transport - self.json_mode = False - self.timeout = 0.25 - - # pyserial-like surface ------------------------------------------------- - def write(self, data: bytes): - for b in bytes(data): - if b in (0x0A, 0x0D): # newline -> process a line - self._process(bytes(self._line)) - self._line.clear() - elif b == 0x08: # backspace (clear-line prefix) -> ignore - pass - else: - self._line.append(b) - return len(data) - - def flush(self): - pass - - def reset_input_buffer(self): - self._in.clear() - - @property - def in_waiting(self): - return len(self._in) - - def read(self, n=1): - out = bytes(self._in[:n]) - del self._in[:n] - return out - - def close(self): - pass - - # firmware emulation ---------------------------------------------------- - def _emit(self, s: str): - self._in.extend(s.encode()) - - def _process(self, line: bytes): - s = line.decode("utf-8", "replace").strip() - if not self.json_mode: - self._emit(s + "\r\n") # echo the text command - if s == "json": - self.json_mode = True - self._emit('{"status":"ok","data":"json mode"}\n') - elif s == "status": - self._emit("firmware : 0.2.0\r\n> ") - elif s.startswith("power "): - self._emit(" eFuse1 ON\r\n> ") - else: - self._emit("> ") - return - # JSON mode: dispatch the object. - if not s.startswith("{"): - self._emit('{"status":"error","message":"json mode"}\n') - return - req = json.loads(s) - cmd = req.get("cmd") - self._emit(f"[cmd] <- {cmd}\n") # interleaved debug — must be skipped - if cmd == "json_exit": - self.json_mode = False - self._emit('{"status":"ok","data":"json mode exited"}\n> ') - elif cmd == "ping": - self._emit('{"status":"ok","data":"pong"}\n') - elif cmd == "status": - self._emit('{"status":"ok","data":{"version":"2.0.0","board":"stm32h563"}}\n') - elif cmd == "la": - la = req["la"] - on = 1 if req.get("pullup", "on") in ("on", 1, "1") else 0 - self._emit('{"status":"ok","data":{"la":%d,"pullup":%d,"ohms":"4.7k"}}\n' - % (la, on)) - elif cmd == "sensor_start": - self._emit('{"status":"ok","data":{"type":"bmp280","addr":118}}\n') - elif cmd == "sensor_regs": - self._emit('{"status":"ok","data":[88,0],"more":true}\n') - self._emit('{"status":"chunk","data":[1],"more":false}\n') - else: - self._emit('{"status":"error","message":"unknown cmd"}\n') - - -def _transport(): - return SerialTransport(port=FakeConsolePort(), timeout=2) - - -def test_command_enters_json_and_skips_debug(): - t = _transport() - assert t.command({"cmd": "ping"}) == "pong" - assert t._json_mode is True - - -def test_samples_chunked_over_json(): - t = _transport() - assert t.samples({"cmd": "sensor_regs", "start": "0xD0", "len": 3}) == [88, 0, 1] - - -def test_sensor_start_request_roundtrip(): - t = _transport() - data = t.command({"cmd": "sensor_start", "type": "bmp280", "sda": 7, "scl": 8}) - assert data["addr"] == 118 - - -def test_text_op_auto_exits_json_mode(): - t = _transport() - t.command({"cmd": "ping"}) # enter json mode - assert t._json_mode is True - t.target_power(1, True) # text op must drop back to console mode - assert t._json_mode is False - - -def test_error_reply_raises(): - t = _transport() - with pytest.raises(FirmwareError): - t.command({"cmd": "bogus"}) - - -def test_pullup_via_client_over_serial(): - from embeddedci.benchpod.client import BenchPod - - bp = BenchPod(transport=_transport(), la_voltage=None) - state = bp.set_pull(1, True) - assert state.la == 1 and state.enabled and state.ohms == "4.7k" and state.direction == "up" - bp.enable_pullup(1, 2) # no error - with pytest.raises(ValueError): - bp.pull_state(9) # LA9 has no bias resistor - - -def test_status_is_a_dict_over_serial(): - t = _transport() - assert isinstance(t.status(), dict) - - -def test_a_stale_reply_is_dropped_before_the_next_request(): - t = _transport() - t.command({"cmd": "ping"}) # in JSON mode now - # A reply to an earlier request that timed out, still in the input buffer. - t._port._emit('{"status":"ok","data":"stale"}\n') - assert t.command({"cmd": "ping"}) == "pong" - - -def test_an_unplugged_port_is_a_transport_error(): - from embeddedci.benchpod.errors import ConnectionClosedError - - t = _transport() - t.command({"cmd": "ping"}) - - def gone(n=1): - raise OSError(6, "Device not configured") - - t._port.read = gone - with pytest.raises(ConnectionClosedError, match="USB serial link failed"): - t.command({"cmd": "ping"}) - - -def test_a_silent_console_is_a_timeout(): - from embeddedci.benchpod.errors import TransportTimeout - - t = SerialTransport(port=FakeConsolePort(), timeout=0.2) - t.command({"cmd": "ping"}) - t._port._process = lambda line: None # the pod stops answering - with pytest.raises(TransportTimeout): - t.command({"cmd": "ping"}) diff --git a/packages/embeddedci/tests/test_serial_text_console.py b/packages/embeddedci/tests/test_serial_text_console.py index 5a8a8d3..6dd1216 100644 --- a/packages/embeddedci/tests/test_serial_text_console.py +++ b/packages/embeddedci/tests/test_serial_text_console.py @@ -162,3 +162,34 @@ def test_trimmed_console_points_at_the_usb_network(): bp.get_la_voltage() with pytest.raises(TransportError, match="USB network"): bp.transport.target_power(1, True) + + +def test_no_json_mode_probe(): + """The console is a text shell: opening it never sends the old `json` mode switch.""" + t = _transport() + t.status() + t.ping() + assert "json" not in t._port.lines + assert t.json_supported is False + + +def test_an_unplugged_port_is_a_transport_error(): + from embeddedci.benchpod.errors import ConnectionClosedError + + t = _transport() + + def gone(n=1): + raise OSError(6, "Device not configured") + + t._port.read = gone + with pytest.raises(ConnectionClosedError, match="USB serial link failed"): + t.ping() + + +def test_a_silent_console_is_a_timeout(): + from embeddedci.benchpod.errors import TransportTimeout + + t = SerialTransport(port=TextConsolePort(), timeout=0.2) + t._port._process = lambda line: None # the pod stops answering + with pytest.raises(TransportTimeout): + t.ping() diff --git a/packages/embeddedci/tests/test_transport_failures.py b/packages/embeddedci/tests/test_transport_failures.py index 6104fdc..a650a8c 100644 --- a/packages/embeddedci/tests/test_transport_failures.py +++ b/packages/embeddedci/tests/test_transport_failures.py @@ -386,39 +386,17 @@ def test_a_text_frame_from_the_tunnel_is_treated_as_bytes(monkeypatch): assert sock.recv(4096) == b'{"status":"ok","data":"pong"}\n' -# -- serial: stale replies before every kind of request ---------------------------------------- +# -- serial: the console is a text shell (no JSON mode) ----------------------------------------- -def _serial(): - from test_serial_json import FakeConsolePort +def test_streamed_commands_over_the_console_name_the_network_way(): + from test_serial_text_console import TextConsolePort - t = SerialTransport(port=FakeConsolePort(), timeout=1) - t.command({"cmd": "ping"}) # enter JSON mode - return t - - -def test_a_stale_error_reply_does_not_fail_the_next_request(): - t = _serial() - t._port._emit('{"status":"error","message":"busy: capture running"}\n') - assert t.command({"cmd": "ping"}) == "pong" - - -def test_several_stale_lines_and_a_half_line_are_all_dropped(): - t = _serial() - t._port._emit('{"status":"ok","data":"old1"}\n[cmd] <- x\n{"status":"ok","data":"old2"}\n{"sta') - assert t.command({"cmd": "status"})["version"] == "2.0.0" - - -def test_a_stale_chunk_is_dropped_before_a_sample_stream(): - t = _serial() - t._port._emit('{"status":"ok","data":[9,9,9],"more":true}\n') - assert t.samples({"cmd": "sensor_regs"}) == [88, 0, 1] - - -def test_a_stale_chunk_is_dropped_before_a_streamed_command(): - t = _serial() - t._port._emit('{"status":"ok","data":[7],"more":false}\n') - chunks = list(t.stream_chunks({"cmd": "sensor_regs"})) - assert [c["data"] for c in chunks] == [[88, 0], [1]] + t = SerialTransport(port=TextConsolePort(), timeout=1) + with pytest.raises(TransportError, match="network"): + t.samples({"cmd": "sensor_regs"}) + with pytest.raises(TransportError, match="usb"): + list(t.stream_chunks({"cmd": "power_profile", "duration_ms": 1000})) + assert t._port.lines == [] # nothing was sent to the pod # -- a long blocking measure_power ---------------------------------------------------------------- @@ -454,15 +432,3 @@ def test_measure_power_over_the_cloud_tunnel_waits_for_its_duration(monkeypatch) monkeypatch.setattr(t, "_open_tunnel", lambda *_a: sock) list(t.stream_chunks({"cmd": "power_profile", "efuse": 1, "duration_ms": 600_000})) assert ws.timeout == pytest.approx(630) - - -def test_measure_power_over_serial_waits_for_its_duration(monkeypatch): - from embeddedci.benchpod.transport import base, serial - - t = _serial() - waited = [] - real = base.request_timeout - monkeypatch.setattr(serial, "request_timeout", lambda req, b: waited.append(real(req, b)) or 0.5) - t._port._process = lambda line: t._port._emit('{"status":"ok","data":{"stats":{}},"more":false}\n') - list(t.stream_chunks({"cmd": "power_profile", "duration_ms": 120_000})) - assert waited == [pytest.approx(121)] From 3a65b31122689fa5cd05ff014917a9a5833cde5f Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 23:09:25 -0500 Subject: [PATCH 14/19] e2e usb tier: firmware 3.9.0's console has no LA voltage or power The console tier set the LA voltage over the console at open; firmware 3.9.0 moved that to the JSON API, so every test errored. It now detects 3.9.0 from the console status's usb line, expects the network-command error for LA voltage and power there, and runs the old checks on older firmware. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/tests/e2e/test_e2e_usb.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/embeddedci/tests/e2e/test_e2e_usb.py b/packages/embeddedci/tests/e2e/test_e2e_usb.py index 823abaf..42c43d8 100644 --- a/packages/embeddedci/tests/e2e/test_e2e_usb.py +++ b/packages/embeddedci/tests/e2e/test_e2e_usb.py @@ -2,8 +2,9 @@ BENCHPOD_E2E_USB=/dev/cu.usbmodem… pytest packages/embeddedci/tests/e2e/test_e2e_usb.py -On the STM32 pod the USB console is a text shell: status, ping, LA voltage and power work; every -other operation must fail at once with a hint to use the network. +On the STM32 pod the USB console is a text shell: status and ping work everywhere; LA voltage and +power work on firmware before 3.9.0 and, from 3.9.0 (which moved them to the JSON API), fail with +the hint to use the network; every other operation fails at once with that hint too. """ from __future__ import annotations @@ -24,13 +25,20 @@ def usb(benchpod_la_voltage): device = os.environ.get("BENCHPOD_E2E_USB") if not device: pytest.skip("set BENCHPOD_E2E_USB to the pod's USB console (a device path or 'usb')") - with BenchPod(device, la_voltage=benchpod_la_voltage, timeout=10) as pod: + with BenchPod(device, la_voltage=None, timeout=10) as pod: if type(pod.transport).__name__ != "SerialTransport": pytest.skip("'usb' reached the pod's USB network adapter (firmware 3.9.0+), not the " "console; set BENCHPOD_E2E_USB to the console's device path") + if not _moved(pod): + pod.set_la_voltage(benchpod_la_voltage) yield pod +def _moved(pod) -> bool: + """Firmware 3.9.0+ (its console status has a usb line): LA voltage and power are JSON-only.""" + return isinstance(pod.status().get("usb"), dict) + + def test_status_is_a_dict(usb): status = usb.status() assert status["device"] == "benchpod" and status["board"] and status["version"] @@ -39,11 +47,15 @@ def test_status_is_a_dict(usb): def test_ping_and_la_voltage(usb): assert usb.ping() + if _moved(usb): + with pytest.raises(TransportError, match="network command"): + usb.get_la_voltage() + return assert usb.get_la_voltage().voltage == 3.3 def test_1v8_on_a_v2_board(usb): - if usb.status().get("board_rev") != "v2": + if _moved(usb) or usb.status().get("board_rev") != "v2": pytest.skip("rev3 pods accept 1.8 V") try: with pytest.raises(FirmwareError, match="v3"): @@ -53,6 +65,10 @@ def test_1v8_on_a_v2_board(usb): def test_power_off(usb): + if _moved(usb): + with pytest.raises(TransportError, match="network command"): + usb.power_off(1) + return usb.power_off(1) with pytest.raises(TransportError, match="delayed"): usb.power_on(1, delay=0.5) From 375ca53eff9c8c9134601a7f68824f34b3d4c7f3 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 23:24:05 -0500 Subject: [PATCH 15/19] docs: two pods on one computer are not tested on hardware yet Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index 6f2d5da..1ee2297 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -225,7 +225,7 @@ console `status`), never from its own version: with that name and raises `ConnectionConfigError` listing the pods it found when none matches. Plain `usb` with two pods plugged in raises `ConnectionConfigError` naming both, for example `2 pods on USB (benchpod-aaaaaa on /dev/cu.usbmodemA, benchpod-bbbbbb on /dev/cu.usbmodemB)`; -with one pod it works as before. macOS lists each pod twice (`/dev/cu.*` and `/dev/tty.*`); the +with one pod it works as before. Two pods on one computer have not been tested on hardware yet. macOS lists each pod twice (`/dev/cu.*` and `/dev/tty.*`); the same name on two ports counts as one pod. `SerialTransport.identity()` reads the name of the pod on an open console. From 9a4349a861df409449d535a49384ce4206b16a4e Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 23:30:03 -0500 Subject: [PATCH 16/19] Refusal of a CA/proxy change from the LAN says "or over USB" Firmware 3.9.0 takes cloud_ca and cloud_proxy changes over the USB network adapter as well as the console, so the refusal on Ethernet/Wi-Fi now reads ": change it from the cloud or over USB". sig_policy and lan_policy keep "... or the USB console". Host tools accept both texts (older firmware). Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/src/embeddedci/benchpod/errors.py | 1 + packages/embeddedci/tests/test_refusal_errors.py | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/embeddedci/src/embeddedci/benchpod/errors.py b/packages/embeddedci/src/embeddedci/benchpod/errors.py index 62f9ffd..63b5460 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/errors.py +++ b/packages/embeddedci/src/embeddedci/benchpod/errors.py @@ -222,6 +222,7 @@ def __init__(self, message: str, *, cmd: Optional[str] = None, _LEASED = re.compile(r"busy: a cloud job holds this pod \((.*), (\d+) s left\)") #: Policy changes the LAN may not make (pod_policy.c). _POLICY_ELSEWHERE = ("change it from the cloud or the USB console", + "change it from the cloud or over USB", "only the USB console can loosen") #: Older firmware's answer to a command it does not have (command_handler.c). _UNKNOWN_CMD = "unknown cmd" diff --git a/packages/embeddedci/tests/test_refusal_errors.py b/packages/embeddedci/tests/test_refusal_errors.py index 8e5f68c..ad08a44 100644 --- a/packages/embeddedci/tests/test_refusal_errors.py +++ b/packages/embeddedci/tests/test_refusal_errors.py @@ -33,6 +33,7 @@ "sig_policy: change it from the cloud or the USB console", "lan_policy: change it from the cloud or the USB console", "cloud_ca: change it from the cloud or the USB console", + "cloud_proxy: change it from the cloud or over USB", "sig_policy: only the USB console can loosen the policy (now required)", ]) def test_locked(text): From 42802db843d0ac10ad1d946ecc412740e4f472a0 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 23:33:23 -0500 Subject: [PATCH 17/19] Locked-LAN refusal says "needs the cloud or USB" Under external LAN policy locked, firmware 3.9.0 still takes T2/T3 commands over its USB network adapter, so the refusal on Ethernet/Wi-Fi now reads "locked: needs the cloud or USB". Host tools match the "locked:" prefix; hwe2e accepts both texts (older firmware). Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/src/embeddedci/benchpod/errors.py | 5 +++-- packages/embeddedci/tests/test_refusal_errors.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/embeddedci/src/embeddedci/benchpod/errors.py b/packages/embeddedci/src/embeddedci/benchpod/errors.py index 63b5460..2f9ab1d 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/errors.py +++ b/packages/embeddedci/src/embeddedci/benchpod/errors.py @@ -169,8 +169,9 @@ def __init__(self, message: str, *, cmd: Optional[str] = None, la: int = 0, class PodLockedError(FirmwareError): """The pod refused a command on its LAN connection: its LAN policy is ``locked`` (or the - command changes a policy), so the command needs the cloud (``embeddedci:``) or the - USB console. Firmware text: ``locked: needs the cloud or the USB console``.""" + command changes a policy), so the command needs the cloud (``embeddedci:``) or USB. + Firmware text: ``locked: needs the cloud or USB`` (before 3.9.0: ``... or the USB + console``).""" class PodBusyError(FirmwareError): diff --git a/packages/embeddedci/tests/test_refusal_errors.py b/packages/embeddedci/tests/test_refusal_errors.py index ad08a44..3ccb354 100644 --- a/packages/embeddedci/tests/test_refusal_errors.py +++ b/packages/embeddedci/tests/test_refusal_errors.py @@ -30,6 +30,7 @@ @pytest.mark.parametrize("text", [ "locked: generate needs the cloud or the USB console", + "locked: generate needs the cloud or USB", "sig_policy: change it from the cloud or the USB console", "lan_policy: change it from the cloud or the USB console", "cloud_ca: change it from the cloud or the USB console", From db35d17c0ec3cbc74bf0eaee63b7ed86c89b7456 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 13 Oct 2026 08:01:59 -0500 Subject: [PATCH 18/19] e2e: LA host-clock test fires its events inside the acquisition 25 events at 0.2 s ran past the 4 s capture; the late ones queued behind the read-back and went out back to back, and over the USB network one arrived while the previous 1 ms step train still held the pin (pin conflict, 5/5 over USB, never over Ethernet). 18 events end at ~3.8 s, as the ADC sibling test already does. Now 3/3 over USB, 2/2 over Ethernet. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/tests/e2e/test_e2e_pod.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/embeddedci/tests/e2e/test_e2e_pod.py b/packages/embeddedci/tests/e2e/test_e2e_pod.py index 54e0abc..06fe366 100644 --- a/packages/embeddedci/tests/e2e/test_e2e_pod.py +++ b/packages/embeddedci/tests/e2e/test_e2e_pod.py @@ -634,9 +634,13 @@ def test_la_reports_the_rate_it_achieved(pod, bench, samples, rate_hz): def test_la_sample_rate_matches_the_host_clock(pod, bench): np = pytest.importorskip("numpy") ch = bench.free_la[0] + # Every event must land inside the 4 s acquisition: one sent after it queues behind the + # read-back, and the queued ones then go out back to back, where a fast link (the USB + # network) can send a step while the previous 1 ms train still holds the pin. 18 events + # end at ~3.8 s. la, stamps = events_during(lambda: pod.capture_la(4_000_000, sample_rate_hz=1_000_000), lambda k: pod.la_step(ch, steps=1, delay=0.001), - count=25, interval=0.2) + count=18, interval=0.2) bits = np.asarray(la.channel(ch), dtype=np.int8) rate = host_clock_rate(np.flatnonzero(np.diff(bits) == 1) + 1, stamps) assert rate == pytest.approx(la.sample_rate_hz, rel=0.005), ( From 189ae5e05c40a995cd4f67c1a3c616970d5f3f6e Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 13 Oct 2026 08:46:51 -0500 Subject: [PATCH 19/19] usb: find the pod from this computer's USB link before the console On Linux the console (/dev/ttyACM0, root:dialout 0660) is closed to users outside the dialout group, so 'usb' never reached the USB network adapter there. The SDK now reads this computer's interfaces (getifaddrs): one /30 in 10.237.0.0/16 names the pod's address with no console; usb: picks among several links by identity_public. TcpTransport.usb_net marks a USB network transport; MCP reports usb_net for it. Found in the 3.9.0 Linux e2e run. Co-Authored-By: Claude Opus 5.5 --- .../src/embeddedci_mcp/session.py | 2 +- packages/embeddedci-mcp/tests/test_tools.py | 6 + packages/embeddedci/README.md | 13 ++- .../embeddedci/benchpod/transport/__init__.py | 18 ++- .../src/embeddedci/benchpod/transport/tcp.py | 7 +- .../embeddedci/benchpod/transport/usblink.py | 107 ++++++++++++++++++ packages/embeddedci/tests/test_usb_net.py | 42 +++++++ 7 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 packages/embeddedci/src/embeddedci/benchpod/transport/usblink.py diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py index da3e0fc..b4f8b4c 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py @@ -64,7 +64,7 @@ def transport_kind(connection: str, pod: Any) -> str: console.""" kind = connection_kind(connection) transport = getattr(pod, "transport", None) - if kind == "serial" and getattr(transport, "usb_console", ""): + if kind == "serial" and (getattr(transport, "usb_net", False) is True or getattr(transport, "usb_console", "")): return "usb_net" if kind == "serial" and getattr(transport, "usb_lan_fallback", ""): return "tcp" # USB networking off or down: the pod's Ethernet or Wi-Fi address diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index 4cd7f62..9456e7c 100644 --- a/packages/embeddedci-mcp/tests/test_tools.py +++ b/packages/embeddedci-mcp/tests/test_tools.py @@ -133,6 +133,12 @@ def test_connect_usb_over_the_usb_network_reports_usb_net(fake_open, fake_transp assert transport_kind("192.168.1.50", console) == "tcp" +def test_connect_usb_over_the_local_usb_link_reports_usb_net(fake_open, fake_transport, monkeypatch): + # 'usb' that found the pod from this computer's end of its USB link: no console was opened. + monkeypatch.setattr(fake_transport, "usb_net", True, raising=False) + assert call("connect", connection="usb", la_voltage=3.3)["kind"] == "usb_net" + + def test_connect_usb_name_reports_usb_net(fake_open, fake_transport, monkeypatch): # usb: picks one of several pods on USB; it upgrades to the USB network like 'usb'. monkeypatch.setattr(fake_transport, "usb_console", "/dev/cu.usbmodemPOD", raising=False) diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index 1ee2297..69cab0f 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -198,11 +198,14 @@ explicitly. The pytest plugin does **not** fall back to it (see [pytest plugin]( (USB id `1209:0001`; older firmware uses `0483:5740`): the console plus a USB network adapter (CDC-NCM) that carries the same JSON API as the LAN. The link is a point-to-point `/30` in `10.237.0.0/16` (the pod is the first address, your computer the second, handed out by the pod's -DHCP server). With connection `usb` the SDK finds the pod's console, reads the pod's address on -that link from its `status`, and when the link is up and port 8080 answers within 1.5 s it talks -to `:8080` over TCP: everything works as on the LAN. The transport is then a -`TcpTransport` whose `usb_console` attribute names the console's device path -(`bp.transport.usb_console`). The SDK falls back to the console, as on older firmware, when the +DHCP server). With connection `usb` the SDK first looks at this computer's own interfaces (Linux +and macOS): one address in `10.237.0.0/16` with a `/30` mask names the pod's address without +opening the console, so it works for a Linux user outside the `dialout` group (with several such +links, `usb:` picks the pod by its identity). Otherwise it finds the pod's console and reads +the pod's address on that link from its `status`. When the link is up and port 8080 answers within +1.5 s it talks to `:8080` over TCP: everything works as on the LAN. The transport is +then a `TcpTransport` with `usb_net` set, and `usb_console` naming the console's device path when +the console was used (`bp.transport.usb_console`). The SDK falls back to the console, as on older firmware, when the pod's firmware is older than 3.9.0, when Wi-Fi is configured on the pod (that turns USB networking off), when the link is down, or when the computer has not configured the adapter (for example macOS under a locked screen); it logs which one it picked. An explicit device path such as diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py b/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py index d592fc8..24d1816 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/__init__.py @@ -142,7 +142,22 @@ def _open_usb(device: str, *, timeout: float, pod: str = "") -> Transport: :data:`USB_NET_PROBE_TIMEOUT`. Anything else (firmware before 3.9.0, Wi-Fi configured on the pod, link down, the host has not configured the interface) keeps the console. """ - from .serial import SerialTransport + from .serial import SerialTransport, normalize_pod_name + + if not device: + # This computer's own end of the pod's USB network link names the pod's address with no + # console: a Linux user outside the dialout group cannot open the console device. + from .usblink import local_usb_links, pick_usb_pod + + links = local_usb_links() + addr = pick_usb_pod(links, normalize_pod_name(pod), DEFAULT_PORT, USB_NET_PROBE_TIMEOUT) if links else None + if addr and _tcp_reachable(addr, DEFAULT_PORT): + from .tcp import TcpTransport + + tcp = TcpTransport(f"{addr}:{DEFAULT_PORT}", timeout=timeout) + tcp.usb_net = True + _log.info("usb: using the pod's USB network at %s (this computer's USB link)", tcp.addr) + return tcp console = SerialTransport(device, timeout=timeout, pod=pod) if device: @@ -179,6 +194,7 @@ def _open_usb(device: str, *, timeout: float, pod: str = "") -> Transport: return console tcp = TcpTransport(f"{ip}:{DEFAULT_PORT}", timeout=timeout) + tcp.usb_net = True tcp.usb_console = console.device console.close() _log.info("usb: using the pod's USB network at %s (console %s)", tcp.addr, console.device) diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py b/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py index a0a886b..56fbe4e 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/tcp.py @@ -87,8 +87,11 @@ def close(self) -> None: class TcpTransport(Transport): """Talks to the pod over JSON/TCP.""" - #: The USB console's device path when connection ``usb`` resolved to the pod's USB network - #: adapter (firmware 3.9.0+) instead of the console; ``""`` for a network connection. + #: Whether connection ``usb`` resolved to the pod's USB network adapter (firmware 3.9.0+). + usb_net: bool = False + #: The USB console's device path when connection ``usb`` found the USB network through the + #: console; ``""`` for a network connection, or when this computer's own end of the USB link + #: named the pod's address and the console was never opened. usb_console: str = "" #: Why connection ``usb`` went to the pod's Ethernet or Wi-Fi address instead (firmware 3.9.0+ #: with its USB network off or down); ``""`` otherwise. diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/usblink.py b/packages/embeddedci/src/embeddedci/benchpod/transport/usblink.py new file mode 100644 index 0000000..4676c12 --- /dev/null +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/usblink.py @@ -0,0 +1,107 @@ +"""This computer's ends of BenchPod USB network links (firmware 3.9.0+), found without the console. + +The pod's USB network adapter puts this computer on a ``/30`` in ``10.237.0.0/16``: the pod is the +first host, this computer the second. Reading the local interface addresses gives the pod's +address with no access to its console device, which a Linux user outside the ``dialout`` group +cannot open. The addresses come from ``getifaddrs(3)`` (Linux, macOS, BSD); elsewhere, such as +Windows, :func:`local_usb_links` returns nothing and the caller uses the console. +""" +from __future__ import annotations + +import base64 +import ctypes +import ctypes.util +import ipaddress +import json +import socket +import sys +from typing import List, Optional, Tuple + +_USB_NET = ipaddress.ip_network("10.237.0.0/16") +_MASK_30 = ipaddress.ip_address("255.255.255.252") + + +class _IfAddrs(ctypes.Structure): + pass + + +_IfAddrs._fields_ = [ + ("ifa_next", ctypes.POINTER(_IfAddrs)), + ("ifa_name", ctypes.c_char_p), + ("ifa_flags", ctypes.c_uint), + ("ifa_addr", ctypes.c_void_p), + ("ifa_netmask", ctypes.c_void_p), +] + + +def _ipv4(sa: Optional[int]) -> Optional[ipaddress.IPv4Address]: + """The IPv4 address in a ``struct sockaddr`` (``None`` for another family).""" + if not sa: + return None + raw = ctypes.string_at(sa, 8) + # BSD and macOS: sa_len (1 byte) then sa_family (1 byte); Linux: sa_family (2 bytes, native). + family = raw[1] if sys.platform == "darwin" or "bsd" in sys.platform else int.from_bytes(raw[:2], sys.byteorder) + if family != socket.AF_INET: + return None + return ipaddress.IPv4Address(raw[4:8]) + + +def local_usb_links() -> List[Tuple[str, str]]: + """``(this computer's address, the pod's address)`` for each local USB network link: an IPv4 + interface address in ``10.237.0.0/16`` with a ``/30`` mask.""" + name = ctypes.util.find_library("c") + if sys.platform.startswith("win") or not name: + return [] + try: + libc = ctypes.CDLL(name) + head = ctypes.POINTER(_IfAddrs)() + if libc.getifaddrs(ctypes.byref(head)) != 0: + return [] + except (OSError, AttributeError): + return [] + out: List[Tuple[str, str]] = [] + try: + node = head + while node: + ifa = node.contents + ip, mask = _ipv4(ifa.ifa_addr), _ipv4(ifa.ifa_netmask) + if ip is not None and mask == _MASK_30 and ip in _USB_NET: + pod = ipaddress.IPv4Address((int(ip) & ~3) + 1) + if (str(ip), str(pod)) not in out: + out.append((str(ip), str(pod))) + node = ifa.ifa_next + finally: + libc.freeifaddrs(head) + return out + + +def _short_id(host: str, port: int, timeout: float) -> str: + """The pod's name (``baea06``): the first 3 bytes of its identity public key, read over the + network (``identity_public``); ``""`` when it does not answer.""" + try: + with socket.create_connection((host, port), timeout=timeout) as s: + s.settimeout(timeout) + s.sendall(b'{"cmd":"identity_public"}\n') + buf = b"" + while b"\n" not in buf: + chunk = s.recv(1024) + if not chunk: + return "" + buf += chunk + pub = (json.loads(buf.split(b"\n", 1)[0]).get("data") or {}).get("public", "") + key = base64.urlsafe_b64decode(pub + "=" * (-len(pub) % 4)) + return key[:3].hex() if len(key) >= 3 else "" + except (OSError, ValueError, AttributeError): + return "" + + +def pick_usb_pod(links: List[Tuple[str, str]], pod: str, port: int, timeout: float) -> Optional[str]: + """The pod's address among ``links``: the only one, or for ``pod`` (a normalized name, + ``baea06``) the one whose identity matches. ``None`` when there is none, or several and no + name.""" + if not pod: + return links[0][1] if len(links) == 1 else None + for _, addr in links: + if _short_id(addr, port, timeout) == pod: + return addr + return None diff --git a/packages/embeddedci/tests/test_usb_net.py b/packages/embeddedci/tests/test_usb_net.py index 4d9dd7a..031088b 100644 --- a/packages/embeddedci/tests/test_usb_net.py +++ b/packages/embeddedci/tests/test_usb_net.py @@ -105,6 +105,11 @@ def probe(host, port, timeout=transport_mod.USB_NET_PROBE_TIMEOUT): return host in r if isinstance(r, (set, frozenset)) else r monkeypatch.setattr(serial_mod, "SerialTransport", make_console) + from embeddedci.benchpod.transport import usblink + cfg["links"] = [] + cfg["ids"] = {} + monkeypatch.setattr(usblink, "local_usb_links", lambda: list(cfg["links"])) + monkeypatch.setattr(usblink, "_short_id", lambda host, port, timeout: cfg["ids"].get(host, "")) monkeypatch.setattr(transport_mod, "_tcp_reachable", probe) monkeypatch.setattr(transport_mod, "_api_answers", probe) cfg["probes"] = probes @@ -250,3 +255,40 @@ def run(): assert transport_mod._api_answers("127.0.0.1", serve(b'{"status":"ok","data":"pong"}\n')) assert not transport_mod._api_answers("127.0.0.1", serve(b"")) + + +def test_usb_takes_the_local_usb_link_without_the_console(usb): + # A Linux user outside the dialout group cannot open the console: this computer's own end of + # the pod's USB link names the pod's address. + usb["links"] = [("10.237.8.86", "10.237.8.85")] + t = open_transport(parse_connection("usb")) + assert isinstance(t, TcpTransport) and t.addr == "10.237.8.85:8080" + assert t.usb_net and t.usb_console == "" + assert FakeConsole.instances == [] # the console was never opened + + +def test_usb_name_picks_its_link_by_identity(usb): + usb["links"] = [("10.237.1.2", "10.237.1.1"), ("10.237.8.86", "10.237.8.85")] + usb["ids"] = {"10.237.1.1": "aaaaaa", "10.237.8.85": "baea06"} + t = open_transport(parse_connection("usb:benchpod-baea06")) + assert isinstance(t, TcpTransport) and t.addr == "10.237.8.85:8080" and not FakeConsole.instances + + +def test_two_links_and_no_name_use_the_console(usb): + # The console's auto-detect then lists both pods (or picks the one it finds). + usb["links"] = [("10.237.1.2", "10.237.1.1"), ("10.237.8.86", "10.237.8.85")] + usb["status"] = _status(USB_ON) + open_transport(parse_connection("usb")) + assert FakeConsole.instances + + +def test_explicit_device_path_ignores_the_local_links(usb): + usb["links"] = [("10.237.8.86", "10.237.8.85")] + t = open_transport(parse_connection("/dev/ttyACM0")) + assert isinstance(t, FakeConsole) + + +def test_local_usb_links_reads_this_computers_interfaces(): + from embeddedci.benchpod.transport.usblink import local_usb_links + for host, pod in local_usb_links(): # whatever this machine has; each is a /30 in 10.237/16 + assert host.startswith("10.237.") and pod.startswith("10.237.")