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#[cfg(feature = "python")]
149#[pyo3::pymethods]
150impl LogMetadata {
151 #[getter(filename)]
152 fn py_filename(&self) -> String {
153 self.filename_lossy().into_owned()
154 }
155
156 #[getter]
157 fn filename_raw(&self) -> Vec<u8> {
158 self.filename_raw.to_vec()
159 }
160
161 #[getter]
162 fn unknown_0x10(&self) -> u16 {
163 self.unknown_0x10
164 }
165
166 #[getter]
167 fn sample_count(&self) -> u16 {
168 self.sample_count
169 }
170
171 #[getter]
172 fn interval_ms(&self) -> f64 {
173 self.interval.get::<millisecond>()
174 }
175
176 #[getter]
177 fn flags(&self) -> u16 {
178 self.flags
179 }
180
181 #[getter]
182 fn recorded_duration_s(&self) -> f64 {
183 self.recorded_duration.get::<second>()
184 }
185
186 #[getter]
187 fn calculated_duration_s(&self) -> f64 {
188 self.calculated_duration().get::<second>()
189 }
190
191 #[getter]
192 fn final_charge_uah(&self) -> i32 {
193 self.final_charge_raw_uah()
194 }
195
196 #[getter]
197 fn final_energy_uwh(&self) -> i32 {
198 self.final_energy_raw_uwh()
199 }
200
201 #[getter]
202 fn data_offset(&self) -> u32 {
203 self.data_offset
204 }
205
206 #[getter(data_size)]
207 fn py_data_size(&self) -> u32 {
208 self.data_size()
209 }
210
211 #[getter(data_address)]
212 fn py_data_address(&self) -> pyo3::PyResult<u32> {
213 self.data_address()
214 .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))
215 }
216
217 #[getter]
218 fn reserved_tail(&self) -> Vec<u8> {
219 self.reserved_tail.to_vec()
220 }
221
222 fn __repr__(&self) -> String {
223 format!(
224 "LogMetadata(filename={:?}, samples={}, interval={}ms)",
225 self.filename_lossy(),
226 self.sample_count,
227 self.interval.get::<millisecond>()
228 )
229 }
230
231 fn __str__(&self) -> String {
232 self.__repr__()
233 }
234}
235
236#[derive(Debug, Clone, PartialEq)]
238#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
239pub enum LogMetadataResponse {
240 Empty,
242 Available(Vec<LogMetadata>),
243}
244
245#[cfg(feature = "python")]
246impl<'py> pyo3::IntoPyObject<'py> for LogMetadataResponse {
247 type Target = pyo3::PyAny;
248 type Output = pyo3::Bound<'py, Self::Target>;
249 type Error = pyo3::PyErr;
250
251 fn into_pyobject(self, py: pyo3::Python<'py>) -> Result<Self::Output, Self::Error> {
252 match self {
253 Self::Empty => Ok(py.None().into_bound(py)),
254 Self::Available(metadata) => Ok(metadata.into_pyobject(py)?.into_any()),
255 }
256 }
257}
258
259#[derive(Debug, Clone, Copy, FromBytes, IntoBytes, KnownLayout, Immutable, Unaligned)]
260#[repr(C)]
261struct OfflineLogSampleWire {
262 voltage_uv: I32,
263 current_ua: I32,
264 charge_uah: I32,
265 energy_uwh: I32,
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
271pub struct OfflineLogSampleRaw {
272 pub voltage_uv: i32,
273 pub current_ua: i32,
274 pub charge_uah: i32,
275 pub energy_uwh: i32,
276}
277
278impl OfflineLogSampleRaw {
279 pub fn from_wire_bytes(bytes: &[u8]) -> Result<Self, KMError> {
281 OfflineLogSampleWire::ref_from_bytes(bytes)
282 .map(|wire| Self::from(*wire))
283 .map_err(|_| KMError::InvalidPacket("Failed to parse offline log sample".to_string()))
284 }
285
286 pub fn decode(self) -> OfflineLogSample {
287 let voltage = ElectricPotential::new::<microvolt>(f64::from(self.voltage_uv));
288 let current = ElectricCurrent::new::<microampere>(f64::from(self.current_ua));
289 OfflineLogSample {
290 raw: self,
291 voltage,
292 current,
293 power: voltage * current,
294 charge: ElectricCharge::new::<microampere_hour>(f64::from(self.charge_uah)),
295 energy: Energy::new::<microwatt_hour>(f64::from(self.energy_uwh)),
296 }
297 }
298}
299
300impl From<OfflineLogSampleWire> for OfflineLogSampleRaw {
301 fn from(wire: OfflineLogSampleWire) -> Self {
302 Self {
303 voltage_uv: wire.voltage_uv.get(),
304 current_ua: wire.current_ua.get(),
305 charge_uah: wire.charge_uah.get(),
306 energy_uwh: wire.energy_uwh.get(),
307 }
308 }
309}
310
311impl From<OfflineLogSampleRaw> for OfflineLogSampleWire {
312 fn from(raw: OfflineLogSampleRaw) -> Self {
313 Self {
314 voltage_uv: I32::new(raw.voltage_uv),
315 current_ua: I32::new(raw.current_ua),
316 charge_uah: I32::new(raw.charge_uah),
317 energy_uwh: I32::new(raw.energy_uwh),
318 }
319 }
320}
321
322#[derive(Debug, Clone, Copy, PartialEq)]
324#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
325pub struct OfflineLogSample {
326 raw: OfflineLogSampleRaw,
327 pub voltage: ElectricPotential,
328 pub current: ElectricCurrent,
329 pub power: Power,
330 pub charge: ElectricCharge,
331 pub energy: Energy,
332}
333
334impl OfflineLogSample {
335 pub const fn raw(&self) -> OfflineLogSampleRaw {
336 self.raw
337 }
338}
339
340#[derive(Debug, Clone, PartialEq)]
342#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
343pub struct OfflineLog {
344 pub metadata: LogMetadata,
345 pub samples: Vec<OfflineLogSample>,
346}
347
348impl OfflineLog {
349 pub fn from_bytes(metadata: LogMetadata, bytes: &[u8]) -> Result<Self, KMError> {
350 let expected = metadata.data_size() as usize;
351 if bytes.len() != expected {
352 return Err(KMError::InvalidPacket(format!(
353 "Offline log data length mismatch: metadata expects {expected} bytes, got {}",
354 bytes.len()
355 )));
356 }
357
358 let samples = bytes
359 .as_chunks::<OFFLINE_LOG_SAMPLE_SIZE>()
360 .0
361 .iter()
362 .map(|bytes| OfflineLogSampleRaw::from_wire_bytes(bytes).map(OfflineLogSampleRaw::decode))
363 .collect::<Result<Vec<_>, _>>()?;
364
365 if let Some(last) = samples.last() {
366 let raw = last.raw();
367 if raw.charge_uah != metadata.final_charge_raw_uah() || raw.energy_uwh != metadata.final_energy_raw_uwh() {
368 return Err(KMError::InvalidPacket(format!(
369 "Offline log final accumulators do not match metadata: sample has charge={} µAh and energy={} µWh, metadata has charge={} µAh and energy={} µWh",
370 raw.charge_uah,
371 raw.energy_uwh,
372 metadata.final_charge_raw_uah(),
373 metadata.final_energy_raw_uwh()
374 )));
375 }
376 }
377
378 Ok(Self { metadata, samples })
379 }
380
381 pub fn to_bytes(&self) -> Vec<u8> {
382 let mut bytes = Vec::with_capacity(self.samples.len() * OFFLINE_LOG_SAMPLE_SIZE);
383 for sample in &self.samples {
384 bytes.extend_from_slice(OfflineLogSampleWire::from(sample.raw()).as_bytes());
385 }
386 bytes
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use uom::si::electric_charge::milliampere_hour;
393 use uom::si::electric_current::ampere;
394 use uom::si::electric_potential::volt;
395 use uom::si::energy::milliwatt_hour;
396 use uom::si::time::{millisecond, second};
397
398 use super::*;
399
400 const CAPTURED_METADATA: &str = concat!(
401 "4130312e640000000000000000000000",
402 "450a09021027000050140000",
403 "a1a2f3ffe04da8ff000000000000000000000000"
404 );
405
406 #[test]
407 fn parses_and_round_trips_captured_metadata() {
408 let bytes = hex::decode(CAPTURED_METADATA).unwrap();
409 let metadata = LogMetadata::from_bytes(&bytes).unwrap();
410
411 assert_eq!(metadata.filename().unwrap(), "A01.d");
412 assert_eq!(metadata.unknown_0x10, 0x0a45);
413 assert_eq!(metadata.sample_count, 521);
414 assert_eq!(metadata.interval.get::<millisecond>(), 10_000.0);
415 assert_eq!(metadata.flags, 0);
416 assert_eq!(metadata.recorded_duration.get::<second>(), 5_200.0);
417 assert_eq!(metadata.calculated_duration().get::<second>(), 5_200.0);
418 assert_eq!(metadata.data_size(), 8_336);
419 assert_eq!(metadata.final_charge.get::<microampere_hour>(), -810_335.0);
420 assert!((metadata.final_energy.get::<microwatt_hour>() + 5_747_232.0).abs() < 1e-8);
421 assert_eq!(metadata.data_offset, 0);
422 assert_eq!(metadata.reserved_tail, [0; 8]);
423 assert_eq!(metadata.to_bytes(), bytes);
424 }
425
426 #[test]
427 fn parses_and_round_trips_captured_samples() {
428 let raw_samples = [
429 "81494c0021f0e2ff56ebffffb998ffff",
430 "bcaa89006e25f2ff2dd5f8fff7fdd6ff",
431 "cf2a8900947dfeffa1a2f3ffe04da8ff",
432 ]
433 .into_iter()
434 .flat_map(|sample| hex::decode(sample).unwrap())
435 .collect::<Vec<_>>();
436 let mut metadata = LogMetadata::from_bytes(&hex::decode(CAPTURED_METADATA).unwrap()).unwrap();
437 metadata.sample_count = 3;
438 let log = OfflineLog::from_bytes(metadata, &raw_samples).unwrap();
439
440 assert_eq!(log.samples[0].voltage.get::<volt>(), 4.999_553);
441 assert_eq!(log.samples[0].current.get::<ampere>(), -1.904_607);
442 assert_eq!(log.samples[0].charge.get::<milliampere_hour>(), -5.29);
443 assert!((log.samples[0].energy.get::<milliwatt_hour>() + 26.439).abs() < 1e-12);
444 assert_eq!(log.samples[1].raw().charge_uah, -469_715);
445 assert_eq!(log.samples[1].raw().energy_uwh, -2_687_497);
446 assert_eq!(log.samples[2].raw().voltage_uv, 8_989_391);
447 assert_eq!(log.to_bytes(), raw_samples);
448 }
449
450 #[test]
451 fn rejects_wrong_metadata_and_log_sizes() {
452 assert!(LogMetadata::from_bytes(&[0; LOG_METADATA_SIZE - 1]).is_err());
453 let mut metadata = LogMetadata::from_bytes(&hex::decode(CAPTURED_METADATA).unwrap()).unwrap();
454 metadata.sample_count = 1;
455 assert!(OfflineLog::from_bytes(metadata, &[0; OFFLINE_LOG_SAMPLE_SIZE - 1]).is_err());
456 }
457
458 #[test]
459 fn rejects_data_from_the_wrong_catalog_entry() {
460 let mut metadata = LogMetadata::from_bytes(&hex::decode(CAPTURED_METADATA).unwrap()).unwrap();
461 metadata.sample_count = 1;
462 let wrong_sample = hex::decode("81494c0021f0e2ff56ebffffb998ffff").unwrap();
463 let error = OfflineLog::from_bytes(metadata, &wrong_sample).unwrap_err();
464 assert!(error.to_string().contains("final accumulators do not match"));
465 }
466
467 #[test]
468 fn parses_available_and_empty_captured_metadata_responses() {
469 use bytes::Bytes;
470
471 use crate::{Packet, RawPacket};
472
473 let available = hex::decode(concat!(
474 "4107c2020002000c",
475 "4130312e640000000000000000000000",
476 "450a09021027000050140000",
477 "a1a2f3ffe04da8ff000000000000000000000000"
478 ))
479 .unwrap();
480 let packet = Packet::try_from(RawPacket::try_from(Bytes::from(available)).unwrap()).unwrap();
481 assert!(matches!(
482 packet.get_log_metadata(),
483 Some(LogMetadataResponse::Available(metadata)) if metadata.len() == 1 && metadata[0].sample_count == 521
484 ));
485
486 let empty = hex::decode("4108c2ff00020000").unwrap();
487 let packet = Packet::try_from(RawPacket::try_from(Bytes::from(empty)).unwrap()).unwrap();
488 assert_eq!(packet.get_log_metadata(), Some(&LogMetadataResponse::Empty));
489 }
490}