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
Expand Up @@ -8,6 +8,14 @@
or several, the error lists the pods it heard (name and address) and what to pass instead:
`host[:port]`, `usb`, or `embeddedci:<device>` (from `cloud_list_devices`).
- The `connect` description, the `status` hint and the server instructions say so.
- Pod refusals name the tool that fixes them instead of a raw protocol command: "la voltage not
set; call set_la_voltage first with the DUT's I/O voltage (1.8 or 3.3)", and a pin conflict
says `release it with gpio_release(la=[4])`, `uart_close`, `disable_i2c_sensor` or
`disable_gps` (a pull conflict `set_pull(las=[7], enabled=false)`). The error kind and the
leading words stay the same; hints it does not know pass through unchanged.
- Target power is named the same way everywhere: "internal 5 V (efuse 1)" and "external
(efuse 2)", in the server instructions, the wiring reference and the tool and result
descriptions.

## 2.9.0

Expand Down
13 changes: 9 additions & 4 deletions packages/embeddedci-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ description, ranges and enums, so an agent sees more than this table.
| --- | --- | --- |
| `power_on` | Switch the target's power rail on (optionally scheduled pod-side). | `efuse`, `delay` |
| `power_off` | Switch the target's power rail off. | `efuse`, `delay` |
| `power_status` | Both eFuse rails: on/off, tripped, bus voltage and current, and the pod's own draw. | none |
| `power_status` | Both target-power rails, internal 5 V (efuse 1) and external (efuse 2): on/off, tripped, bus voltage and current, and the pod's own draw. | none |
| `reset_target` | Drive the DUT's reset line from the pod's reset pin. | `action=pulse`, `pulse=0.1` |

### Power profiles
Expand Down Expand Up @@ -324,8 +324,8 @@ resistors and analog paths) and `benchpod://help` (the server instructions).
flows, units, error contract — so the agent knows to `connect` and `set_la_voltage` before
anything else.
- **Typed, structured results.** Every tool has an input schema with enums and ranges (paths,
sources, LA channels 1-14, eFuse 1/2, …) and an output schema; results come back as structured
content. Units are volts, seconds and hertz.
sources, LA channels 1-14, internal 5 V (efuse 1) or external (efuse 2), …) and an output
schema; results come back as structured content. Units are volts, seconds and hertz.
- **Errors.** A tool that cannot do what was asked fails with an MCP tool error whose message names
the cause, e.g. `FirmwareError: la voltage not set` or `NotConnectedError: …`. A completed
operation with a negative outcome is a normal result: `flash` returns `ok: false` with its logs,
Expand All @@ -339,6 +339,11 @@ resistors and analog paths) and `benchpod://help` (the server instructions).
`UnsupportedFeatureError` means the pod cannot do it at all: its firmware or gateware lacks the
feature, or it is the digital-only board without the analog front end; the hint says which.
`status` also warns when a cloud job holds the pod or the LAN policy is locked.
Refusals whose fix the firmware spells as a raw protocol command name the tool instead:
`FirmwareError: uart_proxy_start: la voltage not set; call set_la_voltage first with the DUT's
I/O voltage (1.8 or 3.3)` and `PinConflictError: uart_proxy_start: pin conflict: LA4 is in use
by gpio; release it with gpio_release(la=[4])`. The other pin owners point to `uart_close`,
`disable_i2c_sensor` and `disable_gps`; a pull conflict to `set_pull(las=[7], enabled=false)`.
- **Capabilities.** `connect` and `status` report what the pod can do as flags, so an agent can
check before it calls a tool: for example `la_pins`, `capture_trigger`, `power_profile`,
`nrst_pin`, `can`, `calibrate`, `current_out`, `pod_current` and `analog` (false on the
Expand Down Expand Up @@ -401,7 +406,7 @@ With a wiring profile stored for the bench, the same run needs no pin numbers:

```
connect()
wiring() # SWCLK on LA11, DUT TX on LA5, rail eFuse 1, …
wiring() # SWCLK on LA11, DUT TX on LA5, rail internal 5 V (efuse 1), …
flash(file="build/app.elf")
power_cycle_and_capture(delay=1.0, duration=5.0, until_regex="APP_OK")
measure_power(duration=2.0, points=100) # what the firmware draws once it is up
Expand Down
8 changes: 4 additions & 4 deletions packages/embeddedci-mcp/src/embeddedci_mcp/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@

Cloud pods: cloud_list_devices lists the pods registered on embeddedci.com (name, online) without connecting to any; connect to one with its `connection` ('embeddedci:<name>'). Tools named cloud_* talk to embeddedci.com, not to a pod. Cloud access (cloud_* and connect to 'embeddedci:<name>') authenticates with BENCHPOD_API_KEY, else the user's `benchpod login` session; if neither exists, ask the user to run `benchpod login`.

Wiring: the pod has 14 generic LA channels (LA1-LA14) and nothing is fixed. Call `wiring` first: it is the bench's profile — which DUT signal is on which channel, the target-power rail, the UART baud, the SWD target. Every channel, baud, rail or SWD argument you omit comes from it, and channel arguments also accept its names ("READY", "uart_rx"). Confirm it with the user if it looks wrong; `set_wiring` replaces it (save=true stores it on embeddedci.com). Target power is eFuse 1 (internal 5 V) or 2 (external). The DUT's reset line goes to the pod's own reset pin: flash(nreset=true), reset_target.
Wiring: the pod has 14 generic LA channels (LA1-LA14) and nothing is fixed. Call `wiring` first: it is the bench's profile: which DUT signal is on which channel, the target-power rail, the UART baud, the SWD target. Every channel, baud, rail or SWD argument you omit comes from it, and channel arguments also accept its names ("READY", "uart_rx"). Confirm it with the user if it looks wrong; `set_wiring` replaces it (save=true stores it on embeddedci.com). Target power is internal 5 V (efuse 1) or external (efuse 2). The DUT's reset line goes to the pod's own reset pin: flash(nreset=true), reset_target.

GPIO: each LA channel has one owner at a time. gpio_mode claims channels (input/output/open_drain), gpio_write drives them, gpio_read reads any channel's live level, gpio_wait polls for one, gpio_pulse emits FPGA-timed pulses, gpio_release frees them. Release a GPIO channel before uart_open, flash or enable_i2c_sensor uses it — otherwise the pod refuses with "pin conflict: LA4 is in use by gpio" and says how to free it; la_pins shows every channel's owner. Captures observe all 14 channels whatever owns them.
GPIO: each LA channel has one owner at a time. gpio_mode claims channels (input/output/open_drain), gpio_write drives them, gpio_read reads any channel's live level, gpio_wait polls for one, gpio_pulse emits FPGA-timed pulses, gpio_release frees them. Release a GPIO channel before uart_open, flash or enable_i2c_sensor uses it, otherwise the pod refuses with "pin conflict: LA4 is in use by gpio; release it with gpio_release(la=[4])": the error names the tool that frees the channel (gpio_release, uart_close, disable_i2c_sensor, disable_gps); la_pins shows every channel's owner. Captures observe all 14 channels whatever owns them.

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.
Expand All @@ -34,7 +34,7 @@
- Power profiles: measure_power(duration) reports the DUT's average/minimum/peak current, voltage, energy and charge over a window, with an optional trace (points). To profile across other tool calls, bracket them with power_profile_start and power_profile_stop.
- CAN: can_open (mode 'internal' self-tests a lone pod), can_write, can_read, can_respond, can_close.

Units are volts, amps, seconds and hertz. A tool that cannot do what was asked fails with an error that names the cause (for example "FirmwareError: la voltage not set" or "NotConnectedError: ..."). A completed operation with a negative outcome is a normal result: flash returns ok=false with its logs, a UART capture returns matched=false. Policy refusals have their own names: PodLockedError (the pod's LAN policy keeps the command for the cloud or USB), PodLeasedError (a cloud job holds the pod; wait and retry), PermissionDeniedError (the credential needs an owner/admin role or access to the device). UnsupportedFeatureError means this pod cannot do it at all (its firmware or gateware lacks the feature, or it is the digital-only board without the analog front end): check the capabilities in status instead of retrying.
Units are volts, amps, seconds and hertz. A tool that cannot do what was asked fails with an error that names the cause (for example "FirmwareError: la voltage not set; call set_la_voltage first ..." or "NotConnectedError: ..."). A completed operation with a negative outcome is a normal result: flash returns ok=false with its logs, a UART capture returns matched=false. Policy refusals have their own names: PodLockedError (the pod's LAN policy keeps the command for the cloud or USB), PodLeasedError (a cloud job holds the pod; wait and retry), PermissionDeniedError (the credential needs an owner/admin role or access to the device). UnsupportedFeatureError means this pod cannot do it at all (its firmware or gateware lacks the feature, or it is the digital-only board without the analog front end): check the capabilities in status instead of retrying.

flash runs OpenOCD on the machine hosting this server and reads `file` from that machine's filesystem. Over the cloud the device is shared: connect takes an exclusive lease, released by disconnect or after the server's idle timeout (the next call reconnects).
"""
Expand All @@ -54,7 +54,7 @@
UART: DUT TX -> pod LA5 rx=5
UART: DUT RX <- pod LA4 tx=4
I2C SDA / SCL LA2 / LA1 sda=2, scl=1 (engage their pull-ups)
Target power eFuse 1 (internal 5 V) efuse=1 (2 = external supply)
Target power internal 5 V (efuse 1) efuse=1 (external: efuse=2)

Bias resistors (3V3-referenced, so unavailable while the LA bank is at 1.8 V):
LA1, LA2 4.7k pull-up LA3, LA4 2.2k pull-up LA5, LA6 10k pull-up
Expand Down
13 changes: 7 additions & 6 deletions packages/embeddedci-mcp/src/embeddedci_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ class WiringResult(BaseModel):
source: str = Field(description="Where the profile came from: defaults, file, server or dict.")
version: int = 1
la_voltage: float = Field(description="LA I/O-bank voltage the profile asks for, in volts.")
efuse: int = Field(description="Target-power rail the tools default to.")
efuse: int = Field(description=(
"Target-power rail the tools default to: internal 5 V (efuse 1) or external (efuse 2)."))
uart_baud: int
i2c_address: int = Field(description="Emulated-sensor address as a 7-bit integer.")
swd_nreset: bool
Expand Down Expand Up @@ -209,7 +210,7 @@ class GpioReleaseResult(BaseModel):
# -- power -----------------------------------------------------------------------

class PowerResult(BaseModel):
efuse: int
efuse: int = Field(description="The rail used: internal 5 V (efuse 1) or external (efuse 2).")
on: bool
delay: Optional[float] = None

Expand All @@ -231,8 +232,8 @@ class PodPowerResult(BaseModel):


class PowerStatusResult(BaseModel):
internal: RailResult
external: RailResult
internal: RailResult = Field(description="The internal 5 V rail (efuse 1).")
external: RailResult = Field(description="The external rail (efuse 2).")
pod: Optional[PodPowerResult] = Field(None, description="The pod's own draw; null on boards that don't measure it.")


Expand All @@ -245,7 +246,7 @@ class PowerProfileResult(BaseModel):
"""A target-power rail profiled over time. Currents are amps, voltages volts, energy joules,
charge coulombs, durations seconds."""

efuse: int
efuse: int = Field(description="The rail used: internal 5 V (efuse 1) or external (efuse 2).")
rate_hz: float = Field(description="Samples per second actually delivered (measured). The pod reads one sensor register per firmware pass, so this lands below what was asked for — roughly 350-450 Hz. Every sample carries its own timestamp, so the trace is exact regardless.")
adc_rate_hz: float = Field(default=0.0, description="Conversion rate the current sensor was configured for — the ceiling, not what arrived.")
n: int = Field(description="Raw samples the statistics cover.")
Expand All @@ -268,7 +269,7 @@ class PowerProfileResult(BaseModel):

class PowerProfileStartResult(BaseModel):
running: bool = True
efuse: int
efuse: int = Field(description="The rail used: internal 5 V (efuse 1) or external (efuse 2).")
rate_hz: float = Field(description="Sample rate asked for; the achieved rate is in the stop result.")
max_duration: float = Field(description="Seconds after which the pod stops sampling by itself.")

Expand Down
68 changes: 62 additions & 6 deletions packages/embeddedci-mcp/src/embeddedci_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@
"LA channel 1-14, or a name from the wiring profile — a signal ('READY') or a role "
"('uart_rx', 'i2c_sda', 'swd_swclk'). Call `wiring` to see the names."))]
BiasPin = Annotated[int, Field(ge=1, le=8)]
EfuseRail = Annotated[Literal[1, 2], Field(description="Target-power eFuse: 1 = internal 5 V, 2 = external supply.")]
EfuseRail = Annotated[Literal[1, 2], Field(description="Target-power rail: internal 5 V (efuse 1) or external (efuse 2).")]
WiredEfuse = Annotated[Optional[Literal[1, 2]], Field(description=(
"Target-power eFuse: 1 = internal 5 V, 2 = external supply. Omit for the wiring profile's rail."))]
"Target-power rail: internal 5 V (efuse 1) or external (efuse 2). Omit for the wiring profile's rail."))]
BaudRate = Annotated[int, Field(ge=300, le=4_000_000)]
WiredBaud = Annotated[Optional[int], Field(ge=300, le=4_000_000, description=(
"UART baud rate; omit for the wiring profile's uart_baud."))]
Expand Down Expand Up @@ -137,8 +137,62 @@ def isa(name: str) -> bool:
return ""


#: The firmware's "la voltage not set; set it with la_voltage (mv 1800 or 3300) first".
_LA_VOLTAGE_UNSET = "la voltage not set"
_LA_VOLTAGE_MCP = ("la voltage not set; call set_la_voltage first with the DUT's I/O voltage "
"(1.8 or 3.3)")

#: Fixes the firmware spells as raw protocol commands (la_pins.c release_hint, the pull and
#: gpio-output checks, command_handler_dap.c, command_handler_dac.c), named as this server's
#: tools. Whole phrases first; anything not listed passes through unchanged. Kept here rather
#: than taken from the SDK so the text is the same with every supported embeddedci release.
_MCP_FIXES = (
(re.compile(r'release it with \{"cmd":"gpio","la":(\d+),"mode":"off"\}'),
r"release it with gpio_release(la=[\1])"),
(re.compile(r"stop the uart proxy first"),
"close the UART session first with uart_close"),
(re.compile(r"end the SWD session first"),
"wait for the running flash (its SWD session) to finish"),
(re.compile(r'stop the sensor emulation first \(\{"cmd":"sensor_stop"\}\)'),
"stop the sensor emulation first with disable_i2c_sensor"),
(re.compile(r'stop the SPI session first \(\{"cmd":"spi_stop"\}\)'),
'stop the SPI session first: another client holds it (the command tool can send '
'{"cmd":"spi_stop"})'),
(re.compile(r'stop the GPS receiver first \(\{"cmd":"gps_stop"\}\)'),
"stop the GPS emulation first with disable_gps"),
(re.compile(r'\(\{"cmd":"spi_stop"\}\)'),
'(another client holds the SPI session; the command tool can send {"cmd":"spi_stop"})'),
(re.compile(r'\{"cmd":"la","la":(\d+),"pullup":"off"\}'), r"set_pull(las=[\1], enabled=false)"),
(re.compile(r'\{"cmd":"gpio","la":(\d+),"mode":"output"\}'), r'gpio_mode(la=[\1], mode="output")'),
(re.compile(r'\{"cmd":"fpga_image","image":0\}'), 'fpga_image(image="loop")'),
(re.compile(r'\{"cmd":"dac_loop_input"\}'), "loop_input"),
)


def _mcp_refusal_text(message: str) -> str:
"""A pod refusal with its protocol-level fix named as this server's tool, or unchanged."""
if message.startswith(_LA_VOLTAGE_UNSET):
return _LA_VOLTAGE_MCP
for pattern, repl in _MCP_FIXES:
message = pattern.sub(repl, message)
return message


def _error_text(exc: BenchPodError) -> str:
"""``str(exc)``, with a firmware refusal's fix rewritten from the pod's own text (the SDK's
message names SDK calls; an agent here needs the tool names)."""
original = getattr(exc, "firmware_message", None)
if not isinstance(exc, _errors.FirmwareError) or not isinstance(original, str):
return str(exc)
text = _mcp_refusal_text(original)
if text == original:
return str(exc)
cmd = getattr(exc, "cmd", None)
return f"{cmd}: {text}" if cmd else text


def _tool_error(exc: BenchPodError) -> ToolError:
msg = f"{type(exc).__name__}: {exc}"
msg = f"{type(exc).__name__}: {_error_text(exc)}"
hint = _refusal_hint(exc)
return ToolError(f"{msg}. {hint}" if hint else msg)

Expand Down Expand Up @@ -449,7 +503,8 @@ def op() -> m.PowerProfileResult:

@mcp.tool(annotations=_ann("Power status", read_only=True))
async def power_status() -> m.PowerStatusResult:
"""Both target-power rails: eFuse on/off and tripped state, bus voltage and current draw."""
"""Both target-power rails, internal 5 V (efuse 1) and external (efuse 2): on/off and tripped
state, bus voltage and current draw."""
def op() -> m.PowerStatusResult:
pod = SESSION.require()
ts, ps = pod.target_status(), pod.power_status()
Expand Down Expand Up @@ -625,7 +680,8 @@ async def flash(
"The target's reset line is wired to the pod's reset pin: enables connect-under-reset. "
"Omit for the profile's swd_nreset."))] = None,
load_address: Annotated[str, Field(description="Load address for raw .bin images, e.g. 0x08000000.")] = "",
target_power: Annotated[Optional[Literal[1, 2]], Field(description="Power this eFuse on before flashing.")] = None,
target_power: Annotated[Optional[Literal[1, 2]], Field(description=(
"Power this rail on before flashing: internal 5 V (efuse 1) or external (efuse 2)."))] = None,
verify: bool = True,
reset: bool = True,
connect_under_reset: Optional[bool] = None,
Expand Down Expand Up @@ -1020,7 +1076,7 @@ async def gpio_mode(
A channel stays GPIO — across disconnects — until gpio_release, and while it is GPIO nothing
else can use it: release it before uart_open, flash or enable_i2c_sensor on that channel.
A channel already owned by another function is refused with a PinConflictError naming the
owner, and an engaged bias resistor that would fight the mode with a PullConflictError.
owner and the tool that frees it, and an engaged bias resistor that would fight the mode with a PullConflictError.
"""
def op() -> m.GpioPinsResult:
pod = SESSION.require()
Expand Down
6 changes: 4 additions & 2 deletions packages/embeddedci-mcp/tests/test_gpio_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,16 @@ def test_gpio_pulse_runs_a_step_train(connected):

def test_pin_conflict_names_the_owner_and_how_to_free_it(connected):
call("uart_open", rx=5, tx=4)
with pytest.raises(ToolError, match="PinConflictError: gpio: pin conflict: LA5 is in use by uart_rx"):
with pytest.raises(ToolError, match="PinConflictError: gpio: pin conflict: LA5 is in use by uart_rx; "
"close the UART session first with uart_close"):
call("gpio_mode", la=[5])
call("uart_close")


def test_a_gpio_channel_blocks_the_uart_until_it_is_released(connected):
call("gpio_mode", la=[5], mode="output")
with pytest.raises(ToolError, match=r"PinConflictError:.*LA5 is in use by gpio"):
with pytest.raises(ToolError, match=r"PinConflictError:.*LA5 is in use by gpio; "
r"release it with gpio_release\(la=\[5\]\)$"):
call("uart_open", rx=5, tx=4)
call("gpio_release", la=[5])
assert call("uart_open", rx=5, tx=4)["open"] is True
Expand Down
Loading