Skip to main content

km003c_lib/
offline.rs

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
17/// Device memory address containing the currently selected offline log.
18pub const OFFLINE_LOG_ADDRESS: u32 = 0x9810_0000;
19/// Size of a `LogMetadata` payload.
20pub const LOG_METADATA_SIZE: usize = 48;
21/// Size of one offline log sample.
22pub 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/// Metadata describing the offline log selected on the device.
40///
41/// Fields whose meaning has not been established are preserved verbatim and
42/// deliberately named after their wire position rather than guessed.
43#[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    /// Offset from `OFFLINE_LOG_ADDRESS` at which this log's samples begin.
56    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    /// Filename bytes up to the first NUL terminator.
87    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    /// Duration implied by the sample count and interval.
126    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/// Response to a `LogMetadata` request.
237#[derive(Debug, Clone, PartialEq)]
238#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
239pub enum LogMetadataResponse {
240    /// No offline log is currently available.
241    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/// Lossless wire representation of one offline log sample.
269#[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    /// Decode one 16-byte wire sample.
280    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/// One decoded offline log sample with typed physical quantities.
323#[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/// A complete offline log downloaded from the device.
341#[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}