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.14

- Pin directions are on by default: `connect` and `set_wiring` send the wiring profile's DUT
directions to the pod (`la_dir`, firmware 3.10.4+; older firmware is skipped). `wiring` shows
each channel's `dut_dir` and both report `directions`; `status` warns when a driven pin kept
them from going out. Profiles take `dut_dirs` to override a role's or signal's default.
- Needs embeddedci 2.13.14.

## 2.13.13

- I2C master tools (gateware v55+): `i2c_open`, `i2c_transfer` (a NACK returns `ack: false` and
Expand Down
12 changes: 10 additions & 2 deletions packages/embeddedci-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ description, ranges and enums, so an agent sees more than this table.
| Tool | Purpose | Parameters |
| --- | --- | --- |
| `wiring` | Which DUT signal is on which LA channel, plus the power rail, UART baud and SWD target. | none |
| `set_wiring` | Replace the wiring profile for this connection, or store it on embeddedci.com. Also sends the profile's pin directions to the pod (`la_dir`; `directions` in the result says whether it worked). | **`profile`**, `save=false` |
| `set_wiring` | Replace the wiring profile for this connection, or store it on embeddedci.com. Also sends the profile's pin directions to the pod (`la_dir`, firmware 3.10.4+; `directions` in the result says whether it worked). | **`profile`**, `save=false` |

### Power

Expand Down Expand Up @@ -301,7 +301,7 @@ version the SDK pins (0.1.0, bundled) unless `BENCHPOD_SENSOR_LIBRARY`, the CLI'
| `gpio_wait` | Wait until an LA channel reads a level (`reached: false` on timeout). | **`la`**, `level=1`, `timeout=5` |
| `gpio_pulse` | FPGA-timed pulses on an LA channel: `width` 4 us to 65.535 ms, `count` up to 65535. | **`la`**, **`width`**, `count=1` |
| `gpio_release` | Return channels to high-Z, watched by captures. | `la` |
| `la_dir` | Which way each pin's DUT signal goes (`dut_input`, `dut_output`, `bidir`, `unknown`); on a DUT output the pod refuses push-pull drivers. `from_wiring` sends the wiring profile's directions; no arguments lists every pin. | `la`, `dir`, `from_wiring=false` |
| `la_dir` | Which way each pin's DUT signal goes (`dut_input`, `dut_output`, `bidir`, `unknown`); on a DUT output the pod refuses push-pull drivers. `connect` and `set_wiring` already send the wiring profile's; `from_wiring` sends them again; no arguments lists every pin. | `la`, `dir`, `from_wiring=false` |

### Analog

Expand Down Expand Up @@ -458,6 +458,14 @@ capture tools' summaries, `la_timing` and `decode_la` instead.
registered, server unreachable, another account) the defaults apply and nothing fails; over the
LAN or USB it gives up after about 3 seconds. The STM32 pod's USB text console cannot report its
registration, so over USB the defaults apply. `BENCHPOD_WIRING_SOURCE=local` skips the lookup.
- **Pin directions on by default.** Every wired role and signal has a DUT direction, shown per
channel in `wiring` (`dut_dir`): `uart_rx` and `spi_miso` are DUT outputs; `uart_tx`, the other
SPI pins and SWCLK DUT inputs; I2C and SWDIO bidirectional; a signal the pod watches (`input`) a
DUT output, one it drives (`output`) a DUT input. The profile's `dut_dirs` overrides any of them.
On firmware 3.10.4+ `connect` and `set_wiring` send them to the pod (`la_dir`), which then
refuses a push-pull driver on a DUT output pin, so the pod never drives against the DUT. Older
firmware is skipped quietly. `wiring` and `set_wiring` report `directions`, and `status` warns
when a driven pin kept them from going out.
- **Agent-sized captures.** `capture_adc` returns calibrated statistics, the dominant frequency and a
min/max envelope; `capture_la` returns per-channel levels, edges and frequencies. The last captures
stay in the session, so `decode_la`, `replay(from_last_capture=true)` and
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.13"
version = "2.13.14"
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.13,<3",
"embeddedci[cloud,discovery,analysis,yaml]>=2.13.14,<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.13",
"version": "2.13.14",
"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.13",
"version": "2.13.14",
"runtimeHint": "uvx",
"transport": {
"type": "stdio"
Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci-mcp/src/embeddedci_mcp/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
Typical flows:
- Flash and boot: flash(file) then power_cycle_and_capture(until_regex) — with a wiring profile that is all; otherwise pass swclk, swdio, target, nreset and rx, tx.
- SPI flash (a 25-series chip on four LA pins, wiring spi_sclk/spi_mosi/spi_miso/spi_cs): spi_flash_info to see it answers, then spi_flash_program(file, addr); hold_reset=true keeps the DUT off the bus. spi_transfer talks to any other SPI device.
- I2C master (gateware v55+): set_pull([sda, scl], true), i2c_open, then i2c_transfer(addr, write_hex, read) or i2c_scan; i2c_close. capture_la(send={...}) runs one I2C, SPI or gpio transaction right after the capture arms and triggers on it. la_dir marks DUT outputs so the pod never drives against them (set_wiring sends the profile's directions).
- I2C master (gateware v55+): set_pull([sda, scl], true), i2c_open, then i2c_transfer(addr, write_hex, read) or i2c_scan; i2c_close. capture_la(send={...}) runs one I2C, SPI or gpio transaction right after the capture arms and triggers on it. la_dir marks DUT outputs so the pod never drives against them; connect and set_wiring send the wiring profile's directions (firmware 3.10.4+), so this is on by default.
- Interactive console: uart_open, power_on, uart_read(until_regex), uart_write, uart_close.
- Emulate sensors (I2C, SPI, UART, analog; firmware with the 'sensors' capability): the pod plays a sensor from a definition. sensor_library lists ready ones (bme280, bmp280, sht4x, mpu6050, gps_nmea, ...) and sensor_show gives one's YAML and params. For I2C, set_pull([sda, scl], true) on LA1-LA6 (LA7/LA8 pull DOWN) first. sensor_start(definition=<name or YAML>, pins={sda, scl} | {tx} | ...), then sensor_set(instance, values) changes readings, sensor_stimulus_load + sensor_stimulus_start play values over time, sensor_fault injects a fault, sensor_status shows bus activity, sensor_capture waits for the DUT's next transaction on an I2C sensor's bus and decodes it, sensor_stop ends it. A part the library lacks: read sensor_format, write a definition from the datasheet, check it with sensor_validate, then sensor_start it. A GPS module is gps_nmea on pins={tx: <LA wired to the DUT RX>}.
- Analog: dac_output (DC), generate (sine/square/sawtooth) and replay drive the DAC and route its path; adc_read gives one calibrated value, capture_adc a waveform summary; dac_stop ends any DAC output.
Expand Down
7 changes: 6 additions & 1 deletion packages/embeddedci-mcp/src/embeddedci_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ class WiringPin(BaseModel):
wired_to: Optional[str] = Field(None, description=(
"The role (uart_rx, i2c_sda, swd_swclk, …) or signal name on this channel; null = unused."))
pull: Optional[str] = Field(None, description="Fixed bias resistor, e.g. '4.7k up'; null on LA9-LA14.")
dut_dir: Optional[str] = Field(None, description=(
"Which way the DUT's signal on this channel goes (dut_input, dut_output or bidir): the "
"role's or signal's default, or the profile's dut_dirs. The pod gets it with la_dir and "
"refuses push-pull drivers on dut_output pins. Null when nothing is wired."))


class WiringResult(BaseModel):
Expand All @@ -173,7 +177,8 @@ class WiringResult(BaseModel):
profile: Dict[str, Any] = Field(default_factory=dict, description="The profile as its JSON object.")
saved: bool = Field(False, description="set_wiring stored this profile on embeddedci.com.")
directions: Optional[str] = Field(None, description=(
"set_wiring: 'sent' when the pod now has the profile's pin directions (la_dir), else why not."))
"'sent' when the pod has the profile's pin directions (la_dir: sent on connect and by "
"set_wiring), else 'not sent: <why>' (older firmware, or a DUT output pin driven right now)."))


# -- LA pin ownership + GPIO -------------------------------------------------------
Expand Down
33 changes: 17 additions & 16 deletions packages/embeddedci-mcp/src/embeddedci_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,10 @@ def _status() -> m.StatusResult:
wiring_source: Optional[str] = pod.wiring.source
except (BenchPodError, ValueError, OSError):
wiring_source = None
dirs = getattr(pod, "pin_directions_status", "")
if isinstance(dirs, str) and dirs.startswith("not sent") and "firmware" not in dirs:
warnings.append(f"the wiring's pin directions were {dirs} (call la_dir(from_wiring=true) "
"once that pin is released)")
if la is None:
warnings.append("LA I/O voltage not selected — call set_la_voltage (1.8 or 3.3, matching "
"the DUT) before flash, UART, LA capture, pulls or sensor emulation")
Expand Down Expand Up @@ -424,7 +428,13 @@ async def wiring() -> m.WiringResult:
plus warnings about risky wiring. Every tool whose channel, baud, rail or SWD arguments are
omitted takes them from this profile, and channel arguments accept these names.
"""
return await _call(lambda: wiring_summary(SESSION.require().wiring))
def op() -> m.WiringResult:
pod = SESSION.require()
result = wiring_summary(pod.wiring)
result.directions = pod.pin_directions_status
return result

return await _call(op)


@mcp.tool(annotations=_ann("Set the wiring profile", idempotent=True, cloud=True))
Expand All @@ -449,23 +459,13 @@ def op() -> m.WiringResult:
if save:
pod.save_wiring()
result = wiring_summary(pod.wiring, saved=save)
result.directions = _push_directions(pod)
# Assigning the profile sent its pin directions (la_dir) on firmware that has it.
result.directions = pod.pin_directions_status
return result

return await _call(op)


def _push_directions(pod: Any) -> str:
"""Send the profile's pin directions (la_dir); 'sent', or why not (older firmware, a driven pin)."""
try:
pod.apply_pin_directions()
return "sent"
except _errors.UnsupportedFeatureError:
return "not sent: this pod's firmware has no la_dir"
except BenchPodError as exc:
return f"not sent: {_error_text(exc)}"


# -- power ----------------------------------------------------------------------------

def _switch_power(efuse: Optional[int], on: bool, delay: Optional[float]) -> m.PowerResult:
Expand Down Expand Up @@ -1349,12 +1349,13 @@ async def la_dir(
dir: Annotated[Optional[Literal["dut_input", "dut_output", "bidir", "unknown"]], Field(description=(
"Which way the DUT signal goes. On dut_output the pod refuses push-pull drivers."))] = None,
from_wiring: Annotated[bool, Field(description=(
"Send the wiring profile's directions instead (uart_rx, spi_miso and input signals are "
"DUT outputs)."))] = False,
"Send the wiring profile's directions again (each role's and signal's default, or the "
"profile's dut_dirs)."))] = False,
) -> m.GpioPinsResult:
"""Tell the pod which way each pin's DUT signal goes, so it never drives against a DUT output.

The pod forgets this on a reboot; set_wiring sends the profile's directions.
connect and set_wiring already send the wiring profile's directions (firmware 3.10.4+);
use this for a pin the profile doesn't name. The pod forgets them on a reboot.
"""
def op() -> m.GpioPinsResult:
pod = SESSION.require()
Expand Down
4 changes: 3 additions & 1 deletion packages/embeddedci-mcp/src/embeddedci_mcp/summaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,13 @@ def _bias(la: int) -> Optional[str]:
def wiring_summary(wiring: Wiring, *, saved: bool = False) -> m.WiringResult:
"""The effective profile, its 14-row pin table and anything risky about it."""
pins = wiring.pins()
dirs = wiring.pin_dut_dirs()
return m.WiringResult(
source=wiring.source, version=wiring.version, la_voltage=wiring.la_voltage,
efuse=wiring.efuse, uart_baud=wiring.uart_baud, i2c_address=wiring.i2c_address,
swd_nreset=wiring.swd_nreset, swd_target=wiring.swd_target,
pins=[m.WiringPin(la=la, wired_to=pins.get(la), pull=_bias(la)) for la in range(1, 15)],
pins=[m.WiringPin(la=la, wired_to=pins.get(la), pull=_bias(la), dut_dir=dirs.get(la))
for la in range(1, 15)],
signals=[m.WiringSignal(name=s.name, la=s.la, direction=s.direction,
active_low=s.active_low, description=s.description)
for s in wiring.signals],
Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci-mcp/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def status(self) -> Any:
caps = list(self.caps)
if self.image is not None:
caps.append("dac_control_loop" if self.image == 0 else "dac_deep_replay")
return {"version": "2.0.0", "board": "stm32h563", "adc_bits": 16, "adc_fullscale_mv": 4096,
return {"version": getattr(self, "version", "2.0.0"), "board": "stm32h563", "adc_bits": 16, "adc_fullscale_mv": 4096,
"caps": caps, "gateware": 55}

def ping(self) -> Any:
Expand Down
20 changes: 19 additions & 1 deletion packages/embeddedci-mcp/tests/test_bus_master_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,27 @@ def test_la_dir_sets_lists_and_follows_the_wiring(connected):


def test_set_wiring_sends_the_directions(connected):
r = call("set_wiring", profile={"uart_rx": 7, "uart_tx": 8})
from embeddedci_mcp.session import SESSION

connected.version = "3.10.4"
SESSION.require().refresh_capabilities()
r = call("set_wiring", profile={"uart_rx": 7, "uart_tx": 8,
"signals": [{"name": "READY", "la": 9}],
"dut_dirs": {"READY": "bidir"}})
assert r["directions"] == "sent"
assert connected.dirs[7] == "dut_output" and connected.dirs[8] == "dut_input"
assert connected.dirs[9] == "bidir" and connected.dirs[5] == "unknown"
pins = {p["la"]: p for p in call("wiring")["pins"]}
assert pins[7]["dut_dir"] == "dut_output" and pins[9]["dut_dir"] == "bidir"
assert pins[3]["dut_dir"] is None


def test_older_firmware_skips_the_directions(connected):
r = call("set_wiring", profile={"uart_rx": 7, "uart_tx": 8})
assert r["directions"] == "not sent: this pod's firmware (2.0.0) has no la_dir (3.10.4+)"
assert 7 not in connected.dirs
# Older firmware is not a problem to warn about.
assert not any("pin directions" in w for w in call("status")["warnings"])


def test_spi_modes_1_and_2(connected):
Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci-mcp/tests/tools_surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -2551,7 +2551,7 @@
},
"from_wiring": {
"default": false,
"description": "Send the wiring profile's directions instead (uart_rx, spi_miso and input signals are DUT outputs).",
"description": "Send the wiring profile's directions again (each role's and signal's default, or the profile's dut_dirs).",
"title": "From Wiring",
"type": "boolean"
},
Expand Down
14 changes: 14 additions & 0 deletions packages/embeddedci/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog — `embeddedci`

## 2.13.14

- Pin directions are on by default. On a pod with firmware 3.10.4+ the SDK sends the wiring
profile's directions with `la_dir` when it connects and whenever `bp.wiring` changes, and marks
the pins the profile doesn't name `unknown`; older firmware is skipped quietly. The connection
never fails over it: `bp.pin_directions_status` is `"sent"` or `"not sent: <why>"`.
- Every wired role and signal has a DUT direction. New defaults: `swd_swclk` is a DUT input,
`swd_swdio` is `bidir` (SWD pins were left out before). The profile's new `dut_dirs` key
overrides any role or signal (`{"uart_rx": "bidir", "READY": "dut_input"}`). Old profiles stay
valid. `Wiring.dut_dir(name)`, `Wiring.pin_dut_dirs()`; `describe()` shows the direction.
- `apply_pin_directions()` sends DUT outputs last, so a driven pin refusing its mark leaves the
other directions in place.
- `Capabilities.la_dir`: the firmware takes pin directions (3.10.4+).

## 2.13.13

- The pod as a bus master (gateware v55+). `bp.open_i2c(sda, scl, *, hz=100_000, pullups="pod")`
Expand Down
Loading