Back to resources
HIL
Testing
Firmware

Emulating an I2C Sensor — and Asserting on the Bus

Have the BenchPod pretend to be a BMP280, capture the DUT's UART, then decode SDA/SCL to prove what the firmware actually did on the wire — present, absent, and everything in between.

Edward Viaene · June 13, 2026 · 3 min read

A lot of firmware lives or dies on a sensor it talks to over I2C. In CI, the usual move is to mock the driver — but mocking the driver tests your mock, not the I2C peripheral, the bus timing, or what the firmware does when the chip answers differently than expected. The BenchPod takes the other approach: it becomes the sensor on real wires, and then it watches the bus to tell you exactly what happened.

A BenchPod wired to a DUT — the same logic-analyzer channels serve the emulated sensor and sample the bus
A BenchPod wired to a DUT — the same logic-analyzer channels serve the emulated sensor and sample the bus

The pod as a BMP280

The pod can drive two of its logic-analyzer channels as an emulated I2C sensor — a BMP280 — while it captures the DUT's UART. The pod's channels are generic (benchpod.PIN1benchpod.PIN12); any signal can be on any of them. But I2C is open-drain and needs a pull-up, and only LA1-8 have pull-ups (LA1/2 = 4.7k, LA3/4 = 2.2k, LA5-8 = 10k), so the SDA/SCL lines must sit on one of those — here LA1/LA2. You enable the pull-ups, then bring the sensor up with the values you want it to report:

from embeddedci import benchpod

with benchpod.BenchPod("192.168.1.213") as bp:
    bp.flash(file="app.elf", target="target/stm32f4x.cfg",
             swclk=benchpod.PIN11, swdio=benchpod.PIN12, target_power=benchpod.INTERNAL)

    # I2C is open-drain — enable the pod's pull-ups (LA1/2 = 4.7k), then become a BMP280.
    bp.enable_pullup(benchpod.PIN1, benchpod.PIN2)
    bp.enable_i2c_sensor(benchpod.Sensor.BMP280, sda=benchpod.PIN1, scl=benchpod.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=benchpod.PIN5, tx=benchpod.PIN6,
                                     delay=1.5, duration=6.0, until=r"APP_OK")
    assert cap.match("APP_OK")
    assert cap.match(r"chip id match=0x58|bmp280_detected=yes")

Now the firmware boots against a sensor that's really on the bus — no soldering, no breakout board, and the readings are whatever your test says they are.

Assert on what the firmware did, not just that it booted

The interesting part: while the pod serves the sensor, it's also sampling SDA and SCL. Decode that capture and you can check the actual transactions — the register pointer the driver wrote, the bytes it read back — instead of trusting a log line:

from embeddedci.benchpod import i2c

txns = bp.i2c_sensor_la_decoded(samples=4096, sample_rate_mhz=0.5)
print(i2c.format_transactions(txns))
# -> S 0x76W+ 0xD0+ Sr 0x76R+ 0x58- P   (write reg 0xD0, read chip id 0x58)
assert i2c.read_register(txns, 0x76, 0xD0) == [0x58]

The decoder understands START / repeated-START / STOP, the R/W bit, per-byte ACK/NACK, and the common "write the register pointer, then read" pattern. So you're asserting that the driver probed 0xD0 and got the BMP280's 0x58 chip ID on the wire — the thing that actually proves the integration works.

Present, absent, and misbehaving

Because you control the sensor, you can test the paths that are hardest to reach on a desk:

  • Present — sensor answers; assert the firmware detects it and reads sane values.
  • Absent — never enable the sensor (or pull it off the bus); assert the firmware reports "not found" and fails safe instead of hanging on a probe.
  • Wrong values — report an out-of-range temperature or a bad chip ID and check the firmware's validation and error handling.

That present/absent split is exactly the kind of branch that ships untested and then strands a device in the field the first time a sensor doesn't come up.

Works over wifi and serial

The whole sensor + decode API runs over both the network transport and a USB serial console — the firmware exposes a JSON console mode that the serial transport enters automatically — so the same test runs against a pod on your LAN or one on your desk with no code change. The full walkthrough and wiring table are in the pytest framework docs; for the analog side of the same bench, see Recording and Replaying Analog Signals.