1pub mod loader;
4#[cfg(feature = "nhtsa")]
5pub mod nhtsa;
6pub mod vin;
7
8use serde::Deserialize;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
14pub struct ModuleId(pub String);
15
16impl ModuleId {
17 pub const ECM: &'static str = "ecm";
18 pub const TCM: &'static str = "tcm";
19 pub const BCM: &'static str = "bcm";
20 pub const ABS: &'static str = "abs";
21 pub const IPC: &'static str = "ipc";
22 pub const AIRBAG: &'static str = "airbag";
23 pub const HVAC: &'static str = "hvac";
24 pub const FICM: &'static str = "ficm";
25
26 pub fn new(id: impl Into<String>) -> Self {
27 Self(id.into())
28 }
29}
30
31#[derive(Debug, Clone, Deserialize)]
34#[non_exhaustive]
35pub enum PhysicalAddress {
36 J1850 { node: u8, header: [u8; 3] },
37 Can11Bit { request_id: u16, response_id: u16 },
38 Can29Bit { request_id: u32, response_id: u32 },
39 J1939 { source_address: u8 },
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
45#[non_exhaustive]
46pub enum Protocol {
47 J1850Vpw,
48 J1850Pwm,
49 Iso9141(KLineInit),
50 Kwp2000(KLineInit),
51 Can11Bit500,
52 Can11Bit250,
53 Can29Bit500,
54 Can29Bit250,
55 Auto,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
59pub enum KLineInit {
60 SlowInit,
61 FastInit,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
67pub struct BusId(pub String);
68
69#[derive(Debug, Clone, Deserialize)]
70pub struct BusConfig {
71 pub id: BusId,
72 pub protocol: Protocol,
73 pub speed_bps: u32,
74 #[serde(default)]
75 pub modules: Vec<Module>,
76 pub description: Option<String>,
77}
78
79#[derive(Debug, Clone, Deserialize)]
82pub struct Module {
83 pub id: ModuleId,
84 pub name: String,
85 pub address: PhysicalAddress,
86 pub bus: BusId,
87}
88
89#[derive(Debug, Clone, Deserialize)]
92pub struct VehicleSpec {
93 pub spec_version: Option<String>,
94 pub identity: SpecIdentity,
95 pub communication: CommunicationSpec,
96 pub thresholds: Option<ThresholdSet>,
97 #[serde(default)]
98 pub polling_groups: Vec<PollingGroup>,
99 #[serde(default)]
100 pub diagnostic_rules: Vec<DiagnosticRule>,
101 #[serde(default)]
102 pub known_issues: Vec<KnownIssue>,
103 pub dtc_library: Option<DtcLibrary>,
104 #[serde(default)]
105 pub enhanced_pids: Vec<crate::protocol::enhanced::EnhancedPid>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
109pub struct SpecIdentity {
110 pub name: String,
111 pub model_years: (u16, u16),
112 pub makes: Vec<String>,
113 pub models: Vec<String>,
114 pub engine: EngineSpec,
115 pub transmission: Option<TransmissionSpec>,
116 pub vin_match: Option<VinMatcher>,
117}
118
119#[derive(Debug, Clone, Deserialize)]
120pub struct VinMatcher {
121 pub vin_8th_digit: Option<Vec<char>>,
122 #[serde(default)]
123 pub wmi_prefixes: Vec<String>,
124 pub year_range: Option<(u16, u16)>,
125}
126
127impl VinMatcher {
128 pub fn matches(&self, vin: &str) -> bool {
130 if vin.len() < 17 {
131 return false;
132 }
133 let chars: Vec<char> = vin.chars().collect();
134
135 let wmi: String = chars[..3].iter().collect();
137 let wmi_ok = self.wmi_prefixes.is_empty()
138 || self.wmi_prefixes.iter().any(|p| wmi.eq_ignore_ascii_case(p));
139
140 let digit_ok = self
142 .vin_8th_digit
143 .as_ref()
144 .map(|digits| digits.contains(&chars[7]))
145 .unwrap_or(true);
146
147 let year_ok = self
149 .year_range
150 .as_ref()
151 .map(|(min, max)| {
152 let (current, previous) = vin::decode_year_candidates(vin);
153 let in_range = |y: Option<i32>| {
154 y.is_some_and(|y| y >= *min as i32 && y <= *max as i32)
155 };
156 in_range(current) || in_range(previous)
158 })
159 .unwrap_or(true);
160
161 wmi_ok && digit_ok && year_ok
162 }
163}
164
165#[derive(Debug, Clone, Deserialize)]
166pub struct EngineSpec {
167 pub code: String,
168 pub displacement_l: f64,
169 pub cylinders: u8,
170 pub layout: String,
171 pub aspiration: String,
172 pub fuel_type: String,
173 pub fuel_system: Option<String>,
174 pub compression_ratio: Option<f64>,
175 pub max_power_kw: Option<f64>,
176 pub max_torque_nm: Option<f64>,
177 pub redline_rpm: u16,
178 pub idle_rpm_warm: u16,
179 pub idle_rpm_cold: u16,
180 pub firing_order: Option<Vec<u8>>,
181 pub ecm_hardware: Option<String>,
182}
183
184#[derive(Debug, Clone, Deserialize)]
185pub struct TransmissionSpec {
186 pub model: String,
187 pub transmission_type: TransmissionType,
188 pub fluid_capacity_l: Option<f64>,
189}
190
191#[derive(Debug, Clone, Deserialize)]
192#[non_exhaustive]
193pub enum TransmissionType {
194 Geared {
195 speeds: u8,
196 gear_ratios: Vec<(String, f64)>,
197 },
198 Cvt {
199 ratio_range: (f64, f64),
200 simulated_steps: Option<u8>,
201 },
202 Dct {
203 speeds: u8,
204 gear_ratios: Vec<(String, f64)>,
205 },
206 Manual {
207 speeds: u8,
208 gear_ratios: Vec<(String, f64)>,
209 },
210}
211
212#[derive(Debug, Clone, Deserialize)]
213pub struct CommunicationSpec {
214 pub buses: Vec<BusConfig>,
215 pub elm327_protocol_code: Option<String>,
216}
217
218#[derive(Debug, Clone, serde::Serialize, Deserialize)]
221pub struct Threshold {
222 pub min: Option<f64>,
223 pub max: Option<f64>,
224 pub warning_low: Option<f64>,
225 pub warning_high: Option<f64>,
226 pub critical_low: Option<f64>,
227 pub critical_high: Option<f64>,
228 pub unit: String,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
232pub enum AlertLevel {
233 Normal,
234 Warning,
235 Critical,
236}
237
238#[derive(Debug, Clone, Copy)]
239pub enum AlertDirection {
240 Low,
241 High,
242}
243
244#[derive(Debug, Clone)]
245pub struct ThresholdResult {
246 pub level: AlertLevel,
247 pub reading: f64,
248 pub limit: f64,
249 pub direction: AlertDirection,
250 pub message: String,
251}
252
253impl Threshold {
254 pub fn evaluate(&self, value: f64, name: &str) -> Option<ThresholdResult> {
256 if let Some(limit) = self.critical_high {
258 if value >= limit {
259 return Some(ThresholdResult {
260 level: AlertLevel::Critical,
261 reading: value,
262 limit,
263 direction: AlertDirection::High,
264 message: format!(
265 "{} critically high: {:.1} >= {:.1} {}",
266 name, value, limit, self.unit
267 ),
268 });
269 }
270 }
271 if let Some(limit) = self.critical_low {
272 if value <= limit {
273 return Some(ThresholdResult {
274 level: AlertLevel::Critical,
275 reading: value,
276 limit,
277 direction: AlertDirection::Low,
278 message: format!(
279 "{} critically low: {:.1} <= {:.1} {}",
280 name, value, limit, self.unit
281 ),
282 });
283 }
284 }
285 if let Some(limit) = self.warning_high {
287 if value >= limit {
288 return Some(ThresholdResult {
289 level: AlertLevel::Warning,
290 reading: value,
291 limit,
292 direction: AlertDirection::High,
293 message: format!(
294 "{} warning high: {:.1} >= {:.1} {}",
295 name, value, limit, self.unit
296 ),
297 });
298 }
299 }
300 if let Some(limit) = self.warning_low {
301 if value <= limit {
302 return Some(ThresholdResult {
303 level: AlertLevel::Warning,
304 reading: value,
305 limit,
306 direction: AlertDirection::Low,
307 message: format!(
308 "{} warning low: {:.1} <= {:.1} {}",
309 name, value, limit, self.unit
310 ),
311 });
312 }
313 }
314 None }
316}
317
318#[derive(Debug, Clone, serde::Serialize, Deserialize)]
319pub struct ThresholdSet {
320 #[serde(default)]
321 pub engine: Vec<NamedThreshold>,
322 #[serde(default)]
323 pub transmission: Vec<NamedThreshold>,
324}
325
326#[derive(Debug, Clone, serde::Serialize, Deserialize)]
327pub struct NamedThreshold {
328 pub name: String,
329 pub threshold: Threshold,
330}
331
332#[derive(Debug, Clone, Deserialize)]
335pub struct PollingGroup {
336 pub name: String,
337 pub description: String,
338 pub target_interval_ms: u32,
339 pub steps: Vec<PollStep>,
340}
341
342#[derive(Debug, Clone, Deserialize)]
343pub struct PollStep {
344 pub target: String, #[serde(default)]
346 pub standard_pids: Vec<u8>, #[serde(default)]
348 pub enhanced_pids: Vec<u16>, }
350
351#[derive(Debug, Clone, Deserialize)]
354pub struct DiagnosticRule {
355 pub name: String,
356 pub trigger: RuleTrigger,
357 pub action: RuleAction,
358 pub description: String,
359}
360
361#[derive(Debug, Clone, Deserialize)]
362#[non_exhaustive]
363pub enum RuleTrigger {
364 DtcPresent(String),
365 DtcRange(String, String),
366}
367
368#[derive(Debug, Clone, Deserialize)]
369#[non_exhaustive]
370pub enum RuleAction {
371 QueryModule { module: String, service: u8 },
372 CheckFirst { pid: u16, module: String, reason: String },
373 Alert(String),
374 MonitorPids(Vec<u16>),
375}
376
377#[derive(Debug, Clone, Deserialize)]
380pub struct KnownIssue {
381 pub rank: u8,
382 pub name: String,
383 pub description: String,
384 pub symptoms: Vec<String>,
385 pub root_cause: String,
386 pub quick_test: Option<QuickTest>,
387 pub fix: String,
388}
389
390#[derive(Debug, Clone, Deserialize)]
391pub struct QuickTest {
392 pub description: String,
393 pub pass_criteria: String,
394}
395
396#[derive(Debug, Clone)]
399pub struct VehicleProfile {
400 pub vin: String,
401 pub decoded_vin: Option<vin::DecodedVin>,
404 pub info: Option<crate::protocol::service::VehicleInfo>,
405 pub spec: Option<VehicleSpec>,
406 pub supported_pids: std::collections::HashSet<crate::protocol::pid::Pid>,
407}
408
409#[derive(Debug, Clone, Default, Deserialize)]
412pub struct DtcLibrary {
413 #[serde(default)]
414 pub ecm: Vec<DtcEntry>,
415 #[serde(default)]
416 pub tcm: Vec<DtcEntry>,
417 #[serde(default)]
418 pub bcm: Vec<DtcEntry>,
419 #[serde(default)]
420 pub network: Vec<DtcEntry>,
421}
422
423#[derive(Debug, Clone, Deserialize)]
424pub struct DtcEntry {
425 pub code: String,
426 pub meaning: String,
427 pub severity: crate::protocol::dtc::Severity,
428 pub notes: Option<String>,
429 pub related_pids: Option<Vec<u16>>,
430 pub category: Option<String>,
431}
432
433impl DtcLibrary {
434 pub fn lookup(&self, code: &str) -> Option<&DtcEntry> {
435 self.ecm
436 .iter()
437 .chain(self.tcm.iter())
438 .chain(self.bcm.iter())
439 .chain(self.network.iter())
440 .find(|e| e.code == code)
441 }
442}
443
444pub struct SpecRegistry {
448 specs: Vec<VehicleSpec>,
449}
450
451impl Default for SpecRegistry {
452 fn default() -> Self {
453 Self::new()
454 }
455}
456
457impl SpecRegistry {
458 pub fn new() -> Self {
460 Self { specs: Vec::new() }
461 }
462
463 pub fn with_defaults() -> Self {
465 let specs = crate::specs::embedded::load_embedded_specs();
466 Self { specs }
467 }
468
469 pub fn load_file(&mut self, path: &std::path::Path) -> Result<(), crate::error::Obd2Error> {
471 let spec = loader::load_spec_from_file(path)?;
472 self.specs.push(spec);
473 Ok(())
474 }
475
476 pub fn load_directory(
478 &mut self,
479 dir: &std::path::Path,
480 ) -> Result<usize, crate::error::Obd2Error> {
481 let mut count = 0;
482 if let Ok(entries) = std::fs::read_dir(dir) {
483 for entry in entries.flatten() {
484 let path = entry.path();
485 if path.extension().is_some_and(|e| e == "yaml" || e == "yml")
486 && self.load_file(&path).is_ok()
487 {
488 count += 1;
489 }
490 }
491 }
492 Ok(count)
493 }
494
495 pub fn match_vin(&self, vin: &str) -> Option<&VehicleSpec> {
497 self.specs.iter().find(|s| {
498 s.identity
499 .vin_match
500 .as_ref()
501 .is_some_and(|m| m.matches(vin))
502 })
503 }
504
505 pub fn match_vehicle(&self, make: &str, model: &str, year: u16) -> Option<&VehicleSpec> {
507 self.specs.iter().find(|s| {
508 let year_ok = year >= s.identity.model_years.0 && year <= s.identity.model_years.1;
509 let make_ok = s
510 .identity
511 .makes
512 .iter()
513 .any(|m| m.eq_ignore_ascii_case(make));
514 let model_ok = s
515 .identity
516 .models
517 .iter()
518 .any(|m| m.eq_ignore_ascii_case(model));
519 year_ok && make_ok && model_ok
520 })
521 }
522
523 pub fn specs(&self) -> &[VehicleSpec] {
525 &self.specs
526 }
527
528 pub fn lookup_dtc(&self, code: &str) -> Option<&DtcEntry> {
530 for spec in &self.specs {
531 if let Some(lib) = &spec.dtc_library {
532 if let Some(entry) = lib.lookup(code) {
533 return Some(entry);
534 }
535 }
536 }
537 None
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn test_module_id_constants() {
547 let ecm = ModuleId::new(ModuleId::ECM);
548 let also_ecm = ModuleId::new("ecm");
549 assert_eq!(ecm, also_ecm);
550 }
551
552 #[test]
553 fn test_module_id_custom() {
554 let vsa = ModuleId::new("vsa");
555 assert_eq!(vsa.0, "vsa");
556 }
557
558 #[test]
559 fn test_physical_address_variants() {
560 let j1850 = PhysicalAddress::J1850 {
561 node: 0x10,
562 header: [0x6C, 0x10, 0xF1],
563 };
564 let can = PhysicalAddress::Can11Bit {
565 request_id: 0x7E0,
566 response_id: 0x7E8,
567 };
568 assert!(matches!(j1850, PhysicalAddress::J1850 { .. }));
569 assert!(matches!(can, PhysicalAddress::Can11Bit { .. }));
570 }
571
572 #[test]
573 fn test_transmission_type_cvt() {
574 let cvt = TransmissionType::Cvt {
575 ratio_range: (0.4, 2.6),
576 simulated_steps: Some(7),
577 };
578 assert!(matches!(cvt, TransmissionType::Cvt { .. }));
579 }
580
581 #[test]
582 fn test_transmission_type_geared() {
583 let geared = TransmissionType::Geared {
584 speeds: 5,
585 gear_ratios: vec![("1st".into(), 3.10), ("2nd".into(), 1.81)],
586 };
587 assert!(matches!(geared, TransmissionType::Geared { speeds: 5, .. }));
588 }
589
590 #[test]
591 fn test_threshold_evaluate_normal() {
592 let t = Threshold {
593 min: Some(0.0),
594 max: Some(120.0),
595 warning_low: None,
596 warning_high: Some(105.0),
597 critical_low: None,
598 critical_high: Some(115.0),
599 unit: "\u{00B0}C".into(),
600 };
601 assert!(t.evaluate(90.0, "coolant").is_none());
602 }
603
604 #[test]
605 fn test_threshold_evaluate_warning_high() {
606 let t = Threshold {
607 min: Some(0.0),
608 max: Some(120.0),
609 warning_low: None,
610 warning_high: Some(105.0),
611 critical_low: None,
612 critical_high: Some(115.0),
613 unit: "\u{00B0}C".into(),
614 };
615 let result = t.evaluate(110.0, "coolant");
616 assert!(result.is_some());
617 assert_eq!(result.unwrap().level, AlertLevel::Warning);
618 }
619
620 #[test]
621 fn test_threshold_evaluate_critical_high() {
622 let t = Threshold {
623 min: Some(0.0),
624 max: Some(120.0),
625 warning_low: None,
626 warning_high: Some(105.0),
627 critical_low: None,
628 critical_high: Some(115.0),
629 unit: "\u{00B0}C".into(),
630 };
631 let result = t.evaluate(118.0, "coolant");
632 assert!(result.is_some());
633 assert_eq!(result.unwrap().level, AlertLevel::Critical);
634 }
635
636 #[test]
637 fn test_threshold_evaluate_warning_low() {
638 let t = Threshold {
639 min: Some(0.0),
640 max: Some(500.0),
641 warning_low: Some(100.0),
642 warning_high: None,
643 critical_low: Some(70.0),
644 critical_high: None,
645 unit: "kPa".into(),
646 };
647 let result = t.evaluate(90.0, "oil_pressure");
648 assert!(result.is_some());
649 assert_eq!(result.unwrap().level, AlertLevel::Warning);
650 }
651
652 #[test]
653 fn test_threshold_evaluate_critical_low() {
654 let t = Threshold {
655 min: Some(0.0),
656 max: Some(500.0),
657 warning_low: Some(100.0),
658 warning_high: None,
659 critical_low: Some(70.0),
660 critical_high: None,
661 unit: "kPa".into(),
662 };
663 let result = t.evaluate(60.0, "oil_pressure");
664 assert!(result.is_some());
665 assert_eq!(result.unwrap().level, AlertLevel::Critical);
666 }
667
668 #[test]
669 fn test_alert_level_ordering() {
670 assert!(AlertLevel::Critical > AlertLevel::Warning);
671 assert!(AlertLevel::Warning > AlertLevel::Normal);
672 }
673
674 #[test]
675 fn test_vin_matcher_matches() {
676 let matcher = VinMatcher {
677 vin_8th_digit: Some(vec!['2']),
678 wmi_prefixes: vec!["1GC".into()],
679 year_range: None,
680 };
681 assert!(matcher.matches("1GCHK23224F000001")); }
683
684 #[test]
685 fn test_vin_matcher_wrong_digit() {
686 let matcher = VinMatcher {
687 vin_8th_digit: Some(vec!['2']),
688 wmi_prefixes: vec!["1GC".into()],
689 year_range: None,
690 };
691 assert!(!matcher.matches("1GCHK23114F000001")); }
693
694 #[test]
695 fn test_vin_matcher_wrong_wmi() {
696 let matcher = VinMatcher {
697 vin_8th_digit: Some(vec!['2']),
698 wmi_prefixes: vec!["1GC".into()],
699 year_range: None,
700 };
701 assert!(!matcher.matches("1FTHK23124F000001")); }
703
704 #[test]
705 fn test_vin_matcher_year_range_match() {
706 let matcher = VinMatcher {
707 vin_8th_digit: None,
708 wmi_prefixes: vec![],
709 year_range: Some((2004, 2005)),
710 };
711 assert!(matcher.matches("1GCHK23124F000001"));
714 }
715
716 #[test]
717 fn test_vin_matcher_year_range_no_match() {
718 let matcher = VinMatcher {
719 vin_8th_digit: None,
720 wmi_prefixes: vec![],
721 year_range: Some((2010, 2012)),
722 };
723 assert!(!matcher.matches("1GCHK23124F000001"));
725 }
726
727 #[test]
728 fn test_vin_matcher_year_range_current_cycle() {
729 let matcher = VinMatcher {
730 vin_8th_digit: None,
731 wmi_prefixes: vec![],
732 year_range: Some((2018, 2020)),
733 };
734 assert!(matcher.matches("1GCHK2312LF000001"));
736 }
737
738 #[test]
739 fn test_vin_matcher_year_range_combined_check() {
740 let matcher = VinMatcher {
742 vin_8th_digit: Some(vec!['2']),
743 wmi_prefixes: vec!["1GC".into()],
744 year_range: Some((2004, 2005)),
745 };
746 assert!(matcher.matches("1GCHK23224F000001"));
748 }
749
750 #[test]
751 fn test_vin_matcher_year_range_fails_other_check() {
752 let matcher = VinMatcher {
753 vin_8th_digit: Some(vec!['9']), wmi_prefixes: vec!["1GC".into()],
755 year_range: Some((2004, 2005)),
756 };
757 assert!(!matcher.matches("1GCHK23224F000001"));
759 }
760
761 #[test]
762 fn test_vin_matcher_short_vin() {
763 let matcher = VinMatcher {
764 vin_8th_digit: None,
765 wmi_prefixes: vec![],
766 year_range: None,
767 };
768 assert!(!matcher.matches("SHORT")); }
770
771 #[test]
772 fn test_dtc_library_lookup() {
773 let lib = DtcLibrary {
774 ecm: vec![DtcEntry {
775 code: "P0087".into(),
776 meaning: "Fuel Rail Pressure Too Low".into(),
777 severity: crate::protocol::dtc::Severity::Critical,
778 notes: None,
779 related_pids: None,
780 category: None,
781 }],
782 tcm: vec![],
783 bcm: vec![],
784 network: vec![],
785 };
786 assert!(lib.lookup("P0087").is_some());
787 assert_eq!(
788 lib.lookup("P0087").unwrap().meaning,
789 "Fuel Rail Pressure Too Low"
790 );
791 assert!(lib.lookup("P9999").is_none());
792 }
793
794 #[test]
795 fn test_load_embedded_duramax() {
796 let registry = SpecRegistry::with_defaults();
797 assert!(
798 !registry.specs().is_empty(),
799 "should have at least one embedded spec"
800 );
801 let spec = ®istry.specs()[0];
802 assert_eq!(spec.identity.engine.code, "LLY");
803 }
804
805 #[test]
806 fn test_registry_match_vin_duramax() {
807 let registry = SpecRegistry::with_defaults();
808 let matched = registry.match_vin("1GCHK23224F000001");
809 assert!(matched.is_some(), "should match Duramax by VIN");
810 assert_eq!(matched.unwrap().identity.engine.code, "LLY");
811 }
812
813 #[test]
814 fn test_registry_no_match() {
815 let registry = SpecRegistry::with_defaults();
816 let matched = registry.match_vin("JH4KA7660PC000001");
817 assert!(matched.is_none(), "Acura should not match any spec");
818 }
819
820 #[test]
821 fn test_registry_match_vehicle() {
822 let registry = SpecRegistry::with_defaults();
823 let matched = registry.match_vehicle("Chevrolet", "Silverado 2500HD", 2004);
824 assert!(matched.is_some());
825 }
826
827 #[test]
828 fn test_load_from_str_invalid() {
829 let result = loader::load_spec_from_str("not: [valid: yaml: spec");
830 assert!(result.is_err());
831 }
832
833 #[test]
834 fn test_spec_has_thresholds() {
835 let registry = SpecRegistry::with_defaults();
836 let spec = ®istry.specs()[0];
837 assert!(
838 spec.thresholds.is_some(),
839 "Duramax spec should have thresholds"
840 );
841 }
842
843 #[test]
844 fn test_spec_has_known_issues() {
845 let registry = SpecRegistry::with_defaults();
846 let spec = ®istry.specs()[0];
847 assert!(
848 !spec.known_issues.is_empty(),
849 "Duramax spec should have known issues"
850 );
851 }
852}