BenchPod pytest framework

The embeddedci Python package is a pytest-friendly client for a BenchPod. It powers a target board, flashes it over SWD, captures its UART, emulates and decodes an I2C sensor, and drives the analog front-end and CAN — straight from your tests. The same test runs against a pod on your desk or a remote pod in CI.

Install

pip install embeddedci                 # Python 3.10+

# optional extras (combine them, e.g. "embeddedci[cloud,pytest]"):
pip install "embeddedci[pytest]"       # installs pytest alongside the plugin
pip install "embeddedci[cloud]"        # the embeddedci:<device> cloud destination
pip install "embeddedci[discovery]"    # find a pod on the LAN via mDNS
pip install "embeddedci[analysis]"     # numpy for Capture.fft / dominant_frequency

Needs Python 3.10 or newer. The core package covers local network and USB pods; the pytest plugin registers itself on install. [cloud] adds the WebSocket client the embeddedci: destination needs, [discovery] adds mDNS for discover, [analysis] adds numpy for the FFT helpers, and [pytest] simply installs pytest too.

OpenOCD (required for flashing)

Flashing shells out to OpenOCD, which must be on your PATH (or passed as openocd_bin=). The pod runs a CMSIS-DAP probe in its firmware, and the SDK drives it through OpenOCD's cmsis-dap adapter with its TCP backend (cmsis_dap_tcp), which ships whole DAP transfers instead of per-bit toggles. That backend only exists in OpenOCD builds newer than 0.12.0 — the stock packages (apt install openocd, brew install open-ocd) are 0.12.0 and lack it. The SDK checks before it flashes and raises a FlashError telling you to update.

# macOS: Homebrew's stable open-ocd is 0.12.0, so build it from HEAD
brew install --HEAD open-ocd

# Linux / CI: the xPack OpenOCD 0.12.0-7 snapshot carries the TCP backend
V=0.12.0-7
curl -fsSL "https://github.com/xpack-dev-tools/openocd-xpack/releases/download/v$V/xpack-openocd-$V-linux-x64.tar.gz" \
  | sudo tar xz -C /opt
export PATH="/opt/xpack-openocd-$V/bin:$PATH"

# or build a recent OpenOCD from source: https://github.com/openocd-org/openocd

Verify your OpenOCD has the CMSIS-DAP TCP backend:

openocd -c "adapter driver cmsis-dap" -c "cmsis-dap backend tcp" \
        -c "cmsis-dap tcp port 4441" -c shutdown
# must exit cleanly — an error on the 'backend tcp' line means the build is too old

Using the SDK directly

Outside of pytest, drive a pod with the BenchPod context manager:

from embeddedci.benchpod import BenchPod, INTERNAL, PIN1, PIN2

# la_voltage selects the LA I/O-bank voltage: the board's I/O voltage, 1.8 for a 1V8 board.
with BenchPod("192.168.1.213", la_voltage=3.3) as bp:   # or "/dev/ttyACM0", "usb", "embeddedci:<device>"
    bp.ping()                                           # raises if the pod doesn't answer
    result = bp.flash(
        file="firmware.elf", target="target/stm32f1x.cfg",
        swclk=PIN1, swdio=PIN2,
        nreset=True,              # a flag: the target's reset is wired to the pod's reset pin (J1 pin 22)
        target_power=INTERNAL,    # power the target from the internal 5 V eFuse first
    )
    assert result.ok              # a failed flash raises FlashError; pass check=False to inspect it instead
    assert not bp.target_status().efuse(INTERNAL).fault   # eFuse not tripped
    print(bp.power_status().rail(INTERNAL).current, "A")  # INA monitor on that rail
    bp.power_off(INTERNAL)

Named constants avoid magic numbers: INTERNAL / EXTERNAL for the target-power eFuse, and PIN1PIN12 for logic-analyzer channels, all importable from embeddedci.benchpod (plain ints still work; an out-of-range value raises ValueError). Units are volts, seconds and hertz throughout, device state comes back as typed results, and a device, transport or server failure raises BenchPodError.

Using it in pytest

Installing the package registers a pytest plugin. Point it at a pod and use the fixtures:

pytest --benchpod-connection=192.168.1.213
# or: export BENCHPOD_CONNECTION=usb

# add the firmware image for tests that flash a target:
#   --benchpod-firmware=build/app.elf
import pytest

# The pod exposes 12 generic LA channels (pins.pin_1 .. pins.pin_12); it has no
# dedicated SWD/UART pins. Which DUT signal is on which channel is the bench's
# wiring profile (the web app's Wiring tab, or --benchpod-wiring=wiring.json),
# handed to tests as the benchpod_wiring fixture — no pin numbers in the test.
@pytest.mark.hardware
def test_firmware_flashes(benchpod, benchpod_wiring, firmware):
    assert benchpod.flash(
        file=firmware, target="target/stm32f4x.cfg",
        swclk=benchpod_wiring.swd_swclk, swdio=benchpod_wiring.swd_swdio,
        target_power=benchpod_wiring.efuse,
    ).ok


# benchpod_target powers the target on for the test and off at teardown:
@pytest.mark.hardware
def test_rail_is_healthy(benchpod_target, pins):
    assert not benchpod_target.target_status().efuse(pins.efuse).fault


# skipped unless the connected pod advertises the capability:
@pytest.mark.benchpod_capability("dac_deep_replay")
def test_long_replay(benchpod_dac):
    ...

Fixtures

FixtureWhat it gives you
benchpodA connected BenchPod for the session (skips if no connection is set). It selects the LA voltage from benchpod_la_voltage on connect and, for a cloud device, holds its lease.
benchpod_la_voltageThe board's I/O voltage (1.8 or 3.3), selected on the pod when the session connects. Override it once in conftest.py. The default returns None, which falls back to BENCHPOD_LA_VOLTAGE.
benchpod_wiringThe bench's wiring profile (a Wiring): which role or named Signal is on each LA channel, plus the LA voltage, target-power rail, baud, I2C address and SWD target. On an embeddedci: connection it is the device's profile from the server — edit it in the web app's Wiring tab; on a LAN or USB connection it comes from --benchpod-wiring, or the defaults. Override it in conftest.py like benchpod_la_voltage. The same profile is bp.wiring on a connected pod, and bp.signal("TRIGGER") looks a named signal up.
benchpod_targetA BenchPod whose target is powered on (--benchpod-efuse) for the test and off at teardown.
pins / benchpod_pinsThe pod's 12 generic logic-analyzer channels (pins.pin_1pins.pin_12) plus the pins.efuse rail. There are no role-named pins — which signal is on which channel lives in the wiring profile (benchpod_wiring). LA1–LA6 have pull-ups (4.7k/2.2k/10k), LA7/LA8 10k pull-downs, and LA9–LA12 neither — check with pins.has_pullup(n) / pins.has_pulldown(n), and engage them with enable_pullup(n) / enable_pulldown(n) (enable_pullup(7) raises ValueError). They engage only at 3.3 V.
firmwareThe firmware path from --benchpod-firmware, for tests that flash a real target (skips without it).
benchpod_sensorA BenchPod that disarms any emulated I2C sensor at teardown.
benchpod_dacA BenchPod that stops any running DAC output (generate / replay) at teardown.
benchpod_capabilitiesThe connected device's resolved Capabilities (ADC scaling, DAC replay depth, feature flags).
benchpod_waveformsThe cloud waveform library, deleting anything the test saved at teardown. Needs server access — a cloud connection or an API key — and skips otherwise.
build_reportOpt-in reporting of the run as a GitHub-sourced build on embeddedci.com (upload the tested firmware, record the wiring). Active only inside GitHub Actions; a no-op elsewhere.
benchpod_connectionThe resolved connection string (skips if none is set).

Markers: @pytest.mark.hardware labels a test that needs a real pod, and @pytest.mark.benchpod_capability("name") skips it unless the connected device advertises that capability (e.g. dac_deep_replay).

Options

Option / envDefaultPurpose
--benchpod-connection / BENCHPOD_CONNECTIONWhich pod to drive (see connection strings below).
--benchpod-la-voltage / BENCHPOD_LA_VOLTAGEOverride the LA I/O-bank voltage (1.8 or 3.3) for one run, e.g. a CI job for a 1V8 board variant. Normally set once in conftest.py via the benchpod_la_voltage fixture. Precedence: flag → fixture → env var.
--benchpod-firmwareFirmware image for the firmware fixture.
--benchpod-wiringThe bench's wiring profile as a .json or .toml file, for the benchpod_wiring fixture — how a LAN or USB run gets its wiring. An embeddedci: connection reads the device's profile from the server.
--benchpod-efuse1Target-power rail: 1 = internal 5 V, 2 = external.
--benchpod-api-key / BENCHPOD_API_KEYAPI key (eci_…). Authenticates the embeddedci: destination from anywhere, and unlocks the cloud waveform library on a LAN/USB connection.
--benchpod-api-base / BENCHPOD_API_BASEhttps://www.embeddedci.comEmbeddedCI server base URL.
--benchpod-lease-wait600Seconds to wait for a busy cloud device before failing with DeviceBusyError.
--benchpod-no-leaseoffSkip the exclusive cloud-device lease (only when nothing else uses the device).
--benchpod-discoveroffWith no connection set, find one pod on the LAN via mDNS (needs [discovery]).
--benchpod-build-target / BENCHPOD_BUILD_TARGETPlatform id recorded by the build_report fixture (e.g. stm32f4).

Connection strings

FormTransport
192.168.1.213 or host:8080wifi/network (JSON over TCP, port 8080 default)
/dev/ttyACM0, COM3USB console (CDC-ACM), explicit device path
usbUSB console, auto-detected by probing the ports
discoverfind a single pod on the LAN via mDNS (needs the [discovery] extra)
embeddedci:<device-name>cloud — drive a named device through embeddedci.com (needs the [cloud] extra). Authenticates with an API key (BENCHPOD_API_KEY) from anywhere, or with the job's OIDC token inside GitHub Actions

Resolution order: --benchpod-connection → the benchpod_connection ini option → the BENCHPOD_CONNECTION env var. A cloud device is shared, so the SDK holds an exclusive lease on it for the session; a run that finds it busy queues for up to --benchpod-lease-wait seconds.

Emulating an I2C sensor + capturing UART

The pod can pretend to be an I2C sensor (a BMP280) on two channels while you capture the DUT's UART — so you can flash an app, power-cycle it, and assert on its boot output with and without the sensor present. It also decodes the bus (i2c_sensor_capture), so you can assert what the firmware actually did on the wire — see Emulating an I2C Sensor.

from embeddedci.benchpod import BenchPod, INTERNAL, PIN1, PIN2, PIN5, PIN6, PIN11, PIN12, Sensor

with BenchPod("192.168.1.213", la_voltage=3.3) as bp:   # board I/O voltage; 1.8 for a 1V8 board
    bp.flash(file="app.elf", target="target/stm32f4x.cfg",
             swclk=PIN11, swdio=PIN12, target_power=INTERNAL)

    # I2C is open-drain: enable the pod's pull-ups on SDA/SCL. Only LA1-LA6 have
    # pull-ups (LA7/LA8 are pull-downs), so the I2C lines go on LA1/LA2.
    bp.enable_pullup(PIN1, PIN2)
    bp.enable_i2c_sensor(Sensor.BMP280, sda=PIN1, scl=PIN2,
                         temperature_c=22.5, pressure_pa=101000)

    # Power-cycle while capturing UART so the boot banner lands in the window.
    cap = bp.power_cycle_and_capture(rx=PIN5, tx=PIN6,
                                     delay=1.5, duration=6.0, until=r"APP_OK")
    assert cap.match("APP_OK")

The rest of the SDK

  • Power & resetpower_on(efuse, delay=1.5) (scheduled pod-side), target_status() (eFuse enabled / tripped), power_status() (rail volts and amps), reset_target(pulse=0.1).
  • UARTcapture_uart(...) for a fixed window, open_uart(...) for a live two-way session, power_cycle_and_capture(...) for a boot banner.
  • Logic analyzercapture_la(n, sample_rate_hz=...) returns an LaCapture you can .decode("i2c" | "uart" | "spi", ...); la_step(la, steps=, delay=) emits step/dir pulse trains.
  • Analog — named paths (analog_path("cal1")), adc_read("ext").voltage, capture_adc(...) with calibrated volts, dac_output("5v", volts=2.5), generate("sine", freq_hz=, amplitude=), replay(capture, fault=Fault(...)), and capture_correlated(...) for ADC + LA from one hardware trigger — see Recording and Replaying Analog Signals.
  • CANopen_can(bitrate=500_000, mode=...) returns a CanBus with write, expect, and firmware-side auto-responders for ECU simulation.
  • Control loopcontrol_loop(curve=...) runs a tabulated transfer function in the FPGA, e.g. a solar-panel I-V curve.
from embeddedci.benchpod import BenchPod

with BenchPod("192.168.1.213", la_voltage=3.3) as bp:   # board I/O voltage; 1.8 for a 1V8 board
    print(bp.adc_read("ext").voltage)                     # one calibrated reading on the front SMA
    cap = bp.capture_adc(8192, sample_rate_hz=100_000)    # Capture: .volts, .mean(), .peak_to_peak()
    with bp.replay(cap, dac_path="5v"):                   # loops on the DAC until the block exits
        la = bp.capture_la(8192, sample_rate_hz=1_000_000)

    with bp.open_can(bitrate=500_000, mode="internal") as can:   # loopback self-test, no bus needed
        can.write(0x123, [1, 2, 3])
        assert can.expect(can_id=0x123, timeout=1.0).data == bytes([1, 2, 3])

Everything exported from embeddedci.benchpod is covered by semantic versioning: within 2.x, names and signatures only grow. bp.command(...), bp.transport and bp.lowlevel are escape hatches outside that promise.