Back to resources
HIL
Testing
Firmware

The BenchPod Is Now an I2C and SPI Master: Register Checks in a pytest

Board bring-up usually starts with a scan and a few register reads by hand. The BenchPod can now do that itself, on any two LA pins, and capture the bus in the same shot, so the check becomes a test that runs on every board revision.

Edward Viaene · November 15, 2026 · 6 min read

The first hour with a new board revision always looks the same. Someone plugs in a USB-to-I2C adapter, scans the bus, reads a chip ID, writes a config register and reads it back. If the numbers look right, the board "works". Then that knowledge lives in a terminal history on one laptop, and the next revision gets the same hour.

Until now the BenchPod could only be the other side of that bus: it emulated sensors for the firmware to talk to, and it watched the wires. With firmware 3.10.4 (gateware v55) it can also be the master. It scans, reads and writes registers over I2C and SPI, on any LA pins, from pytest, the CLI, the web app or an AI agent. So the bring-up hour becomes a test file.

Why the pod should drive the bus

On most boards the MCU owns the I2C and SPI buses. That's fine once the firmware works, but it means every check of a peripheral goes through firmware you might be debugging. If the sensor doesn't answer, is it the sensor, the solder joint, the pull-ups or the driver?

When the pod is the master, the firmware is out of the picture. We hold the MCU in reset, so its pins float, and talk to the chips directly. A NACK now means the hardware, not the code.

A scan and a register read

The pod's I2C master runs from 10 kHz to 1 MHz on any two LA pins. I2C is open drain, so the lines need pull-ups: the pod has its own on LA1 to LA6, or you use the board's.

from embeddedci.benchpod import BenchPod

with BenchPod("embeddedci:my-bench", la_voltage=3.3) as bp:
    bp.set_reset(True)                    # keep the MCU off the bus
    bp.enable_pullup(1, 2)                # SDA on LA1, SCL on LA2

    with bp.open_i2c(1, 2, hz=400_000) as i2c:
        found = i2c.scan()
        print([hex(a) for a in found])    # ['0x50', '0x76']

        assert 0x76 in found, "environment sensor missing"
        assert i2c.read_reg(0x76, 0xD0) == b"\x58"   # BMP280 chip ID

That's the whole bring-up check for one part: it's on the bus, at the address the schematic says, and it is the part the BOM says. A wrong strap resistor shows up as 0x77 instead of 0x76. A missing pull-up shows up as an empty scan. A swapped part shows up as the wrong chip ID.

Writes work the same way. Configure the part, read the register back, and you know the write path works too:

        i2c.write_reg(0x76, 0xF4, b"\x27")   # normal mode, x1 oversampling
        assert i2c.read_reg(0x76, 0xF4) == b"\x27"

read_reg and write_reg also take a two-byte register address for EEPROMs and larger parts. xfer gives the raw transfer (up to 128 bytes out, 248 back) with the ACK of each phase, for the parts that don't fit the register pattern.

SPI, the same way

The SPI master runs on four LA pins, modes 0 to 3, from 190 kHz up to 6 MHz:

    with bp.open_spi(sck=13, mosi=14, miso=6, cs=5, hz=6_000_000) as spi:
        flash = spi.flash_id()
        print(flash)                      # JEDEC ID, size
        reply = spi.transfer(b"\x8f\x00") # read WHO_AM_I on an SPI accelerometer

For a 25-series flash chip there are helpers on top: flash_read, flash_erase and flash_program (with verify). That's how you load a filesystem image or a calibration block into the board's external flash before the firmware ever runs.

If the pins are in your bench's wiring profile, you can leave them out: bp.open_i2c() and bp.open_spi() take i2c_sda, i2c_scl and the spi_* names from it.

Proof on the wire

A register read that returns the right value is good. A capture of the actual transaction is better, because it shows the timing, the ACKs and the clock rate the bus really ran.

The pod can do both in one command: capture_la(send=...) arms the logic analyzer, runs the transfer and triggers on it. No race between "start capturing" and "start talking":

from embeddedci.benchpod import Trigger, i2c as i2c_decode

    with bp.open_i2c(1, 2, hz=400_000) as i2c:
        cap = bp.capture_la(8192, sample_rate_hz=12e6,
                            trigger=Trigger(1, "falling"),        # SDA starts the transfer
                            send=i2c.send(0x76, b"\xd0", read=1))

    assert cap.send_result.ok
    txns = bp.decode(cap, "i2c", sda=1, scl=2)
    print(i2c_decode.format_transactions(txns))
    # S 0x76W+ 0xD0+ Sr 0x76R+ 0x58- P
    assert i2c_decode.read_register(txns, 0x76, 0xD0) == [0x58]

The same works for SPI (trigger on CS falling) and for a single GPIO edge. The capture keeps 64 samples from before the trigger, so the decoder sees the first START.

This is also how we check the bus itself on a new revision. Measure the SCL frequency in the capture: on a real board it runs a bit slower than the setting, because the rise time through the pull-up adds to every period. If a 400 kHz bus runs at 280 kHz, the pull-ups are too weak for the trace capacitance. That's a board problem you want to find before the firmware team does.

The pod won't fight your board

A master drives lines, and driving a line the board also drives is how you damage a pin. So the pod checks first. In the wiring profile, each pin has a direction: dut_input, dut_output or bidir. On a pin marked dut_output, the pod refuses every push-pull driver (an SPI clock, a GPIO output) with a clear error instead of a short. I2C is open drain, so it's safe on shared lines either way.

The profile's directions are sent when you connect (firmware 3.10.4 and later), so this protection is on by default. bp.la_dir() lists every pin's direction if you want to check.

One test per board revision

Put it together and the bring-up hour is a pytest file that runs against every board that comes off the line:

CheckWhat it catches
I2C scan matches the schematicmissing or swapped parts, wrong address straps, no pull-ups
Chip ID per partwrong part fitted, counterfeit or substituted parts
Write, then read backa dead write path, a part stuck in reset
SPI flash JEDEC ID and sizea different flash than the BOM
Captured SCL ratepull-ups too weak for the bus

It runs the same way on your desk over USB, on the LAN, and in GitHub Actions through the EmbeddedCI cloud. The pytest docs have the full I2C and SPI reference.

Without writing the test first

You don't have to start from code. The Drive tab in the web app and the bus console in BenchPod Desktop (free, no account needed) run the same I2C, SPI and GPIO calls by hand, and an AI agent gets them as MCP tools (i2c_scan, i2c_transfer, spi_transfer). Click through the bring-up once, then save it as a test. We'll show that flow in a later article.

The I2C master needs firmware 3.10.4 or later and SDK 2.13.13 or later. SPI modes 1 and 2 need the same; mode 0 and 3 work on older firmware too. Pods in the field get it with a firmware update, on the same hardware.