diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index a42aa42..2bdb355 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog — `embeddedci-mcp` +## 2.13.15 + +- `connect` over the cloud to a pod with firmware 3.10.4 works again: its `status` was too large + for the cloud channel, which failed the connect. `status` now reports the firmware fields as + unknown with a warning to update to firmware 3.10.5. With firmware 3.10.5's short cloud status, + `status` fills `firmware.lan_tls` from the `lan_tls` command. +- Needs embeddedci 2.13.15. + ## 2.13.14 - Pin directions are on by default: `connect` and `set_wiring` send the wiring profile's DUT diff --git a/packages/embeddedci-mcp/pyproject.toml b/packages/embeddedci-mcp/pyproject.toml index 51380af..b81d9bf 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.13.14" +version = "2.13.15" 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; yaml: sensor definitions an agent writes. - "embeddedci[cloud,discovery,analysis,yaml]>=2.13.14,<3", + "embeddedci[cloud,discovery,analysis,yaml]>=2.13.15,<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 b03c849..aa0bb02 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.13.14", + "version": "2.13.15", "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.13.14", + "version": "2.13.15", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index 0a2f9d7..7f4ee9f 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -62,6 +62,7 @@ from embeddedci.benchpod.cloud_auth import DEFAULT_API_BASE from embeddedci.benchpod.connection import CLOUD_PREFIX from embeddedci.benchpod import errors as _errors +from embeddedci.benchpod.errors import STATUS_FITS_FIRMWARE, FirmwareError, status_too_large from embeddedci.benchpod.errors import BenchPodError from embeddedci.benchpod.server_api import ServerApi @@ -317,8 +318,25 @@ def _status() -> m.StatusResult: "else pass a host, 'usb' or 'embeddedci:'") return m.StatusResult(connected=False, warnings=[f"not connected — {hint}"]) pod = SESSION.require() - firmware = pod.status() warnings: List[str] = [] + try: + firmware = pod.status() + except FirmwareError as exc: + # Firmware 3.10.4's status can be too large for the cloud channel. The connection works: + # report the status as unknown with the fix, rather than failing the connect. + if not status_too_large(exc.firmware_message, exc.cmd): + raise + firmware = {} + warnings.append(f"this pod's status is too large for the cloud channel (firmware " + f"{pod.capabilities.firmware_version or 'older'}): update the pod to firmware " + f"{STATUS_FITS_FIRMWARE} or later; the status fields are unknown until then") + if isinstance(firmware, dict) and firmware.get("short_form") and "lan_tls" not in firmware: + # The cloud status is the short form (firmware 3.10.5+): lan_tls is its own command there. + try: + tls = pod.lan_tls() + firmware["lan_tls"] = {"port": tls.port, "https_only": tls.https_only, "sha256": tls.sha256} + except BenchPodError: + pass # From this status, not the cached capabilities: it describes the boot running now. boot = Capabilities.from_status(firmware).boot_warning() if boot: diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index 03ace47..047bce5 100644 --- a/packages/embeddedci-mcp/tests/test_tools.py +++ b/packages/embeddedci-mcp/tests/test_tools.py @@ -121,6 +121,29 @@ def test_status_warns_about_safe_mode_and_crashes(fake_open, fake_transport, mon assert call("status")["warnings"] == [] +def test_status_too_large_for_the_cloud_keeps_the_connection(fake_open, fake_transport, monkeypatch): + # Firmware 3.10.4 over the cloud: its status did not fit the cloud channel and connect failed. + from embeddedci.benchpod.errors import FirmwareError + + def too_large(): + raise FirmwareError("reply too large for the cloud channel (1838 bytes, limit 1791)", + cmd="status") + monkeypatch.setattr(fake_transport, "status", too_large) + result = call("connect", connection="192.168.1.50", la_voltage=3.3) + assert result["connected"] is True and result["firmware"] == {} + assert any("too large for the cloud channel" in w and "3.10.5" in w for w in result["warnings"]) + + +def test_short_form_status_gets_lan_tls_from_its_command(fake_open, fake_transport, monkeypatch): + # Firmware 3.10.5's cloud status is the short form: lan_tls is its own command there. + base = fake_transport.status + monkeypatch.setattr(fake_transport, "status", lambda: {**base(), "short_form": True}) + fake_transport._cmd_lan_tls = lambda req: {"port": 8443, "https_only": True, "sessions": 0, + "sha256": "ab" * 32} + result = call("connect", connection="192.168.1.50", la_voltage=3.3) + assert result["firmware"]["lan_tls"] == {"port": 8443, "https_only": True, "sha256": "ab" * 32} + + def test_connect_without_la_voltage_warns(fake_open): result = call("connect", connection="usb") assert result["kind"] == "serial" and result["la_voltage"] is None diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index b680c24..020ddba 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog — `embeddedci` +## 2.13.15 + +- A pod whose `status` is too large for the cloud channel (firmware 3.10.4 with the USB network + adapter up and a lease held) no longer leaves the SDK guessing: the error names the fix, + firmware 3.10.5, and `bp.capabilities` uses the server's capability set instead of reading the + status again on every access. Firmware 3.10.5 sends a short `status` over the cloud + (`"short_form": true`); it leaves out `lan_tls` (use `bp.lan_tls()`), `lease`, the diagnostics + and the fixed feature flags the server already has. + ## 2.13.14 - Pin directions are on by default. On a pod with firmware 3.10.4+ the SDK sends the wiring diff --git a/packages/embeddedci/pyproject.toml b/packages/embeddedci/pyproject.toml index 03bb293..893a4a9 100644 --- a/packages/embeddedci/pyproject.toml +++ b/packages/embeddedci/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "embeddedci" -version = "2.13.14" +version = "2.13.15" description = "BenchPod SDK and pytest plugin for EmbeddedCI hardware-in-the-loop testing" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/embeddedci/src/embeddedci/benchpod/client.py b/packages/embeddedci/src/embeddedci/benchpod/client.py index f184c3e..48cc215 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/client.py +++ b/packages/embeddedci/src/embeddedci/benchpod/client.py @@ -91,7 +91,8 @@ coerce_pin, ) from .connection import resolve_connection -from .errors import BenchPodError, FirmwareError, UnsupportedFeatureError, classify_firmware_error +from .errors import (BenchPodError, FirmwareError, UnsupportedFeatureError, classify_firmware_error, + status_too_large) from .flash import FlashResult from .gpio import LA_DIRS, GpioPin, LaPinState from .lease import DEFAULT_LEASE_TTL, DEFAULT_LEASE_WAIT, DeviceLease @@ -399,6 +400,11 @@ def capabilities(self) -> Capabilities: status_ok = True try: status = self.status() + except FirmwareError as exc: + # Firmware 3.10.4's status can be too large for the cloud channel. Reading it again + # will not help: use the server's capabilities alone, and keep them. + _log.warning("could not read the pod's status for its capabilities: %s", exc) + status, status_ok = {}, status_too_large(exc.firmware_message, exc.cmd) except BenchPodError as exc: _log.warning("could not read the pod's status for its capabilities: %s", exc) status, status_ok = {}, False diff --git a/packages/embeddedci/src/embeddedci/benchpod/errors.py b/packages/embeddedci/src/embeddedci/benchpod/errors.py index 4e4ed26..cbbbf49 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/errors.py +++ b/packages/embeddedci/src/embeddedci/benchpod/errors.py @@ -75,6 +75,9 @@ def __init__(self, message: str, *, cmd: Optional[str] = None) -> None: self.firmware_message = message self.cmd = cmd text = _sdk_refusal_text(message) + if status_too_large(message, cmd): + text += (f"; firmware {STATUS_FITS_FIRMWARE} and later send a status that fits: update " + "the pod (`benchpod flash-self`, or the firmware update in the web app)") if cmd: super().__init__(f"{cmd}: {text}") else: @@ -269,6 +272,17 @@ def __init__(self, message: str, *, cmd: Optional[str] = None, #: The firmware's "la voltage not set; set it with la_voltage (mv 1800 or 3300) first" #: (command_handler.c require_la_voltage). Matched on the leading words only. _LA_VOLTAGE_UNSET = "la voltage not set" + +#: Firmware 3.10.4 and older could answer ``status`` over the cloud with more than the cloud +#: channel takes ("reply too large for the cloud channel (1838 bytes, limit 1791)"). From this +#: version a cloud ``status`` is a short form that always fits. +STATUS_FITS_FIRMWARE = "3.10.5" +_STATUS_TOO_LARGE = "too large for the cloud channel" + + +def status_too_large(message: str, cmd: Optional[str] = "status") -> bool: + """Whether a pod refusal is a ``status`` reply that did not fit the cloud channel.""" + return cmd == "status" and _STATUS_TOO_LARGE in message _LA_VOLTAGE_SDK = ("la voltage not set; set the board's I/O voltage first: " "BenchPod(conn, la_voltage=3.3) (1.8 for a 1.8 V target), the " "benchpod_la_voltage fixture in conftest.py, or bp.set_la_voltage(3.3)") diff --git a/packages/embeddedci/tests/test_refusal_errors.py b/packages/embeddedci/tests/test_refusal_errors.py index c5177c4..304e232 100644 --- a/packages/embeddedci/tests/test_refusal_errors.py +++ b/packages/embeddedci/tests/test_refusal_errors.py @@ -52,6 +52,15 @@ def test_leased_carries_holder_and_time_left(): assert err.firmware_message.startswith("busy:") and err.cmd == "gpio" +def test_status_too_large_for_the_cloud_names_the_fix(): + err = firmware_error("reply too large for the cloud channel (1838 bytes, limit 1791)", cmd="status") + assert isinstance(err, FirmwareError) + assert "too large for the cloud channel" in str(err) and "firmware 3.10.5" in str(err) + # Other commands' size refusals are left as they are. + other = firmware_error("reply too large for the cloud channel (1900 bytes, limit 1791)", cmd="la_pins") + assert "3.10.5" not in str(other) + + def test_busy_without_a_lease(): err = firmware_error("busy: a capture or upload is running") assert type(err) is PodBusyError