Skip to main content

obd2_core/protocol/
j1939.rs

1//! J1939 heavy-duty vehicle protocol support.
2//!
3//! SAE J1939 is the standard for heavy-duty truck communication over CAN bus
4//! (29-bit extended identifiers, 250 kbps). This module provides:
5//!
6//! - [`Pgn`] type with constants for common parameter groups
7//! - Decoder functions for fleet-critical PGNs (engine, vehicle speed, temps)
8//! - [`J1939Dtc`] type using SPN+FMI format (distinct from OBD-II P-codes)
9//!
10//! ## Usage with ELM327/STN adapters
11//!
12//! Most OBD-II adapters support J1939 via CAN 29-bit mode (`AT SP A` for
13//! 29-bit 250 kbps). Use [`Session::read_j1939_pgn`] which handles the
14//! CAN addressing internally via `raw_request()`.
15//!
16//! ## PGN request format
17//!
18//! A J1939 request message uses a 29-bit CAN ID:
19//! ```text
20//! Priority(3) | Reserved(1) | Data Page(1) | PDU Format(8) | PDU Specific(8) | Source Address(8)
21//! ```
22//! For destination-specific PGNs (PDU Format < 240), PDU Specific = destination address.
23//! For broadcast PGNs (PDU Format >= 240), PDU Specific is part of the PGN.
24
25/// A J1939 Parameter Group Number.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub struct Pgn(pub u32);
28
29impl Pgn {
30    // ── Engine ──
31
32    /// Electronic Engine Controller 1 — engine speed, torque.
33    /// 8 bytes, broadcast, 100ms default rate.
34    pub const EEC1: Pgn = Pgn(61444);
35
36    /// Engine Temperature 1 — coolant temp, fuel temp.
37    /// 8 bytes, broadcast, 1000ms default rate.
38    pub const ET1: Pgn = Pgn(65262);
39
40    /// Engine Fluid Level/Pressure 1 — oil pressure, coolant pressure, oil level.
41    /// 8 bytes, broadcast, 500ms default rate.
42    pub const EFLP1: Pgn = Pgn(65263);
43
44    /// Fuel Economy (Liquid) — fuel rate, instantaneous fuel economy.
45    /// 8 bytes, broadcast, 100ms default rate.
46    pub const LFE: Pgn = Pgn(65266);
47
48    // ── Vehicle ──
49
50    /// Cruise Control/Vehicle Speed — vehicle speed, brake, cruise control.
51    /// 8 bytes, broadcast, 100ms default rate.
52    pub const CCVS: Pgn = Pgn(65265);
53
54    // ── Diagnostics ──
55
56    /// DM1 — Active Diagnostic Trouble Codes.
57    /// Variable length, broadcast, 1000ms default rate.
58    pub const DM1: Pgn = Pgn(65226);
59
60    /// DM2 — Previously Active Diagnostic Trouble Codes.
61    /// Variable length, on-request.
62    pub const DM2: Pgn = Pgn(65227);
63
64    /// Return the PGN name, if known.
65    pub fn name(&self) -> &'static str {
66        match self.0 {
67            61444 => "EEC1 (Electronic Engine Controller 1)",
68            65262 => "ET1 (Engine Temperature 1)",
69            65263 => "EFLP1 (Engine Fluid Level/Pressure 1)",
70            65265 => "CCVS (Cruise Control/Vehicle Speed)",
71            65266 => "LFE (Fuel Economy - Liquid)",
72            65226 => "DM1 (Active DTCs)",
73            65227 => "DM2 (Previously Active DTCs)",
74            _ => "Unknown PGN",
75        }
76    }
77}
78
79impl std::fmt::Display for Pgn {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        write!(f, "PGN {} ({})", self.0, self.name())
82    }
83}
84
85// ── Decoded Parameter Groups ──
86
87/// Decoded Electronic Engine Controller 1 (PGN 61444).
88///
89/// Fields are `None` when the ECU reports "not available" (0xFF/0xFFFF sentinel).
90#[derive(Debug, Clone)]
91pub struct Eec1 {
92    /// Engine speed in RPM. SPN 190, bytes 4-5.
93    pub engine_rpm: Option<f64>,
94    /// Driver's demand engine torque as percent. SPN 512, byte 2.
95    pub driver_demand_torque_pct: Option<f64>,
96    /// Actual engine torque as percent. SPN 513, byte 3.
97    pub actual_torque_pct: Option<f64>,
98    /// Engine torque mode. SPN 899, byte 1 bits 0-3.
99    pub torque_mode: u8,
100}
101
102/// Decoded Cruise Control/Vehicle Speed (PGN 65265).
103///
104/// Fields are `None` when the ECU reports "not available" (0xFF/0xFFFF sentinel).
105#[derive(Debug, Clone)]
106pub struct Ccvs {
107    /// Vehicle speed in km/h. SPN 84, bytes 2-3.
108    pub vehicle_speed: Option<f64>,
109    /// Brake switch active. SPN 597, byte 4 bits 2-3. `None` if not available.
110    pub brake_switch: Option<bool>,
111    /// Cruise control active. SPN 595, byte 1 bits 0-1. `None` if not available.
112    pub cruise_active: Option<bool>,
113}
114
115/// Decoded Engine Temperature 1 (PGN 65262).
116///
117/// Fields are `None` when the ECU reports "not available" (0xFF/0xFFFF sentinel).
118#[derive(Debug, Clone)]
119pub struct Et1 {
120    /// Engine coolant temperature in °C. SPN 110, byte 1.
121    pub coolant_temp: Option<f64>,
122    /// Fuel temperature in °C. SPN 174, byte 2.
123    pub fuel_temp: Option<f64>,
124    /// Engine oil temperature in °C. SPN 175, bytes 3-4.
125    pub oil_temp: Option<f64>,
126}
127
128/// Decoded Engine Fluid Level/Pressure 1 (PGN 65263).
129///
130/// Fields are `None` when the ECU reports "not available" (0xFF/0xFFFF sentinel).
131#[derive(Debug, Clone)]
132pub struct Eflp1 {
133    /// Engine oil pressure in kPa. SPN 100, byte 4.
134    pub oil_pressure: Option<f64>,
135    /// Coolant pressure in kPa. SPN 109, byte 2.
136    pub coolant_pressure: Option<f64>,
137}
138
139/// Decoded Fuel Economy - Liquid (PGN 65266).
140///
141/// Fields are `None` when the ECU reports "not available" (0xFF/0xFFFF sentinel).
142#[derive(Debug, Clone)]
143pub struct Lfe {
144    /// Engine fuel rate in L/h. SPN 183, bytes 1-2.
145    pub fuel_rate: Option<f64>,
146    /// Instantaneous fuel economy in km/L. SPN 184, bytes 3-4.
147    pub instantaneous_fuel_economy: Option<f64>,
148}
149
150/// A J1939 Diagnostic Trouble Code (SPN + FMI format).
151///
152/// Unlike OBD-II P-codes, J1939 uses Suspect Parameter Number (SPN) to identify
153/// the faulting parameter and Failure Mode Identifier (FMI) to describe the
154/// failure type.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct J1939Dtc {
157    /// Suspect Parameter Number — identifies the parameter at fault.
158    pub spn: u32,
159    /// Failure Mode Identifier — describes the type of failure (0-31).
160    pub fmi: u8,
161    /// Occurrence count (0-126, 127 = not available).
162    pub occurrence_count: u8,
163    /// SPN Conversion Method (0 = standard, 1 = extended).
164    pub conversion_method: u8,
165}
166
167impl J1939Dtc {
168    /// Decode a J1939 DTC from the 4-byte DM1/DM2 format.
169    ///
170    /// Byte layout:
171    /// - Bytes 0-1: SPN bits 0-15 (little-endian)
172    /// - Byte 2 bits 5-7: SPN bits 16-18
173    /// - Byte 2 bits 0-4: FMI
174    /// - Byte 3 bit 7: SPN Conversion Method
175    /// - Byte 3 bits 0-6: Occurrence Count
176    pub fn from_bytes(data: &[u8]) -> Option<Self> {
177        if data.len() < 4 {
178            return None;
179        }
180        let spn_low = u16::from_le_bytes([data[0], data[1]]) as u32;
181        let spn_high = ((data[2] >> 5) & 0x07) as u32;
182        let spn = spn_low | (spn_high << 16);
183        let fmi = data[2] & 0x1F;
184        let conversion_method = (data[3] >> 7) & 0x01;
185        let occurrence_count = data[3] & 0x7F;
186
187        Some(Self {
188            spn,
189            fmi,
190            occurrence_count,
191            conversion_method,
192        })
193    }
194
195    /// Human-readable FMI description.
196    pub fn fmi_description(&self) -> &'static str {
197        match self.fmi {
198            0 => "Data Valid But Above Normal Operational Range - Most Severe",
199            1 => "Data Valid But Below Normal Operational Range - Most Severe",
200            2 => "Data Erratic, Intermittent Or Incorrect",
201            3 => "Voltage Above Normal, Or Shorted To High Source",
202            4 => "Voltage Below Normal, Or Shorted To Low Source",
203            5 => "Current Below Normal Or Open Circuit",
204            6 => "Current Above Normal Or Grounded Circuit",
205            7 => "Mechanical System Not Responding Or Out Of Adjustment",
206            8 => "Abnormal Frequency Or Pulse Width Or Period",
207            9 => "Abnormal Update Rate",
208            10 => "Abnormal Rate Of Change",
209            11 => "Root Cause Not Known",
210            12 => "Bad Intelligent Device Or Component",
211            13 => "Out Of Calibration",
212            14 => "Special Instructions",
213            15 => "Data Valid But Above Normal Operating Range - Least Severe",
214            16 => "Data Valid But Above Normal Operating Range - Moderately Severe",
215            17 => "Data Valid But Below Normal Operating Range - Least Severe",
216            18 => "Data Valid But Below Normal Operating Range - Moderately Severe",
217            19 => "Received Network Data In Error",
218            20 => "Data Drifted High",
219            21 => "Data Drifted Low",
220            31 => "Condition Exists",
221            _ => "Reserved",
222        }
223    }
224}
225
226impl std::fmt::Display for J1939Dtc {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        write!(f, "SPN {} FMI {} ({})", self.spn, self.fmi, self.fmi_description())
229    }
230}
231
232// ── PGN Decoders ──
233
234// J1939 "not available" sentinels
235const NA_BYTE: u8 = 0xFF;
236const NA_WORD: u16 = 0xFFFF;
237
238/// Convert a single-byte J1939 value, returning `None` if the byte is `0xFF` (not available).
239fn byte_available(b: u8) -> Option<u8> {
240    if b == NA_BYTE { None } else { Some(b) }
241}
242
243/// Convert a two-byte J1939 value, returning `None` if the word is `0xFFFF` (not available).
244fn word_available(w: u16) -> Option<u16> {
245    if w == NA_WORD { None } else { Some(w) }
246}
247
248/// Decode EEC1 (PGN 61444) from 8 raw bytes.
249///
250/// Fields are `None` when the ECU reports "not available" (0xFF/0xFFFF).
251pub fn decode_eec1(data: &[u8]) -> Option<Eec1> {
252    if data.len() < 8 {
253        return None;
254    }
255    // SPN 899: Torque mode (byte 1, bits 0-3)
256    let torque_mode = data[0] & 0x0F;
257
258    // SPN 512: Driver's Demand Torque (byte 2) — offset -125, resolution 1%
259    let driver_demand_torque_pct = byte_available(data[1]).map(|b| b as f64 - 125.0);
260
261    // SPN 513: Actual Engine Torque (byte 3) — offset -125, resolution 1%
262    let actual_torque_pct = byte_available(data[2]).map(|b| b as f64 - 125.0);
263
264    // SPN 190: Engine Speed (bytes 4-5) — resolution 0.125 RPM
265    let rpm_raw = u16::from_le_bytes([data[3], data[4]]);
266    let engine_rpm = word_available(rpm_raw).map(|w| w as f64 * 0.125);
267
268    Some(Eec1 {
269        engine_rpm,
270        driver_demand_torque_pct,
271        actual_torque_pct,
272        torque_mode,
273    })
274}
275
276/// Decode CCVS (PGN 65265) from 8 raw bytes.
277///
278/// Fields are `None` when the ECU reports "not available" (0xFF/0xFFFF).
279pub fn decode_ccvs(data: &[u8]) -> Option<Ccvs> {
280    if data.len() < 8 {
281        return None;
282    }
283    // SPN 84: Vehicle Speed (bytes 2-3) — resolution 1/256 km/h
284    let speed_raw = u16::from_le_bytes([data[1], data[2]]);
285    let vehicle_speed = word_available(speed_raw).map(|w| w as f64 / 256.0);
286
287    // SPN 597: Brake Switch (byte 4, bits 2-3) — 0b11 = not available
288    let brake_bits = (data[3] >> 2) & 0x03;
289    let brake_switch = if brake_bits == 0x03 { None } else { Some(brake_bits == 1) };
290
291    // SPN 595: Cruise Control Active (byte 1, bits 0-1) — 0b11 = not available
292    let cruise_bits = data[0] & 0x03;
293    let cruise_active = if cruise_bits == 0x03 { None } else { Some(cruise_bits == 1) };
294
295    Some(Ccvs {
296        vehicle_speed,
297        brake_switch,
298        cruise_active,
299    })
300}
301
302/// Decode ET1 (PGN 65262) from 8 raw bytes.
303///
304/// Fields are `None` when the ECU reports "not available" (0xFF/0xFFFF).
305pub fn decode_et1(data: &[u8]) -> Option<Et1> {
306    if data.len() < 4 {
307        return None;
308    }
309    // SPN 110: Engine Coolant Temp (byte 1) — offset -40°C
310    let coolant_temp = byte_available(data[0]).map(|b| b as f64 - 40.0);
311
312    // SPN 174: Fuel Temp (byte 2) — offset -40°C
313    let fuel_temp = byte_available(data[1]).map(|b| b as f64 - 40.0);
314
315    // SPN 175: Engine Oil Temp (bytes 3-4) — resolution 0.03125°C, offset -273°C
316    let oil_raw = u16::from_le_bytes([data[2], data[3]]);
317    let oil_temp = word_available(oil_raw).map(|w| w as f64 * 0.03125 - 273.0);
318
319    Some(Et1 {
320        coolant_temp,
321        fuel_temp,
322        oil_temp,
323    })
324}
325
326/// Decode EFLP1 (PGN 65263) from 8 raw bytes.
327///
328/// Fields are `None` when the ECU reports "not available" (0xFF/0xFFFF).
329pub fn decode_eflp1(data: &[u8]) -> Option<Eflp1> {
330    if data.len() < 4 {
331        return None;
332    }
333    // SPN 109: Coolant Pressure (byte 2) — resolution 2 kPa
334    let coolant_pressure = byte_available(data[1]).map(|b| b as f64 * 2.0);
335
336    // SPN 100: Engine Oil Pressure (byte 4) — resolution 4 kPa
337    let oil_pressure = byte_available(data[3]).map(|b| b as f64 * 4.0);
338
339    Some(Eflp1 {
340        oil_pressure,
341        coolant_pressure,
342    })
343}
344
345/// Decode LFE (PGN 65266) from 8 raw bytes.
346///
347/// Fields are `None` when the ECU reports "not available" (0xFF/0xFFFF).
348pub fn decode_lfe(data: &[u8]) -> Option<Lfe> {
349    if data.len() < 4 {
350        return None;
351    }
352    // SPN 183: Engine Fuel Rate (bytes 1-2) — resolution 0.05 L/h
353    let rate_raw = u16::from_le_bytes([data[0], data[1]]);
354    let fuel_rate = word_available(rate_raw).map(|w| w as f64 * 0.05);
355
356    // SPN 184: Instantaneous Fuel Economy (bytes 3-4) — resolution 1/512 km/L
357    let econ_raw = u16::from_le_bytes([data[2], data[3]]);
358    let instantaneous_fuel_economy = word_available(econ_raw).map(|w| w as f64 / 512.0);
359
360    Some(Lfe {
361        fuel_rate,
362        instantaneous_fuel_economy,
363    })
364}
365
366/// Decode DM1/DM2 active DTCs from a J1939 diagnostic message.
367///
368/// The first 2 bytes are the lamp status, followed by 4-byte DTC entries.
369pub fn decode_dm1(data: &[u8]) -> Vec<J1939Dtc> {
370    if data.len() < 6 {
371        return vec![];
372    }
373    // Skip 2 bytes of lamp status (MIL, RSL, AWL, PL)
374    let dtc_data = &data[2..];
375    dtc_data
376        .chunks(4)
377        .filter_map(J1939Dtc::from_bytes)
378        .collect()
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_pgn_constants() {
387        assert_eq!(Pgn::EEC1.0, 61444);
388        assert_eq!(Pgn::CCVS.0, 65265);
389        assert_eq!(Pgn::ET1.0, 65262);
390        assert_eq!(Pgn::DM1.0, 65226);
391    }
392
393    #[test]
394    fn test_pgn_name() {
395        assert!(Pgn::EEC1.name().contains("Electronic Engine"));
396        assert!(Pgn::CCVS.name().contains("Vehicle Speed"));
397        assert_eq!(Pgn(99999).name(), "Unknown PGN");
398    }
399
400    #[test]
401    fn test_pgn_display() {
402        let s = format!("{}", Pgn::EEC1);
403        assert!(s.contains("61444"));
404        assert!(s.contains("EEC1"));
405    }
406
407    #[test]
408    fn test_decode_eec1() {
409        // Torque mode 0, demand -125+155=30%, actual -125+155=30%, RPM = 5440*0.125 = 680
410        let data = [0x00, 155, 155, 0x40, 0x15, 0xFF, 0xFF, 0xFF];
411        let eec1 = decode_eec1(&data).unwrap();
412        assert!((eec1.engine_rpm.unwrap() - 680.0).abs() < 0.2);
413        assert!((eec1.driver_demand_torque_pct.unwrap() - 30.0).abs() < 0.1);
414        assert!((eec1.actual_torque_pct.unwrap() - 30.0).abs() < 0.1);
415    }
416
417    #[test]
418    fn test_decode_eec1_not_available() {
419        // All 0xFF = not available for torque and RPM fields
420        let data = [0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
421        let eec1 = decode_eec1(&data).unwrap();
422        assert!(eec1.engine_rpm.is_none());
423        assert!(eec1.driver_demand_torque_pct.is_none());
424        assert!(eec1.actual_torque_pct.is_none());
425    }
426
427    #[test]
428    fn test_decode_eec1_too_short() {
429        assert!(decode_eec1(&[0x00, 0x01]).is_none());
430    }
431
432    #[test]
433    fn test_decode_ccvs() {
434        // Speed: 0x1A00 / 256 = 26.0 km/h, brake off, cruise off
435        let data = [0x00, 0x00, 0x1A, 0x00, 0x00, 0x00, 0x00, 0x00];
436        let ccvs = decode_ccvs(&data).unwrap();
437        assert!((ccvs.vehicle_speed.unwrap() - 26.0).abs() < 0.1);
438        assert_eq!(ccvs.brake_switch, Some(false));
439        assert_eq!(ccvs.cruise_active, Some(false));
440    }
441
442    #[test]
443    fn test_decode_ccvs_not_available() {
444        let data = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
445        let ccvs = decode_ccvs(&data).unwrap();
446        assert!(ccvs.vehicle_speed.is_none());
447        assert!(ccvs.brake_switch.is_none());
448        assert!(ccvs.cruise_active.is_none());
449    }
450
451    #[test]
452    fn test_decode_et1() {
453        // Coolant: 90-40 = 50°C, Fuel: 60-40 = 20°C
454        let data = [90, 60, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF];
455        let et1 = decode_et1(&data).unwrap();
456        assert!((et1.coolant_temp.unwrap() - 50.0).abs() < 0.1);
457        assert!((et1.fuel_temp.unwrap() - 20.0).abs() < 0.1);
458    }
459
460    #[test]
461    fn test_decode_et1_not_available() {
462        let data = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
463        let et1 = decode_et1(&data).unwrap();
464        assert!(et1.coolant_temp.is_none());
465        assert!(et1.fuel_temp.is_none());
466        assert!(et1.oil_temp.is_none());
467    }
468
469    #[test]
470    fn test_decode_eflp1() {
471        // Coolant pressure: 50*2 = 100 kPa, Oil pressure: 100*4 = 400 kPa
472        let data = [0xFF, 50, 0xFF, 100, 0xFF, 0xFF, 0xFF, 0xFF];
473        let eflp1 = decode_eflp1(&data).unwrap();
474        assert!((eflp1.coolant_pressure.unwrap() - 100.0).abs() < 0.1);
475        assert!((eflp1.oil_pressure.unwrap() - 400.0).abs() < 0.1);
476    }
477
478    #[test]
479    fn test_decode_eflp1_not_available() {
480        let data = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
481        let eflp1 = decode_eflp1(&data).unwrap();
482        assert!(eflp1.oil_pressure.is_none());
483        assert!(eflp1.coolant_pressure.is_none());
484    }
485
486    #[test]
487    fn test_decode_lfe() {
488        // Fuel rate: 100 * 0.05 = 5.0 L/h
489        let data = [100, 0x00, 0x00, 0x02, 0xFF, 0xFF, 0xFF, 0xFF];
490        let lfe = decode_lfe(&data).unwrap();
491        assert!((lfe.fuel_rate.unwrap() - 5.0).abs() < 0.1);
492    }
493
494    #[test]
495    fn test_decode_lfe_not_available() {
496        let data = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
497        let lfe = decode_lfe(&data).unwrap();
498        assert!(lfe.fuel_rate.is_none());
499        assert!(lfe.instantaneous_fuel_economy.is_none());
500    }
501
502    #[test]
503    fn test_j1939_dtc_from_bytes() {
504        // SPN 190 (engine speed), FMI 2 (erratic)
505        let data = [0xBE, 0x00, 0x02, 0x01]; // SPN low = 0x00BE = 190, high bits = 0, FMI = 2, OC = 1
506        let dtc = J1939Dtc::from_bytes(&data).unwrap();
507        assert_eq!(dtc.spn, 190);
508        assert_eq!(dtc.fmi, 2);
509        assert_eq!(dtc.occurrence_count, 1);
510    }
511
512    #[test]
513    fn test_j1939_dtc_from_bytes_too_short() {
514        assert!(J1939Dtc::from_bytes(&[0x00, 0x01]).is_none());
515    }
516
517    #[test]
518    fn test_j1939_dtc_display() {
519        let dtc = J1939Dtc {
520            spn: 190,
521            fmi: 2,
522            occurrence_count: 1,
523            conversion_method: 0,
524        };
525        let s = format!("{}", dtc);
526        assert!(s.contains("SPN 190"));
527        assert!(s.contains("FMI 2"));
528        assert!(s.contains("Erratic"));
529    }
530
531    #[test]
532    fn test_j1939_dtc_fmi_descriptions() {
533        let dtc = J1939Dtc { spn: 0, fmi: 0, occurrence_count: 0, conversion_method: 0 };
534        assert!(dtc.fmi_description().contains("Above Normal"));
535        let dtc = J1939Dtc { spn: 0, fmi: 11, occurrence_count: 0, conversion_method: 0 };
536        assert!(dtc.fmi_description().contains("Root Cause Not Known"));
537    }
538
539    #[test]
540    fn test_decode_dm1() {
541        // 2 bytes lamp status + 1 DTC (4 bytes)
542        let data = [0x00, 0x00, 0xBE, 0x00, 0x02, 0x01];
543        let dtcs = decode_dm1(&data);
544        assert_eq!(dtcs.len(), 1);
545        assert_eq!(dtcs[0].spn, 190);
546        assert_eq!(dtcs[0].fmi, 2);
547    }
548
549    #[test]
550    fn test_decode_dm1_empty() {
551        assert!(decode_dm1(&[0x00, 0x00]).is_empty());
552    }
553
554    #[test]
555    fn test_decode_dm1_multiple_dtcs() {
556        let data = [
557            0x00, 0x00, // lamp status
558            0xBE, 0x00, 0x02, 0x01, // SPN 190 FMI 2
559            0x64, 0x00, 0x03, 0x02, // SPN 100 FMI 3
560        ];
561        let dtcs = decode_dm1(&data);
562        assert_eq!(dtcs.len(), 2);
563        assert_eq!(dtcs[0].spn, 190);
564        assert_eq!(dtcs[1].spn, 100);
565    }
566}