If you've ever plugged a scan tool into a heavy-duty truck, you've seen codes like SPN 3226 FMI 13 or SPN 5246 FMI 0. They look cryptic, but they follow the SAE J1939 standard, and decoding them is a nice little bit-manipulation exercise.
I built Professional Diesel Repair, a free diagnostic library with 767 pages covering SPN/FMI, PID and OBD-II P-codes across brands like Cummins, Detroit Diesel, Volvo, Mack, Freightliner, Caterpillar, PACCAR, Ford Power Stroke and GM Duramax. This post covers the data side: how the codes are structured and how the library is organized.
What SPN and FMI mean
- SPN (Suspect Parameter Number) — what is affected, e.g. SPN 100 = engine oil pressure, SPN 110 = coolant temperature, SPN 157 = fuel rail pressure.
- FMI (Failure Mode Identifier) — how it failed, e.g. FMI 0 = data valid but above normal range (most severe level), FMI 1 = data valid but below normal range (most severe level), FMI 3 = voltage above normal or shorted high, FMI 4 = voltage below normal or shorted low.
The same FMI means the same thing across every SPN, which is what makes a structured library possible.
Decoding a DTC from a DM1 message
In J1939, active faults are broadcast in the DM1 message. Each diagnostic trouble code (DTC) is 4 bytes, packing a 19-bit SPN, a 5-bit FMI, a conversion-method bit and a 7-bit occurrence count:
interface Dtc { spn: number; fmi: number; occurrences: number; cm: number }
export function decodeDtc(b: Uint8Array, offset = 0): Dtc {
const b0 = b[offset], b1 = b[offset + 1], b2 = b[offset + 2], b3 = b[offset + 3];
const spn = b0 | (b1 << 8) | ((b2 & 0xe0) << 11); // low 16 bits + top 3 bits
const fmi = b2 & 0x1f;
const cm = (b3 & 0x80) >> 7;
const occurrences = b3 & 0x7f;
return { spn, fmi, occurrences, cm };
}
(b2 & 0xe0) << 11 moves the three high bits (bits 5–7 of the third byte) into SPN bits 16–18. This layout applies to the current conversion method (CM = 0); older ECUs using other conversion methods need a different mapping.
A quick test:
// SPN 100 (0x64), FMI 1, 3 occurrences
expect(decodeDtc(Uint8Array.from([0x64, 0x00, 0x01, 0x03]))).toEqual({
spn: 100, fmi: 1, occurrences: 3, cm: 0,
});
Modeling the library
Each code page is a record, and the same SPN/FMI can have brand-specific notes, because the troubleshooting order differs between manufacturers:
interface FaultCode {
brand: string; // "Cummins", "Detroit Diesel", ...
kind: "SPN" | "PID" | "P-code";
spn?: number; fmi?: number; code?: string;
component: string; // "DEF Dosing Unit"
description: string;
system: "Aftertreatment" | "Fuel" | "Coolant" | "Air/Turbo" | "Sensors/Other";
severity: "MEDIUM" | "HIGH" | "CRITICAL";
testOrder: string[]; // the steps a shop would actually follow
}
The homepage filter (brand, code type, severity, system) is just a set of predicates over these records, with counts computed per facet so users can see "Cummins (60)" before clicking.
Severity drives the UX
A driver on the shoulder needs a different answer than a technician at a bench. Severity is shown first, and critical codes — like SCR inducement derates (SPN 5246) or low oil pressure (SPN 100 FMI 1) — link straight to guidance on whether it's safe to keep driving or time to call a tow.
Guides for the problems codes don't explain
Some of the most useful pages aren't about a single code: DPF ash and soot, DEF in the diesel tank (and vice versa), fuel gelling, oil analysis trends, injector cup failures and emissions compliance like CARB Clean Truck Check. They link back to the relevant codes, and the codes link to them.
Takeaways
- J1939 DTCs are compact bit-packed structs — decode with masks and shifts, and test with known values.
- Model FMI meanings once; they apply to every SPN.
- Facet counts make big filterable libraries far easier to navigate.
- Put severity and "is it safe to drive?" first for real-world users.
Look up a code at professionaldieselrepair.com. Anyone else here work with CAN bus or J1939 data? I'd like to hear what tooling you use.
Top comments (0)