Few days ago I built a CI pipeline that flashes AutoSD onto a Raspberry Pi 4 and tests it on real hardware. It builds the image, flips an SD-Wire mux, power-cycles the board through a smart plug, waits for SSH, and runs a pytest suite — on every push. That was the fun part.

This is the sequel, and it has a sillier premise: what if the same pipeline also tested my actual car?

It started because I found an old OBD-II cable in a drawer.

ELM327 cable running from the car's OBD-II port to the Raspberry Pi
The car side of the rig: a cheap ELM327 USB cable from the OBD-II port into the Raspberry Pi running AutoSD.

The cable with no driver

Every car built since the late ’90s has an OBD-II port, usually tucked under the dashboard. It’s the same connector a garage plugs into to read why your check-engine light is on. The cable I found was a generic ELM327 USB adapter — it shows up as a USB-serial device built on the ever-present CH340 chip (1a86:7523).

On the software side, Python already has the excellent python-obd library for talking to ELM327 adapters. What it didn’t have was a Jumpstarter driver — Jumpstarter is the hardware-in-the-loop framework my pipeline is built on, and it had drivers for SD muxes, power switches, and SSH, but nothing that could read a vehicle bus.

So I wrote one — a full Jumpstarter driver: a server-side driver, a matching client, a test suite, and packaging, built on top of python-obd. The heart of it is four methods exposed over Jumpstarter’s RPC layer:

@dataclass(kw_only=True)
class OBD(Driver):
    port: Optional[str] = field(default=None)     # None = auto-detect
    baudrate: int = field(default=38400)
    fast: bool = field(default=False)             # ELM327 fast mode (flaky on clones)

    @export
    def query(self, command_name: str) -> Optional[str]:
        cmd = getattr(obd.commands, command_name, None)
        if cmd is None:
            raise ValueError(f"Unknown OBD command: {command_name}")
        response = self._connection.query(cmd)
        return None if response.is_null() else self._serialize(response.value)

    @export
    def status(self) -> str: ...
    @export
    def supported_commands(self) -> list[str]: ...
    @export
    def is_connected(self) -> bool: ...

Four methods: ask the ECU what it supports, query a PID by name, check the connection, report status. That’s enough to do real diagnostics.

Testing it on a real car

Before wiring anything into CI, I drove out to the car with a laptop and worked through it in stages — adapter on the bench, then plugged into the car ignition-off, then ignition-on, then engine running.

The bench-to-car transition is satisfyingly visible in one number: ELM voltage. On the desk the adapter reports 0.1 V; the moment it’s seated in the OBD-II port it reads battery voltage (~11.5 V), and with the engine running and the alternator charging it climbs to ~14 V. A free “am I actually plugged into a car” check.

Two things surprised me.

First, the car is a hybrid — and the data proved it before I remembered. I drove a slow loop and captured telemetry, and got this:

PIDover 30 seconds
SPEED0 → 10 km/h (the car was moving)
RPM0 the entire time
MAF (airflow)~0.4 g/s, flat
Engine load0%, flat

A petrol engine cannot sit at 0 RPM while the car rolls under its own power — but an electric motor can. I was creeping along in EV mode with the combustion engine off. The driver was faithfully reporting an off-engine state; it just happened to be a great demonstration that the readings were real and live, not cached.

Second, real hardware found bugs that mocks never would. Most PIDs come back as tidy physical quantities — 405.1 degree_Celsius, 99 kilopascal. But a full sweep of all 104 PIDs the ECU advertised turned up two value types my naive str(value) mangled:

  • VIN came back as a bytearray, serializing to the literal text bytearray(b'1HGBH41J...')
  • STATUS returned an object with no string form, serializing to <obd.OBDResponse.Status object at 0x107cf9550> — a memory address, different every run

Both are now handled (decode bytes, render object fields), and they’re exactly the kind of thing you only hit against a real ECU. The good news from the diagnostics themselves: no trouble codes, every emissions monitor passed, zero misfires across all four cylinders. The car is healthy. The VIN decoded correctly, too.

Wiring it into the pipeline

Here’s where the premise pays off. The ELM327 cable plugs into the Raspberry Pi — the same board the pipeline just flashed with a fresh AutoSD image. So the device under test, running the automotive OS I built, is the thing reading the car. In Jumpstarter, the car is declared as one more piece of hardware alongside the SD-Wire and power switch:

export:
  storage:   { type: ...SDWireMacOS, ... }    # flashes the Pi
  power:     { type: ...HttpPower, ... }       # power-cycles the Pi
  ssh:       { type: ...SSHWrapper, ... }      # talks to the Pi
  obd:                                         # ...the Pi talks to the car
    type: jumpstarter_driver_obd.driver.OBD
    config:
      port: /dev/ttyUSB0        # ELM327 on the Pi's USB
      baudrate: 38400

The pipeline builds an AutoSD image, flashes it to the Raspberry Pi, boots it, runs the test suite — and now the booted device reaches out through the OBD-II port and reads the car’s ECU. Same run, same framework. It builds an automotive operating system and then has that operating system read my car’s coolant temperature.

And because the device under test is the one polling the car, the OBD numbers are a real property of the AutoSD build — not just a host-side curiosity. Which makes the next part actually meaningful.

Before and after an update

The thing worth measuring is OBD sampling performance — how fast and how consistently the AutoSD device can poll the bus. That’s the kind of number a software change can move, and it fits the real-time theme of the original project.

So I built a small capture-and-compare tool: take a baseline, apply an AutoSD update (1.3.1 → 1.3.2), capture again, and diff the two. It flags regressions in poll rate, latency percentiles, and per-PID answer rate. The report below has the engine idling at the top (a warm ~720 RPM — the hybrid fires its petrol engine to charge, separate from the EV-mode crawl earlier), and underneath, the OBD poll latency the device sustained before and after the update:

Two back-to-back runs land within about a millisecond on every latency percentile and on throughput — no regression, which is the healthy result. The one thing that did move is jitter: it roughly doubled between runs (visible as the noisier line in the latency-over-time chart). That’s ordinary scheduling variation, well within thresholds, but it’s the most sensitive metric — and on real RT hardware it’s exactly the signal you’d watch across an update.

Where this goes

The original pipeline proved out the hard parts — automated flashing, power control, a test harness wired to real hardware. Teaching it to read a car was a weekend’s worth of driver code on top.

The driver is a handful of @export methods around python-obd; the value was in running it against a real, slightly weird (hybrid!) vehicle and letting the hardware tell me where my assumptions were wrong. It’s now open upstream as jumpstarter-dev/jumpstarter#789. The pipeline now flashes an automotive Linux image to a Raspberry Pi, validates its real-time kernel, and reads diagnostics off the car parked outside — all from one git push.

It’s a stand-in for something more serious, the same way the Pi 4 stands in for a real ECU. But the plumbing is solved, and that was always the point.