Reverse-engineering my car with GLM-5.3-Flash

My car engine suddenly stopped one morning — luckily only 500 meters from home. The dashboard was showing « système antipollution défaillant », which is French for "give me money". I didn't find any open-source OBD software that could talk to my car, so I reverse-engineered the whole thing with GLM-5.3-Flash. This is the story of going from a silent USB dongle to reading my engine fuel pressure live, in pure C.

1. The problem

The car is a 2008 Citroën C3 1.6 HDi. One morning it cut out without warning, luckily close enough to home that it was an annoyance instead of a disaster. French cars of this era announce any trouble with the antipollution message, which can cover anything from a faulty sensor to a dying injection pump. A garage would plug in their DiagBox, read two codes, and charge you the price of a nice dinner.

Every car sold since 2001 has a standardized OBD-II port under the dash. Every developer story I had ever read said the same thing: buy a cheap ELM327 dongle, run some open-source scanner, read your codes. So I bought the dongle. Around fifteen euros. What could go wrong?

2. When the usual tools don't work

Well, almost everything went wrong. No open-source OBD software I tried could communicate with my car. I first assumed the dongle was broken, or that the port was simply dead. It wasn't. The real problem was somewhere else: my car doesn't speak the same protocol as modern cars.

Modern cars (roughly 2008 and later, depending on who you ask) use CAN bus at 500 kbit/s. Generic OBD apps open exactly that, hear silence, and give up. Mine answers on ISO 14230-4, KWP2000 with fast init, over the K-line, an older and slower protocol. That is why the usual apps found nothing: they never asked the right question.

3. Finding the protocol

This is where GLM-5.3-Flash entered the picture, driving a real terminal against the real dongle. We didn't know what the car was speaking, so we just asked it, one protocol at a time. The ELM327 accepts a protocol-per-request command, so we sent 0100 (the OBD "are you alive?" ping) under every ISO variant until something answered. The whole discovery fits in a dozen lines of Python:

def cmd(s, c, timeout=4.0):
    """Send a command, read until the ELM prompt '>' or timeout."""
    s.reset_input_buffer()
    s.write((c + "\r").encode())
    end = time.time() + timeout
    buf = b""
    while time.time() < end:
        data = s.read(512)
        if data:
            buf += data
            if b">" in buf:
                break
    return buf.decode(errors="replace")

protocols = [
    ("ATSP6", "CAN 11/500"),  ("ATSP7", "CAN 29/500"),
    ("ATSP8", "CAN 11/250"),  ("ATSP9", "CAN 29/250"),
    ("ATSP3", "ISO 9141-2"),  ("ATSP4", "KWP 5-baud"),
    ("ATSP5", "KWP fast"),
]

for atsp, name in protocols:
    cmd(s, atsp)
    reply = cmd(s, "0100", timeout=12)   # "are you alive?"
    if "41 00" in reply:                 # positive ECU response!
        print(f"{name}: ALIVE -> {reply}")
CAN 11/500 → NO DATA        CAN 29/500 → NO DATA
CAN 11/250 → CAN ERROR      CAN 29/250 → CAN ERROR
ISO 9141-2 → BUS INIT ERROR KWP 5-baud → BUS INIT ERROR
KWP fast   → BUS INIT: OK    86 F1 10 41 00 98 3B 40 11 EC

One survivor. One ECU answering at address 0x10, with valid framing and checksums. A quick honesty note here: "reverse-engineering" is a generous word for this part — the ELM327 command set and OBD-II services are public standards, so finding the protocol by probing is just asking questions in order. What really felt like reverse engineering is what comes next.

4. What the ECU can and cannot do

Once the connection worked, we probed every OBD mode and PID one by one to map the ECU firmware. My car's engine computer turned out to be EOBD-minimal: it accepts the legally required basics (read live data, read/clear codes, open a session) and rejects everything else with a crisp 7F … 12 ("service not supported"):

  • accepted: mode 01 (six PIDs, plus — surprisingly — rail pressure), 03/07 (stored/pending codes), 10 81 (diagnostic session);
  • rejected: 09 (VIN!), 02 (freeze frame), KWP identifier services 21/1A/22.

This map answers the two questions a car owner actually has. The VIN is unreadable over OBD on this car (PSA keeps it in the BSI, dealer tools only), just read the windshield plate. And the freeze frame doesn't exist here, no matter what the documentation promises. We measured all of this: nothing in this article is an assumption, everything comes from the car itself.

5. Writing the tools

Generic software was useless, so we wrote our own. Python probes for the exploratory work, and then a pure C tool for daily use — raw termios, select() with wall-clock deadlines, hex-token parsing of KWP frames, checksums and all. About 175 lines, no dependencies. Every command follows the same shape: flush, write, read until the > prompt or the deadline kills it:

static void send_cmd(const char *cmd, char *buf, size_t bufsz, int timeout_ms)
{
    char tmp[256];
    tcflush(fd, TCIFLUSH);
    snprintf(tmp, sizeof tmp, "%s\r", cmd);
    write(fd, tmp, strlen(tmp));
    read_reply(buf, bufsz, timeout_ms);   /* select() + wall clock, no infinite hangs */
}

send_cmd("ATZ",   buf, sizeof buf, 4000);   /* -> ELM327 v1.5                  */
send_cmd("ATSP5", buf, sizeof buf, 2000);   /* KWP2000 fast init               */
send_cmd("010C",  buf, sizeof buf, 10000);  /* -> 84 F1 10 41 0C 0A 5C D2 */
/* locate "41 0C" in the reply — RPM = ((A<<8)|B) / 4 */
for (int i = 0; i + 3 < n; i++) {
    if (toks[i] == 0x41 && toks[i + 1] == 0x0C) {
        *ok = 1;
        return ((toks[i + 2] << 8) | toks[i + 3]) / 4.0;
    }
}

$ ./obd_rpm /dev/cu.usbserial-A7BL6VXS 5
RPM:  758.0
RPM:  764.0
RPM:  762.0
RPM:  762.0
RPM:  763.0

Sitting in the driver seat, watching live engine data scroll in my own terminal, built by an AI I was arguing with five minutes earlier. That was the moment it became fun.

6. Reading the fault codes

The codes behind the dashboard message: P1166 and P1351. Reading them is one more mode — 03 for stored codes, 07 for pending — plus a few lines of bit-shuffling to turn raw bytes into human codes:

def decode_dtc(b1, b2):
    """Raw bytes -> human code:  0x11 0x66 -> P1166."""
    letter = "PCBU"[(b1 >> 6) & 3]
    return f"{letter}{(b1 >> 4) & 3}{b1 & 0xF}{b2 >> 4}{b2 & 0xF}"

cmd(s, "03")
# '86 F1 10 43 11 66 13 51 00 00 A6'
#           └─ data: 43 = mode 03 echo, then three 2-byte slots:
#              11 66 -> P1166   rail pressure above maximum
#              13 51 -> P1351   glow relay coherence fault
#              00 00 -> empty slot

Generic code dictionaries were useless here, they listed Honda oxygen-sensor meanings. The real definitions are PSA-specific, and we recovered them with a deep-research pass:

P1166 — « Régulation haute pression carburant : pression rail mesurée supérieure à la pression maximale ». Rail pressure above maximum, held five seconds. The ECU's downgrade: kill the engine.
P1351 — « Circuit relais pré/post-chauffage : relais commandé et bougies non alimentées ». The glow-plug relay coherence fault — and the pending copy of this code kept coming back, meaning the fault was still live.

The research also surfaced a trap that probably saved me real money: there are documented cases of owners replacing injectors, regulators, sensors and even ECUs for this exact code, without fixing anything. The best story is a C3 Picasso owner whose "implausible" sensor reading had everyone reaching for the parts catalog, when the wiring was guilty. On this engine, the low-pressure fuel side (a €20 filter, a pin-holed primer bulb, a tired O-ring) explains most of these faults, because the pump has to suck diesel from the tank, and any micro-leak admits air instead of showing a drip.

7. Measuring the rail pressure

A stored code tells you what the ECU saw in the past, not what the car is doing now. However, it turned out the ECU exposes one precious live value: fuel rail pressure (PID 0x23). So we wrote a logger — same C discipline, timestamped CSV — and I sat in the car for two minutes with the engine idling:

t=   0.0s rail= 27500 kPa  rpm= 782.75  coolant=34  map=102
t=  30.2s rail= 25760 kPa  rpm= 780.75  coolant=35  map=102
t=  60.7s rail= 26850 kPa  rpm= 779.75  coolant=37  map=102
t=  90.9s rail= 26410 kPa  rpm= 777.75  coolant=39  map=102
t= 119.2s rail= 25760 kPa  rpm= 773.75  coolant=40  map=102
healthy idle range0801602403200s30s60s89s119sRail pressure (bar)
Warm-idle rail pressure capture — 98 samples over 120 s (~1.2 s interval). Green band = healthy DV6 warm-idle setpoint (~230–280 bar; field reference idle ≈290 bar). The value holds the band, no dips, no spikes toward the ≈1750–1800 bar limiter → regulation healthy at idle.

Mean rail pressure: 264 bar, wobbling ±6 bar around the idle setpoint, engine speed steady at 774–785 rpm for two minutes. In other words the high-pressure fuel system regulates perfectly at idle. So the fault lives in the regimes we didn't sample yet, most likely cold cranking, where air in the suction line shows up first, and where the glow plug fault (P1351) does the worst damage.

8. What this says about AI and the physical world

Step back from the repair for a second, because something bigger happened here. A general-purpose language model, with no fine-tuning and no Citroën wiring diagram in its training set, took a fifteen-euro dongle and did the job of a diagnostic bench: it interrogated a 2008 ECU, mapped what that ECU refuses to do, decoded two manufacturer-specific faults, and reduced a dashboard scare to one €20 part.

The bridge is the whole story. Give a model a terminal and it stays a very eloquent opinion. But give it a serial port that answers back, and every claim can be checked: send the command, read the answer, update the theory. Hallucination stops being the failure mode because the wire settles every argument. The job becomes designing the next measurement instead of knowing things: hypothesis, probe, evidence, repeat. That loop is what made science work. The physical world is the best fact-checker ever built: it doesn't care how confident the text sounded.

And why stop at cars ? The same loop works on a boiler, a 3D printer, a solar installation, a lab instrument — anywhere a sensor or a bus turns the physical world into bytes. A general model, a cheap adapter and a terminal become a diagnostic bench that costs almost nothing. It will not replace the people who turn the wrenches, it will hand them the measurement that used to require the dealer machine. And for everyone else it moves the line between "pay the garage" and "replace a fuel filter yourself".

I don't think the next jump in AI capability will come from a bigger context window. It will come from models that stop only reading about the world and start measuring it, one protocol and one serial line at a time. In my case it started with 500 meters of coasting and a quiet K-line nobody else would talk to.

If you try this yourself: get an ELM327 that speaks KWP2000, use /dev/cu.* on macOS, and don't trust any software that assumes your car was built this decade. And if you find an open-source OBD tool that handles K-line relics properly — I owe you a coffee.