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
9 changes: 9 additions & 0 deletions packages/embeddedci-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog — `embeddedci-mcp`

## 2.13.4

- New tool `adc_range`: read or switch the ADC input range (normal ±32.8 V, low ±5.46 V at 6x the
resolution; analog module v3r4+, firmware 3.9.5+), or run its self-test (`test=true`).
`analog_path`, `adc_read` and `capture_adc` take `range`; `calibrate_adc_gain` takes `range=low`
for the low range's gain. `adc_read` reports `range`, `fullscale` and `overrange`, `capture_adc`
`range` and `clipped_samples`, `calibration` `adc_low_gain_ppm`.
- Requires embeddedci 2.13.4.

## 2.13.3

- New tools `calibrate_dac` and `calibrate_adc_gain`: the DMM-guided calibration (firmware 3.9.4+).
Expand Down
9 changes: 5 additions & 4 deletions packages/embeddedci-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,20 +286,21 @@ the pod's second UART, so `uart_open` keeps the console. Needs gateware v48 (`gp

| Tool | Purpose | Parameters |
| --- | --- | --- |
| `analog_path` | Set every analog mux and relay for a named path in one step. | **`path`** |
| `analog_path` | Set every analog mux and relay for a named path in one step; `range` sets the ADC input range too. | **`path`**, `range` |
| `dac_output` | Route a DAC output and drive a calibrated DC voltage on it. | **`path`**, `volts` |
| `current_out` | Hold a current on the 4-20 mA output (J9), in amps, or read its range. | `current` |
| `adc_read` | One calibrated reading: front SMA, DAC loopbacks or the 4-20 mA input (J8). | `source=ext` |
| `adc_read` | One calibrated reading: front SMA, DAC loopbacks or the 4-20 mA input (J8); reports the ADC range, its full scale and overrange. | `source=ext`, `range` |
| `adc_range` | Read or switch the ADC input range: normal ±32.8 V or low ±5.46 V at 6x the resolution (analog module v3r4+, firmware 3.9.5+; 1x leads, the low range's input is 1.82 MΩ), or self-test it through the cal1 loopback. | `range`, `test=false` |
| `calibration` | The stored calibration: the ADC offset (measured on the 4-20 mA input) and the fit it uses, plus the DMM lines and gains (firmware 3.9.4+). | none |
| `calibrate` | Measure and store the ADC offset on the 4-20 mA input (J8 open), or clear the calibration (all of it, or one part). | `source=current_in`, `clear=false`, `clear_part` |
| `calibrate_dac` | Store this pod's line for a DAC output from two DMM readings of set points driven with `dac_output` (firmware 3.9.4+). | **`path`**, **`code1`**, **`volts1`**, **`code2`**, **`volts2`** |
| `calibrate_adc_gain` | Measure an ADC source's gain through a loopback against a DMM-calibrated output (firmware 3.9.4+). | **`source`** |
| `calibrate_adc_gain` | Measure an ADC source's gain through a loopback against a DMM-calibrated output (firmware 3.9.4+); `range=low` the ADC low range's gain through cal1 (firmware 3.9.5+). | **`source`**, `range=normal` |

### Capture and decode

| Tool | Purpose | Parameters |
| --- | --- | --- |
| `capture_adc` | Capture the ADC: calibrated statistics, dominant frequency and a min/max envelope. | `samples=4096`, `sample_rate_hz`, `source`, `points=200`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10` |
| `capture_adc` | Capture the ADC: calibrated statistics, dominant frequency and a min/max envelope; `clipped_samples` counts samples past the range's full scale. | `samples=4096`, `sample_rate_hz`, `source`, `points=200`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10`, `range` |
| `capture_la` | Capture all 14 LA channels: levels, edge count, first edge and frequency per channel. | `samples=4096`, `sample_rate_hz`, `stop_dac_after`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10` |
| `capture_correlated` | ADC and LA from one hardware trigger, on one timebase. | `adc_samples=4096`, `adc_sample_rate_hz`, `la_samples=4096`, `la_sample_rate_hz`, `stop_dac_after`, `points=200`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10`, `source` |
| `decode_la` | Decode I2C, UART or SPI from the last LA capture (or a new one). | **`protocol`**, `sda`, `scl`, `rx`, `baud`, `sclk`, `mosi`, `miso`, `cs`, `mode=0`, `capture=last`, `samples=16384`, `sample_rate_hz=1000000`, `max_items=200` |
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.3"
version = "2.13.4"
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.
"embeddedci[cloud,discovery,analysis]>=2.13.3,<3",
"embeddedci[cloud,discovery,analysis]>=2.13.4,<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.3",
"version": "2.13.4",
"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.3",
"version": "2.13.4",
"runtimeHint": "uvx",
"transport": {
"type": "stdio"
Expand Down
35 changes: 35 additions & 0 deletions packages/embeddedci-mcp/src/embeddedci_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,29 @@ class PullStatusResult(BaseModel):

class AnalogPathResult(BaseModel):
path: str
range: Optional[str] = Field(None, description=(
"The ADC input range in use afterwards: normal or low (firmware 3.9.5+; null on older firmware)."))


class AdcRangeTestPointResult(BaseModel):
set_voltage: float = Field(description="What the 5 V output put into the cal1 loopback, volts.")
normal: float = Field(description="What the normal range read, volts.")
low: float = Field(description="What the low range read, volts.")
ok: bool


class AdcRangeTestResult(BaseModel):
passed: bool = Field(description="Both ranges agree with the set points within 5 % + 30 mV.")
points: List[AdcRangeTestPointResult]


class AdcRangeResult(BaseModel):
range: str = Field(description="The ADC input range in use: normal (±32.8 V) or low (±5.46 V).")
supported: bool = Field(description="The board has the low range (analog module v3r4 and later).")
fullscale: float = Field(description="Full scale of the range in use, volts (±).")
normal_fullscale: float = Field(description="Full scale of the normal range, volts (±).")
low_fullscale: Optional[float] = Field(None, description="Full scale of the low range, volts (±); null without it.")
test: Optional[AdcRangeTestResult] = Field(None, description="The self-test's result (test=true only).")


class DacOutputResult(BaseModel):
Expand Down Expand Up @@ -416,6 +439,12 @@ class AdcReadResult(BaseModel):
"The pod's own calibration offset in volts, already taken out of voltage (current_in only)."))
current: Optional[float] = Field(None, description=(
"Loop current in amps on the 4-20 mA input (current_in only): 0.004 to 0.020 for a live loop."))
range: Optional[str] = Field(None, description=(
"The ADC input range the reading was taken in: normal or low (firmware 3.9.5+)."))
fullscale: Optional[float] = Field(None, description="That range's full scale, volts (±).")
overrange: bool = Field(False, description=(
"A sample sat on an ADC rail: the input is past fullscale and voltage is only a bound. "
"Read again in the normal range."))


class DacLineResult(BaseModel):
Expand All @@ -439,6 +468,8 @@ class CalibrationResult(BaseModel):
adc_gain_ppm: Dict[str, Optional[int]] = Field(default_factory=dict, description=(
"ADC gain correction per source (cal1, cal2, ext) in ppm; null = the built-in gain."))
dmm: bool = Field(False, description="True when any DMM-guided part is stored.")
adc_low_gain_ppm: Optional[int] = Field(None, description=(
"The ADC low range's gain correction in ppm (analog module v3r4+); null = the nominal divider ratio."))
zero_count: Optional[float] = Field(None, description=(
"The raw zero the offset is derived from: the mean ADC count with J8 open, unwrapped (firmware 3.9.4+)."))
measured: Optional[Dict[str, float]] = Field(None, description=(
Expand Down Expand Up @@ -467,6 +498,10 @@ class AdcCaptureResult(BaseModel):
trigger: Optional[str] = Field(None, description=(
"The trigger that started the capture, e.g. 'LA9 rising'; null for a free-running capture. "
"t = 0 is the trigger moment."))
range: Optional[str] = Field(None, description=(
"The ADC input range the capture was scaled for: normal or low (firmware 3.9.5+)."))
clipped_samples: int = Field(0, description=(
"Samples on an ADC rail: the input went past the range's full scale. Capture again in the normal range."))


class LaChannelSummary(BaseModel):
Expand Down
62 changes: 52 additions & 10 deletions packages/embeddedci-mcp/src/embeddedci_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
AdcSource,
AnalogPath,
AdcGainSource,
AdcRange,
CalibrateSource,
CalibrationPart,
DacCalPath,
Expand Down Expand Up @@ -1286,16 +1287,49 @@ def op() -> m.GpioReleaseResult:

# -- analog ------------------------------------------------------------------------------

AdcRangeArg = Annotated[Optional[AdcRange], Field(description=(
"ADC input range: normal (±32.8 V, ~1 mV/count) or low (±5.46 V, ~167 µV/count, 1.82 MΩ input: 1x leads only; "
"analog module v3r4+). Omit to keep the range the pod is in."))]


@mcp.tool(annotations=_ann("Apply an analog path", idempotent=True))
async def analog_path(path: AnalogPath) -> m.AnalogPathResult:
async def analog_path(path: AnalogPath, range: AdcRangeArg = None) -> m.AnalogPathResult:
"""Set every analog mux and relay for a named path in one step.

dac_3v3/dac_5v/dac_12v route the DAC to an output; adc_ext connects the ADC to the front SMA;
cal1/cal2 loop the 5 V/12 V DAC output back into the ADC; current_in reads the 4-20 mA input (J8);
current_out switches the DAC voltage outputs off for the 4-20 mA output (J9); off parks everything.
range also sets the ADC input range (see adc_range); off and cal2 put it back to normal.
"""
state = await _call(lambda: SESSION.require().analog_path(path, range=range))
return m.AnalogPathResult(path=state.path, range=state.range or None)


@mcp.tool(annotations=_ann("Read or switch the ADC input range", idempotent=True))
async def adc_range(
range: AdcRangeArg = None,
test: Annotated[bool, Field(description=(
"Run the self-test instead: the 5 V output drives the cal1 loopback at 1 V and 3.5 V and both ranges read it. "
"Nothing may be connected to the 5 V output."))] = False,
) -> m.AdcRangeResult:
"""Read or switch the ADC input range (analog module v3r4 and later, firmware 3.9.5+).

normal is the ÷12 divider: ±32.8 V, about 1 mV per count. low is ÷2: ±5.46 V, about 167 µV per count,
6x the resolution for signals up to ±5 V. The low range's input is 1.82 MΩ: tell the user to use 1x
leads, not a 10x scope probe. It applies to ext, cal1 and current_in (not cal2), and stays until changed,
analog_path off or cal2, or a pod restart. capture_adc and adc_read scale with the range in use.
A board without the low range refuses low. Use low for small signals; switch back to normal when a
reading or capture reports overrange.
"""
state = await _call(lambda: SESSION.require().analog_path(path))
return m.AnalogPathResult(path=state.path)
result = None
if test:
res = await _call(lambda: SESSION.require().adc_range_test())
result = m.AdcRangeTestResult(passed=res.passed, points=[
m.AdcRangeTestPointResult(set_voltage=p.set_voltage, normal=p.normal, low=p.low, ok=p.ok)
for p in res.points])
st = await _call(lambda: SESSION.require().adc_range(None if test else range))
return m.AdcRangeResult(range=st.range, supported=st.supported, fullscale=st.fullscale,
normal_fullscale=st.normal_fullscale, low_fullscale=st.low_fullscale, test=result)


@mcp.tool(annotations=_ann("Set a DC output voltage", destructive=True, idempotent=True))
Expand Down Expand Up @@ -1336,16 +1370,18 @@ async def current_out(


@mcp.tool(annotations=_ann("Read a voltage", read_only=True))
async def adc_read(source: AdcSource = "ext") -> m.AdcReadResult:
async def adc_read(source: AdcSource = "ext", range: AdcRangeArg = None) -> m.AdcReadResult:
"""One calibrated voltage: ext = front SMA (true volts), cal1/cal2 = DAC loopbacks, current_in = 4-20 mA input (J8).

current_in also returns current, the loop current in amps.
current_in also returns current, the loop current in amps. range low reads small signals (up to ±5.46 V)
at 6x the resolution on analog module v3r4+; overrange says the input clipped.

Refused while the input is still moving (e.g. a DAC output left running).
"""
r = await _call(lambda: SESSION.require().adc_read(source))
r = await _call(lambda: SESSION.require().adc_read(source, range=range))
return m.AdcReadResult(source=r.source, voltage=r.voltage, count=r.count, span=r.span,
offset=r.offset, current=r.current)
offset=r.offset, current=r.current, range=r.range or None,
fullscale=r.fullscale, overrange=r.overrange)


def _calibration_result(c: Any) -> m.CalibrationResult:
Expand All @@ -1354,6 +1390,7 @@ def _calibration_result(c: Any) -> m.CalibrationResult:
return m.CalibrationResult(source=c.source, calibrated=c.calibrated, offset=c.offset, a=c.a, b=c.b,
count=c.count, span=c.span, samples=c.samples, dac=dac,
adc_gain_ppm=dict(c.adc_gain_ppm or {}), dmm=c.dmm, measured=c.measured,
adc_low_gain_ppm=c.adc_low_gain_ppm,
zero_count=c.zero_count)


Expand Down Expand Up @@ -1414,14 +1451,17 @@ async def calibrate_adc_gain(
source: Annotated[AdcGainSource, Field(description=(
"cal1 (and current_in) through the 5 V output, cal2 through the ±12 V output, ext (the front SMA, "
"captures and the control loop) through an SMA cable from the ±12 V output to the ADC input."))],
range: Annotated[AdcRange, Field(description=(
"low measures the ADC low range's gain instead (analog module v3r4+, firmware 3.9.5+): source must be "
"cal1, and the one gain serves every source in the low range."))] = "normal",
) -> m.CalibrationResult:
"""Measure and store an ADC source's gain against a DMM-calibrated DAC output (firmware 3.9.4+).

Needs the ADC offset (calibrate) and the driving output's DMM line (calibrate_dac on 5v for cal1, 12v for
cal2 and ext). For ext, ask the user to connect the SMA cable first. The pod drives two set points itself
and switches the outputs off afterwards; measured says what it saw. Nothing to read on a DMM.
"""
return _calibration_result(await _call(lambda: SESSION.require().calibrate_adc_gain(source)))
return _calibration_result(await _call(lambda: SESSION.require().calibrate_adc_gain(source, range=range)))


@mcp.tool(annotations=_ann("Capture an ADC waveform"))
Expand All @@ -1433,6 +1473,7 @@ async def capture_adc(
trigger_la: TriggerLa = None,
trigger_edge: Annotated[TriggerEdge, Field(description="What starts the capture on trigger_la.")] = "rising",
trigger_timeout: TriggerTimeoutS = 10.0,
range: AdcRangeArg = None,
ctx: Context = None, # type: ignore[assignment]
) -> m.AdcCaptureResult:
"""Capture the ADC and summarise it: calibrated stats, dominant frequency and a min/max envelope.
Expand All @@ -1442,12 +1483,13 @@ async def capture_adc(

Above 32768 samples the capture streams from PSRAM (multi-second captures work). With
trigger_la the capture waits for that edge or level, so t = 0 is the trigger moment. The
capture is kept for replay and save_capture_as_recording.
capture is kept for replay and save_capture_as_recording. range low captures small signals
(up to ±5.46 V) at 6x the resolution (see adc_range); clipped_samples > 0 means it clipped.
"""
def op() -> m.AdcCaptureResult:
cap = SESSION.require().capture_adc(samples, sample_rate_hz=sample_rate_hz, source=source,
trigger=_trigger(trigger_la, trigger_edge),
trigger_timeout=trigger_timeout)
trigger_timeout=trigger_timeout, range=range)
SESSION.last_adc = cap
return adc_summary(cap, points)

Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci-mcp/src/embeddedci_mcp/summaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def adc_summary(cap: Capture, points: int) -> m.AdcCaptureResult:
source=cap.source or None, unit=unit, mean=r(cap.mean()), min=r(cap.min()), max=r(cap.max()),
peak_to_peak=r(cap.peak_to_peak()), rms=r(cap.rms()), rms_ac=r(cap.rms_ac()),
dominant_frequency_hz=dominant, envelope_step=step, envelope_min=mins, envelope_max=maxs,
trigger=trigger_text(cap.trigger),
trigger=trigger_text(cap.trigger), range=cap.range or None, clipped_samples=cap.clipped_samples,
)


Expand Down
20 changes: 20 additions & 0 deletions packages/embeddedci-mcp/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,28 @@ def _cmd_current_out(self, req):
code = max(0, round((req["ua"] - 4015.686) / 0.245098))
return {"ua": round(4015.686 + 0.245098 * code), "code": code, **rng}

adc_range_state = "normal"

def _cmd_adc_range(self, req):
# analog module v3r4 (firmware 3.9.5+): normal / low, and the self-test.
if req.get("test"):
self.adc_range_state = "normal"
return {"points": [{"set_mv": 1000, "normal_mv": 1001, "low_mv": 999, "ok": True},
{"set_mv": 3500, "normal_mv": 3502, "low_mv": 3497, "ok": True}],
"pass": True, "range": "normal"}
if "range" in req:
self.adc_range_state = req["range"]
low = self.adc_range_state == "low"
return {"range": self.adc_range_state, "supported": True, "fullscale_mv": 5462 if low else 32856,
"normal_fullscale_mv": 32856, "low_fullscale_mv": 5462,
"adc_cal_a_uv": 10923784 if low else 65708158, "adc_cal_b_nv": -166687 if low else -1002644}

def _cmd_adc_read(self, req):
if "range" in req:
self.adc_range_state = req["range"]
reply = {"source": req.get("source", "ext"), "mv": 3301, "count": 63049, "span": 2}
if "range" in req:
reply.update({"range": self.adc_range_state, "fullscale_mv": 5462, "overrange": False})
if reply["source"] == "current_in":
reply["offset_mv"] = round(self.current_in_offset_uv / 1000)
reply["ua"] = round(reply["mv"] * 1000 / 249)
Expand Down
Loading