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 info: Option<crate::protocol::service::VehicleInfo>,
402 pub spec: Option<VehicleSpec>,
403 pub supported_pids: std::collections::HashSet<crate::protocol::pid::Pid>,
404}
405
406#[derive(Debug, Clone, Default, Deserialize)]
409pub struct DtcLibrary {
410 #[serde(default)]
411 pub ecm: Vec<DtcEntry>,
412 #[serde(default)]
413 pub tcm: Vec<DtcEntry>,
414 #[serde(default)]
415 pub bcm: Vec<DtcEntry>,
416 #[serde(default)]
417 pub network: Vec<DtcEntry>,
418}
419
420#[derive(Debug, Clone, Deserialize)]
421pub struct DtcEntry {
422 pub code: String,
423 pub meaning: String,
424 pub severity: crate::protocol::dtc::Severity,
425 pub notes: Option<String>,
426 pub related_pids: Option<Vec<u16>>,
427 pub category: Option<String>,
428}
429
430impl DtcLibrary {
431 pub fn lookup(&self, code: &str) -> Option<&DtcEntry> {
432 self.ecm
433 .iter()
434 .chain(self.tcm.iter())
435 .chain(self.bcm.iter())
436 .chain(self.network.iter())
437 .find(|e| e.code == code)
438 }
439}
440
441pub struct SpecRegistry {
445 specs: Vec<VehicleSpec>,
446}
447
448impl Default for SpecRegistry {
449 fn default() -> Self {
450 Self::new()
451 }
452}
453
454impl SpecRegistry {
455 pub fn new() -> Self {
457 Self { specs: Vec::new() }
458 }
459
460 pub fn with_defaults() -> Self {
462 let specs = crate::specs::embedded::load_embedded_specs();
463 Self { specs }
464 }
465
466 pub fn load_file(&mut self, path: &std::path::Path) -> Result<(), crate::error::Obd2Error> {
468 let spec = loader::load_spec_from_file(path)?;
469 self.specs.push(spec);
470 Ok(())
471 }
472
473 pub fn load_directory(
475 &mut self,
476 dir: &std::path::Path,
477 ) -> Result<usize, crate::error::Obd2Error> {
478 let mut count = 0;
479 if let Ok(entries) = std::fs::read_dir(dir) {
480 for entry in entries.flatten() {
481 let path = entry.path();
482 if path.extension().is_some_and(|e| e == "yaml" || e == "yml")
483 && self.load_file(&path).is_ok()
484 {
485 count += 1;
486 }
487 }
488 }
489 Ok(count)
490 }
491
492 pub fn match_vin(&self, vin: &str) -> Option<&VehicleSpec> {
494 self.specs.iter().find(|s| {
495 s.identity
496 .vin_match
497 .as_ref()
498 .is_some_and(|m| m.matches(vin))
499 })
500 }
501
502 pub fn match_vehicle(&self, make: &str, model: &str, year: u16) -> Option<&VehicleSpec> {
504 self.specs.iter().find(|s| {
505 let year_ok = year >= s.identity.model_years.0 && year <= s.identity.model_years.1;
506 let make_ok = s
507 .identity
508 .makes
509 .iter()
510 .any(|m| m.eq_ignore_ascii_case(make));
511 let model_ok = s
512 .identity
513 .models
514 .iter()
515 .any(|m| m.eq_ignore_ascii_case(model));
516 year_ok && make_ok && model_ok
517 })
518 }
519
520 pub fn specs(&self) -> &[VehicleSpec] {
522 &self.specs
523 }
524
525 pub fn lookup_dtc(&self, code: &str) -> Option<&DtcEntry> {
527 for spec in &self.specs {
528 if let Some(lib) = &spec.dtc_library {
529 if let Some(entry) = lib.lookup(code) {
530 return Some(entry);
531 }
532 }
533 }
534 None
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn test_module_id_constants() {
544 let ecm = ModuleId::new(ModuleId::ECM);
545 let also_ecm = ModuleId::new("ecm");
546 assert_eq!(ecm, also_ecm);
547 }
548
549 #[test]
550 fn test_module_id_custom() {
551 let vsa = ModuleId::new("vsa");
552 assert_eq!(vsa.0, "vsa");
553 }
554
555 #[test]
556 fn test_physical_address_variants() {
557 let j1850 = PhysicalAddress::J1850 {
558 node: 0x10,
559 header: [0x6C, 0x10, 0xF1],
560 };
561 let can = PhysicalAddress::Can11Bit {
562 request_id: 0x7E0,
563 response_id: 0x7E8,
564 };
565 assert!(matches!(j1850, PhysicalAddress::J1850 { .. }));
566 assert!(matches!(can, PhysicalAddress::Can11Bit { .. }));
567 }
568
569 #[test]
570 fn test_transmission_type_cvt() {
571 let cvt = TransmissionType::Cvt {
572 ratio_range: (0.4, 2.6),
573 simulated_steps: Some(7),
574 };
575 assert!(matches!(cvt, TransmissionType::Cvt { .. }));
576 }
577
578 #[test]
579 fn test_transmission_type_geared() {
580 let geared = TransmissionType::Geared {
581 speeds: 5,
582 gear_ratios: vec![("1st".into(), 3.10), ("2nd".into(), 1.81)],
583 };
584 assert!(matches!(geared, TransmissionType::Geared { speeds: 5, .. }));
585 }
586
587 #[test]
588 fn test_threshold_evaluate_normal() {
589 let t = Threshold {
590 min: Some(0.0),
591 max: Some(120.0),
592 warning_low: None,
593 warning_high: Some(105.0),
594 critical_low: None,
595 critical_high: Some(115.0),
596 unit: "\u{00B0}C".into(),
597 };
598 assert!(t.evaluate(90.0, "coolant").is_none());
599 }
600
601 #[test]
602 fn test_threshold_evaluate_warning_high() {
603 let t = Threshold {
604 min: Some(0.0),
605 max: Some(120.0),
606 warning_low: None,
607 warning_high: Some(105.0),
608 critical_low: None,
609 critical_high: Some(115.0),
610 unit: "\u{00B0}C".into(),
611 };
612 let result = t.evaluate(110.0, "coolant");
613 assert!(result.is_some());
614 assert_eq!(result.unwrap().level, AlertLevel::Warning);
615 }
616
617 #[test]
618 fn test_threshold_evaluate_critical_high() {
619 let t = Threshold {
620 min: Some(0.0),
621 max: Some(120.0),
622 warning_low: None,
623 warning_high: Some(105.0),
624 critical_low: None,
625 critical_high: Some(115.0),
626 unit: "\u{00B0}C".into(),
627 };
628 let result = t.evaluate(118.0, "coolant");
629 assert!(result.is_some());
630 assert_eq!(result.unwrap().level, AlertLevel::Critical);
631 }
632
633 #[test]
634 fn test_threshold_evaluate_warning_low() {
635 let t = Threshold {
636 min: Some(0.0),
637 max: Some(500.0),
638 warning_low: Some(100.0),
639 warning_high: None,
640 critical_low: Some(70.0),
641 critical_high: None,
642 unit: "kPa".into(),
643 };
644 let result = t.evaluate(90.0, "oil_pressure");
645 assert!(result.is_some());
646 assert_eq!(result.unwrap().level, AlertLevel::Warning);
647 }
648
649 #[test]
650 fn test_threshold_evaluate_critical_low() {
651 let t = Threshold {
652 min: Some(0.0),
653 max: Some(500.0),
654 warning_low: Some(100.0),
655 warning_high: None,
656 critical_low: Some(70.0),
657 critical_high: None,
658 unit: "kPa".into(),
659 };
660 let result = t.evaluate(60.0, "oil_pressure");
661 assert!(result.is_some());
662 assert_eq!(result.unwrap().level, AlertLevel::Critical);
663 }
664
665 #[test]
666 fn test_alert_level_ordering() {
667 assert!(AlertLevel::Critical > AlertLevel::Warning);
668 assert!(AlertLevel::Warning > AlertLevel::Normal);
669 }
670
671 #[test]
672 fn test_vin_matcher_matches() {
673 let matcher = VinMatcher {
674 vin_8th_digit: Some(vec!['2']),
675 wmi_prefixes: vec!["1GC".into()],
676 year_range: None,
677 };
678 assert!(matcher.matches("1GCHK23224F000001")); }
680
681 #[test]
682 fn test_vin_matcher_wrong_digit() {
683 let matcher = VinMatcher {
684 vin_8th_digit: Some(vec!['2']),
685 wmi_prefixes: vec!["1GC".into()],
686 year_range: None,
687 };
688 assert!(!matcher.matches("1GCHK23114F000001")); }
690
691 #[test]
692 fn test_vin_matcher_wrong_wmi() {
693 let matcher = VinMatcher {
694 vin_8th_digit: Some(vec!['2']),
695 wmi_prefixes: vec!["1GC".into()],
696 year_range: None,
697 };
698 assert!(!matcher.matches("1FTHK23124F000001")); }
700
701 #[test]
702 fn test_vin_matcher_year_range_match() {
703 let matcher = VinMatcher {
704 vin_8th_digit: None,
705 wmi_prefixes: vec![],
706 year_range: Some((2004, 2005)),
707 };
708 assert!(matcher.matches("1GCHK23124F000001"));
711 }
712
713 #[test]
714 fn test_vin_matcher_year_range_no_match() {
715 let matcher = VinMatcher {
716 vin_8th_digit: None,
717 wmi_prefixes: vec![],
718 year_range: Some((2010, 2012)),
719 };
720 assert!(!matcher.matches("1GCHK23124F000001"));
722 }
723
724 #[test]
725 fn test_vin_matcher_year_range_current_cycle() {
726 let matcher = VinMatcher {
727 vin_8th_digit: None,
728 wmi_prefixes: vec![],
729 year_range: Some((2018, 2020)),
730 };
731 assert!(matcher.matches("1GCHK2312LF000001"));
733 }
734
735 #[test]
736 fn test_vin_matcher_year_range_combined_check() {
737 let matcher = VinMatcher {
739 vin_8th_digit: Some(vec!['2']),
740 wmi_prefixes: vec!["1GC".into()],
741 year_range: Some((2004, 2005)),
742 };
743 assert!(matcher.matches("1GCHK23224F000001"));
745 }
746
747 #[test]
748 fn test_vin_matcher_year_range_fails_other_check() {
749 let matcher = VinMatcher {
750 vin_8th_digit: Some(vec!['9']), wmi_prefixes: vec!["1GC".into()],
752 year_range: Some((2004, 2005)),
753 };
754 assert!(!matcher.matches("1GCHK23224F000001"));
756 }
757
758 #[test]
759 fn test_vin_matcher_short_vin() {
760 let matcher = VinMatcher {
761 vin_8th_digit: None,
762 wmi_prefixes: vec![],
763 year_range: None,
764 };
765 assert!(!matcher.matches("SHORT")); }
767
768 #[test]
769 fn test_dtc_library_lookup() {
770 let lib = DtcLibrary {
771 ecm: vec![DtcEntry {
772 code: "P0087".into(),
773 meaning: "Fuel Rail Pressure Too Low".into(),
774 severity: crate::protocol::dtc::Severity::Critical,
775 notes: None,
776 related_pids: None,
777 category: None,
778 }],
779 tcm: vec![],
780 bcm: vec![],
781 network: vec![],
782 };
783 assert!(lib.lookup("P0087").is_some());
784 assert_eq!(
785 lib.lookup("P0087").unwrap().meaning,
786 "Fuel Rail Pressure Too Low"
787 );
788 assert!(lib.lookup("P9999").is_none());
789 }
790
791 #[test]
792 fn test_load_embedded_duramax() {
793 let registry = SpecRegistry::with_defaults();
794 assert!(
795 !registry.specs().is_empty(),
796 "should have at least one embedded spec"
797 );
798 let spec = ®istry.specs()[0];
799 assert_eq!(spec.identity.engine.code, "LLY");
800 }
801
802 #[test]
803 fn test_registry_match_vin_duramax() {
804 let registry = SpecRegistry::with_defaults();
805 let matched = registry.match_vin("1GCHK23224F000001");
806 assert!(matched.is_some(), "should match Duramax by VIN");
807 assert_eq!(matched.unwrap().identity.engine.code, "LLY");
808 }
809
810 #[test]
811 fn test_registry_no_match() {
812 let registry = SpecRegistry::with_defaults();
813 let matched = registry.match_vin("JH4KA7660PC000001");
814 assert!(matched.is_none(), "Acura should not match any spec");
815 }
816
817 #[test]
818 fn test_registry_match_vehicle() {
819 let registry = SpecRegistry::with_defaults();
820 let matched = registry.match_vehicle("Chevrolet", "Silverado 2500HD", 2004);
821 assert!(matched.is_some());
822 }
823
824 #[test]
825 fn test_load_from_str_invalid() {
826 let result = loader::load_spec_from_str("not: [valid: yaml: spec");
827 assert!(result.is_err());
828 }
829
830 #[test]
831 fn test_spec_has_thresholds() {
832 let registry = SpecRegistry::with_defaults();
833 let spec = ®istry.specs()[0];
834 assert!(
835 spec.thresholds.is_some(),
836 "Duramax spec should have thresholds"
837 );
838 }
839
840 #[test]
841 fn test_spec_has_known_issues() {
842 let registry = SpecRegistry::with_defaults();
843 let spec = ®istry.specs()[0];
844 assert!(
845 !spec.known_issues.is_empty(),
846 "Duramax spec should have known issues"
847 );
848 }
849}