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 oldUsing 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 PIN1 … PIN12 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
| Fixture | What it gives you |
|---|---|
benchpod | A 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_voltage | The 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_wiring | The 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_target | A BenchPod whose target is powered on (--benchpod-efuse) for the test and off at teardown. |
pins / benchpod_pins | The pod's 12 generic logic-analyzer channels (pins.pin_1 … pins.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. |
firmware | The firmware path from --benchpod-firmware, for tests that flash a real target (skips without it). |
benchpod_sensor | A BenchPod that disarms any emulated I2C sensor at teardown. |
benchpod_dac | A BenchPod that stops any running DAC output (generate / replay) at teardown. |
benchpod_capabilities | The connected device's resolved Capabilities (ADC scaling, DAC replay depth, feature flags). |
benchpod_waveforms | The cloud waveform library, deleting anything the test saved at teardown. Needs server access — a cloud connection or an API key — and skips otherwise. |
build_report | Opt-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_connection | The 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 / env | Default | Purpose |
|---|---|---|
--benchpod-connection / BENCHPOD_CONNECTION | — | Which pod to drive (see connection strings below). |
--benchpod-la-voltage / BENCHPOD_LA_VOLTAGE | — | Override 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-firmware | — | Firmware image for the firmware fixture. |
--benchpod-wiring | — | The 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-efuse | 1 | Target-power rail: 1 = internal 5 V, 2 = external. |
--benchpod-api-key / BENCHPOD_API_KEY | — | API key (eci_…). Authenticates the embeddedci: destination from anywhere, and unlocks the cloud waveform library on a LAN/USB connection. |
--benchpod-api-base / BENCHPOD_API_BASE | https://www.embeddedci.com | EmbeddedCI server base URL. |
--benchpod-lease-wait | 600 | Seconds to wait for a busy cloud device before failing with DeviceBusyError. |
--benchpod-no-lease | off | Skip the exclusive cloud-device lease (only when nothing else uses the device). |
--benchpod-discover | off | With no connection set, find one pod on the LAN via mDNS (needs [discovery]). |
--benchpod-build-target / BENCHPOD_BUILD_TARGET | — | Platform id recorded by the build_report fixture (e.g. stm32f4). |
Connection strings
| Form | Transport |
|---|---|
192.168.1.213 or host:8080 | wifi/network (JSON over TCP, port 8080 default) |
/dev/ttyACM0, COM3 | USB console (CDC-ACM), explicit device path |
usb | USB console, auto-detected by probing the ports |
discover | find 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 & reset —
power_on(efuse, delay=1.5)(scheduled pod-side),target_status()(eFuse enabled / tripped),power_status()(rail volts and amps),reset_target(pulse=0.1). - UART —
capture_uart(...)for a fixed window,open_uart(...)for a live two-way session,power_cycle_and_capture(...)for a boot banner. - Logic analyzer —
capture_la(n, sample_rate_hz=...)returns anLaCaptureyou 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(...)), andcapture_correlated(...)for ADC + LA from one hardware trigger — see Recording and Replaying Analog Signals. - CAN —
open_can(bitrate=500_000, mode=...)returns aCanBuswithwrite,expect, and firmware-side auto-responders for ECU simulation. - Control loop —
control_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.