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
12 changes: 8 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Developer shortcuts. Unit tests need no hardware; the e2e targets drive a real BenchPod.
#
# make test
# make e2e POD=192.168.1.215 [FIRMWARE=path/to/scenario-sensors.elf] [USB=/dev/cu.usbmodem…] [REV=v3]
# make e2e POD=192.168.1.215 [FIRMWARE=path/to/scenario-sensors.elf] [USB=/dev/cu.usbmodem…] [REV=v3] [BOARD=digital]
# BENCHPOD_API_KEY=eci_… make e2e-cloud CLOUD_DEVICE=benchpod-v2.0.0
#
# The board's LA voltage is set once in packages/embeddedci/tests/conftest.py; the bench wiring in
Expand All @@ -13,13 +13,17 @@ FIRMWARE ?=
USB ?=
CLOUD_DEVICE ?=
REV ?=
BOARD ?=
# 1 (default): clear the pod's DAC output limits for the run and restore them afterwards
# (pytest_plugin._benchpod_lift_dac_limits). LIFT_DAC_LIMITS=0 when an output stage is live.
LIFT_DAC_LIMITS ?= 1

FIRMWARE_OPT = $(if $(FIRMWARE),--benchpod-firmware=$(abspath $(FIRMWARE)),)
# REV=v3 makes a pod that reports another board revision fail instead of taking the v2 branches.
REV_ENV = $(if $(REV),BENCHPOD_E2E_BOARD_REV=$(REV),) BENCHPOD_LIFT_DAC_LIMITS=$(LIFT_DAC_LIMITS)
# BOARD=analog|digital fails the run on the other board: a digital pod skips every analog test, so
# an analog pod whose front end went undetected would otherwise pass green.
REV_ENV = $(if $(REV),BENCHPOD_E2E_BOARD_REV=$(REV),) $(if $(BOARD),BENCHPOD_EXPECT_BOARD=$(BOARD),) \
BENCHPOD_LIFT_DAC_LIMITS=$(LIFT_DAC_LIMITS)

.PHONY: test e2e e2e-cloud check-sdk

Expand All @@ -36,7 +40,7 @@ check-sdk:
@$(PYTHON) scripts/check_sdk.py

e2e: check-sdk
@test -n "$(POD)" || { echo "usage: make e2e POD=<pod host> [FIRMWARE=app.elf] [USB=/dev/…] [REV=v3]"; exit 2; }
@test -n "$(POD)" || { echo "usage: make e2e POD=<pod host> [FIRMWARE=app.elf] [USB=/dev/…] [REV=v3] [BOARD=digital]"; exit 2; }
# tests/examples too: it is the first thing a new user runs, so it must not rot.
$(REV_ENV) BENCHPOD_E2E_USB="$(USB)" $(PYTHON) -m pytest -v -rs \
packages/embeddedci/tests/e2e packages/embeddedci/tests/examples \
Expand All @@ -48,6 +52,6 @@ e2e: check-sdk

e2e-cloud:
@test -n "$(CLOUD_DEVICE)" || { echo "usage: BENCHPOD_API_KEY=eci_… make e2e-cloud CLOUD_DEVICE=<name>"; exit 2; }
BENCHPOD_E2E_CLOUD_DEVICE="$(CLOUD_DEVICE)" BENCHPOD_LIFT_DAC_LIMITS=$(LIFT_DAC_LIMITS) \
BENCHPOD_E2E_CLOUD_DEVICE="$(CLOUD_DEVICE)" $(REV_ENV) \
$(PYTHON) -m pytest -v -rs packages/embeddedci/tests/e2e/test_e2e_cloud.py \
--benchpod-connection=embeddedci:$(CLOUD_DEVICE)
34 changes: 31 additions & 3 deletions packages/embeddedci-mcp/tests/test_e2e_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
[--benchpod-firmware=<scenario-sensors.elf>]

Skips without a connection. The DUT part expects the examples/scenario-sensors-stm32 app with its
UART on BENCHPOD_E2E_UART_RX/TX (default LA3/LA4) and SWD on LA11/LA12.
UART on BENCHPOD_E2E_UART_RX/TX (default LA3/LA4) and SWD on LA11/LA12. On the digital-only board
the analog tests skip and test_digital_board_refuses_the_analog_tools runs instead;
BENCHPOD_EXPECT_BOARD=analog|digital fails the run on the other board.
"""

from __future__ import annotations
Expand All @@ -30,15 +32,26 @@ def _pin(name: str, default: int) -> int:
def connected(benchpod_connection):
status = call("connect", connection=benchpod_connection, la_voltage=LA_VOLTAGE)
assert status["connected"] and status["la_voltage"] == LA_VOLTAGE, status
board = "digital" if status["capabilities"].get("analog") is False else "analog"
want = os.environ.get("BENCHPOD_EXPECT_BOARD", "").strip().lower()
if want and want != board:
pytest.fail(f"the pod reports the {board} board, the bench expects {want} (BENCHPOD_EXPECT_BOARD)")
yield status
for tool, args in (("dac_stop", {}), ("analog_path", {"path": "off"}), ("can_close", {}),
("disable_i2c_sensor", {}), ("disconnect", {})):
cleanup = [("dac_stop", {}), ("can_close", {}), ("disable_i2c_sensor", {}), ("disconnect", {})]
if board == "analog":
cleanup.insert(1, ("analog_path", {"path": "off"}))
for tool, args in cleanup:
try:
call(tool, **args)
except ToolError:
pass


def _require_analog(connected) -> None:
if connected["capabilities"].get("analog") is False:
pytest.skip("digital-only board: no analog front end (DAC, ADC, 4-20 mA)")


def test_status_and_typed_state(connected):
assert connected["capabilities"]["board"]
# A bench pod on a computer's USB port is told about its 500 mA port; nothing else may warn.
Expand All @@ -49,6 +62,7 @@ def test_status_and_typed_state(connected):


def test_analog_round_trip(connected):
_require_analog(connected)
out = call("dac_output", path="5v", volts=2.5)
assert out["path"] == "5v" and abs(out["voltage"] - 2.5) < 0.05
reading = call("adc_read", source="cal1")
Expand All @@ -59,6 +73,7 @@ def test_current_loop_terminals(connected):
"""The 4-20 mA tools with nothing wired: a held current and a waveform in amps reach the DAC
(read back on the 5 V loopback), and the open input is captured in amps with the logic
channels. Loop current itself needs the bench fixture."""
_require_analog(connected)
if not connected["capabilities"].get("current_out"):
pytest.skip("this pod's firmware has no current_out command")
if os.environ.get("BENCHPOD_E2E_J8_WIRED") == "1":
Expand Down Expand Up @@ -93,6 +108,7 @@ def test_current_loop_terminals(connected):


def test_generate_capture_summary_and_replay(connected):
_require_analog(connected)
pytest.importorskip("numpy")
call("analog_path", path="cal1")
call("generate", waveform="sine", freq_hz=200, amplitude=0.8, offset=1.5, route=False)
Expand All @@ -104,6 +120,18 @@ def test_generate_capture_summary_and_replay(connected):
assert replay["samples"] == 2000 and not replay["deep"]


def test_digital_board_refuses_the_analog_tools(connected):
if connected["capabilities"].get("analog") is not False:
pytest.skip("analog board: the digital-only checks do not apply")
assert not connected["capabilities"].get("scope")
for tool, args in (("dac_output", {"path": "5v", "volts": 1.0}), ("adc_read", {"source": "cal1"}),
("current_out", {"current": 0.012}), ("capture_adc", {"samples": 1024})):
with pytest.raises(ToolError, match=r"UnsupportedFeatureError: .*digital-only board"):
call(tool, **args)
call("dac_stop") # the cleanup every agent runs still works
assert "internal" in call("power_status")


def test_logic_capture_and_decode(connected):
la = call("capture_la", samples=8192, sample_rate_hz=1_000_000)
assert la["samples"] == 8192 and len(la["channels"]) == 14
Expand Down
11 changes: 11 additions & 0 deletions packages/embeddedci-openhtf/tests/test_e2e_openhtf.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,16 @@ def _run(*phases):
return record, failed


def _require_analog(connection: str) -> None:
from embeddedci.benchpod import BenchPod

with BenchPod(connection, lease=False) as probe:
if not probe.can_analog:
pytest.skip("digital-only board: no analog front end (DAC, ADC, 4-20 mA)")


def test_analog_phases_against_the_pod(benchpod_connection):
_require_analog(benchpod_connection)
bench = benchpod_plug(benchpod_connection, la_voltage=LA_VOLTAGE)
record, failed = _run(
dac_output_phase(bench, path="5v", volts=2.5),
Expand All @@ -62,6 +71,8 @@ def test_current_loop_phases_against_the_pod(benchpod_connection):
from embeddedci.benchpod import BenchPod

with BenchPod(benchpod_connection, lease=False) as probe:
if not probe.can_analog:
pytest.skip("digital-only board: no analog front end (DAC, ADC, 4-20 mA)")
if not probe.capabilities.current_out:
pytest.skip("this pod's firmware has no current_out command")
if os.environ.get("BENCHPOD_E2E_J8_WIRED") == "1":
Expand Down
8 changes: 8 additions & 0 deletions packages/embeddedci/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog — `embeddedci`

## Unreleased

- `@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).
- On the digital-only board, `current_out` and `calibrate` raise `UnsupportedFeatureError` that
names the missing analog front end (`feature="analog"`) instead of asking for newer firmware.

## 2.12.0

- Firmware 3.9.0 keeps ST's USB id `0483:5740` (pre-release builds used `1209:0001`); auto-detect
Expand Down
3 changes: 3 additions & 0 deletions packages/embeddedci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,9 @@ nothing changes.
`dac_deep_replay`, `dac_control_loop`, `dac_loop_sources`, `dac_cotrig`, …). For the
image-bound `dac_control_loop` and `dac_deep_replay` it switches the pod's gateware image instead
of skipping, when the pod carries both images (see [Gateware images](#gateware-images)).
`benchpod_capability("analog")` skips only on the digital-only board (no DAC, ADC or 4-20 mA);
firmware that does not announce the flag ran on analog boards only and counts as analog, like
`BenchPod.can_analog`.

## Wiring profile

Expand Down
11 changes: 11 additions & 0 deletions packages/embeddedci/src/embeddedci/benchpod/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@

_log = logging.getLogger("embeddedci.benchpod")

#: Capability flags only the analog board announces: the digital-only board lacks them whatever its
#: firmware (cloud_caps_hw.c).
_ANALOG_FLAGS = frozenset({"scope", "dac_replay", "dac_deep_replay", "dac_control_loop", "dac_cotrig",
"dac_loop_sources", "dac_loop_input_map", "calibrate", "current_out"})

#: The capability flag and FPGA_FEATURES bit each gateware image carries.
_IMAGE_FLAG = {FpgaImage.LOOP: "dac_control_loop", FpgaImage.DEEP_REPLAY: "dac_deep_replay"}
_IMAGE_FEATURE = {FpgaImage.LOOP: 0x01, FpgaImage.DEEP_REPLAY: 0x02}
Expand Down Expand Up @@ -426,6 +431,12 @@ def can_analog(self) -> bool:
def _require_capability(self, flag: str, what: str) -> None:
if not self.supports(flag):
fw = self.capabilities.firmware_version or "unknown"
if flag in _ANALOG_FLAGS and not self.can_analog:
# The digital board drops these flags: newer firmware would not bring them back.
raise UnsupportedFeatureError(
f"{what} need the analog front end: this is the digital-only board (no DAC, "
f"ADC or 4-20 mA)", feature="analog",
firmware_version=self.capabilities.firmware_version)
raise UnsupportedFeatureError(
f"{what} need newer pod firmware (the {flag!r} capability is missing; "
f"this pod runs firmware {fw})",
Expand Down
11 changes: 9 additions & 2 deletions packages/embeddedci/src/embeddedci/benchpod/pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ def pytest_configure(config: "pytest.Config") -> None:
"markers",
"benchpod_capability(name): skip the test unless the connected device advertises the "
"given capability (e.g. 'scope', 'analyzer', 'dac_replay'); 'dac_control_loop' and "
"'dac_deep_replay' switch the gateware image when the pod carries both.",
"'dac_deep_replay' switch the gateware image when the pod carries both; 'analog' skips "
"on the digital-only board (firmware that does not say counts as analog).",
)


Expand All @@ -239,6 +240,7 @@ def _benchpod_capability_gate(request: "pytest.FixtureRequest") -> None:
``benchpod`` device and skips on any capability it lacks — except an image-bound one
(``dac_control_loop`` / ``dac_deep_replay``) on a pod that carries both gateware images, which
is switched to the right image instead, as the SDK does for ``control_loop`` / ``replay``.
``analog`` follows :attr:`BenchPod.can_analog`: only the digital-only board skips it.
"""
if request.node.get_closest_marker("hardware") and not _resolve_connection(request.config):
pytest.skip(f"hardware test: no BenchPod connection configured (--benchpod-connection / {ENV_VAR})")
Expand All @@ -251,6 +253,10 @@ def _benchpod_capability_gate(request: "pytest.FixtureRequest") -> None:
caps = pod.capabilities
for mark in marks:
for name in map(str, mark.args):
if name == "analog":
if not pod.can_analog:
pytest.skip("digital-only board: no analog front end (DAC, ADC, 4-20 mA)")
continue
if getattr(caps, name, False):
continue
image = _IMAGE_CAPABILITIES.get(name)
Expand Down Expand Up @@ -332,7 +338,8 @@ def _benchpod_lift_dac_limits(pytestconfig: "pytest.Config") -> Iterator[None]:
kwargs = _cloud_kwargs(pytestconfig)
try:
with BenchPod(conn, **kwargs) as pod:
limits = pod.command({"cmd": "dac_limits"})
# The digital-only board has no DAC (and refuses dac_limits): nothing to lift.
limits = pod.command({"cmd": "dac_limits"}) if pod.can_analog else None
if isinstance(limits, dict) and limits.get("enabled"):
pod.command({"cmd": "dac_limits", "enabled": False})
saved = limits
Expand Down
17 changes: 17 additions & 0 deletions packages/embeddedci/tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ make e2e POD=192.168.1.215 FIRMWARE=../examples/scenario-sensors-stm32/build/sce
# a rev3 (v3) pod: REV=v3 makes a misdetected board fail instead of passing as v2
make e2e POD=192.168.1.215 FIRMWARE=… REV=v3

# a digital-only pod (no analog board): BOARD=digital fails the run on an analog pod and the
# reverse, so a front end that went undetected cannot skip the analog tests and pass green
make e2e POD=192.168.1.215 FIRMWARE=… BOARD=digital

# add the USB console and the cloud tiers
make e2e POD=192.168.1.215 FIRMWARE=… USB=/dev/cu.usbmodem357D377F31331
BENCHPOD_API_KEY=eci_… make e2e-cloud CLOUD_DEVICE=benchpod-v2.0.0
Expand All @@ -22,13 +26,25 @@ BENCHPOD_API_KEY=eci_… make e2e-cloud CLOUD_DEVICE=benchpod-v2.0.0
| gpio | `test_e2e_gpio.py` | `--benchpod-connection`; firmware with `la_pins`, gateware v35+ for triggers | GPIO output/open-drain read-back, pin ownership (UART refused on a GPIO pin and the reverse, release), bias-resistor conflicts both ways, step trains on GPIO pins, LA-voltage lock (rev3), wiring signal names with active-low, rising/level triggers, trigger timeout, triggered ADC capture, FPGA pulse widths measured from the trigger |
| rev3 | `test_e2e_rev3.py` | `--benchpod-connection` on a rev3 pod; `BENCHPOD_E2E_NRST_LA` for the wire checks | board strap (v3, ~3.0 V), 1.8 V bank read back from the mux status pin, pull-ups released and refused at 1.8 V, GPIO + capture on the 1.8 V bank, reset pin hold/release/pulse, pulses capped at 1 s, and with the jumper: the reset line open-drain to the bank rail at 3.3 V and 1.8 V and the pulse width on the wire; USB-C CC report consistency (and a cable seen with `BENCHPOD_E2E_USB_CC=1`) |
| dut | `test_e2e_dut.py` | + `--benchpod-firmware` and the wired board | SWD flash, boot banner via power cycle, power monitor + eFuse see the board, interactive console (help/status/reset), firmware detects the emulated BMP280, firmware reports a missing sensor, `i2c_sensor_capture` sees the boot probe, a 4 s logic capture of the boot decodes the chip-id read (and every transaction the pod served) and the UART banner; power profiles of a boot, of a delayed power-on and of an unpowered rail; with `BENCHPOD_E2E_NRESET=1` (rev3): flash under reset, a reset pulse reboots the DUT with the rail up, a held reset keeps it quiet until released |
| digital | `test_e2e_digital.py` | `--benchpod-connection` on a digital-only pod (skips on the analog board) | status says `analog: false` and drops the analog capabilities; every analog call (DAC, ADC, 4-20 mA, ADC capture, correlated capture with ADC samples, generate, replay) fails fast with `UnsupportedFeatureError` (`feature="analog"`), raw commands carry the pod's own refusal, `dac_stop` still works and the pod still answers and captures LA data afterwards |
| usb | `test_e2e_usb.py` | `BENCHPOD_E2E_USB` | the text console: status/ping/LA voltage/power, fail-fast hints for everything else |
| cloud | `test_e2e_cloud.py` | `BENCHPOD_E2E_CLOUD_DEVICE` + `BENCHPOD_API_KEY` | lease, command channel, tunnel captures, commands during a UART session, waveform library save → replay → delete |

The MCP server and the OpenHTF plug have their own e2e files, run by `make e2e` too:
`packages/embeddedci-mcp/tests/test_e2e_mcp.py` and
`packages/embeddedci-openhtf/tests/test_e2e_openhtf.py`.

## Digital-only board

The digital-only BenchPod (digital-v3, digital-v3r2) has no analog front end: its firmware reports
`analog: false` and refuses the analog commands. Tests that need the DAC, ADC or 4-20 mA carry
`ANALOG` (`@pytest.mark.benchpod_capability("analog")`, from `e2e_helpers.py`) or call
`require_analog(pod)` when the pod is not the `benchpod` fixture (the cloud tier); they skip there.
Tests that mix analog and digital checks are split, so the digital half still runs. The MCP and
OpenHTF e2e files skip their analog tests the same way. Set `BENCHPOD_EXPECT_BOARD=analog` or
`digital` (`make e2e BOARD=…`) on a bench whose board you know: a pod reporting the other one fails
the run.

## Bench configuration

The LA voltage (3.3 V) is set once in `tests/conftest.py`. The wiring defaults in `conftest.py`
Expand All @@ -41,6 +57,7 @@ match the EmbeddedCI bench — a NUCLEO-F446RE running `examples/scenario-sensor
| I2C SDA / SCL | LA2 / LA1 | `BENCHPOD_E2E_I2C_SDA` / `_I2C_SCL` |
| SWCLK / SWDIO | LA11 / LA12 | `BENCHPOD_E2E_SWCLK` / `_SWDIO` |
| PCB revision the pod must report | any | `BENCHPOD_E2E_BOARD_REV=v3` (`make e2e REV=v3`) |
| Board the pod must report (analog or digital-only) | any | `BENCHPOD_EXPECT_BOARD=digital` (`make e2e BOARD=digital`) |
| DUT reset on the pod's reset pin (J1 pin 22) | no | `BENCHPOD_E2E_NRESET=1` |
| LA channel jumpered to the reset pin | none | `BENCHPOD_E2E_NRST_LA` (e.g. 8) |
| Pod's USB-C port plugged into a host | no | `BENCHPOD_E2E_USB_CC=1` |
Expand Down
13 changes: 12 additions & 1 deletion packages/embeddedci/tests/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
The LA voltage comes from ``tests/conftest.py`` (3.3 V). The bench wiring below matches the
EmbeddedCI bench (NUCLEO-F446RE running examples/scenario-sensors-stm32); override any of it with
``BENCHPOD_E2E_*`` environment variables for another bench.

On the digital-only board (no analog front end) the analog tests skip: they carry
``ANALOG`` (``@pytest.mark.benchpod_capability("analog")``) or call ``require_analog`` (both in
e2e_helpers.py).
``BENCHPOD_EXPECT_BOARD=analog|digital`` makes the run fail when the pod reports the other board,
so a pod whose analog section went undetected cannot skip half the suite and pass green.
"""

from __future__ import annotations
Expand All @@ -15,6 +21,8 @@

from embeddedci.benchpod import BenchPod, BenchPodError, FpgaImage

from e2e_helpers import check_expected_board, require_analog


def pytest_report_header(config):
import embeddedci
Expand Down Expand Up @@ -81,7 +89,8 @@ def bench() -> Bench:

def quiesce(pod: BenchPod) -> None:
"""Stop every output a test may have left running (never raises)."""
for step in (pod.dac_stop, lambda: pod.analog_path("off"), pod.disable_i2c_sensor,
for step in (pod.dac_stop, lambda: pod.analog_path("off") if pod.can_analog else None,
pod.disable_i2c_sensor,
pod.can_respond_clear, pod.can_disable,
lambda: pod.release_gpio() if pod.capabilities.la_pins else None):
try:
Expand All @@ -94,6 +103,7 @@ def quiesce(pod: BenchPod) -> None:
def pod(benchpod: BenchPod):
"""The session's pod; the gateware image it started on is restored at the end."""
started_on_loop = benchpod.refresh_capabilities().dac_control_loop
check_expected_board(benchpod)
yield benchpod
quiesce(benchpod)
try:
Expand All @@ -111,6 +121,7 @@ def _leave_the_pod_quiet(request):


def _on_image(pod: BenchPod, image: FpgaImage, flag: str) -> BenchPod:
require_analog(pod) # both images are for the DAC; the digital board has neither feature
if not getattr(pod.refresh_capabilities(), flag):
try:
pod.fpga_image(image)
Expand Down
Loading