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.
- 3 Distribution channels
- 2 Servers kept byte-identical
- 0 Third-party runtime deps
- 7 Example scenarios, each a fixture
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 fromYour 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.
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.
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.
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.