Skip to main content

obd2_core/session/
diagnostics.rs

1//! Diagnostic intelligence — DTC enrichment, rules, known issues.
2//!
3//! ## DTC Description Pipeline
4//!
5//! obd2-core provides a two-tier DTC description system:
6//!
7//! 1. **Universal SAE J2012 table** (~200 codes) — built into [`crate::protocol::dtc`],
8//!    auto-populated when DTCs are created via `from_bytes()` or `from_code()`.
9//!
10//! 2. **Vehicle-specific enrichment** — [`enrich_dtcs`] overlays descriptions,
11//!    severity, and technician notes from the matched [`VehicleSpec`]'s DTC library.
12//!
13//! Consumers do NOT need their own DTC description tables. Call `enrich_dtcs()`
14//! after reading DTCs to get the most specific descriptions available.
15
16use crate::protocol::dtc::{Dtc, universal_dtc_description};
17use crate::vehicle::{VehicleSpec, DiagnosticRule, RuleTrigger, KnownIssue};
18
19/// Enrich a list of DTCs with descriptions, severity, and notes from the spec.
20///
21/// Resolution order (BR-4.4):
22/// 1. Vehicle spec DTC library (most specific)
23/// 2. Universal SAE J2012 descriptions
24/// 3. "Unknown DTC" fallback
25pub fn enrich_dtcs(dtcs: &mut [Dtc], spec: Option<&VehicleSpec>) {
26    for dtc in dtcs.iter_mut() {
27        // Try spec DTC library first
28        if let Some(spec) = spec {
29            if let Some(lib) = &spec.dtc_library {
30                if let Some(entry) = lib.lookup(&dtc.code) {
31                    dtc.description = Some(entry.meaning.clone());
32                    dtc.severity = Some(entry.severity);
33                    dtc.notes = entry.notes.clone();
34                    continue;
35                }
36            }
37        }
38
39        // Fall back to universal descriptions
40        if dtc.description.is_none() {
41            dtc.description = universal_dtc_description(&dtc.code).map(String::from);
42        }
43    }
44}
45
46/// Find diagnostic rules that fire for the current set of DTCs.
47///
48/// Rules fire based on triggers (BR-4.2):
49/// - DtcPresent: fires when a specific DTC code is in the list
50/// - DtcRange: fires when any DTC in the range is present
51pub fn active_rules<'a>(
52    dtcs: &[Dtc],
53    spec: Option<&'a VehicleSpec>,
54) -> Vec<&'a DiagnosticRule> {
55    let spec = match spec {
56        Some(s) => s,
57        None => return vec![],
58    };
59
60    spec.diagnostic_rules.iter().filter(|rule| {
61        match &rule.trigger {
62            RuleTrigger::DtcPresent(code) => {
63                dtcs.iter().any(|d| d.code == *code)
64            }
65            RuleTrigger::DtcRange(start, end) => {
66                dtcs.iter().any(|d| d.code >= *start && d.code <= *end)
67            }
68        }
69    }).collect()
70}
71
72/// Find known issues that match current DTCs by symptom codes.
73pub fn matching_issues<'a>(
74    dtcs: &[Dtc],
75    spec: Option<&'a VehicleSpec>,
76) -> Vec<&'a KnownIssue> {
77    let spec = match spec {
78        Some(s) => s,
79        None => return vec![],
80    };
81
82    let dtc_codes: Vec<&str> = dtcs.iter().map(|d| d.code.as_str()).collect();
83
84    let mut matches: Vec<&KnownIssue> = spec.known_issues.iter().filter(|issue| {
85        issue.symptoms.iter().any(|symptom| dtc_codes.contains(&symptom.as_str()))
86    }).collect();
87
88    // Sort by rank (lowest = most common = first)
89    matches.sort_by_key(|i| i.rank);
90    matches
91}
92
93/// Deduplicate DTCs by code, keeping the most informative version.
94pub fn dedup_dtcs(dtcs: &mut Vec<Dtc>) {
95    let mut seen = std::collections::HashSet::new();
96    dtcs.retain(|dtc| seen.insert(dtc.code.clone()));
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::protocol::dtc::Severity;
103    use crate::vehicle::*;
104
105    fn make_spec_with_dtcs() -> VehicleSpec {
106        VehicleSpec {
107            spec_version: Some("1.0".into()),
108            identity: SpecIdentity {
109                name: "Test".into(),
110                model_years: (2004, 2005),
111                makes: vec!["Chevrolet".into()],
112                models: vec!["Silverado".into()],
113                engine: EngineSpec {
114                    code: "LLY".into(),
115                    displacement_l: 6.6,
116                    cylinders: 8,
117                    layout: "V8".into(),
118                    aspiration: "Turbo".into(),
119                    fuel_type: "Diesel".into(),
120                    fuel_system: None,
121                    compression_ratio: None,
122                    max_power_kw: None,
123                    max_torque_nm: None,
124                    redline_rpm: 3250,
125                    idle_rpm_warm: 680,
126                    idle_rpm_cold: 780,
127                    firing_order: None,
128                    ecm_hardware: None,
129                },
130                transmission: None,
131                vin_match: None,
132            },
133            communication: CommunicationSpec {
134                buses: vec![],
135                elm327_protocol_code: None,
136            },
137            thresholds: None,
138            dtc_library: Some(DtcLibrary {
139                ecm: vec![
140                    DtcEntry {
141                        code: "P0087".into(),
142                        meaning: "Fuel Rail Pressure Too Low".into(),
143                        severity: Severity::Critical,
144                        notes: Some("CP3 pump failure or fuel filter".into()),
145                        related_pids: None,
146                        category: None,
147                    },
148                    DtcEntry {
149                        code: "P0234".into(),
150                        meaning: "Turbo Overboost Condition".into(),
151                        severity: Severity::Critical,
152                        notes: Some("VGT vanes stuck closed".into()),
153                        related_pids: None,
154                        category: None,
155                    },
156                ],
157                tcm: vec![],
158                bcm: vec![],
159                network: vec![],
160            }),
161            polling_groups: vec![],
162            diagnostic_rules: vec![
163                DiagnosticRule {
164                    name: "P0700 TCM redirect".into(),
165                    trigger: RuleTrigger::DtcPresent("P0700".into()),
166                    action: RuleAction::QueryModule {
167                        module: "tcm".into(),
168                        service: 0x03,
169                    },
170                    description: "P0700 means query TCM directly for real DTCs".into(),
171                },
172                DiagnosticRule {
173                    name: "FICM check".into(),
174                    trigger: RuleTrigger::DtcRange("P0201".into(), "P0208".into()),
175                    action: RuleAction::CheckFirst {
176                        pid: 0x1100,
177                        module: "ficm".into(),
178                        reason: "Check FICM 48V before condemning injectors".into(),
179                    },
180                    description: "90% of injector circuit codes are FICM failures".into(),
181                },
182            ],
183            known_issues: vec![
184                KnownIssue {
185                    rank: 1,
186                    name: "turbo_vane_sticking".into(),
187                    description: "VGT vanes stick from carbon buildup".into(),
188                    symptoms: vec!["P0234".into(), "P0299".into()],
189                    root_cause: "Carbon/soot in unison ring".into(),
190                    quick_test: Some(QuickTest {
191                        description: "Monitor VGT Position Error under load".into(),
192                        pass_criteria: "Error < 10%".into(),
193                    }),
194                    fix: "Remove turbo, clean unison ring".into(),
195                },
196                KnownIssue {
197                    rank: 2,
198                    name: "ficm_failure".into(),
199                    description: "FICM 48V capacitor bank degradation".into(),
200                    symptoms: vec!["P0201".into(), "P0611".into(), "P2146".into()],
201                    root_cause: "Internal capacitor failure".into(),
202                    quick_test: None,
203                    fix: "Replace or rebuild FICM".into(),
204                },
205            ],
206            enhanced_pids: vec![],
207        }
208    }
209
210    #[test]
211    fn test_enrich_from_spec() {
212        let spec = make_spec_with_dtcs();
213        let mut dtcs = vec![Dtc::from_code("P0087")];
214        enrich_dtcs(&mut dtcs, Some(&spec));
215        assert_eq!(dtcs[0].description.as_deref(), Some("Fuel Rail Pressure Too Low"));
216        assert_eq!(dtcs[0].severity, Some(Severity::Critical));
217        assert!(dtcs[0].notes.is_some());
218    }
219
220    #[test]
221    fn test_enrich_fallback_universal() {
222        let spec = make_spec_with_dtcs();
223        let mut dtcs = vec![Dtc::from_code("P0420")]; // not in spec, but in universal
224        enrich_dtcs(&mut dtcs, Some(&spec));
225        assert!(dtcs[0].description.is_some());
226        assert!(dtcs[0].description.as_ref().unwrap().contains("Catalyst"));
227    }
228
229    #[test]
230    fn test_enrich_no_spec() {
231        let mut dtcs = vec![Dtc::from_code("P0420")];
232        enrich_dtcs(&mut dtcs, None);
233        assert!(dtcs[0].description.is_some()); // universal fallback
234    }
235
236    #[test]
237    fn test_active_rules_p0700() {
238        let spec = make_spec_with_dtcs();
239        let dtcs = vec![Dtc::from_code("P0700")];
240        let rules = active_rules(&dtcs, Some(&spec));
241        assert_eq!(rules.len(), 1);
242        assert_eq!(rules[0].name, "P0700 TCM redirect");
243    }
244
245    #[test]
246    fn test_active_rules_injector_range() {
247        let spec = make_spec_with_dtcs();
248        let dtcs = vec![Dtc::from_code("P0204")]; // within P0201-P0208
249        let rules = active_rules(&dtcs, Some(&spec));
250        assert_eq!(rules.len(), 1);
251        assert_eq!(rules[0].name, "FICM check");
252    }
253
254    #[test]
255    fn test_active_rules_none() {
256        let spec = make_spec_with_dtcs();
257        let dtcs = vec![Dtc::from_code("P0420")]; // no rule for this
258        let rules = active_rules(&dtcs, Some(&spec));
259        assert!(rules.is_empty());
260    }
261
262    #[test]
263    fn test_matching_issues_turbo() {
264        let spec = make_spec_with_dtcs();
265        let dtcs = vec![Dtc::from_code("P0234")];
266        let issues = matching_issues(&dtcs, Some(&spec));
267        assert_eq!(issues.len(), 1);
268        assert_eq!(issues[0].name, "turbo_vane_sticking");
269    }
270
271    #[test]
272    fn test_matching_issues_ficm() {
273        let spec = make_spec_with_dtcs();
274        let dtcs = vec![Dtc::from_code("P0201")];
275        let issues = matching_issues(&dtcs, Some(&spec));
276        assert_eq!(issues.len(), 1);
277        assert_eq!(issues[0].name, "ficm_failure");
278    }
279
280    #[test]
281    fn test_matching_issues_multiple() {
282        let spec = make_spec_with_dtcs();
283        let dtcs = vec![Dtc::from_code("P0234"), Dtc::from_code("P0201")];
284        let issues = matching_issues(&dtcs, Some(&spec));
285        assert_eq!(issues.len(), 2);
286        assert_eq!(issues[0].rank, 1); // sorted by rank
287    }
288
289    #[test]
290    fn test_matching_issues_no_match() {
291        let spec = make_spec_with_dtcs();
292        let dtcs = vec![Dtc::from_code("P0420")];
293        let issues = matching_issues(&dtcs, Some(&spec));
294        assert!(issues.is_empty());
295    }
296
297    #[test]
298    fn test_dedup_dtcs() {
299        let mut dtcs = vec![
300            Dtc::from_code("P0420"),
301            Dtc::from_code("P0171"),
302            Dtc::from_code("P0420"), // duplicate
303        ];
304        dedup_dtcs(&mut dtcs);
305        assert_eq!(dtcs.len(), 2);
306    }
307}