From a7137aea4df0ece55a78193d914f837af5cecccb Mon Sep 17 00:00:00 2001 From: Mariusz Bialonczyk Date: Fri, 6 Nov 2026 21:21:20 +0100 Subject: [PATCH 1/5] feat(profiles): read the estimated range on the Zoe The Zoe profile reported no range. The EVC answers PID 223451 on 7E4/7EC with the range the car shows, in km, as a 16-bit value in the last two bytes of the reply. This is the PID CanZE reads for the range of Zoe Phase 2 cars (field 654.42), and it sits on the same ECU as the state of charge, so it is one more request on the existing addressing. The new range_km field uses the same byte_index -2 form as battery_level_wh. It reaches aa-proxy-rs through raw_topic; aa-proxy-rs ignores it on /battery. A car that does not answer the PID logs a failed request and the daemon goes on with the next source. Evidence: read from a 2016 Zoe R240, 223451 returned range_km 30 at 24.28 % state of charge and 4990 Wh, which is about 16.6 kWh/100 km. The replay test serves that reply (62 34 51 00 1E) in the Zoe read cycle. Broadcast frame 654, where Phase 1 CanZE reads the range, returned no frames on that car, so it is not used. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 ++ profiles/zoe.json | 10 ++++++++++ src/adapter/replay_tests.rs | 5 ++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cb1a00f..37ee344 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ 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 `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. diff --git a/profiles/zoe.json b/profiles/zoe.json index 920b549..fad1af2 100644 --- a/profiles/zoe.json +++ b/profiles/zoe.json @@ -34,6 +34,16 @@ {"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": "745", diff --git a/src/adapter/replay_tests.rs b/src/adapter/replay_tests.rs index 62ccb02..e5c11af 100644 --- a/src/adapter/replay_tests.rs +++ b/src/adapter/replay_tests.rs @@ -347,7 +347,8 @@ 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()), ("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()), ]; From a8ed2faf23a68595449ea05ab44feb87b6f8f8aa Mon Sep 17 00:00:00 2001 From: Mariusz Bialonczyk Date: Fri, 6 Nov 2026 23:14:30 +0100 Subject: [PATCH 2/5] fix(elm327): ignore a stray prompt right after ATMA Some adapters print a bare '>' right after ATMA and still start monitoring. The scan took it for the end of monitoring and returned after about 40 ms with no frames, so the external temperature was never read. A prompt that arrives before any frame is now ignored; a frame after it shows the ELM is monitoring, so the stop CR is still sent. A prompt after frames still ends the scan, and silence ends it through the idle timeout. In that case the ELM may still be monitoring, so the stop CR is sent; the prompt is not taken for an idle adapter. The scan logs why it ended, how long it took, how many stray prompts and which CAN-IDs it saw, and warns when a stop_when frame never arrived. Each field read from a broadcast frame is logged at debug level like the UDS ones; the raw lines are logged at trace level. Co-Authored-By: Claude Sonnet 5.5 --- src/adapter/elm327.rs | 52 +++++++++++++++++++---- src/adapter/replay_tests.rs | 82 +++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/src/adapter/elm327.rs b/src/adapter/elm327.rs index 096a969..fd162c6 100644 --- a/src/adapter/elm327.rs +++ b/src/adapter/elm327.rs @@ -388,13 +388,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 +401,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 +425,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 +450,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 +500,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); } } diff --git a/src/adapter/replay_tests.rs b/src/adapter/replay_tests.rs index e5c11af..2a534a3 100644 --- a/src/adapter/replay_tests.rs +++ b/src/adapter/replay_tests.rs @@ -569,6 +569,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. From 491011946ef4fc53519c6cde3d3183133d3ff9ed Mon Sep 17 00:00:00 2001 From: Mariusz Bialonczyk Date: Sat, 7 Nov 2026 05:03:30 +0100 Subject: [PATCH 3/5] feat(profiles): allow four-byte uds_pid fields Some values are four bytes long, such as the Zoe trip distance. A field with length 4 is now decoded, unsigned or signed, and scaled in f64, since an f32 cannot hold such a raw value exactly. Validation accepts length 1 to 4 for uds_pid sources; broadcast frames stay at 1 to 2. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 ++-- src/adapter/elm327.rs | 26 ++++++++++++++++++++++++++ src/profile.rs | 7 ++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 37ee344..e0b78cd 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,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. @@ -200,7 +200,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/src/adapter/elm327.rs b/src/adapter/elm327.rs index fd162c6..f805dc0 100644 --- a/src/adapter/elm327.rs +++ b/src/adapter/elm327.rs @@ -689,6 +689,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, @@ -816,6 +827,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/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> { From 815a0b5bc190d43f8eb97b09c39bd7b86f2c925f Mon Sep 17 00:00:00 2001 From: Mariusz Bialonczyk Date: Sat, 7 Nov 2026 05:50:16 +0100 Subject: [PATCH 4/5] fix(elm327): send pre_request on multiframe sources too poll_uds_pid sent a source's pre_request before the PID, but poll_uds_pid_multiframe ignored it, so a multiframe source could not open a diagnostic session first. It now sends it after ATH0, with errors ignored as in the single-frame path. Co-Authored-By: Claude Sonnet 5.5 --- src/adapter/elm327.rs | 4 ++++ src/adapter/replay_tests.rs | 26 +++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/adapter/elm327.rs b/src/adapter/elm327.rs index f805dc0..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?; diff --git a/src/adapter/replay_tests.rs b/src/adapter/replay_tests.rs index 2a534a3..cce91da 100644 --- a/src/adapter/replay_tests.rs +++ b/src/adapter/replay_tests.rs @@ -745,7 +745,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; @@ -763,6 +763,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)] From a77599e285779df2d8d43fc834720b8ab0e9118d Mon Sep 17 00:00:00 2001 From: Mariusz Bialonczyk Date: Sat, 7 Nov 2026 05:50:27 +0100 Subject: [PATCH 5/5] feat(profiles): read trip and battery temperature on the Zoe Add three values the original canze-rs read, with the PIDs and scaling checked against a real car log: - trip_km (2233DE, four bytes, x0.0001), sent to /odometer with odometer_km - trip_energy_kwh (2233DD, three bytes, x0.001) - battery_module_1_temp_celsius (2104 on the battery ECU 79B/7BB, byte 4 minus 40, after 10C0; this is the temperature of module 1) The last two go out through raw_topic. The Zoe replay test covers all three with the values from the log. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 ++ profiles/zoe.json | 31 +++++++++++++++++++++++++++++++ src/adapter/replay_tests.rs | 21 ++++++++++++++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e0b78cd..0fbeb81 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ A single Linux daemon that reads live EV telemetry (battery, odometer, tire pres 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. diff --git a/profiles/zoe.json b/profiles/zoe.json index fad1af2..f149025 100644 --- a/profiles/zoe.json +++ b/profiles/zoe.json @@ -44,6 +44,37 @@ {"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/replay_tests.rs b/src/adapter/replay_tests.rs index cce91da..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. @@ -348,7 +357,13 @@ async fn zoe_cycle(cfg: EmuConfig, tires: [u8; 4]) -> (Vec<(String, Result Vec<(String, HashMap)> { ("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()), ];