diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index 2bdb355..d753ffe 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog — `embeddedci-mcp` +## 2.13.16 + +- `can_close` switches the 120 Ω termination off as well, so a bus opened with `term` no longer + keeps it in after closing. +- `i2c_open` refused because another connection (the desktop app, the web app or a script) holds + the pod's I2C master now says so, with that session's pins, instead of pointing at a session + this server never opened. + ## 2.13.15 - `connect` over the cloud to a pod with firmware 3.10.4 works again: its `status` was too large diff --git a/packages/embeddedci-mcp/README.md b/packages/embeddedci-mcp/README.md index 16d3696..2961c4b 100644 --- a/packages/embeddedci-mcp/README.md +++ b/packages/embeddedci-mcp/README.md @@ -356,7 +356,7 @@ version the SDK pins (0.1.0, bundled) unless `BENCHPOD_SENSOR_LIBRARY`, the CLI' | `can_read` | Read received frames, or wait for one id. | `timeout=0`, `can_id`, `max_frames=32` | | `can_respond` | Answer a CAN id from the pod firmware (ECU simulation), or clear all rules. | `match_id`, `reply_id`, `reply_data`, `match_ext=false`, `reply_ext=false`, `reply_fd`, `reply_brs`, `clear=false` | | `can_status` | Link state: mode, bitrate, error counters, bus-off. | none | -| `can_close` | Clear responder rules and stop CAN. | none | +| `can_close` | Clear responder rules, stop CAN and switch the 120 Ω termination off. | none | ### Other diff --git a/packages/embeddedci-mcp/pyproject.toml b/packages/embeddedci-mcp/pyproject.toml index b81d9bf..98470cf 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.15" +version = "2.13.16" 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.15,<3", + "embeddedci[cloud,discovery,analysis,yaml]>=2.13.16,<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 aa0bb02..cdad25f 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.15", + "version": "2.13.16", "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.15", + "version": "2.13.16", "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 7f4ee9f..16371bc 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -749,6 +749,8 @@ async def i2c_open( """Make the pod an I2C master on two LA pins (gateware v55+), replacing any open session. Then i2c_transfer, i2c_scan, or capture_la with send. SWD, SPI and I2C exclude each other. + "i2c busy" means another connection (the desktop app, the web app, a script) holds the + pod's I2C master: close it there. """ def op() -> m.I2cSessionResult: pod = SESSION.require() @@ -2320,7 +2322,7 @@ async def can_status() -> m.DeviceReply: @mcp.tool(annotations=_ann("Close CAN", idempotent=True)) async def can_close() -> m.CanOpenResult: - """Clear responder rules and stop CAN (safe when not open).""" + """Clear responder rules, stop CAN and switch the 120 ohm termination off (safe when not open).""" await _call(SESSION.close_can) return m.CanOpenResult(open=False) diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 020ddba..d4c31ce 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog — `embeddedci` +## 2.13.16 + +- `open_i2c()` refused as busy names who holds the I2C master. When another connection has it + open (the desktop app, the web app, an AI agent or another script) the error says so, with its + pins and rate from `i2c_status`, instead of telling this script to close an `open_i2c()` session + it never opened. When this `BenchPod` still has its own session open, the error says to close + that one. `I2CMaster.closed` tells whether `close()` ran. +- Closing a `CanBus` (`close()`, or leaving its `with` block) also switches the 120 Ω termination + off (`can_term(False)`). Before, a bus opened with `term=True` left the termination in after + close. Firmware 3.10.6 does this in `can_disable` as well. + ## 2.13.15 - A pod whose `status` is too large for the cloud channel (firmware 3.10.4 with the USB network diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index 59192fb..227d516 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -758,6 +758,11 @@ pod's view of the session. SWD, SPI and I2C sessions share one engine in the pod, so they exclude each other. Every command fits one cloud frame: this works over the LAN, USB and the cloud alike. +The pod has one I2C master for all its connections. While another connection has it open (the +desktop app's I2C tab, the web app, an AI agent or another script), `open_i2c()` raises a +`FirmwareError` that says so and names that session's pins and rate; close the session there +first. If this `BenchPod` still has its own session open, the error says to close that one. + ## Motor & battery emulator The BenchPod motor emulator boards (ECP5, up to four on one SPI link) are driven with @@ -1688,8 +1693,8 @@ with bp.control_loop(curve=curve, input_map=shunt) as loop: ## CAN The pod has one CAN node (FDCAN + TCAN1044 transceiver), classic CAN or CAN FD. `open_can` -configures it and returns a `CanBus`, which clears autonomous-responder rules and disables CAN on -exit. +configures it and returns a `CanBus`, which clears autonomous-responder rules, disables CAN and +switches the 120 Ω termination off on exit. | `mode` | Behaviour | |---|---| diff --git a/packages/embeddedci/pyproject.toml b/packages/embeddedci/pyproject.toml index 893a4a9..7a02398 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.15" +version = "2.13.16" 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/can.py b/packages/embeddedci/src/embeddedci/benchpod/can.py index fc05658..1ce8f86 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/can.py +++ b/packages/embeddedci/src/embeddedci/benchpod/can.py @@ -305,6 +305,13 @@ def close(self) -> None: self._bp.can_disable() except Exception: pass + # Termination off too: it is a relay, not part of FDCAN, so before firmware 3.10.6 + # can_disable left it in and the next user of the bus found 120 ohm on it. + try: + self._bp.can_term(False) + self.term = False + except Exception: + pass def __enter__(self) -> "CanBus": return self diff --git a/packages/embeddedci/src/embeddedci/benchpod/client.py b/packages/embeddedci/src/embeddedci/benchpod/client.py index 48cc215..ee77dc3 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/client.py +++ b/packages/embeddedci/src/embeddedci/benchpod/client.py @@ -31,6 +31,7 @@ import os import re import time +import weakref from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple, Union @@ -300,6 +301,9 @@ def __init__( self._waveforms: Optional["WaveformLibrary"] = None self._sensors: Optional["SensorEmulation"] = None self._device_name = "" + # The last I2CMaster this BenchPod opened, to tell its own open session from another + # connection's when i2c_start is refused as busy. + self._i2c_master_ref: Optional["weakref.ReferenceType[_i2c_master.I2CMaster]"] = None #: What happened to the wiring profile's pin directions the last time the SDK sent them on #: its own (on connect, on a new :attr:`wiring`): ``"sent"`` or ``"not sent: "``. self.pin_directions_status = "not sent: not connected yet" @@ -1645,9 +1649,35 @@ def open_i2c( "hz": int(hz), "pullups": pullups} if req["sda"] == req["scl"]: raise ValueError("sda and scl must be different LA pins") - with _classified_errors(): - info = _dict(self.command(req)) - return _i2c_master.I2CMaster(self._classified_command, info) + try: + with _classified_errors(): + info = _dict(self.command(req)) + except FirmwareError as exc: + if exc.firmware_message.startswith("i2c busy"): + exc.args = (self._i2c_busy_text(exc),) + raise + i2c = _i2c_master.I2CMaster(self._classified_command, info) + self._i2c_master_ref = weakref.ref(i2c) + return i2c + + def _i2c_busy_text(self, exc: FirmwareError) -> str: + """The refusal of ``i2c_start`` while a session is open, naming who holds it: this + BenchPod's own unclosed :meth:`open_i2c` session, or another connection to the pod.""" + prefix = f"{exc.cmd}: " if exc.cmd else "" + mine = self._i2c_master_ref() if self._i2c_master_ref is not None else None + if mine is not None and not mine.closed: + return (f"{prefix}i2c busy: this BenchPod already has an I2C session open (LA{mine.sda} " + f"SDA, LA{mine.scl} SCL): close it first (i2c.close(), or leave its with block)") + where = "" + try: + st = _dict(self.command({"cmd": "i2c_status"})) + if st.get("active"): + where = f" (LA{st.get('sda')} SDA, LA{st.get('scl')} SCL, {st.get('hz')} Hz)" + except Exception: + pass + return (f"{prefix}i2c busy: another connection to this pod holds its I2C master{where}, " + "for example the desktop app, the web app, an AI agent or another script. Close the " + "I2C session there first; it is not one this script opened") def _classified_command(self, req: Dict[str, Any]) -> Any: with _classified_errors(): diff --git a/packages/embeddedci/src/embeddedci/benchpod/i2c_master.py b/packages/embeddedci/src/embeddedci/benchpod/i2c_master.py index 0663508..2bf8865 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/i2c_master.py +++ b/packages/embeddedci/src/embeddedci/benchpod/i2c_master.py @@ -134,6 +134,11 @@ def __init__(self, command: Callable[[Dict[str, Any]], Any], info: Dict[str, Any self._closed = False # -- lifetime ---------------------------------------------------------------------------- + @property + def closed(self) -> bool: + """Whether :meth:`close` ran.""" + return self._closed + def close(self) -> None: """Stop the I2C master and release its pins. Idempotent.""" if not self._closed: diff --git a/packages/embeddedci/tests/api_surface.json b/packages/embeddedci/tests/api_surface.json index 2a2b0a2..4573803 100644 --- a/packages/embeddedci/tests/api_surface.json +++ b/packages/embeddedci/tests/api_surface.json @@ -557,6 +557,7 @@ "I2CMaster": { "__init__": "(self, command: 'Callable[[Dict[str, Any]], Any]', info: 'Dict[str, Any]') -> 'None'", "close": "(self) -> 'None'", + "closed": "property", "probe": "(self, addr: 'int') -> 'bool'", "read": "(self, addr: 'int', n: 'int') -> 'bytes'", "read_reg": "(self, addr: 'int', reg: 'Union[int, Data]', n: 'int' = 1) -> 'bytes'", diff --git a/packages/embeddedci/tests/test_can.py b/packages/embeddedci/tests/test_can.py index 6d42e89..f087159 100644 --- a/packages/embeddedci/tests/test_can.py +++ b/packages/embeddedci/tests/test_can.py @@ -225,10 +225,20 @@ def test_open_can_expect_times_out(bp): def test_open_can_disables_on_exit(bp, pod): with bp.open_can(mode="internal"): pass - assert pod.requests[-1]["cmd"] == "can_disable" + assert [r["cmd"] for r in pod.requests[-2:]] == ["can_disable", "can_term"] + assert pod.requests[-1]["on"] is False assert pod._enabled is False +def test_close_switches_termination_off(bp, pod): + """Closing a bus that had the 120 ohm termination in leaves it out (firmware before 3.10.6 + kept it on after can_disable).""" + can = bp.open_can(mode="internal", term=True) + assert pod._term is True + can.close() + assert pod._term is False and can.term is False + + def test_canbus_rejects_bad_mode(bp): with pytest.raises(ValueError): bp.open_can(mode="bogus") diff --git a/packages/embeddedci/tests/test_i2c_master.py b/packages/embeddedci/tests/test_i2c_master.py index 1251899..d86c97a 100644 --- a/packages/embeddedci/tests/test_i2c_master.py +++ b/packages/embeddedci/tests/test_i2c_master.py @@ -100,7 +100,10 @@ def command(self, req: dict) -> Any: self.driven = {k: v for k, v in self.driven.items() if not v.startswith("i2cm")} return "i2c stopped" if cmd == "i2c_status": - return {"active": bool(self.session)} + if not self.session: + return {"active": False} + return {"active": True, "sda": self.session["sda"], "scl": self.session["scl"], + "hz": self.session["hz"], "pullups": self.session["pullups"]} if cmd in ("i2c_xfer", "i2c_scan") and not self.session: raise FirmwareError("no I2C session: send i2c_start first", cmd=cmd) if cmd == "i2c_xfer": @@ -189,6 +192,32 @@ def test_open_i2c_accepts_wiring_names_and_validates(): bp.open_i2c(1, 1) +def test_busy_names_another_connection_as_the_holder(): + """Another client (the desktop app, the web app) holds the pod's I2C master: the refusal says + so and where, instead of pointing at an open_i2c() this script never called.""" + bp, fake = pod() + fake.session = {"sda": 1, "scl": 2, "hz": 100_000, "pullups": "pod"} + with pytest.raises(FirmwareError) as e: + bp.open_i2c() + msg = str(e.value) + assert "another connection to this pod holds its I2C master (LA1 SDA, LA2 SCL, 100000 Hz)" in msg + assert "desktop app" in msg and "bp.open_i2c() first" not in msg + assert e.value.firmware_message == "i2c busy: send i2c_stop first" + assert fake.session["hz"] == 100_000 # nothing was stopped + + +def test_busy_names_this_benchpods_own_open_session(): + bp, fake = pod() + i2c = bp.open_i2c() + with pytest.raises(FirmwareError, match="this BenchPod already has an I2C session open .LA1 SDA, LA2 SCL.: close it first"): + bp.open_i2c() + assert not i2c.closed + i2c.close() + assert i2c.closed + with bp.open_i2c(): # free again + pass + + def test_write_read_and_register_helpers(): bp, fake = pod() with bp.open_i2c() as i2c: @@ -256,7 +285,7 @@ def test_firmware_gateware_refusal_is_unsupported_feature(): def test_refusals_name_sdk_calls(): bp, fake = pod() i2c = bp.open_i2c() - with pytest.raises(FirmwareError, match=r"bp\.open_i2c\(\)"): + with pytest.raises(FirmwareError, match=r"close it first \(i2c\.close\(\)"): bp.open_i2c() i2c.close() i2c.close() # idempotent