#![cfg(feature = "dbc")]
use falcon_mdf::CanDatabase;
use falcon_mdf::Mf4File;
use std::path::PathBuf;
const OBD2_LOG: &str =
"test_data/mf4-sample-data-v2.1/OBD2 (Audi A4)/LOG/31CB1F25/00000022/00000002.MF4";
fn log_path() -> Option<PathBuf> {
let candidates = [
PathBuf::from(OBD2_LOG),
PathBuf::from("../../falcon_mdf").join(OBD2_LOG),
];
candidates.into_iter().find(|p| p.exists())
}
const RESPONSE_ID: u32 = 0x7E8;
const OBD2_DBC: &str = r#"VERSION "1"
NS_ :
BS_:
BU_: Tester ECU
BO_ 2024 OBD2: 8 ECU
SG_ PID M : 23|8@0+ (1,0) [0|255] "" Tester
SG_ CoolantTemperature m5 : 31|8@0+ (1,-40) [-40|215] "degC" Tester
SG_ IntakeManifoldPressure m11 : 31|8@0+ (1,0) [0|255] "kPa" Tester
SG_ EngineSpeed m12 : 31|16@0+ (0.25,0) [0|16383.75] "rpm" Tester
SG_ VehicleSpeed m13 : 31|8@0+ (1,0) [0|255] "km/h" Tester
SG_ ThrottlePosition m17 : 31|8@0+ (0.3921568627,0) [0|100] "%" Tester
SG_ RunTimeSinceStart m31 : 31|16@0+ (1,0) [0|65535] "s" Tester
"#;
fn database() -> CanDatabase {
CanDatabase::from_dbc(OBD2_DBC.as_bytes()).expect("the OBD2 database must parse")
}
fn skip() -> bool {
if log_path().is_some() {
return false;
}
eprintln!("SKIP: {OBD2_LOG} is absent");
true
}
fn responses() -> Vec<(f64, Vec<u8>)> {
let file = Mf4File::open(log_path().unwrap()).unwrap();
let mut out = Vec::new();
for group in file.can_frame_groups() {
for frame in file.can_frames(group).unwrap().iter() {
if frame.id == RESPONSE_ID {
out.push((frame.timestamp, frame.data.to_vec()));
}
}
}
out
}
#[test]
fn the_database_names_the_message() {
let db = database();
assert_eq!(db.message_name(RESPONSE_ID), Some("OBD2"));
assert_eq!(db.message_name(0x7DF), None, "requests are not in this DBC");
}
#[test]
fn an_unknown_identifier_decodes_to_nothing() {
let db = database();
assert!(db.decode(0x123, &[0xFF; 8]).is_empty());
assert!(db
.decode(0x7DF, &[0x02, 0x01, 0x0C, 0, 0, 0, 0, 0])
.is_empty());
}
#[test]
fn the_pid_selects_one_signal() {
if skip() {
return;
}
let db = database();
let mut seen = std::collections::BTreeSet::new();
for (_, payload) in responses() {
let decoded = db.decode(RESPONSE_ID, &payload);
let named: Vec<&str> = decoded
.iter()
.map(|s| s.name)
.filter(|name| *name != "PID")
.collect();
assert!(
named.len() <= 1,
"PID {:#04X} selected {named:?}",
payload[2]
);
if let Some(name) = named.first() {
seen.insert(name.to_string());
}
let pid = decoded.iter().find(|s| s.name == "PID").expect("no PID");
assert_eq!(pid.value, f64::from(payload[2]), "the multiplexor's value");
}
assert_eq!(
seen.len(),
6,
"expected all six PIDs the log carries, saw {seen:?}"
);
}
#[test]
fn run_time_tracks_the_logs_own_clock() {
if skip() {
return;
}
let db = database();
let mut samples: Vec<(f64, f64)> = Vec::new();
for (timestamp, payload) in responses() {
for signal in db.decode(RESPONSE_ID, &payload) {
if signal.name == "RunTimeSinceStart" {
samples.push((timestamp, signal.value));
}
}
}
assert!(
samples.len() > 1000,
"expected a run of run-time samples, got {}",
samples.len()
);
let (first_t, first_v) = samples[0];
let (last_t, last_v) = samples[samples.len() - 1];
let elapsed_clock = last_t - first_t;
let elapsed_engine = last_v - first_v;
assert!(
elapsed_clock > 60.0,
"the log is too short to compare clocks: {elapsed_clock} s"
);
let drift = (elapsed_engine - elapsed_clock).abs();
assert!(
drift < 0.02 * elapsed_clock,
"engine run time advanced {elapsed_engine} s while the log advanced \
{elapsed_clock} s — {drift} s apart"
);
for pair in samples.windows(2) {
assert!(
pair[1].1 >= pair[0].1,
"run time went backwards: {} then {}",
pair[0].1,
pair[1].1
);
}
}
#[test]
fn decoded_values_match_the_published_formulas() {
if skip() {
return;
}
let db = database();
let mut checked = 0usize;
for (_, payload) in responses() {
let a = f64::from(payload[3]);
let b = f64::from(payload[4]);
let expected: Option<(&str, f64)> = match payload[2] {
0x05 => Some(("CoolantTemperature", a - 40.0)),
0x0B => Some(("IntakeManifoldPressure", a)),
0x0C => Some(("EngineSpeed", (a * 256.0 + b) / 4.0)),
0x0D => Some(("VehicleSpeed", a)),
0x1F => Some(("RunTimeSinceStart", a * 256.0 + b)),
_ => None,
};
let Some((name, want)) = expected else {
continue;
};
let decoded = db.decode(RESPONSE_ID, &payload);
let got = decoded
.iter()
.find(|s| s.name == name)
.unwrap_or_else(|| panic!("PID {:#04X} did not decode {name}", payload[2]));
assert!(
(got.value - want).abs() < 1e-9,
"{name}: decoded {} from {:02X?}, the formula gives {want}",
got.value,
&payload[..5]
);
checked += 1;
}
assert!(checked > 5000, "only {checked} values were checked");
eprintln!("checked {checked} decoded values against J1979 formulas");
}
#[test]
fn decoded_values_are_physically_plausible() {
if skip() {
return;
}
let db = database();
let mut ranges: std::collections::BTreeMap<String, (f64, f64)> = Default::default();
for (_, payload) in responses() {
for signal in db.decode(RESPONSE_ID, &payload) {
let entry = ranges
.entry(signal.name.to_string())
.or_insert((f64::INFINITY, f64::NEG_INFINITY));
entry.0 = entry.0.min(signal.value);
entry.1 = entry.1.max(signal.value);
}
}
let (rpm_min, rpm_max) = ranges["EngineSpeed"];
assert!(
rpm_min >= 0.0 && rpm_max < 8000.0,
"engine speed ranged {rpm_min}..{rpm_max} rpm"
);
assert!(rpm_max > 600.0, "the engine never left idle: {rpm_max} rpm");
let (coolant_min, coolant_max) = ranges["CoolantTemperature"];
assert!(
coolant_min > -40.0 && coolant_max < 130.0,
"coolant ranged {coolant_min}..{coolant_max} °C"
);
let (speed_min, speed_max) = ranges["VehicleSpeed"];
assert!(
speed_min >= 0.0 && speed_max <= 255.0,
"vehicle speed ranged {speed_min}..{speed_max} km/h"
);
let (throttle_min, throttle_max) = ranges["ThrottlePosition"];
assert!(
throttle_min >= 0.0 && throttle_max <= 100.0,
"throttle ranged {throttle_min}..{throttle_max} %"
);
eprintln!("decoded ranges: {ranges:?}");
}