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/// Response to a `LogMetadata` request.
149#[derive(Debug, Clone, PartialEq)]
150#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
151pub enum LogMetadataResponse {
152    /// No offline log is currently available.
153    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/// Lossless wire representation of one offline log sample.
181#[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/// One decoded offline log sample with typed physical quantities.
228#[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/// A complete offline log downloaded from the device.
246#[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}