All projects
03 / 06 Product Case study

elmulator

An open-source ELM327 adapter emulator that puts a car app's Bluetooth code under test in CI, with no car and no hardware.

Role
Creator, sole author
Period
2026
Scope
Swift + Python packages, two CLIs, a GitHub Action, a conformance suite
Status
Open source · live on PyPI, Swift Package Index & the GitHub Marketplace
Focus
  • Test infrastructure
  • Cross-language conformance
  • Protocol-oriented design
Stack
  • Swift 6
  • SwiftPM
  • CoreBluetooth
  • Python (stdlib)
  • GitHub Actions
  • JSON Schema
02 Outcomes at a glance
  • 3 Distribution channels
  • 2 Servers kept byte-identical
  • 0 Third-party runtime deps
  • 7 Example scenarios, each a fixture
03

The premise

Most OBD2 apps reach the car through a Bluetooth adapter, and that link is the single hardest thing to test. The iOS Simulator has no Bluetooth at all. There is no supported way to mock CBPeripheral. So the Bluetooth path, the part most likely to break in a stranger’s driveway, is usually the part that never gets a test.

The usual workaround is a phone, a real adapter, and a car in the parking lot. That doesn’t run in CI, and it doesn’t run at 2am when a refactor lands.

A different idea

elmulator is a fake ELM327 you can script. It started as the test harness inside my own OBD2 app and came out as a standalone, MIT-licensed tool so the Bluetooth code can run on an ordinary CI runner instead of a car.

One JSON scenario describes a synthetic conversation: what the adapter replies, how it chunks the reply across BLE notifications, how long it waits, and when it stalls, drops the connection, or sends back garbage. The same scenario can drive a fast in-process unit test, your real CoreBluetooth code through a mock peripheral, an on-device BLE peripheral, or a plain TCP socket for any language.

Three decisions that shaped it

Two implementations, held byte-for-byte identical. There is a Swift server and a pure-standard-library Python server, and a conformance suite runs the same scenarios through both and diffs the bytes. Neither is allowed to drift from the other. That differential test is the spine of the project: it is what lets a scenario mean exactly one thing no matter which runtime plays it.

A protocol seam, not a mock framework. The connection logic sits behind a CentralStack protocol that the real CoreBluetooth central and the fake one both implement. Tests swap one for the other and run the whole power-on, scan, connect, discover, subscribe, ready flow with no radio. The state machine is a plain value type, so it is trivial to drive and assert on.

Scenarios are fixtures. Each scenario also records the scan result it should produce, so the file that fakes the adapter is the same file that guards against regressions. Add a scenario, and you have both a new test case and a new piece of documentation.

Shipped, not just published

elmulator is distributed the way a dependency should be: pip install elmulator, a Swift package on the Swift Package Index, and a one-line GitHub Action on the Marketplace. Releases publish themselves through OIDC trusted publishing, CI gates every change on the conformance suite, and there are zero third-party runtime dependencies to carry.

It is the open-source counterpart to the car apps: the same domain, taken down a layer, and handed to anyone building on iOS OBD2.

Two servers, one scenario, and a diff that never lets them disagree.

Where the trust comes from
04 The pitch in one image

Your real Bluetooth code, tested with no hardware.

The app connects to a scripted ELM327 exactly as it would to a real adapter, over a mock BLE central, and the whole scan runs green on an ordinary CI runner. No car, no adapter, no radio.

elmulator architecture: your OBD2 app sends commands and receives replies from elmulator, a scripted ELM327 over mock BLE, and swift test passes on any runner, with the car, adapter, and radio all crossed out.
One image, the whole idea. The app talks to elmulator instead of a car, and CI goes green with the three things you normally need, car, adapter, radio, struck out.
05 One engine, every surface

Six ways to fake the same adapter.

A single scenario engine backs an in-process double, a CoreBluetooth-Mock bridge for real CBCentralManager code, an on-device peripheral, a TCP server for any language, a GitHub Action, and a Python server held byte-for-byte identical to the Swift one.

elmulator capability grid: in-process BLE double, CoreBluetooth-Mock bridge, real BLE peripheral, TCP server for any language, GitHub Action, and byte-for-byte parity between the Python and Swift servers.
The same JSON scenario drives all six. Pick the surface the test needs, from a millisecond in-process check to your real CoreBluetooth stack under swift test.
06 The receipt

Green on a runner with no radio.

The suite that ships with elmulator runs a full BLE scan against a scripted adapter, reassembles a reply split across many notifications, and refuses to crash on garbage, all on a plain macOS runner.

A terminal running swift test against elmulator: connects over BLE and reads a stored P0420, reassembles a reply split across many BLE notifications, does not crash on malformed adapter output, then 3 tests passed on a plain macOS runner with zero hardware.
Real CoreBluetooth code, a scripted adapter, and a green run with zero hardware attached.

elmulator ships the way a dependency should: pip install elmulator, a package on the Swift Package Index, and a one-line GitHub Action on the Marketplace, releasing themselves through OIDC trusted publishing with the conformance suite gating every change. It is the open-source counterpart to the car apps, the same domain taken down a layer and handed to anyone building on iOS OBD2.