From 02f5c041b1834da54606b7bb925683d609021ba2 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 13 Oct 2026 09:33:37 -0500 Subject: [PATCH] usb_network(): the pod's USB network setting in the SDK and MCP BenchPod.usb_network(on=None) reads the setting; False turns the adapter off over the USB link (the pod replies, then re-enumerates as its console only). The device-only refusal is a PodLockedError. MCP tool usb_network (read, or set=off) with a note on turning it back on. README: the setting, and that SDK 2.11's auto-detect finds a 3.9.0 pod under its new USB id (HW-checked, as is MCP 2.11 connect usb). Changelogs, API and tool surfaces updated. HW (baea06): read over USB and Ethernet, change refused over Ethernet. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 2 ++ packages/embeddedci-mcp/README.md | 1 + .../src/embeddedci_mcp/models.py | 6 ++++ .../src/embeddedci_mcp/server.py | 22 ++++++++++++++ packages/embeddedci-mcp/tests/test_tools.py | 12 +++++++- .../embeddedci-mcp/tests/tools_surface.json | 29 +++++++++++++++++++ packages/embeddedci/CHANGELOG.md | 7 +++++ packages/embeddedci/README.md | 12 ++++++-- .../src/embeddedci/benchpod/client.py | 16 ++++++++++ .../src/embeddedci/benchpod/errors.py | 1 + packages/embeddedci/tests/api_surface.json | 1 + .../embeddedci/tests/test_refusal_errors.py | 1 + packages/embeddedci/tests/test_usb_net.py | 15 ++++++++++ 13 files changed, 122 insertions(+), 3 deletions(-) diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index 66b0b5e..a4e8517 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2.12.0 +- New tool `usb_network`: read the pod's USB network setting, or turn the adapter off over the USB + link (`set="off"`). Turning it back on is `benchpod usb-network on` on the USB console. - 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 diff --git a/packages/embeddedci-mcp/README.md b/packages/embeddedci-mcp/README.md index 0e6a32c..dfde187 100644 --- a/packages/embeddedci-mcp/README.md +++ b/packages/embeddedci-mcp/README.md @@ -261,6 +261,7 @@ the pod's second UART, so `uart_open` keeps the console. Needs gateware v48 (`gp | --- | --- | --- | | `set_pull` | Engage or release the bias resistors: LA1-LA6 pull up, LA7/LA8 pull down. | **`las`**, **`enabled`** | | `pull_status` | State, direction and value of the bias resistor on LA1-LA8. | none | +| `usb_network` | Read the pod's USB network setting (firmware 3.9.0+), or turn the adapter off over the USB link. Only the device changes it; turning it back on is `benchpod usb-network on` on the USB console. | `set=off` | ### GPIO on the LA pins diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py index c45f703..e94c103 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py @@ -654,3 +654,9 @@ class StepResult(BaseModel): steps: int delay: float status: str = "started" + + +class UsbNetworkResult(BaseModel): + """The pod's USB network setting (firmware 3.9.0+).""" + setting: str = Field(description='"on" or "off": whether the pod\'s USB port is also a network adapter.') + note: str = Field("", description="What happens next, after a change.") diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index 7b82405..60d702c 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -1075,6 +1075,28 @@ def op() -> m.PullStatusResult: return await _call(op) +@mcp.tool(annotations=_ann("USB network setting", destructive=True)) +async def usb_network( + set: Annotated[Optional[Literal["off"]], Field(description=( + 'Omit to read the setting. "off" turns the pod\'s USB network adapter off (only over the ' + "pod's USB network link: connection 'usb'); the pod then re-enumerates as its USB console " + "only and this connection ends. Turning it back on needs the USB console: the user runs " + "`benchpod usb-network on`."))] = None, +) -> m.UsbNetworkResult: + """Read the pod's USB network setting (firmware 3.9.0+), or turn the adapter off. Only the device + changes it: Ethernet, Wi-Fi and the cloud may only read it.""" + def op() -> m.UsbNetworkResult: + pod = SESSION.require() + on = pod.usb_network(None if set is None else False) + note = "" + if set == "off": + note = ("The pod re-enumerates as its USB console only; this connection ends. Ask the user to run " + "`benchpod usb-network on` over the USB console to turn it back on.") + return m.UsbNetworkResult(setting="on" if on else "off", note=note) + + return await _call(op) + + @mcp.tool(annotations=_ann("Pull resistor status", read_only=True)) async def pull_status() -> m.PullStatusResult: """State, direction and value of the bias resistor on LA1-LA8.""" diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index 9456e7c..565e1f2 100644 --- a/packages/embeddedci-mcp/tests/test_tools.py +++ b/packages/embeddedci-mcp/tests/test_tools.py @@ -24,7 +24,7 @@ "enable_i2c_sensor", "set_i2c_sensor", "disable_i2c_sensor", "i2c_sensor_status", "i2c_sensor_regs", "i2c_sensor_capture", "i2c_sensor_types", "enable_gps", "set_gps", "disable_gps", "gps_status", - "set_pull", "pull_status", + "set_pull", "pull_status", "usb_network", "analog_path", "dac_output", "current_out", "adc_read", "calibration", "calibrate", "capture_adc", "capture_la", "capture_correlated", "decode_la", "la_timing", "generate", "dac_stop", "replay", "list_waveforms", "replay_waveform", @@ -605,3 +605,13 @@ def test_la_step_and_command(connected): assert call("command", request={"cmd": "adc_read", "source": "cal1"})["reply"]["source"] == "cal1" with pytest.raises(ToolError): call("command", request={"samples": 1}) + + +def test_usb_network_reads_and_turns_off(fake_open, fake_transport, monkeypatch): + call("connect", connection="usb", la_voltage=3.3) + pod = SESSION.require() + seen = [] + monkeypatch.setattr(pod, "usb_network", lambda on=None: seen.append(on) or (on is not False), raising=False) + assert call("usb_network")["setting"] == "on" + res = call("usb_network", set="off") + assert res["setting"] == "off" and "usb-network on" in res["note"] and seen == [None, False] diff --git a/packages/embeddedci-mcp/tests/tools_surface.json b/packages/embeddedci-mcp/tests/tools_surface.json index 938cc87..885da67 100644 --- a/packages/embeddedci-mcp/tests/tools_surface.json +++ b/packages/embeddedci-mcp/tests/tools_surface.json @@ -4411,6 +4411,35 @@ "type": "object" } }, + "usb_network": { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "USB network setting" + }, + "input": { + "properties": { + "set": { + "anyOf": [ + { + "const": "off", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Omit to read the setting. \"off\" turns the pod's USB network adapter off (only over the pod's USB network link: connection 'usb'); the pod then re-enumerates as its USB console only and this connection ends. Turning it back on needs the USB console: the user runs `benchpod usb-network on`.", + "title": "Set" + } + }, + "title": "usb_networkArguments", + "type": "object" + } + }, "wiring": { "annotations": { "idempotentHint": false, diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index ebe4809..0a7c1fb 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -2,6 +2,13 @@ ## 2.12.0 +- `BenchPod.usb_network(on=None)`: read the pod's USB network setting (firmware 3.9.0+), or turn + the adapter off over the USB link (`False`). Ethernet, Wi-Fi and the cloud get + `PodLockedError`; turning it back on is `benchpod usb-network on` on the USB console. +- Connection `usb` finds the pod from this computer's own end of its USB network link (Linux and + macOS, `getifaddrs`) before opening the console, so it works for a Linux user outside the + `dialout` group; `usb:` picks among several links by identity. `TcpTransport.usb_net` + marks a USB network transport. - 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 (the console answers those with a diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index 69cab0f..06a0ef6 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -215,13 +215,21 @@ The USB network adapter works with the in-box drivers of macOS, Linux and Window 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". +**Turning the adapter off.** The pod keeps a USB network setting, on by default and separate from +its external LAN policy. `bp.usb_network()` reads it (`True` = on) over any connection; +`bp.usb_network(False)` turns the adapter off, which only works over the USB link itself: the pod +replies, then re-enumerates as its USB console only, which ends the connection. Over Ethernet, +Wi-Fi or the cloud the pod refuses the change (`PodLockedError`). Turning it back on needs the USB +console: `benchpod usb-network on`. Status reports it as `usb.setting` and, when the adapter is +off, `usb.off_reason` (`"setting"` or `"wifi"`). + **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. 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. | +| **An older SDK (2.11.x)** | Unchanged. | `usb` is the console: its auto-detect probes every USB serial port, so it finds a 3.9.0 pod under its new USB id `1209:0001` (tested). 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 @@ -1520,7 +1528,7 @@ lease_wait=600.0, lease_ttl=120, wiring=None)`. A context manager; `close()` is | Area | Methods and properties | |---|---| -| Connection and device | `ping()`, `status()`, `capabilities`, `refresh_capabilities()` (drop the cached capabilities and read them again), `close()`, `leased` (true while this client holds the cloud lease), `wiring`, `save_wiring(wiring=None)` (store a cloud device's profile on embeddedci.com), `signal(name)`, `fpga_image(image)`, `usb_cc()` | +| Connection and device | `ping()`, `status()`, `capabilities`, `refresh_capabilities()` (drop the cached capabilities and read them again), `close()`, `leased` (true while this client holds the cloud lease), `wiring`, `save_wiring(wiring=None)` (store a cloud device's profile on embeddedci.com), `signal(name)`, `fpga_image(image)`, `usb_cc()`, `usb_network(on=None)` (the USB network setting; `False` turns the adapter off over the USB link) | | LA voltage | `set_la_voltage(volts)`, `get_la_voltage()` | | Power and reset | `power_on(efuse=None, *, delay=None)`, `power_off(efuse=None, *, delay=None)`, `target_power(efuse=None, *, on, delay=None)`, `target_status()`, `power_status()`, `measure_power(duration, *, efuse=None, rate_hz=500.0, keep_samples=0)`, `power_profile(*, efuse=None, rate_hz=500.0, keep_samples=4096, max_duration=60.0)`, `reset_target(*, pulse=0.1)`, `set_reset(asserted)`, `reset_state()` | | Flashing | `flash(...)`, `spi_flash(image, addr=0, *, erase=True, verify=True, hold_reset=False, sck=, mosi=, miso=, cs=)`, `open_spi(...)`, `open_motor_emulator(...)` | diff --git a/packages/embeddedci/src/embeddedci/benchpod/client.py b/packages/embeddedci/src/embeddedci/benchpod/client.py index b012c1b..12e276a 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/client.py +++ b/packages/embeddedci/src/embeddedci/benchpod/client.py @@ -676,6 +676,22 @@ def usb_cc(self) -> UsbCcStatus: """The pod's USB-C cable orientation and the current the upstream source advertises (rev3).""" return UsbCcStatus.from_reply(self.command({"cmd": "usb_cc"})) + def usb_network(self, on: Optional[bool] = None) -> bool: + """The pod's USB network setting (firmware 3.9.0+): whether its USB port is also a network + adapter. Returns the stored setting (``True`` = on). + + ``on=False`` turns the adapter off. Only the device may change it: this works over the + pod's USB network link itself (connection ``usb``); over Ethernet, Wi-Fi or the cloud the + pod refuses with :class:`PodLockedError`. The pod replies, then re-enumerates as its USB + console only, which ends this connection. Turning it back on needs the USB console: + ``benchpod usb-network on``. + """ + req: Dict[str, Any] = {"cmd": "usb_network"} + if on is not None: + req["set"] = "on" if on else "off" + data = self.command(req) + return isinstance(data, dict) and data.get("setting") == "on" + # -- flash -------------------------------------------------------------- def flash( diff --git a/packages/embeddedci/src/embeddedci/benchpod/errors.py b/packages/embeddedci/src/embeddedci/benchpod/errors.py index 2f9ab1d..dc7c2d5 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/errors.py +++ b/packages/embeddedci/src/embeddedci/benchpod/errors.py @@ -224,6 +224,7 @@ def __init__(self, message: str, *, cmd: Optional[str] = None, #: 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", + "change it on the device", "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/api_surface.json b/packages/embeddedci/tests/api_surface.json index af6e08c..de70078 100644 --- a/packages/embeddedci/tests/api_surface.json +++ b/packages/embeddedci/tests/api_surface.json @@ -136,6 +136,7 @@ "target_status": "(self) -> 'TargetStatus'", "transport": "property", "usb_cc": "(self) -> 'UsbCcStatus'", + "usb_network": "(self, on: 'Optional[bool]' = None) -> 'bool'", "wait_for_level": "(self, la: 'Union[Pin, int, str]', level: 'int', *, timeout: 'float', poll: 'float' = 0.005) -> 'bool'", "waveforms": "property", "wiring": "property" diff --git a/packages/embeddedci/tests/test_refusal_errors.py b/packages/embeddedci/tests/test_refusal_errors.py index 3ccb354..7befd67 100644 --- a/packages/embeddedci/tests/test_refusal_errors.py +++ b/packages/embeddedci/tests/test_refusal_errors.py @@ -35,6 +35,7 @@ "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", + "usb_network: change it on the device (the USB console, or over the USB link)", "sig_policy: only the USB console can loosen the policy (now required)", ]) def test_locked(text): diff --git a/packages/embeddedci/tests/test_usb_net.py b/packages/embeddedci/tests/test_usb_net.py index 031088b..74c73e4 100644 --- a/packages/embeddedci/tests/test_usb_net.py +++ b/packages/embeddedci/tests/test_usb_net.py @@ -292,3 +292,18 @@ 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.") + + +def test_usb_network_method_reads_and_turns_off(): + from embeddedci.benchpod import BenchPod + sent = [] + + class T: + def command(self, req, **kw): + sent.append(req) + return {"setting": "off" if req.get("set") == "off" else "on"} + + pod = BenchPod.__new__(BenchPod) + pod.command = T().command # the method only needs command() + assert pod.usb_network() is True and sent[-1] == {"cmd": "usb_network"} + assert pod.usb_network(False) is False and sent[-1] == {"cmd": "usb_network", "set": "off"}