DEV Community

Cover image for DIY AI Car Diagnostics with a $15 Bluetooth Adapter and Python
Petr Pátek
Petr Pátek

Posted on • Originally published at bitvea.com

DIY AI Car Diagnostics with a $15 Bluetooth Adapter and Python

A few weeks ago the folding roof on my 2010 Ford Focus CC stopped working.

The obvious suspect was the roof module, but FORScan showed nothing there: zero stored fault codes. The useful clues were somewhere else, in the passenger door module, and they didn't look related at first glance.

I wanted to see whether Claude could work with that kind of diagnostic state directly instead of me copy-pasting screenshots from FORScan. So I built a small MCP server around an OBD-II adapter.

This is not a story about Claude magically fixing a car. It's a story about giving an LLM a narrow, read-only set of tools, fighting a BLE adapter for a few hours, and seeing whether the model could produce a useful explanation from real diagnostic state.

The setup

┌──────────┐    Bluetooth    ┌───────────────┐    Python    ┌──────────────┐    MCP    ┌─────────┐
│   Car    │ ◄─────────────► │  vLinker FD   │ ◄──────────► │  MCP Server  │ ◄───────► │  Claude │
│  OBD-II  │                 │  ($15 dongle) │              │  (250 lines) │           │         │
└──────────┘                 └───────────────┘              └──────────────┘           └─────────┘
Enter fullscreen mode Exit fullscreen mode

For this experiment I used only read-only tools:

Tool What it does
read_dtc Read stored fault codes from all or specific modules
get_live_data Real-time sensor readings (RPM, coolant temp, speed, etc.)
get_vehicle_info VIN, calibration IDs, ECU name
list_modules Enumerate all ECUs on the CAN bus
get_freeze_frame Sensor snapshot from when a DTC was set
explain_dtc Look up a code in the local DTC database

There is a guarded clear_dtc command in the repo (requires explicit confirmation), but I didn't use it for this experiment and wouldn't include it in a truly read-only profile.

The server is about 250 lines of Python and intentionally boring. Claude helped with the Python boilerplate, but the useful work was figuring out the adapter behavior and the diagnostic boundaries.

The BLE problem (where most of the time went)

The car side was straightforward. Plug the Vgate vLinker FD into the OBD port, turn the ignition on, pair via Bluetooth (PIN is 1234, not the 0000 macOS suggests).

macOS created a serial port:

$ ls /dev/tty.*vLinker*
/dev/tty.vLinkerFD-Android
Enter fullscreen mode Exit fullscreen mode

Looks good. Let's talk to it:

import serial
conn = serial.Serial('/dev/tty.vLinkerFD-Android', baudrate=115200, timeout=2)
conn.write(b'ATZ\r')  # Reset the ELM327 chip
response = conn.read(100)
print(response)  # b''
Enter fullscreen mode Exit fullscreen mode

Nothing. Empty bytes. I tried every common baud rate (9600, 38400, 115200, 500000). All of them opened the port without error, accepted writes, and returned nothing.

I spent a couple of hours on this. Tried different AT commands, reconnected the adapter, verified the ignition was on. The port was there, it opened cleanly, and nobody was home.

The actual problem: the vLinker FD is a BLE device, not classic Bluetooth. These are two completely different protocols. Classic Bluetooth creates a serial port (SPP/RFCOMM) and you talk to it like a USB cable. BLE uses GATT characteristics instead, small read/write endpoints organized into services.

When macOS paired with the vLinker, it created a serial port entry because that's what macOS does with Bluetooth devices. But the adapter never actually serves data on RFCOMM. It only listens on its BLE GATT characteristics. The serial port was a phantom.

This is why FORScan works fine on iOS. It uses Core Bluetooth to talk BLE directly. It never tries the serial port.

The fix was the bleak library (Python BLE client). The adapter exposes a GATT service with specific characteristics for communication:

Service: 0000fff0-0000-1000-8000-00805f9b34fb
Write:   0000fff2-0000-1000-8000-00805f9b34fb
Notify:  0000fff1-0000-1000-8000-00805f9b34fb
Enter fullscreen mode Exit fullscreen mode

You send ELM327 commands to the write characteristic and listen for responses on the notify characteristic:

async with BleakClient(address) as client:
    await client.start_notify(NOTIFY_UUID, on_response)
    await client.write_gatt_char(WRITE_UUID, b'ATZ\r')
Enter fullscreen mode Exit fullscreen mode

First successful adapter interaction after hours of debugging:

> ATZ
ELM327 v1.5

> AT SP 6
OK

> 0100
41 00 BE 3E B8 13
Enter fullscreen mode Exit fullscreen mode

The lesson I keep coming back to: the hard part of connecting an LLM to hardware was not the LLM. It was getting bytes from point A to point B through the correct abstraction layer.

Why MCP instead of just pasting logs?

The useful part of MCP here is not sophistication. It's the boundary.

Claude doesn't get arbitrary access to my laptop or the car. It gets a small set of named tools with typed inputs and outputs: list modules, read DTCs, explain one code, fetch freeze-frame data. That's it.

That made the interaction auditable. I could see exactly what Claude asked for, what the adapter returned, and where the final explanation came from. If Claude produced a bad answer, I could trace it back to which tool call returned unexpected data versus where the model's reasoning went wrong.

For a system that talks to actual hardware, that boundary matters more than convenience.

The DTC database

The car returns codes, not rich explanations. OBD-II gives you hex strings like B1310, but the standard doesn't include descriptions. Manufacturer-specific codes aren't in any universal reference.

For the prototype, I built a local lookup database from publicly available DTC references using an Apify crawler. It covers over 20 brands (Ford's coverage is the deepest, with codes across powertrain, body, and chassis categories). The database is not authoritative, and I wouldn't treat it as a replacement for OEM service data. The point was to avoid asking Claude to invent meanings for manufacturer-specific codes from memory.

What Claude actually did

Here's the context I hadn't explained yet. The passenger window regulator on the car is broken: the motor spins, but the glass doesn't move. On a convertible, that matters because the roof sequence depends on the side windows dropping before the roof panels can fold over them.

The interesting diagnostic puzzle was that the folding top module had no stored DTCs. The only relevant faults were in the passenger door module.

Important caveat: the Focus CC module scan shown below is reproduced in the mock adapter from my actual FORScan session. The MCP server can talk to the BLE adapter and read standard ELM327 responses, but full Ford MS-CAN module enumeration (which is where body modules like the roof controller live) needs extended commands beyond what generic OBD-II provides. The mock exists so the MCP workflow can be tested end-to-end without pretending that generic OBD-II gives you all body-module data for free.

Simplified scan output from mock mode (module addresses and fault codes match my real FORScan session):

Module                              Address    Protocol    DTCs
──────────────────────────────────────────────────────────────────
PCM (Powertrain Control)            0x7E0      HS-CAN      0
ABS Module                          0x760      HS-CAN      0
Instrument Cluster                  0x720      MS-CAN      0
Driver Door Control Unit            0x740      MS-CAN      0
Passenger Door Control Unit         0x741      MS-CAN      2
  → B1310: Power door unlock circuit failure
  → B166A: Heated mirror circuit open
Folding Top Control Module          0x750      MS-CAN      0
HVAC Module                         0x733      MS-CAN      0
Enter fullscreen mode Exit fullscreen mode

Reconstructed tool-call sequence from the mock-mode run:

1. list_modules()             → found 7 ECUs
2. read_dtc()                 → scanned all modules, found 2 codes on passenger door
3. explain_dtc("B1310")       → "Power door unlock circuit failure"
4. explain_dtc("B166A")       → "Heated mirror circuit open"
5. get_freeze_frame("B1310")  → sensor snapshot from when the code was set
Enter fullscreen mode Exit fullscreen mode

The codes themselves don't say "broken window regulator." B1310 and B166A point to passenger-door electrical faults. The missing piece was the symptom history: the window motor spins but the glass doesn't move, and the roof stopped working around the same time.

I asked Claude: "The roof won't operate but the roof module has no fault codes. Why?"

Claude's hypothesis: the roof controller performs a live precondition check before the folding sequence. It asks the windows to move. If the passenger side doesn't respond correctly (because the regulator is broken), the sequence aborts. Since the roof module itself isn't failing, it doesn't store a DTC. The relevant faults are on the door module, not the roof module.

The important connection was not B1310 specifically. It was that the only module with faults was in the same subsystem the roof controller depends on for its pre-flight sequence.

That matched what I later found in Ford service documentation. I haven't replaced the regulator yet, so this is still a high-confidence hypothesis rather than a confirmed repair. But it was useful novice-level triage: connecting live module state, symptom history, and reference data into a coherent next thing to check. That's more than I had before building this.

What this did not prove

This didn't prove that Claude can diagnose cars. It proved something narrower:

  • MCP is a clean interface for exposing diagnostic tools to an LLM.
  • The model is more useful when it can query real state instead of working from pasted screenshots.
  • The hard part is still protocol handling, adapter quirks, and incomplete documentation.
  • The result was a plausible diagnostic hypothesis, not a certified repair instruction.

What I intentionally didn't build

The server is read-only. I didn't expose actuator tests, module coding, service resets, or anything that can change vehicle state.

The DTC database includes diagnostic procedures for each brand: how to run self-tests, cycle actuators, reset service intervals. In theory, you could wrap those as MCP tools. I chose not to.

I'm a developer, not a car mechanic. Reading fault codes is passive, you're just asking the car what it already knows. But sending active commands to modules is a different thing entirely. A wrong routine ID, a command sent at the wrong time, or an actuator test while someone's hand is near a moving part. That's real risk. The gap between "technically possible via UDS Service $31" and "safe to let an LLM trigger autonomously" is enormous.

Honest limitations

Standard OBD-II is limited. The OBD-II standard only covers emissions-related PIDs. Body modules (roof controllers, door modules) live on manufacturer-specific CAN buses. The python-obd library doesn't natively scan Ford's MS-CAN bus. The mock adapter simulates this based on real FORScan data, but real MS-CAN scanning from the MCP server needs FORScan-level extended commands that aren't implemented yet.

The DTC database is scraped, not authoritative. It's built from publicly available references, not Ford's official FRIDA/DRIS database. Some descriptions may be incomplete or outdated.

Claude can hallucinate. It produced a plausible explanation for my roof problem, but LLMs can also produce confident-sounding nonsense. This is an explanation layer, not a repair manual. Verify anything it says against actual service documentation before acting on it.

The repo

The code is here: github.com/petrpatek/obd2-mcp-server

git clone https://github.com/petrpatek/obd2-mcp-server.git
cd obd2-mcp-server
./setup.sh
source .venv/bin/activate
obd2-mcp --mock  # runs with simulated car data, no hardware needed
Enter fullscreen mode Exit fullscreen mode

The mock simulates the Focus CC scenario with the same module addresses and fault codes I saw in FORScan. If you have an ELM327-compatible adapter, swap --mock for --ble (or --port for USB) and point it at a real car.

It passes 28 tests and works for the narrow path I built it for, but it's a prototype. I wouldn't rely on it for real diagnostics without verifying the output against proper service documentation.

What's next

I'm working on dynamic PID discovery (asking each ECU what standard PIDs it supports), live data streaming, and better brand coverage for the DTC database. All read-only.

The pattern I care about is narrower than "AI for every industry": take a system that already exposes diagnostic state, wrap a few safe read-only queries as tools, and let the model explain what it sees without letting it push buttons.

That seems useful beyond cars, but only if the boring parts are done well: protocol quirks, incomplete documentation, adapter weirdness, safety boundaries, and knowing when not to let the model act.

If you've tried similar MCP wrappers for CAN, Modbus, or other diagnostic protocols, I'd be curious to compare notes.

Top comments (0)