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

## Unreleased

- `generate` plays the calibrated 16-bit waveform on firmware 3.9.1+ (through the SDK's
`generate16`), and its result says which form ran: `bits` 16 or 8.

## 2.12.0

- New tool `usb_network`: read the pod's USB network setting, or turn the adapter off over the USB
Expand Down
2 changes: 2 additions & 0 deletions packages/embeddedci-mcp/src/embeddedci_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,8 @@ class GenerateResult(BaseModel):
freq_hz: float
dac_path: str
cotrig: bool
bits: int = Field(default=8, description=(
"16 when the pod played the calibrated 16-bit waveform (firmware 3.9.1+), 8 for 8-bit levels."))


class StopResult(BaseModel):
Expand Down
11 changes: 8 additions & 3 deletions packages/embeddedci-mcp/src/embeddedci_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1550,17 +1550,22 @@ async def generate(
"Route dac_path first. false = keep the current analog path (e.g. after analog_path('cal1') "
"for a DAC-to-ADC loopback); dac_path then only sets the volts scaling."))] = True,
) -> m.GenerateResult:
"""Drive a sine, square or sawtooth on a DAC output (built from 8-bit levels).
"""Drive a sine, square or sawtooth on a DAC output.

On firmware 3.9.1+ the pod takes the volts, calibrates them for dac_path and plays the waveform
at the DAC's full 16 bits (bits=16 in the result); both peaks must then be on the path. Older firmware builds it from 8-bit levels (bits=8): 256 steps over the path.

dac_path current_out plays it as a current on the 4-20 mA output (J9), with amplitude and offset
in amps and steps of about 63 uA. See current_out for the wiring it needs. The DAC voltage
in amps and steps of about 0.25 uA (63 uA on older firmware). See current_out for the wiring it needs. The DAC voltage
outputs are switched off for it. dac_stop then leaves the loop where the waveform stopped: call
current_out(0.004) to go back to 4 mA.
"""
handle = await _call(lambda: SESSION.require().generate(
waveform, freq_hz=freq_hz, amplitude=amplitude, offset=offset, dac_path=dac_path,
duration=duration, sample_rate_hz=sample_rate_hz, on_capture=on_capture, route=route))
return m.GenerateResult(waveform=waveform, freq_hz=freq_hz, dac_path=dac_path, cotrig=handle.cotrig)
bits = 16 if (handle.data or {}).get("bits") == 16 else 8
return m.GenerateResult(waveform=waveform, freq_hz=freq_hz, dac_path=dac_path, cotrig=handle.cotrig,
bits=bits)


@mcp.tool(annotations=_ann("Stop the DAC", idempotent=True))
Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci-mcp/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ def test_capture_correlated(connected):

def test_generate_and_stop(connected):
result = call("generate", waveform="sine", freq_hz=1000, amplitude=1.0, on_capture=True)
assert result == {"waveform": "sine", "freq_hz": 1000.0, "dac_path": "5v", "cotrig": True}
assert result == {"waveform": "sine", "freq_hz": 1000.0, "dac_path": "5v", "cotrig": True, "bits": 8}
assert connected.requests[-2] == {"cmd": "dac_out", "path": "5v"}
assert connected.requests[-1]["amplitude"] == 51
assert call("dac_stop") == {"stopped": True}
Expand Down
5 changes: 5 additions & 0 deletions packages/embeddedci/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

- `generate()` on firmware 3.9.1+ (`Capabilities.generate16`) sends the volts (or microamps on
`current_out`) and the pod plays the waveform calibrated at the DAC's full 16 bits, instead of
8-bit levels (256 steps: about 13 mV on `3v3`, 63 µA on the 4-20 mA output). Both peaks must be
on the path. Older firmware keeps the 8-bit levels. `route=False` still keeps the analog
switching.
- `@pytest.mark.benchpod_capability("analog")` skips a test on the digital-only board and runs it
on firmware that does not announce the flag, as `BenchPod.can_analog` does (it used to skip
there too).
Expand Down
13 changes: 11 additions & 2 deletions packages/embeddedci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1017,7 +1017,8 @@ print(max(loop.currents)) # a capture of J8 carries the loop cu
bp.replay(loop, dac_path="current_out") # play the captured loop back on J9
```

- `generate` builds 8-bit levels: steps of about 63 µA. `replay` is 16-bit.
- `generate` steps are about 0.25 µA on firmware 3.9.1+ (`capabilities.generate16`, 16-bit) and
63 µA on older firmware (8-bit levels). `replay` is 16-bit.
- Stopping the returned handle returns the loop to 4 mA. A bare `dac_stop()` does not.
- A capture or recording of a voltage has no current to reproduce: on `current_out` it needs
`mapping="fit"`, which stretches its shape over the output's range. The same holds for a
Expand Down Expand Up @@ -1144,7 +1145,15 @@ from embeddedci.benchpod import Fault

# A parametric waveform, in volts: 0-3.3 V square wave that stops by itself after 5 s.
bp.generate("square", freq_hz=10, amplitude=1.65, offset=1.65, dac_path="3v3", duration=5.0)
```

On firmware 3.9.1+ (`capabilities.generate16`) the pod takes the volts, applies its own per-path
calibration and plays the waveform at the DAC's full 16 bits (about 50 µV a step on `3v3`); both
peaks must be on the path (0 to 3.3 V works: the pod clamps a peak just past an end it cannot quite
reach). Older firmware builds it from 8-bit levels: 256 steps
over the path (about 13 mV on `3v3`).

```python
golden = bp.capture_adc(16_384, sample_rate_hz=100_000, source="ext")

with bp.replay(golden, dac_path="5v"): # loops until the block exits
Expand Down Expand Up @@ -1627,7 +1636,7 @@ reported keeps its default (`False`, `0` or `""`), so a feature check is just
|---|---|
| Identity | `board`, `firmware_version`, `board_rev` (`"v2"`, `"v3"`, `"unknown"`, or `""`), `la_vccio_mv` (the LA bank voltage the pod reports) |
| ADC | `adc_bits`, `adc_fullscale_mv`, `adc_channels`, `adc_offset_counts`, `adc_affine` (the front-end fit), `adc_max_count`, `counts_to_volts(count)` |
| DAC and replay | `dac`, `dac_dc`, `dac_replay`, `dac_deep_replay`, `dac_control_loop`, `dac_loop_sources` (gateware v29+), `dac_loop_input_map` (v30+), `dac_cotrig` (v27+), `dac_bits`, `dac_replay_bits`, `dac_replay_max_samples`, `dac_fullscale_mv`, `dac_channels`, `dac_limits` (output limits on the DAC paths) |
| DAC and replay | `dac`, `dac_dc`, `dac_replay`, `dac_deep_replay`, `dac_control_loop`, `dac_loop_sources` (gateware v29+), `dac_loop_input_map` (v30+), `dac_cotrig` (v27+), `generate16` (firmware 3.9.1+: `generate` in calibrated 16-bit volts), `dac_bits`, `dac_replay_bits`, `dac_replay_max_samples`, `dac_fullscale_mv`, `dac_channels`, `dac_limits` (output limits on the DAC paths) |
| Features | `scope`, `analyzer`, `serial`, `tunnel`, `command`, `ota`, `la_pins`, `gpio_read` (direct pin reads, v35+), `capture_trigger` (v35+), `power_profile`, `capture_b64` (faster ADC read-back, used automatically), `nrst_pin` (the dedicated reset pin, rev3), `usb_cc` (rev3), `spi_master` (v45+), `spi_stream`, `calibrate`, `current_out`, `can`, `pod_current` (`PowerStatus.pod`), `sensor_types` (the BME280/SHT4x/MPU-6050 models), `gps` (the GPS receiver, gateware v48+), `analog` (`False` on the digital-only board, `None` when not announced) |
| Firmware updates and policies | `flash_kb` (MCU flash in KiB, 0 when not reported), `blob_slots`, `ota_sig`, `sig_policy` (`"audit"`, `"required"`, ...), `sig_keys`, `sig_policy_cmd`, `lan_policy` (`"open"`, `"locked"`, `"off"`), `lan_policy_cmd`, `tunnel_max_tier`, `lease_state` (the pod refuses LAN writes while a cloud job holds it), `cloud_ca`, `cloud_proxy`, `ws_auth_v2` (server only) |
| Boot health | `safe_mode`, `safe_reason`, `last_crash`, `reset_cause`, `boot_warning()` (a one-line warning after safe mode or a crash, else `None`) |
Expand Down
6 changes: 6 additions & 0 deletions packages/embeddedci/src/embeddedci/benchpod/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ class Capabilities:
#: DAC↔capture co-trigger: a replay can be armed to fire on the next capture's hardware t0
#: (phase-locked, gateware >= v27). See ``replay_waveform(on_capture=True)``.
dac_cotrig: bool = False
#: ``generate`` takes the waveform in volts (or amps on the 4-20 mA output) and plays it at
#: the DAC's full 16 bits, calibrated (firmware 3.9.1+). Without it the generator works in
#: 8-bit levels: 256 steps over the output's range.
generate16: bool = False
dac_bits: int = 8
dac_replay_bits: int = 8
dac_replay_max_samples: int = 4096
Expand Down Expand Up @@ -295,6 +299,7 @@ def from_status(cls, status: Mapping[str, Any]) -> "Capabilities":
("dac_deep_replay", "dac_deep_replay"),
("dac_control_loop", "dac_control_loop"),
("dac_cotrig", "dac_cotrig"),
("generate16", "generate16"),
("dac_loop_sources", "dac_loop_sources"),
("dac_loop_input_map", "dac_loop_input_map"),
("ota", "ota"),
Expand Down Expand Up @@ -355,6 +360,7 @@ def from_parameters(cls, params: Mapping[str, Any]) -> "Capabilities":
c.dac_fullscale_mv = v
if (v := _as_int(params, "cap.dac_channels")) is not None and v > 0:
c.dac_channels = v
c.generate16 = _as_int(params, "cap.generate_bits") == 16
for attr, key in (
("dac", "cap.dac"), ("dac_dc", "cap.dac_dc"), ("dac_replay", "cap.dac_replay"),
("dac_deep_replay", "cap.dac_deep_replay"),
Expand Down
53 changes: 50 additions & 3 deletions packages/embeddedci/src/embeddedci/benchpod/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1604,13 +1604,15 @@ def generate(self, waveform: Waveshape = "sine", *, freq_hz: float, amplitude: f
"""Generate a parametric waveform (``sine``/``square``/``sawtooth``) on a DAC output path.

``amplitude`` is the peak in volts and ``offset`` the centre in volts (default: the middle
of ``dac_path``'s range — 0 V on the bipolar ±12 V ``12v`` path). The firmware builds the
waveform from 8-bit levels, so volts are
of ``dac_path``'s range — 0 V on the bipolar ±12 V ``12v`` path). On firmware 3.9.1+
(:attr:`Capabilities.generate16`) the pod takes the volts, converts them with its per-path
calibration and plays the waveform at the DAC's full 16 bits; both peaks must then be on
the path (a peak within 5% of an end the pod cannot quite reach is clamped to it). Older firmware builds it from 8-bit levels, so volts are
quantised to ``full_scale / 255`` using the same volts→code mapping as :meth:`replay`.

``dac_path="current_out"`` plays it as a current on the 4-20 mA output (J9):
``amplitude`` and ``offset`` are then AMPS (``amplitude=0.006, offset=0.012`` swings 6 to
18 mA, in steps of about 63 µA). The DAC voltage outputs are switched off, and stopping
18 mA, in steps of about 0.25 µA at 16 bits, 63 µA at 8 bits). The DAC voltage outputs are switched off, and stopping
the handle returns the loop to 4 mA. See :meth:`current_out` for the wiring.
``duration`` (seconds) stops it by itself; omitted, it runs until :meth:`dac_stop` or the
returned handle's ``stop()``. ``on_capture=True`` defers the start to the next capture's
Expand All @@ -1625,6 +1627,10 @@ def generate(self, waveform: Waveshape = "sine", *, freq_hz: float, amplitude: f
check_choice(dac_path, DAC_PATHS, "dac_path")
if freq_hz <= 0:
raise ValueError(f"freq_hz must be > 0, got {freq_hz!r}")
if self.capabilities.generate16:
return self._generate16(waveform, freq_hz=freq_hz, amplitude=amplitude, offset=offset,
dac_path=dac_path, duration=duration,
sample_rate_hz=sample_rate_hz, on_capture=on_capture, route=route)
vmin, vmax = self._output_range(dac_path, generator=True)
unit = "A" if dac_path == _CURRENT_OUT else "V"
span = vmax - vmin
Expand Down Expand Up @@ -1658,6 +1664,47 @@ def generate(self, waveform: Waveshape = "sine", *, freq_hz: float, amplitude: f
return DacHandle(stop=self._stop_for(dac_path), dac_path=dac_path,
cotrig=bool(d.get("cotrig", False)), data=d)

def _generate16(self, waveform: str, *, freq_hz: float, amplitude: float, offset: Optional[float],
dac_path: str, duration: Optional[float], sample_rate_hz: Optional[float],
on_capture: bool, route: bool) -> DacHandle:
""":meth:`generate` on firmware 3.9.1+: the calibrated 16-bit form. The pod routes the
output itself (unless ``route=False``) and refuses peaks it cannot reach."""
vmin, vmax = self._output_range(dac_path)
current = dac_path == _CURRENT_OUT
unit = "A" if current else "V"
amp = float(amplitude)
step = (vmax - vmin) / 65535.0
if not amp >= step:
raise ValueError(f"amplitude must be at least {step:.3g} {unit} (one 16-bit step) on the "
f"{dac_path} path, got {amplitude!r}")
off = (vmin + vmax) / 2.0 if offset is None else float(offset)
# A little slack for the float edges of the range; the pod checks against its calibration.
slack = (vmax - vmin) * 1e-6
if off - amp < vmin - slack or off + amp > vmax + slack:
raise ValueError(f"the peaks {off - amp:.4g} to {off + amp:.4g} {unit} must stay within "
f"{vmin:.4g}..{vmax:.4g} {unit} on the {dac_path} path")
req: Dict[str, Any] = {"cmd": "generate", "waveform": waveform, "freq": float(freq_hz)}
if current:
req["amplitude_ua"] = round(amp * 1e6)
req["offset_ua"] = round(off * 1e6)
else:
req.update({"path": dac_path, "amplitude_v": amp, "offset_v": off})
if duration is not None:
ms = _seconds_to_ms(duration, "duration")
if ms <= 0:
raise ValueError("duration must be at least 1 ms; omit it to run until stopped")
req["duration_ms"] = ms
mhz = _capture.rate_mhz(sample_rate_hz)
if mhz is not None:
req["sample_rate_mhz"] = mhz
if on_capture:
req["on_capture"] = True
if not route:
req["route"] = False
d = _dict(self.command(req))
return DacHandle(stop=self._stop_for(dac_path), dac_path=dac_path,
cotrig=bool(d.get("cotrig", False)), data=d)

def dac_stop(self) -> None:
"""Stop any DAC output: generator, replay or control loop. Idempotent."""
self.command({"cmd": "dac_stop"})
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 @@ -223,6 +223,7 @@
"dac_loop_sources",
"dac_loop_input_map",
"dac_cotrig",
"generate16",
"dac_bits",
"dac_replay_bits",
"dac_replay_max_samples",
Expand Down
4 changes: 2 additions & 2 deletions packages/embeddedci/tests/e2e/test_e2e_pod.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def test_reset_line_and_usb_cc(pod, rev3):
lambda p: p.enable_pullup(7),
lambda p: p.enable_pulldown(1),
lambda p: p.set_la_voltage(5),
lambda p: p.generate("sine", freq_hz=100, amplitude=0.0001),
lambda p: p.generate("sine", freq_hz=100, amplitude=0.0),
lambda p: p.capture_adc(0),
lambda p: p.capture_la(64, sample_rate_hz=0),
lambda p: p.replay([1.0], dac_path="24v"),
Expand All @@ -106,7 +106,7 @@ def test_reset_line_and_usb_cc(pod, rev3):
lambda p: p.decode([0, 1], "can"),
lambda p: p.control_loop(vmin=50000, vmax=1000),
], ids=["analog_path alias", "volts on off", "adc source", "pullup on LA7", "pulldown on LA1",
"la voltage", "tiny amplitude", "zero samples", "zero rate", "dac path", "fpga image",
"la voltage", "zero amplitude", "zero samples", "zero rate", "dac path", "fpga image",
"zero steps", "9 CAN bytes", "protocol", "inverted clamp"])
def test_invalid_arguments_raise_before_touching_the_pod(pod, bad_call):
with pytest.raises(ValueError):
Expand Down
86 changes: 86 additions & 0 deletions packages/embeddedci/tests/test_generate16.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""generate() on firmware 3.9.1+ (Capabilities.generate16): the calibrated 16-bit form.

The pod gets volts (or microamps on the 4-20 mA output) and converts them with its own per-path
calibration, so the SDK sends the values, not 8-bit levels, and lets the pod route the output.
"""

from __future__ import annotations

import pytest

from embeddedci.benchpod.capabilities import Capabilities
from embeddedci.benchpod.client import BenchPod


class _Fake:
def __init__(self, caps):
self.caps = caps
self.commands = []

def status(self):
return {"board": "stm32h563", "adc_bits": 16, "caps": self.caps}

def command(self, req):
self.commands.append(req)
if req.get("cmd") == "current_out":
return {"min_ua": 4055, "max_ua": 20094}
if req.get("cmd") == "generate":
return {"bits": 16} if "generate16" in self.caps else None
return {}

def close(self):
pass


def _pod(monkeypatch, caps):
monkeypatch.delenv("BENCHPOD_LA_VOLTAGE", raising=False)
t = _Fake(caps)
return BenchPod(transport=t, lease=False), t


def test_volts_are_sent_and_the_pod_routes(monkeypatch):
bp, t = _pod(monkeypatch, ["analog", "dac", "generate16"])
assert bp.capabilities.generate16
h = bp.generate("sine", freq_hz=1000, amplitude=0.004, offset=1.65, dac_path="3v3")
assert t.commands == [{"cmd": "generate", "waveform": "sine", "freq": 1000.0, "path": "3v3",
"amplitude_v": 0.004, "offset_v": 1.65}]
assert h.data == {"bits": 16}


def test_route_false_and_on_capture(monkeypatch):
bp, t = _pod(monkeypatch, ["generate16"])
bp.generate("sawtooth", freq_hz=100, amplitude=0.05, offset=2.5, dac_path="5v", route=False,
on_capture=True, duration=0.5)
assert t.commands[-1] == {"cmd": "generate", "waveform": "sawtooth", "freq": 100.0, "path": "5v",
"amplitude_v": 0.05, "offset_v": 2.5, "duration_ms": 500,
"on_capture": True, "route": False}


def test_current_out_in_microamps(monkeypatch):
bp, t = _pod(monkeypatch, ["generate16", "current_out"])
bp.generate("square", freq_hz=10, amplitude=0.004, offset=0.012, dac_path="current_out")
gen = [c for c in t.commands if c["cmd"] == "generate"][-1]
assert gen["amplitude_ua"] == 4000 and gen["offset_ua"] == 12000 and "path" not in gen


def test_peaks_past_the_path_are_refused_not_clipped(monkeypatch):
bp, t = _pod(monkeypatch, ["generate16"])
with pytest.raises(ValueError, match="must stay within 0..3.3 V"):
bp.generate("sine", freq_hz=10, amplitude=1.0, offset=3.0, dac_path="3v3")
with pytest.raises(ValueError, match="one 16-bit step"):
bp.generate("sine", freq_hz=10, amplitude=0, offset=1.0, dac_path="3v3")
assert not [c for c in t.commands if c["cmd"] == "generate"]


def test_older_firmware_keeps_the_8bit_levels(monkeypatch):
bp, t = _pod(monkeypatch, ["analog", "dac"])
assert not bp.capabilities.generate16
bp.generate("sine", freq_hz=10, amplitude=1.0, offset=2.5, dac_path="5v")
gen = t.commands[-1]
assert gen["amplitude"] == 51 and gen["offset"] == 128 and "amplitude_v" not in gen


def test_cloud_parameters():
assert Capabilities.from_parameters({"cap.generate_bits": "16"}).generate16
assert not Capabilities.from_parameters({"cap.generate_bits": "8"}).generate16
assert not Capabilities.from_parameters({}).generate16