diff --git a/README.md b/README.md index cb1a00f..0fbeb81 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ A single Linux daemon that reads live EV telemetry (battery, odometer, tire pres | Kia Niro | `niro` | ✅ | | | | | | VW Golf Mk7 | `golf_mk7` | | | | | | +The `zoe` profile also reads `range_km`, the estimated range in km that the car shows, with PID `223451` on the same ECU (`7E4`/`7EC`) as the state of charge. It was read from a 2016 Zoe R240 (30 km at 24 % state of charge). The value reaches aa-proxy-rs through `raw_topic`, for example as `{{ws.obd.range_km:0}}` in the overlay; aa-proxy-rs ignores it on `/battery`. A car that does not answer the PID only gets a logged failed request, and the daemon goes on with the next source. + +The `zoe` profile also reads three trip and battery values, taken from the original `canze-rs`: `trip_km` (PID `2233DE`, four bytes, ×0.0001) and `trip_energy_kwh` (PID `2233DD`, ×0.001) on the `7E4`/`7EC` ECU, and `battery_module_1_temp_celsius` (PID `2104`, a multi-frame reply, byte 4 minus 40, the temperature of module 1) on the battery ECU `79B`/`7BB`. `trip_km` is sent with `odometer_km` to `/odometer`; `trip_energy_kwh` and `battery_module_1_temp_celsius` go through `raw_topic` only, like `range_km`. + The `niro` profile is checked against OBDb recordings (model years 2019-2026), not on a car. Besides the state of charge it reads `battery_voltage`, `battery_current`, `aux_battery_voltage`, `battery_temp_max_celsius`, `battery_temp_min_celsius`, `battery_module__temp_celsius` (four modules) from the same `220101` reply, so no extra request is needed. They reach aa-proxy-rs through `raw_topic`; aa-proxy-rs accepts and ignores `battery_voltage` and `battery_current` on `/battery`. The sign of `battery_current` is not documented for this car and is sent as read. Switched off with `"enabled": false`: `battery_inlet_temp_celsius`, `isolation_resistance_kohm`, `cell_voltage_max`, `cell_voltage_min`, `charge_power_available_kw`, `discharge_power_available_kw`, `charging_flag`, `dc_plug_flag`, `ac_plug_flag` and `main_relay_flag`. The `kona` profile covers the first-generation Kona Electric (model years 2019-2024) and is checked against OBDb recordings, not on a car. It reports no Wh directly; with `battery_capacity_wh` set, the daemon derives `battery_level_wh` from SOC. @@ -144,7 +148,7 @@ The `wican` device type connects over Bluetooth, so it shares the `bt_*` options The config is validated at load and a failure exits with code 1 and a message naming the key. `poll_interval_secs` and `car_sleep_interval_secs` must be finite and at least 0.1; `usb_baud` must be at least 1; `bt_mac` must not be the placeholder `AA:BB:CC:DD:EE:FF` shown in the examples above, so set it to the adapter's MAC. Unknown keys (for example a misspelled `poll_intervall_secs`) are logged as warnings and ignored. `raw_topic`, when set, must be 1 to 64 characters of letters, digits, `_` or `-`. -Profiles are validated the same way when loaded: `vehicle.profile` may contain only `a-z`, `0-9`, `_`, `-` and `.` (no `..` or path separators), ECU IDs must be 3, 6 or 8 hex digits, PIDs hex with an even length, and each field needs a complete `byte_index`+`length` pair (length 1 to 3, or 1 to 2 in broadcast frames) or `bit_offset`+`bit_length` pair (length 1 to 16); when both are set the bit pair is used. Broadcast sources need `deadline_ms` > 0, with `can_id` of 3 or 8 hex digits. +Profiles are validated the same way when loaded: `vehicle.profile` may contain only `a-z`, `0-9`, `_`, `-` and `.` (no `..` or path separators), ECU IDs must be 3, 6 or 8 hex digits, PIDs hex with an even length, and each field needs a complete `byte_index`+`length` pair (length 1 to 4, or 1 to 2 in broadcast frames) or `bit_offset`+`bit_length` pair (length 1 to 16); when both are set the bit pair is used. Broadcast sources need `deadline_ms` > 0, with `can_id` of 3 or 8 hex digits. ## Vehicle profiles Profile JSON files ship under [`profiles/`](profiles/). At install time, copy them to `/etc/aa-proxy-obd/.json` (e.g. `profiles/ev6.json` → `/etc/aa-proxy-obd/ev6.json`). The daemon lowercases `vehicle.profile` before building the path, so the file name must be lowercase. @@ -198,7 +202,7 @@ Only `kind` and `frames` are required; the values below are the defaults. |---|---|:---:|---| | `name` | string | yes | Recognised names route into a known endpoint payload; unknown names are logged but not POSTed. | | `byte_index` | i32 | one of byte/bit | **`uds_pid`:** offset from after the UDS header (e.g. `62 XX XX`), so 0 = first data byte; negative counts from the end. **`broadcast`:** offset from the start of the CAN data bytes (no header). | -| `length` | usize | with `byte_index` | 1, 2, or 3 bytes | +| `length` | usize | with `byte_index` | 1, 2, 3 or 4 bytes (`uds_pid`); 1 or 2 bytes (`broadcast`) | | `bit_offset` | u32 | one of byte/bit | Motorola/big-endian bit index (bit 0 = MSB of byte 0) | | `bit_length` | u32 | with `bit_offset` | 1..=16 | | `multiplier` | f32 | no (default 1.0) | `raw × multiplier + offset` | diff --git a/profiles/zoe.json b/profiles/zoe.json index 920b549..f149025 100644 --- a/profiles/zoe.json +++ b/profiles/zoe.json @@ -34,6 +34,47 @@ {"name": "battery_level_wh", "byte_index": -2, "length": 2, "multiplier": 5.0, "offset": 0.0} ] }, + { + "kind": "uds_pid", + "ecu_tx": "7E4", + "ecu_rx": "7EC", + "pid": "223451", + "pre_request": "10C0", + "fields": [ + {"name": "range_km", "byte_index": -2, "length": 2, "multiplier": 1.0, "offset": 0.0} + ] + }, + { + "kind": "uds_pid", + "ecu_tx": "7E4", + "ecu_rx": "7EC", + "pid": "2233DD", + "pre_request": "10C0", + "fields": [ + {"name": "trip_energy_kwh", "byte_index": -3, "length": 3, "multiplier": 0.001, "offset": 0.0} + ] + }, + { + "kind": "uds_pid", + "ecu_tx": "7E4", + "ecu_rx": "7EC", + "pid": "2233DE", + "pre_request": "10C0", + "fields": [ + {"name": "trip_km", "byte_index": -4, "length": 4, "multiplier": 0.0001, "offset": 0.0} + ] + }, + { + "kind": "uds_pid", + "ecu_tx": "79B", + "ecu_rx": "7BB", + "pid": "2104", + "pre_request": "10C0", + "multiframe": true, + "fields": [ + {"name": "battery_module_1_temp_celsius", "byte_index": 1, "length": 1, "multiplier": 1.0, "offset": -40.0} + ] + }, { "kind": "uds_pid", "ecu_tx": "745", diff --git a/src/adapter/elm327.rs b/src/adapter/elm327.rs index 096a969..1704b23 100644 --- a/src/adapter/elm327.rs +++ b/src/adapter/elm327.rs @@ -297,6 +297,10 @@ where self.send_cmd("ATFCSD300000").await?; self.send_cmd("ATFCSM1").await?; self.send_cmd("ATH0").await?; + if let Some(pre) = &uds.pre_request { + // As in poll_uds_pid, errors are ignored. + let _ = self.send_cmd(pre).await; + } // Send the PID directly (then read lines rather than wait for a prompt). self.resync().await?; @@ -388,13 +392,9 @@ where } } // ATMA streams continuously; do not wait for a '>' prompt. - self.resync().await?; - self.stream.write_all(format!("{}\r", spec.command).as_bytes()).await - .context("broadcast command write")?; - - let deadline = tokio::time::Instant::now() + Duration::from_millis(spec.deadline_ms); + let scan_start = tokio::time::Instant::now(); + let deadline = scan_start + Duration::from_millis(spec.deadline_ms); let mut lines: Vec = Vec::new(); - let mut consecutive_timeouts: u64 = 0; let idle_attempts = spec.idle_timeout_ms.div_ceil(200).max(1); // CAN-IDs whose frame carries a field listed in stop_when. Once a line @@ -405,7 +405,17 @@ where .map(|f| f.can_id.clone()) .collect(); let mut seen_ids: HashSet = HashSet::new(); + // True while the last thing read from the adapter was a prompt, i.e. + // the ELM is idle and a CR would repeat the command. let mut prompt_seen = false; + // Why the scan ended, for the log. + let mut ended_by = "deadline"; + let mut stray_prompts = 0u32; + + self.resync().await?; + self.stream.write_all(format!("{}\r", spec.command).as_bytes()).await + .context("broadcast command write")?; + let mut consecutive_timeouts: u64 = 0; while tokio::time::Instant::now() < deadline { match self.read_line(200).await { @@ -419,10 +429,24 @@ where consecutive_timeouts = 0; let ascii = String::from_utf8_lossy(&b).into_owned(); if ascii.contains('>') { + if lines.is_empty() { + // Some adapters print a prompt right after the + // command and still start monitoring. Writing + // anything now would stop it, so keep reading. + // This does not count as the ELM being idle + // (prompt_seen stays false): on a quiet bus it + // may still be monitoring when the idle timeout + // ends the scan, and then it needs the stop CR. + stray_prompts += 1; + continue; + } prompt_seen = true; + ended_by = "prompt from the adapter"; break; } if !ascii.trim().is_empty() { + // Data after a prompt: the ELM is monitoring. + prompt_seen = false; if let Some(id) = ascii.split_whitespace().next() { if id.chars().all(|c| c.is_ascii_hexdigit()) { seen_ids.insert(id.to_string()); @@ -430,18 +454,35 @@ where } lines.push(ascii); if !needed_ids.is_empty() && needed_ids.iter().all(|id| seen_ids.contains(id)) { + ended_by = "stop_when frames seen"; break; } } } Err(e) if e.kind() == ErrorKind::TimedOut => { consecutive_timeouts += 1; - if consecutive_timeouts >= idle_attempts { break; } + if consecutive_timeouts >= idle_attempts { + ended_by = "idle timeout"; + break; + } } Err(e) => return Err(anyhow::Error::new(e).context("broadcast read")), } } + let mut ids: Vec<&String> = seen_ids.iter().collect(); + ids.sort(); + debug!( + "broadcast scan ended by {ended_by} after {} ms, {} lines, {stray_prompts} stray prompt(s), CAN-IDs seen: {ids:?}", + scan_start.elapsed().as_millis(), lines.len() + ); + trace!("broadcast lines (first 20): {:?}", &lines[..lines.len().min(20)]); + let mut missing: Vec<&String> = needed_ids.iter().filter(|id| !seen_ids.contains(*id)).collect(); + missing.sort(); + if !missing.is_empty() { + warn!("broadcast scan ended by {ended_by} without frame(s) {missing:?}"); + } + // Stop ATMA cleanly: send a CR, then read until the '>' prompt. The // ELM completes the line it is printing first, so the wait is bounded // overall rather than per byte. When the ELM already left monitoring @@ -463,6 +504,7 @@ where if let Some(latest) = payloads.last() { for fld in frame.fields.iter().filter(|f| f.enabled) { if let Some(v) = extract_value_broadcast(latest, fld) { + debug!("{}: {}", fld.name, v); metrics.insert(fld.name.clone(), v); } } @@ -651,6 +693,17 @@ pub fn extract_value(payload: &[u8], field: &FieldSpec) -> Option { }; if idx.checked_add(length)? > payload.len() { return None; } + // Four bytes do not fit an f32 exactly, so scale them in f64. + if length == 4 { + let b = [payload[idx], payload[idx + 1], payload[idx + 2], payload[idx + 3]]; + let raw = if field.signed.unwrap_or(false) { + i32::from_be_bytes(b) as f64 + } else { + u32::from_be_bytes(b) as f64 + }; + return Some((raw * field.multiplier as f64 + field.offset as f64) as f32); + } + let raw = if field.signed.unwrap_or(false) { match length { 1 => payload[idx] as i8 as f32, @@ -778,6 +831,21 @@ pub fn extract_value_broadcast(payload: &[u8], field: &FieldSpec) -> Option mod tests { use super::*; + #[test] + fn extract_four_byte_value_from_the_end() { + // Zoe 2233DE answer from a real car log: 62 33 DE 00 96 86 C4. + let p = [0x62, 0x33, 0xDE, 0x00, 0x96, 0x86, 0xC4]; + let f = FieldSpec { + name: "trip_km".into(), byte_index: Some(-4), length: Some(4), + bit_offset: None, bit_length: None, + multiplier: 0.0001, offset: 0.0, signed: None, enabled: true, + }; + assert_eq!(extract_value(&p, &f), Some(986.49)); + let neg = [0x62, 0x33, 0xDE, 0xFF, 0xFF, 0xFF, 0xFE]; + let f3 = FieldSpec { signed: Some(true), multiplier: 1.0, ..f }; + assert_eq!(extract_value(&neg, &f3), Some(-2.0)); + } + #[test] fn extract_unsigned_byte_with_multiplier_and_offset() { let mut payload = vec![0u8; 32]; diff --git a/src/adapter/replay_tests.rs b/src/adapter/replay_tests.rs index 62ccb02..b125f1e 100644 --- a/src/adapter/replay_tests.rs +++ b/src/adapter/replay_tests.rs @@ -336,6 +336,15 @@ fn zoe_tpms_payload(tires: [u8; 4]) -> String { b.iter().map(|x| format!("{x:02X}")).collect() } +/// Zoe 2104 reply from the battery ECU. The first bytes are from a real car log +/// (61 04 09 99 37 09: byte 4 is 0x37, 15 degrees); the rest is filler up to the +/// real reply length of 0x4D bytes. +fn zoe_lbc_payload() -> String { + let mut b = vec![0x61, 0x04, 0x09, 0x99, 0x37, 0x09]; + b.resize(0x4D, 0x00); + b.iter().map(|x| format!("{x:02X}")).collect() +} + /// Run two full Zoe cycles over the uds sources. Replies are rebuilt from the /// values a 2026-05-29 Renault Zoe log shows (SOC 51.96 %, 10705 Wh, /// 136452 km); the raw bytes are derived, not recorded. @@ -347,7 +356,14 @@ async fn zoe_cycle(cfg: EmuConfig, tires: [u8; 4]) -> (Vec<(String, Result Vec<(String, HashMap)> { ("222002".to_string(), [("battery_level_percentage".to_string(), 51.96)].into()), ("222006".to_string(), [("odometer_km".to_string(), 136452.0)].into()), ("22320C".to_string(), [("battery_level_wh".to_string(), 10705.0)].into()), + // From a real car log: 223451 answered 62 34 51 00 1E at 24.28 % SOC. + ("223451".to_string(), [("range_km".to_string(), 30.0)].into()), + // From a real car log (62 33 DD 02 2C 3F, 62 33 DE 00 96 86 C4). + ("2233DD".to_string(), [("trip_energy_kwh".to_string(), 142.399)].into()), + ("2233DE".to_string(), [("trip_km".to_string(), 986.49)].into()), + ("2104".to_string(), [("battery_module_1_temp_celsius".to_string(), 15.0)].into()), ("2174".to_string(), [("tire_rr_kpa".to_string(), t(tires[0])), ("tire_rl_kpa".to_string(), t(tires[1])), ("tire_fr_kpa".to_string(), t(tires[2])), ("tire_fl_kpa".to_string(), t(tires[3]))].into()), ]; @@ -566,6 +588,88 @@ async fn broadcast_then_poll_stays_in_sync() { assert!(bad.is_empty(), "{}\nlog: {log:?}", bad.join("\n")); } +/// The adapter prints a prompt right after ATMA and then starts monitoring +/// anyway (seen on a real dongle). The prompt is not the end of the scan, and +/// ATMA is not sent again, which would stop the monitoring. +#[tokio::test(start_paused = true)] +async fn broadcast_scan_ignores_a_stray_prompt_after_atma() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let p = profile("zoe"); + let spec = p.sources.iter().find_map(|src| match src { + Source::Broadcast(b) => Some(b.clone()), + _ => None, + }).unwrap(); + let (a, mut b) = tokio::io::duplex(1024); + let adapter = tokio::spawn(async move { + let mut atma = 0; + let mut buf = [0u8; 64]; + loop { + let n = b.read(&mut buf).await.unwrap(); + if n == 0 { return atma; } + let cmd = String::from_utf8_lossy(&buf[..n]).trim().to_string(); + match cmd.as_str() { + "ATMA" => { + atma += 1; + // Prompt first, monitoring starts anyway. + b.write_all(b">").await.unwrap(); + tokio::time::sleep(Duration::from_millis(60)).await; + b.write_all(b"656 00 00 00 00 00 00 40 00\r").await.unwrap(); + } + "" | "\r" => { b.write_all(b"\r>").await.unwrap(); } + _ => { b.write_all(b"OK\r\r>").await.unwrap(); } + } + } + }); + let mut s = Elm327Session::new(a); + let got = s.monitor_broadcast(&spec).await.unwrap(); + assert_eq!(got.get("external_temp_celsius"), Some(&24.0), "{got:?}"); + drop(s); + assert_eq!(adapter.await.unwrap(), 1, "ATMA must not be sent again while monitoring"); +} + +/// The adapter prints a stray prompt after ATMA, keeps monitoring and no frame +/// comes before the idle timeout (quiet bus). The ELM is still monitoring, so +/// the scan must stop it with a CR; otherwise the next command's first +/// character is taken as the stop character and every reply is off by one. +#[tokio::test(start_paused = true)] +async fn broadcast_stray_prompt_then_silence_sends_stop_cr() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let p = profile("zoe"); + let spec = p.sources.iter().find_map(|src| match src { + Source::Broadcast(b) => Some(b.clone()), + _ => None, + }).unwrap(); + let (a, mut b) = tokio::io::duplex(1024); + let adapter = tokio::spawn(async move { + let mut monitoring = false; + let mut buf = [0u8; 64]; + loop { + let n = b.read(&mut buf).await.unwrap(); + if n == 0 { return; } + let cmd = String::from_utf8_lossy(&buf[..n]).trim().to_string(); + if monitoring { + // Any character stops monitoring; the rest is read as a command. + monitoring = false; + b.write_all(b"\r>").await.unwrap(); + if !cmd.is_empty() { b.write_all(b"?\r\r>").await.unwrap(); } + continue; + } + match cmd.as_str() { + "ATMA" => { monitoring = true; b.write_all(b">").await.unwrap(); } + "ATI" => b.write_all(b"ELM327 v1.5\r\r>").await.unwrap(), + _ => b.write_all(b"OK\r\r>").await.unwrap(), + } + } + }); + let mut s = Elm327Session::new(a); + let got = s.monitor_broadcast(&spec).await.unwrap(); + assert!(got.is_empty(), "{got:?}"); + let reply = s.send_cmd("ATI").await.unwrap(); + assert!(String::from_utf8_lossy(&reply).contains("ELM327"), "{reply:?}"); + drop(s); + adapter.await.unwrap(); +} + /// When the ELM leaves monitoring by itself and prints its prompt, no stop /// character is sent: a lone CR would repeat ATMA, and the next command's first /// character would then be taken as the stop character. @@ -660,7 +764,7 @@ async fn poll_sources_reports_link_closed_during_multiframe() { if byte[0] != b'\r' { pending.push(byte[0]); continue; } let cmd = String::from_utf8_lossy(&pending).into_owned(); pending.clear(); - if cmd.starts_with("AT") { + if cmd.starts_with("AT") || cmd == "10C0" { if peer.write_all(b"OK\r\r>").await.is_err() { return; } } else { let _ = peer.write_all(b"7EC 10 08 62 20 02 0A\r").await; @@ -678,6 +782,30 @@ async fn poll_sources_reports_link_closed_during_multiframe() { } } +/// A multiframe source sends its pre_request before the PID, as a +/// single-frame one does. +#[tokio::test(start_paused = true)] +async fn multiframe_sends_the_pre_request_before_the_pid() { + let ecus = vec![ + Ecu::new(0x79B, 0x7BB) + .respond_payload("10C0", "50 C0") + .respond_payload("2104", "61 04 09 99 37 09 00 00 00 00 00 00 00 00 00 00 00 00 00"), + ]; + let (host, log) = emu::spawn(EmuConfig::default(), ecus); + let mut s = Elm327Session::new(host); + s.run_init(&profile_init(&profile("zoe"))).await.unwrap(); + let src = UdsPidSource { + ecu_tx: "79B".into(), ecu_rx: "7BB".into(), pid: "2104".into(), + pre_request: Some("10C0".into()), multiframe: true, fields: vec![], + }; + let payload = s.poll_uds_pid_multiframe(&src).await.unwrap(); + assert_eq!(&payload[..6], &[0x61, 0x04, 0x09, 0x99, 0x37, 0x09]); + let sent: Vec = log.lock().unwrap().iter().map(|(c, _)| c.clone()).collect(); + let pre = sent.iter().position(|c| c == "10C0").expect("10C0 not sent"); + let pid = sent.iter().position(|c| c == "2104").expect("2104 not sent"); + assert!(pre < pid, "{sent:?}"); +} + /// A field with `enabled: false` is not extracted; the other fields of the /// same source still are. #[tokio::test(start_paused = true)] diff --git a/src/profile.rs b/src/profile.rs index e6f2c1b..feb04c2 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -157,7 +157,7 @@ impl VehicleProfile { )); } for f in &s.fields { - check_field(&ctx, f, 3)?; + check_field(&ctx, f, 4)?; } } Source::Broadcast(b) => { @@ -350,8 +350,8 @@ mod tests { (r#"{"name":"f"}"#, "byte_index+length or bit_offset"), (r#"{"name":"f","byte_index":1}"#, "together"), (r#"{"name":"f","bit_offset":1}"#, "together"), - (r#"{"name":"f","byte_index":1,"length":0}"#, "length must be 1..=3"), - (r#"{"name":"f","byte_index":1,"length":4}"#, "length must be 1..=3"), + (r#"{"name":"f","byte_index":1,"length":0}"#, "length must be 1..=4"), + (r#"{"name":"f","byte_index":1,"length":5}"#, "length must be 1..=4"), (r#"{"name":"f","bit_offset":1,"bit_length":0}"#, "bit_length must be 1..=16"), (r#"{"name":"f","bit_offset":1,"bit_length":17}"#, "bit_length must be 1..=16"), ]; @@ -363,6 +363,7 @@ mod tests { let both = r#"{"name":"f","byte_index":1,"length":1,"bit_offset":0,"bit_length":1}"#; with_source(both, OK_HEAD).unwrap(); with_source(r#"{"name":"f","byte_index":-2,"length":3}"#, OK_HEAD).unwrap(); + with_source(r#"{"name":"f","byte_index":-4,"length":4}"#, OK_HEAD).unwrap(); } fn broadcast(deadline: u64, idle: u64, can_id: &str) -> Result<(), String> {