1use std::borrow::Cow;
2
3use uom::si::electric_charge::microampere_hour;
4use uom::si::electric_current::microampere;
5use uom::si::electric_potential::microvolt;
6use uom::si::energy::microwatt_hour;
7use uom::si::f64::{ElectricCharge, ElectricCurrent, ElectricPotential, Energy, Power, Time};
8use uom::si::time::{millisecond, second};
9use zerocopy::byteorder::little_endian::{I32, U16, U32};
10use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout, Unaligned};
11
12use crate::error::KMError;
13
14#[cfg(feature = "serde")]
15use serde::{Deserialize, Serialize};
16
17pub const OFFLINE_LOG_ADDRESS: u32 = 0x9810_0000;
19pub const LOG_METADATA_SIZE: usize = 48;
21pub const OFFLINE_LOG_SAMPLE_SIZE: usize = 16;
23
24#[derive(Debug, Clone, Copy, FromBytes, IntoBytes, KnownLayout, Immutable, Unaligned)]
25#[repr(C)]
26struct LogMetadataWire {
27 filename: [u8; 16],
28 unknown_0x10: U16,
29 sample_count: U16,
30 interval_ms: U16,
31 flags: U16,
32 recorded_duration_seconds: U32,
33 final_charge_uah: I32,
34 final_energy_uwh: I32,
35 data_offset: U32,
36 reserved_tail: [u8; 8],
37}
38
39#[derive(Debug, Clone, PartialEq)]
44#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
45#[cfg_attr(feature = "python", pyo3::pyclass(skip_from_py_object))]
46pub struct LogMetadata {
47 pub filename_raw: [u8; 16],
48 pub unknown_0x10: u16,
49 pub sample_count: u16,
50 pub interval: Time,
51 pub flags: u16,
52 pub recorded_duration: Time,
53 pub final_charge: ElectricCharge,
54 pub final_energy: Energy,
55 pub data_offset: u32,
57 pub reserved_tail: [u8; 8],
58}
59
60impl LogMetadata {
61 pub fn from_bytes(bytes: &[u8]) -> Result<Self, KMError> {
62 if bytes.len() != LOG_METADATA_SIZE {
63 return Err(KMError::InvalidPacket(format!(
64 "LogMetadata payload must be exactly {LOG_METADATA_SIZE} bytes, got {}",
65 bytes.len()
66 )));
67 }
68
69 let wire = LogMetadataWire::ref_from_bytes(bytes)
70 .map_err(|_| KMError::InvalidPacket("Failed to parse LogMetadata".to_string()))?;
71
72 Ok(Self {
73 filename_raw: wire.filename,
74 unknown_0x10: wire.unknown_0x10.get(),
75 sample_count: wire.sample_count.get(),
76 interval: Time::new::<millisecond>(f64::from(wire.interval_ms.get())),
77 flags: wire.flags.get(),
78 recorded_duration: Time::new::<second>(f64::from(wire.recorded_duration_seconds.get())),
79 final_charge: ElectricCharge::new::<microampere_hour>(f64::from(wire.final_charge_uah.get())),
80 final_energy: Energy::new::<microwatt_hour>(f64::from(wire.final_energy_uwh.get())),
81 data_offset: wire.data_offset.get(),
82 reserved_tail: wire.reserved_tail,
83 })
84 }
85
86 pub fn filename_bytes(&self) -> &[u8] {
88 let length = self
89 .filename_raw
90 .iter()
91 .position(|byte| *byte == 0)
92 .unwrap_or(self.filename_raw.len());
93 &self.filename_raw[..length]
94 }
95
96 pub fn filename(&self) -> Result<&str, std::str::Utf8Error> {
97 std::str::from_utf8(self.filename_bytes())
98 }
99
100 pub fn filename_lossy(&self) -> Cow<'_, str> {
101 String::from_utf8_lossy(self.filename_bytes())
102 }
103
104 pub const fn data_size(&self) -> u32 {
105 self.sample_count as u32 * OFFLINE_LOG_SAMPLE_SIZE as u32
106 }
107
108 pub fn data_address(&self) -> Result<u32, KMError> {
109 OFFLINE_LOG_ADDRESS.checked_add(self.data_offset).ok_or_else(|| {
110 KMError::Protocol(format!(
111 "Offline log data offset 0x{:08X} overflows the base address",
112 self.data_offset
113 ))
114 })
115 }
116
117 pub fn final_charge_raw_uah(&self) -> i32 {
118 self.final_charge.get::<microampere_hour>().round() as i32
119 }
120
121 pub fn final_energy_raw_uwh(&self) -> i32 {
122 self.final_energy.get::<microwatt_hour>().round() as i32
123 }
124
125 pub fn calculated_duration(&self) -> Time {
127 self.interval * f64::from(self.sample_count.saturating_sub(1))
128 }
129
130 pub fn to_bytes(&self) -> Vec<u8> {
131 LogMetadataWire {
132 filename: self.filename_raw,
133 unknown_0x10: U16::new(self.unknown_0x10),
134 sample_count: U16::new(self.sample_count),
135 interval_ms: U16::new(self.interval.get::<millisecond>().round() as u16),
136 flags: U16::new(self.flags),
137 recorded_duration_seconds: U32::new(self.recorded_duration.get::<second>().round() as u32),
138 final_charge_uah: I32::new(self.final_charge_raw_uah()),
139 final_energy_uwh: I32::new(self.final_energy_raw_uwh()),
140 data_offset: U32::new(self.data_offset),
141 reserved_tail: self.reserved_tail,
142 }
143 .as_bytes()
144 .to_vec()
145 }
146}
147
148#[derive(Debug, Clone, PartialEq)]
150#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
151pub enum LogMetadataResponse {
152 Empty,
154 Available(Vec<LogMetadata>),
155}
156
157#[cfg(feature = "python")]
158impl<'py> pyo3::IntoPyObject<'py> for LogMetadataResponse {
159 type Target = pyo3::PyAny;
160 type Output = pyo3::Bound<'py, Self::Target>;
161 type Error = pyo3::PyErr;
162
163 fn into_pyobject(self, py: pyo3::Python<'py>) -> Result<Self::Output, Self::Error> {
164 match self {
165 Self::Empty => Ok(py.None().into_bound(py)),
166 Self::Available(metadata) => Ok(metadata.into_pyobject(py)?.into_any()),
167 }
168 }
169}
170
171#[derive(Debug, Clone, Copy, FromBytes, IntoBytes, KnownLayout, Immutable, Unaligned)]
172#[repr(C)]
173struct OfflineLogSampleWire {
174 voltage_uv: I32,
175 current_ua: I32,
176 charge_uah: I32,
177 energy_uwh: I32,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
183pub struct OfflineLogSampleRaw {
184 pub voltage_uv: i32,
185 pub current_ua: i32,
186 pub charge_uah: i32,
187 pub energy_uwh: i32,
188}
189
190impl OfflineLogSampleRaw {
191 pub fn decode(self) -> OfflineLogSample {
192 let voltage = ElectricPotential::new::<microvolt>(f64::from(self.voltage_uv));
193 let current = ElectricCurrent::new::<microampere>(f64::from(self.current_ua));
194 OfflineLogSample {
195 raw: self,
196 voltage,
197 current,
198 power: voltage * current,
199 charge: ElectricCharge::new::<microampere_hour>(f64::from(self.charge_uah)),
200 energy: Energy::new::<microwatt_hour>(f64::from(self.energy_uwh)),
201 }
202 }
203}
204
205impl From<OfflineLogSampleWire> for OfflineLogSampleRaw {
206 fn from(wire: OfflineLogSampleWire) -> Self {
207 Self {
208 voltage_uv: wire.voltage_uv.get(),
209 current_ua: wire.current_ua.get(),
210 charge_uah: wire.charge_uah.get(),
211 energy_uwh: wire.energy_uwh.get(),
212 }
213 }
214}
215
216impl From<OfflineLogSampleRaw> for OfflineLogSampleWire {
217 fn from(raw: OfflineLogSampleRaw) -> Self {
218 Self {
219 voltage_uv: I32::new(raw.voltage_uv),
220 current_ua: I32::new(raw.current_ua),
221 charge_uah: I32::new(raw.charge_uah),
222 energy_uwh: I32::new(raw.energy_uwh),
223 }
224 }
225}
226
227#[derive(Debug, Clone, Copy, PartialEq)]
229#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
230pub struct OfflineLogSample {
231 raw: OfflineLogSampleRaw,
232 pub voltage: ElectricPotential,
233 pub current: ElectricCurrent,
234 pub power: Power,
235 pub charge: ElectricCharge,
236 pub energy: Energy,
237}
238
239impl OfflineLogSample {
240 pub const fn raw(&self) -> OfflineLogSampleRaw {
241 self.raw
242 }
243}
244
245#[derive(Debug, Clone, PartialEq)]
247#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
248pub struct OfflineLog {
249 pub metadata: LogMetadata,
250 pub samples: Vec<OfflineLogSample>,
251}
252
253impl OfflineLog {
254 pub fn from_bytes(metadata: LogMetadata, bytes: &[u8]) -> Result<Self, KMError> {
255 let expected = metadata.data_size() as usize;
256 if bytes.len() != expected {
257 return Err(KMError::InvalidPacket(format!(
258 "Offline log data length mismatch: metadata expects {expected} bytes, got {}",
259 bytes.len()
260 )));
261 }
262
263 let samples = bytes
264 .chunks_exact(OFFLINE_LOG_SAMPLE_SIZE)
265 .map(|bytes| {
266 OfflineLogSampleWire::ref_from_bytes(bytes)
267 .map(|wire| OfflineLogSampleRaw::from(*wire).decode())
268 .map_err(|_| KMError::InvalidPacket("Failed to parse offline log sample".to_string()))
269 })
270 .collect::<Result<Vec<_>, _>>()?;
271
272 if let Some(last) = samples.last() {
273 let raw = last.raw();
274 if raw.charge_uah != metadata.final_charge_raw_uah() || raw.energy_uwh != metadata.final_energy_raw_uwh() {
275 return Err(KMError::InvalidPacket(format!(
276 "Offline log final accumulators do not match metadata: sample has charge={} µAh and energy={} µWh, metadata has charge={} µAh and energy={} µWh",
277 raw.charge_uah,
278 raw.energy_uwh,
279 metadata.final_charge_raw_uah(),
280 metadata.final_energy_raw_uwh()
281 )));
282 }
283 }
284
285 Ok(Self { metadata, samples })
286 }
287
288 pub fn to_bytes(&self) -> Vec<u8> {
289 self.samples
290 .iter()
291 .flat_map(|sample| OfflineLogSampleWire::from(sample.raw()).as_bytes().to_vec())
292 .collect()
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use uom::si::electric_charge::milliampere_hour;
299 use uom::si::electric_current::ampere;
300 use uom::si::electric_potential::volt;
301 use uom::si::energy::milliwatt_hour;
302 use uom::si::time::{millisecond, second};
303
304 use super::*;
305
306 const CAPTURED_METADATA: &str = concat!(
307 "4130312e640000000000000000000000",
308 "450a09021027000050140000",
309 "a1a2f3ffe04da8ff000000000000000000000000"
310 );
311
312 #[test]
313 fn parses_and_round_trips_captured_metadata() {
314 let bytes = hex::decode(CAPTURED_METADATA).unwrap();
315 let metadata = LogMetadata::from_bytes(&bytes).unwrap();
316
317 assert_eq!(metadata.filename().unwrap(), "A01.d");
318 assert_eq!(metadata.unknown_0x10, 0x0a45);
319 assert_eq!(metadata.sample_count, 521);
320 assert_eq!(metadata.interval.get::<millisecond>(), 10_000.0);
321 assert_eq!(metadata.flags, 0);
322 assert_eq!(metadata.recorded_duration.get::<second>(), 5_200.0);
323 assert_eq!(metadata.calculated_duration().get::<second>(), 5_200.0);
324 assert_eq!(metadata.data_size(), 8_336);
325 assert_eq!(metadata.final_charge.get::<microampere_hour>(), -810_335.0);
326 assert!((metadata.final_energy.get::<microwatt_hour>() + 5_747_232.0).abs() < 1e-8);
327 assert_eq!(metadata.data_offset, 0);
328 assert_eq!(metadata.reserved_tail, [0; 8]);
329 assert_eq!(metadata.to_bytes(), bytes);
330 }
331
332 #[test]
333 fn parses_and_round_trips_captured_samples() {
334 let raw_samples = [
335 "81494c0021f0e2ff56ebffffb998ffff",
336 "bcaa89006e25f2ff2dd5f8fff7fdd6ff",
337 "cf2a8900947dfeffa1a2f3ffe04da8ff",
338 ]
339 .into_iter()
340 .flat_map(|sample| hex::decode(sample).unwrap())
341 .collect::<Vec<_>>();
342 let mut metadata = LogMetadata::from_bytes(&hex::decode(CAPTURED_METADATA).unwrap()).unwrap();
343 metadata.sample_count = 3;
344 let log = OfflineLog::from_bytes(metadata, &raw_samples).unwrap();
345
346 assert_eq!(log.samples[0].voltage.get::<volt>(), 4.999_553);
347 assert_eq!(log.samples[0].current.get::<ampere>(), -1.904_607);
348 assert_eq!(log.samples[0].charge.get::<milliampere_hour>(), -5.29);
349 assert!((log.samples[0].energy.get::<milliwatt_hour>() + 26.439).abs() < 1e-12);
350 assert_eq!(log.samples[1].raw().charge_uah, -469_715);
351 assert_eq!(log.samples[1].raw().energy_uwh, -2_687_497);
352 assert_eq!(log.samples[2].raw().voltage_uv, 8_989_391);
353 assert_eq!(log.to_bytes(), raw_samples);
354 }
355
356 #[test]
357 fn rejects_wrong_metadata_and_log_sizes() {
358 assert!(LogMetadata::from_bytes(&[0; LOG_METADATA_SIZE - 1]).is_err());
359 let mut metadata = LogMetadata::from_bytes(&hex::decode(CAPTURED_METADATA).unwrap()).unwrap();
360 metadata.sample_count = 1;
361 assert!(OfflineLog::from_bytes(metadata, &[0; OFFLINE_LOG_SAMPLE_SIZE - 1]).is_err());
362 }
363
364 #[test]
365 fn rejects_data_from_the_wrong_catalog_entry() {
366 let mut metadata = LogMetadata::from_bytes(&hex::decode(CAPTURED_METADATA).unwrap()).unwrap();
367 metadata.sample_count = 1;
368 let wrong_sample = hex::decode("81494c0021f0e2ff56ebffffb998ffff").unwrap();
369 let error = OfflineLog::from_bytes(metadata, &wrong_sample).unwrap_err();
370 assert!(error.to_string().contains("final accumulators do not match"));
371 }
372
373 #[test]
374 fn parses_available_and_empty_captured_metadata_responses() {
375 use bytes::Bytes;
376
377 use crate::{Packet, RawPacket};
378
379 let available = hex::decode(concat!(
380 "4107c2020002000c",
381 "4130312e640000000000000000000000",
382 "450a09021027000050140000",
383 "a1a2f3ffe04da8ff000000000000000000000000"
384 ))
385 .unwrap();
386 let packet = Packet::try_from(RawPacket::try_from(Bytes::from(available)).unwrap()).unwrap();
387 assert!(matches!(
388 packet.get_log_metadata(),
389 Some(LogMetadataResponse::Available(metadata)) if metadata.len() == 1 && metadata[0].sample_count == 521
390 ));
391
392 let empty = hex::decode("4108c2ff00020000").unwrap();
393 let packet = Packet::try_from(RawPacket::try_from(Bytes::from(empty)).unwrap()).unwrap();
394 assert_eq!(packet.get_log_metadata(), Some(&LogMetadataResponse::Empty));
395 }
396}