Chasing a Vacuum Leak with a Bluetooth OBD-II Adapter

The airbag light in my 2001 Ford Expedition has been on for a while. I wanted to know what it was complaining about without paying a shop to tell me, so I bought a Veepeak OBDCheck BLE adapter, which is about the cheapest way to get a wireless connection to a car’s diagnostic port, and plugged it in under the dash.

Three days later I had 2,500 lines of Python, a web dashboard, and a diagnosis. Not of the airbag light. I will come back to that.

The result is obdscope, which I have open sourced under the MIT license: a command line tool, a web dashboard, and a full vehicle simulator, at roughly 2,500 lines of Python and 56 tests, over eighteen commits between the 21st and the 23rd of September. GitHub can tell you about the twelve commands. What follows is the part that was actually interesting.

Why This Needed Writing at All

Almost every OBD-II tutorial opens by telling you to point your code at /dev/ttyUSB0. That works when the adapter is USB, or when it is Bluetooth Classic and the operating system hands you a serial port for it. On macOS, a Bluetooth Low Energy adapter does neither. It never shows up as a serial port at all. You reach it through CoreBluetooth, which in Python means bleak: discover the peripheral, connect, find the right GATT characteristics, subscribe to notifications, write bytes.

Underneath that layer the adapter is not exotic. It is a UART over Bluetooth. You write a command as plain ASCII, notifications come back, and the response ends with a > prompt meaning the adapter is ready for the next one. Once you have that loop working, everything above it is protocol work.

So the tool is layered, with each piece swappable:

Diagram of the obdscope layer stack. The CLI and the web dashboard both sit on a shared reports layer, which sits on OBD services, which sits on the ELM327 command and framing layer. Below that the stack forks two ways: through the BLE transport to the Veepeak adapter and the 2001 Expedition, or to the simulator.
The stack, and the fork at the bottom that the next section is about.

The reports layer is the one that earns its keep. The command line and the dashboard show the same vehicle information because they call the same function, not because I remembered to change both.

The Dashboard

The obdscope web dashboard showing live gauges for engine RPM, vehicle speed, coolant temperature, calculated engine load, throttle position, and short and long term fuel trims for both banks, each with a small trend line
The dashboard, connected to one of the built-in simulated vehicles.

It is aiohttp plus server-sent events, vanilla JavaScript, no build step. One process owns the Bluetooth connection, because the adapter will only accept one, and streams samples out to any tab that happens to be open.

That is a local web server that can clear diagnostic codes, so it is worth being careful about. Three choices, all cheap:

There is a --host 0.0.0.0 option for reading the dashboard on a phone while you are under the hood, which is genuinely useful, and which the README is blunt about: no password, so only do it on a network you trust.

The Simulator, Which Was the Best Decision I Made

The trick that made the rest of the project pleasant was deciding to fake the adapter rather than fake the data.

A simulator that returns tidy Python objects tests almost nothing, because the hard part of this problem is the wire format, and that is exactly the part such a simulator skips. So the one in obdscope speaks real ELM327 text: AT commands, command echo, J1850 framing with real CRCs, CAN ISO-TP multi-frame responses, SEARCHING..., NO DATA, negative responses. Everything above the transport runs the same code against the simulator that it runs against a car.

There are two profiles. The Expedition: J1850 PWM, a single module. And a 2019 RAV4: CAN, engine and transmission modules answering separately, a wideband O2 sensor, a VIN. Between them they cover both eras of the protocol.

Both drive a repeating two-minute cycle from a cold start: idle, city, highway, then slowing to a stop. Coolant climbs, the fuel system switches from open to closed loop, the O2 sensors start switching, and RPM is derived from speed and gear rather than made up independently. The point is that the numbers move the way real numbers move, so a chart that looks wrong looks wrong.

It paid for itself twice. Its tests caught a crash in probe that would otherwise have hit on the first real successful probe: an import lost in an earlier refactor, on a path that only runs when a module actually answers. And it let me build and screenshot the entire web UI before the adapter ever met the car.

What the Expedition Actually Said

The first real connection failed with UNABLE TO CONNECT, which is the adapter telling you it cannot find a vehicle. There was no key in the ignition, and the engine computer sleeps. That produced two fixes: raw now works with the vehicle asleep, so you can at least ask the adapter its own voltage, and the error message now tells you to turn the ignition on instead of leaving you to guess.

With the key on, it turned out to be talkative:

That last pair is a nice illustration of why the two things are separate questions. “No stored codes” and “ready for inspection” are different claims, and only one of them was true.

The Number That Makes the Article

What fuel trim is, briefly

The engine computer calculates how much fuel to inject, then corrects that calculation based on what the oxygen sensors report. Fuel trim is that correction, as a percentage. Zero means no correction; positive means it is adding fuel because the exhaust is reading lean.

There are two of them. Short term reacts within seconds and swings around constantly. Long term is what the computer has learned over time and stored, so that it does not have to rediscover the same correction on every start. The real correction is the two added together, and a sustained combined correction much beyond roughly plus or minus 10 percent is worth investigating.

Warmed up and idling, the Expedition reported short term fuel trim around 0% and long term at +28% on both banks. The engine is asking for 28% more fuel than its base calculation says it needs, and it has been asking long enough to have written that down. Both banks, equally, which argues against a bank-specific injector or sensor problem and points toward something the two banks share.

The question is what. The useful test compares idle against a held 2000 rpm:

Measured on the vehicle, warmed up, no load. The bottom row is airflow multiplied by correction.
Idle (700 rpm) 2000 rpm
Airflow 6.0 g/s 16.0 g/s
Fuel correction +26.8% +10.7%
Implied extra air 1.61 g/s 1.71 g/s

The percentage falls sharply with engine speed: +26.8% at idle down to +10.7% at 2000 rpm, a drop of 16.1 percentage points. The absolute quantity barely moves, staying within about 6% of itself while the percentage more than halves.

The reasoning is simple once the numbers are in front of you. A hole of a fixed size passes roughly the same mass of air whatever the throttle is doing, so it is a large fraction of the small airflow at idle and a small fraction of the larger airflow at 2000 rpm. A mass airflow sensor reading proportionally low would instead give about the same percentage at both points. Low fuel pressure would get worse under load rather than better. Neither fits. Call it somewhere around 1.6 to 1.7 grams per second of equivalent unmetered airflow, with the caveat that this is a back-of-the-envelope figure derived from two scan tool readings rather than a direct measurement of anything.

Ford Published This Test Twenty Years Ago

The pleasing part came afterwards, when I went looking to see whether the reasoning held up. It does, and it is not original.

Ford TSB 04-17-4, issued in September 2004, applies to 2000–2004 Expeditions among a long list of others. It tells the technician to add long term and short term fuel trim for each bank at idle, hold 2500 rpm, and add them again. Ford’s stated reason is the same argument I had just talked myself into: fuel trims at idle are particularly sensitive to vacuum leaks because the unmetered leakage flow is a higher percentage of total airflow at idle than at part throttle. If the correction drops by more than 15 points, the bulletin says a vacuum leak is most likely the cause. If it stays about the same, it says to look elsewhere. It also notes that a leak bad enough to set a lean code typically shows total correction around +20% or more at idle.

My idle correction was +26.8% and my drop was 16.1 points. I held 2000 rpm rather than Ford’s 2500, so what I have is consistent with the published procedure rather than a clean execution of it. Landing just over a threshold Ford set for this exact generation of Expedition, having arrived at the test from the physics rather than from the bulletin, was the most satisfying twenty minutes of the project.

One caveat that belongs here rather than in a footnote: an exhaust leak ahead of the oxygen sensors produces a very similar signature, because it lets air get drawn in during the low-pressure part of the exhaust pulses, the sensors read lean, and the computer adds fuel it does not need. Cracked exhaust manifolds are a signature failure on the 5.4 Triton. The data I have cannot separate the two cases, and Ford’s bulletin is about vacuum leaks specifically, so it does not settle the question either. It is still unresolved.

Why There Is No P0171

A +28% long term correction with no lean code looked contradictory, and my first explanation was wrong. I assumed the fuel system monitor only evaluated under cruise conditions, where the correction is smaller, and so never looked at idle. It does not work that way: Ford’s fuel system monitor runs continuously whenever the engine is in closed loop, idle included.

The real explanation is better, and it is sitting in the data. Setting a lean code takes the long term trim being high and the short term trim still correcting lean on top of it, because that combination is what says the computer has run out of adjustment. Mine was around +28% long term with short term near zero. The PCM had already learned nearly the whole correction and was no longer fighting anything in real time, so the second condition was never met.

Which is worth remembering the next time someone offers “there are no codes” as evidence that a vehicle is healthy. This one has been quietly compensating for a fault for long enough to have made it look normal.

On the command line the same measurement looks like this, here running against the simulated Expedition, which carries a milder version of the same fault because that is what I built it to reproduce:

             short    long    total   measuring baseline (6/8)
  bank 1     +0.0%  +12.5%   +12.5%
  bank 2     +0.0%  +14.1%   +14.1%
  engine rpm 797 rpm   mass air flow 9.0 g/s   coolant temperature 80.6 °F
  hold steady at idle...

It holds the first eight samples as a baseline, then switches that right-hand column to the change from it, with a bar running from leaner to richer and a line of text saying which way it moved. The web version does the same thing with more room:

The leak hunt panel showing total fuel correction for bank 1 at plus 3.1 percent and bank 2 at plus 4.7 percent, each 6.6 percent below baseline, with sliders tracking leaner against richer and a message reading LEANER by 6.6 percent, whatever you just blocked is part of the leak
Leak hunt mode: hold a baseline, block a suspected leak, and watch whether the correction drops.

This is not a feature I invented either. Ford’s next step, once the idle-versus-rpm comparison points at a vacuum leak, is to pinch off vacuum lines one at a time and watch short term fuel trim: a shift of more than 15 points in the negative direction means you just blocked the leak. Saying so in words matters more than it sounds, because the alternative is squinting at a scan tool with one hand while holding pinch-off pliers with the other. On a phone propped against the windshield it becomes a one-person job.

Two Experiments with Clean Results

Does Clearing Codes Reset the Learned Fuel Trims?

This is folklore you hear both ways, and it is easy to test. I cleared codes with the engine off, then restarted. Long term fuel trim was back at +28.1% within a minute, with short term sitting near zero.

That is what a value loaded from memory looks like. Relearning looks different: short term does the work while long term climbs toward it slowly. I had the contrast case for free, because the battery had been disconnected earlier, and after that long term genuinely sat at 0% for minutes while short term carried the whole correction.

What makes this more interesting than it first looks is that Ford’s own bulletin warns technicians that the adaptive fuel values will not be available if a KAM or OBD-II reset was recently performed, and instructs them not to clear codes before running the test for exactly that reason. On this PCM, the generic mode 04 clear I sent did not appear to erase the learned correction. I would not generalize that to Ford’s own scan tool, which has a separate KAM reset and may well do more than mode 04 does, but it is a useful thing to know before you throw away your own evidence.

Can You Read Airbag Codes over OBD-II?

Not with the generic OBD-II path I had built. That turns out to be a different statement from the one I first made, which was that the adapter simply is not wired to the restraints module.

My sweep of all 256 addresses found two modules: the PCM at 10 and the instrument cluster at 60. No restraints module. But that sweep ran over J1850 PWM, which is the powertrain network, and on this generation of Ford SUV the restraints control module is not on it. It sits on a separate ISO 9141 diagnostic network, which does reach the same connector under the dash, and which a standard ELM327 adapter has pins for.

So I was looking on the wrong bus with the right cable. Reaching that module would mean switching protocols and implementing the manufacturer-specific diagnostics that live there, none of which is part of generic OBD-II and none of which obdscope does yet. Whether it would then answer, I genuinely do not know.

Two things worth knowing came out of the sweep anyway. Older Fords read codes with mode 13 rather than the standard mode 03, which they reject outright. And the instrument cluster is holding B1600, an anti-theft key transponder fault, plus one more.

The part I was most pleased about is that the decoding validated itself. The PCM returned 10 00 00 00 00 00, which decodes to P1000, meaning “monitors not complete.” That is exactly what a Ford sets after the clear I had just performed. Getting a known-correct answer out of the byte format before trusting it on the cluster’s codes was worth more than any unit test I could have written, because it checked the assumption rather than the implementation.

Bugs That Only Real Hardware Found

The adapter appends <DATA ERROR to any frame that fails its checksum. On a J1850 bus that is ordinary noise and happens a few times a minute. My parser treated it as fatal and aborted the entire read, which meant some commands worked intermittently for no reason I could see from the code. Now it drops the bad frame and retries.

Which gives the line I keep coming back to: the simulator caught a bug the car never would have, and the car caught a bug the simulator never would have. Neither one was a substitute for the other, and I would build it the same way again.

Odds and Ends

Three small things that cost more time than they should have:

Open Sourcing a Three-Day Hack

So I open sourced it. obdscope is public on GitHub under the MIT license, which means anyone can read it, fork it, or build something commercial on top of it without asking me first. Getting it to a state where that was a reasonable thing to do took most of the final day, on work that adds no features whatsoever.

There is a CI matrix on GitHub Actions running the tests and pyflakes across Python 3.11, 3.12 and 3.13. The suite is 56 tests and finishes in under two seconds, and it needs no adapter and no car, because it runs entirely against scripted fakes and the simulator. That is the simulator paying for itself a third time: it is the reason someone who owns neither the dongle nor a 25-year-old Ford can clone the repository and watch the whole thing work.

The README grew the sections a weekend project usually skips. A safety and scope section naming the two commands that are not passive, because clear-dtc also resets the readiness monitors and probe sends undocumented manufacturer requests. A no-warranty line. A note not to operate it while driving. The vehicle-specific material moved out into a gitignored notes/ directory, on the grounds that my VIN and my repair history are not documentation. And a section on what has actually been tested, which is the one that makes the rest of the README worth believing: one adapter, one vehicle, and the simulator for everything else, the entire CAN path included. It also points at python-OBD as the established library in this space and explains how this project differs, rather than implying it is better.

I renamed it, too. It started life as veepeak, after the adapter I happened to buy, which is a poor name for something that works with any ELM327 dongle. It is obdscope now, with the adapter named in the README’s first line and in the package keywords instead, so that someone who bought a Veepeak and knows nothing else still finds it. veepeak survives as a command alias, because renaming things out from under people is rude.

The bug report form asks for the vehicle, the adapter and a --log transcript, on the theory that a vehicle-specific report without a transcript is one nobody can act on, me included.

None of that makes it better at reading a car. It is the entire difference between a directory on my laptop and something a stranger can use without me.

What I Am Not Claiming

Three honest limits, since this is the sort of project where it is easy to overstate what has been proven:

I set out to read one code and failed. What I got instead was a number: a fuel correction equivalent to roughly 1.6 grams per second of unmetered air entering an engine that has no idea it is there. I would not have had that number from a code reader, or from a shop visit that ended in “no codes stored.” It took building enough of a tool to ask a question the tool was not designed to answer, which is usually where the interesting part of a project starts.

If you want to try it, including the parts that need no car at all:

git clone https://github.com/PaulJulius/obdscope
cd obdscope
uv sync
uv run obdscope ui --simulate expedition

Technical Diligence and CTO Advisory

For technical due diligence, technical health checks, and investor-focused CTO advisory, Paul Julius works through Willowbark Consulting LLC.

Willowbark helps investors, founders, CEOs, and CTOs understand technical risk, engineering capability, architecture, scalability, operations, and AI readiness before major business decisions.