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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/embeddedci-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions packages/embeddedci-mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -26,7 +26,7 @@ classifiers = [
dependencies = [
# cloud: the embeddedci:<device> 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",
Expand Down
4 changes: 2 additions & 2 deletions packages/embeddedci-mcp/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion packages/embeddedci-mcp/src/embeddedci_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions packages/embeddedci/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 7 additions & 2 deletions packages/embeddedci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
|---|---|
Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions packages/embeddedci/src/embeddedci/benchpod/can.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 33 additions & 3 deletions packages/embeddedci/src/embeddedci/benchpod/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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: <why>"``.
self.pin_directions_status = "not sent: not connected yet"
Expand Down Expand Up @@ -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():
Expand Down
5 changes: 5 additions & 0 deletions packages/embeddedci/src/embeddedci/benchpod/i2c_master.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions packages/embeddedci/tests/api_surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
12 changes: 11 additions & 1 deletion packages/embeddedci/tests/test_can.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
33 changes: 31 additions & 2 deletions packages/embeddedci/tests/test_i2c_master.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down