use std::borrow::Cow;
use uom::si::electric_charge::microampere_hour;
use uom::si::electric_current::microampere;
use uom::si::electric_potential::microvolt;
use uom::si::energy::microwatt_hour;
use uom::si::f64::{ElectricCharge, ElectricCurrent, ElectricPotential, Energy, Power, Time};
use uom::si::time::{millisecond, second};
use zerocopy::byteorder::little_endian::{I32, U16, U32};
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout, Unaligned};
use crate::error::KMError;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
pub const OFFLINE_LOG_ADDRESS: u32 = 0x9810_0000;
pub const LOG_METADATA_SIZE: usize = 48;
pub const OFFLINE_LOG_SAMPLE_SIZE: usize = 16;
#[derive(Debug, Clone, Copy, FromBytes, IntoBytes, KnownLayout, Immutable, Unaligned)]
#[repr(C)]
struct LogMetadataWire {
filename: [u8; 16],
unknown_0x10: U16,
sample_count: U16,
interval_ms: U16,
flags: U16,
recorded_duration_seconds: U32,
final_charge_uah: I32,
final_energy_uwh: I32,
data_offset: U32,
reserved_tail: [u8; 8],
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "python", pyo3::pyclass(skip_from_py_object))]
pub struct LogMetadata {
pub filename_raw: [u8; 16],
pub unknown_0x10: u16,
pub sample_count: u16,
pub interval: Time,
pub flags: u16,
pub recorded_duration: Time,
pub final_charge: ElectricCharge,
pub final_energy: Energy,
pub data_offset: u32,
pub reserved_tail: [u8; 8],
}
impl LogMetadata {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, KMError> {
if bytes.len() != LOG_METADATA_SIZE {
return Err(KMError::InvalidPacket(format!(
"LogMetadata payload must be exactly {LOG_METADATA_SIZE} bytes, got {}",
bytes.len()
)));
}
let wire = LogMetadataWire::ref_from_bytes(bytes)
.map_err(|_| KMError::InvalidPacket("Failed to parse LogMetadata".to_string()))?;
Ok(Self {
filename_raw: wire.filename,
unknown_0x10: wire.unknown_0x10.get(),
sample_count: wire.sample_count.get(),
interval: Time::new::<millisecond>(f64::from(wire.interval_ms.get())),
flags: wire.flags.get(),
recorded_duration: Time::new::<second>(f64::from(wire.recorded_duration_seconds.get())),
final_charge: ElectricCharge::new::<microampere_hour>(f64::from(wire.final_charge_uah.get())),
final_energy: Energy::new::<microwatt_hour>(f64::from(wire.final_energy_uwh.get())),
data_offset: wire.data_offset.get(),
reserved_tail: wire.reserved_tail,
})
}
pub fn filename_bytes(&self) -> &[u8] {
let length = self
.filename_raw
.iter()
.position(|byte| *byte == 0)
.unwrap_or(self.filename_raw.len());
&self.filename_raw[..length]
}
pub fn filename(&self) -> Result<&str, std::str::Utf8Error> {
std::str::from_utf8(self.filename_bytes())
}
pub fn filename_lossy(&self) -> Cow<'_, str> {
String::from_utf8_lossy(self.filename_bytes())
}
pub const fn data_size(&self) -> u32 {
self.sample_count as u32 * OFFLINE_LOG_SAMPLE_SIZE as u32
}
pub fn data_address(&self) -> Result<u32, KMError> {
OFFLINE_LOG_ADDRESS.checked_add(self.data_offset).ok_or_else(|| {
KMError::Protocol(format!(
"Offline log data offset 0x{:08X} overflows the base address",
self.data_offset
))
})
}
pub fn final_charge_raw_uah(&self) -> i32 {
self.final_charge.get::<microampere_hour>().round() as i32
}
pub fn final_energy_raw_uwh(&self) -> i32 {
self.final_energy.get::<microwatt_hour>().round() as i32
}
pub fn calculated_duration(&self) -> Time {
self.interval * f64::from(self.sample_count.saturating_sub(1))
}
pub fn to_bytes(&self) -> Vec<u8> {
LogMetadataWire {
filename: self.filename_raw,
unknown_0x10: U16::new(self.unknown_0x10),
sample_count: U16::new(self.sample_count),
interval_ms: U16::new(self.interval.get::<millisecond>().round() as u16),
flags: U16::new(self.flags),
recorded_duration_seconds: U32::new(self.recorded_duration.get::<second>().round() as u32),
final_charge_uah: I32::new(self.final_charge_raw_uah()),
final_energy_uwh: I32::new(self.final_energy_raw_uwh()),
data_offset: U32::new(self.data_offset),
reserved_tail: self.reserved_tail,
}
.as_bytes()
.to_vec()
}
}
#[cfg(feature = "python")]
#[pyo3::pymethods]
impl LogMetadata {
#[getter(filename)]
fn py_filename(&self) -> String {
self.filename_lossy().into_owned()
}
#[getter]
fn filename_raw(&self) -> Vec<u8> {
self.filename_raw.to_vec()
}
#[getter]
fn unknown_0x10(&self) -> u16 {
self.unknown_0x10
}
#[getter]
fn sample_count(&self) -> u16 {
self.sample_count
}
#[getter]
fn interval_ms(&self) -> f64 {
self.interval.get::<millisecond>()
}
#[getter]
fn flags(&self) -> u16 {
self.flags
}
#[getter]
fn recorded_duration_s(&self) -> f64 {
self.recorded_duration.get::<second>()
}
#[getter]
fn calculated_duration_s(&self) -> f64 {
self.calculated_duration().get::<second>()
}
#[getter]
fn final_charge_uah(&self) -> i32 {
self.final_charge_raw_uah()
}
#[getter]
fn final_energy_uwh(&self) -> i32 {
self.final_energy_raw_uwh()
}
#[getter]
fn data_offset(&self) -> u32 {
self.data_offset
}
#[getter(data_size)]
fn py_data_size(&self) -> u32 {
self.data_size()
}
#[getter(data_address)]
fn py_data_address(&self) -> pyo3::PyResult<u32> {
self.data_address()
.map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))
}
#[getter]
fn reserved_tail(&self) -> Vec<u8> {
self.reserved_tail.to_vec()
}
fn __repr__(&self) -> String {
format!(
"LogMetadata(filename={:?}, samples={}, interval={}ms)",
self.filename_lossy(),
self.sample_count,
self.interval.get::<millisecond>()
)
}
fn __str__(&self) -> String {
self.__repr__()
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum LogMetadataResponse {
Empty,
Available(Vec<LogMetadata>),
}
#[cfg(feature = "python")]
impl<'py> pyo3::IntoPyObject<'py> for LogMetadataResponse {
type Target = pyo3::PyAny;
type Output = pyo3::Bound<'py, Self::Target>;
type Error = pyo3::PyErr;
fn into_pyobject(self, py: pyo3::Python<'py>) -> Result<Self::Output, Self::Error> {
match self {
Self::Empty => Ok(py.None().into_bound(py)),
Self::Available(metadata) => Ok(metadata.into_pyobject(py)?.into_any()),
}
}
}
#[derive(Debug, Clone, Copy, FromBytes, IntoBytes, KnownLayout, Immutable, Unaligned)]
#[repr(C)]
struct OfflineLogSampleWire {
voltage_uv: I32,
current_ua: I32,
charge_uah: I32,
energy_uwh: I32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct OfflineLogSampleRaw {
pub voltage_uv: i32,
pub current_ua: i32,
pub charge_uah: i32,
pub energy_uwh: i32,
}
impl OfflineLogSampleRaw {
pub fn from_wire_bytes(bytes: &[u8]) -> Result<Self, KMError> {
OfflineLogSampleWire::ref_from_bytes(bytes)
.map(|wire| Self::from(*wire))
.map_err(|_| KMError::InvalidPacket("Failed to parse offline log sample".to_string()))
}
pub fn decode(self) -> OfflineLogSample {
let voltage = ElectricPotential::new::<microvolt>(f64::from(self.voltage_uv));
let current = ElectricCurrent::new::<microampere>(f64::from(self.current_ua));
OfflineLogSample {
raw: self,
voltage,
current,
power: voltage * current,
charge: ElectricCharge::new::<microampere_hour>(f64::from(self.charge_uah)),
energy: Energy::new::<microwatt_hour>(f64::from(self.energy_uwh)),
}
}
}
impl From<OfflineLogSampleWire> for OfflineLogSampleRaw {
fn from(wire: OfflineLogSampleWire) -> Self {
Self {
voltage_uv: wire.voltage_uv.get(),
current_ua: wire.current_ua.get(),
charge_uah: wire.charge_uah.get(),
energy_uwh: wire.energy_uwh.get(),
}
}
}
impl From<OfflineLogSampleRaw> for OfflineLogSampleWire {
fn from(raw: OfflineLogSampleRaw) -> Self {
Self {
voltage_uv: I32::new(raw.voltage_uv),
current_ua: I32::new(raw.current_ua),
charge_uah: I32::new(raw.charge_uah),
energy_uwh: I32::new(raw.energy_uwh),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct OfflineLogSample {
raw: OfflineLogSampleRaw,
pub voltage: ElectricPotential,
pub current: ElectricCurrent,
pub power: Power,
pub charge: ElectricCharge,
pub energy: Energy,
}
impl OfflineLogSample {
pub const fn raw(&self) -> OfflineLogSampleRaw {
self.raw
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct OfflineLog {
pub metadata: LogMetadata,
pub samples: Vec<OfflineLogSample>,
}
impl OfflineLog {
pub fn from_bytes(metadata: LogMetadata, bytes: &[u8]) -> Result<Self, KMError> {
let expected = metadata.data_size() as usize;
if bytes.len() != expected {
return Err(KMError::InvalidPacket(format!(
"Offline log data length mismatch: metadata expects {expected} bytes, got {}",
bytes.len()
)));
}
let samples = bytes
.as_chunks::<OFFLINE_LOG_SAMPLE_SIZE>()
.0
.iter()
.map(|bytes| OfflineLogSampleRaw::from_wire_bytes(bytes).map(OfflineLogSampleRaw::decode))
.collect::<Result<Vec<_>, _>>()?;
if let Some(last) = samples.last() {
let raw = last.raw();
if raw.charge_uah != metadata.final_charge_raw_uah() || raw.energy_uwh != metadata.final_energy_raw_uwh() {
return Err(KMError::InvalidPacket(format!(
"Offline log final accumulators do not match metadata: sample has charge={} µAh and energy={} µWh, metadata has charge={} µAh and energy={} µWh",
raw.charge_uah,
raw.energy_uwh,
metadata.final_charge_raw_uah(),
metadata.final_energy_raw_uwh()
)));
}
}
Ok(Self { metadata, samples })
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(self.samples.len() * OFFLINE_LOG_SAMPLE_SIZE);
for sample in &self.samples {
bytes.extend_from_slice(OfflineLogSampleWire::from(sample.raw()).as_bytes());
}
bytes
}
}
#[cfg(test)]
mod tests {
use uom::si::electric_charge::milliampere_hour;
use uom::si::electric_current::ampere;
use uom::si::electric_potential::volt;
use uom::si::energy::milliwatt_hour;
use uom::si::time::{millisecond, second};
use super::*;
const CAPTURED_METADATA: &str = concat!(
"4130312e640000000000000000000000",
"450a09021027000050140000",
"a1a2f3ffe04da8ff000000000000000000000000"
);
#[test]
fn parses_and_round_trips_captured_metadata() {
let bytes = hex::decode(CAPTURED_METADATA).unwrap();
let metadata = LogMetadata::from_bytes(&bytes).unwrap();
assert_eq!(metadata.filename().unwrap(), "A01.d");
assert_eq!(metadata.unknown_0x10, 0x0a45);
assert_eq!(metadata.sample_count, 521);
assert_eq!(metadata.interval.get::<millisecond>(), 10_000.0);
assert_eq!(metadata.flags, 0);
assert_eq!(metadata.recorded_duration.get::<second>(), 5_200.0);
assert_eq!(metadata.calculated_duration().get::<second>(), 5_200.0);
assert_eq!(metadata.data_size(), 8_336);
assert_eq!(metadata.final_charge.get::<microampere_hour>(), -810_335.0);
assert!((metadata.final_energy.get::<microwatt_hour>() + 5_747_232.0).abs() < 1e-8);
assert_eq!(metadata.data_offset, 0);
assert_eq!(metadata.reserved_tail, [0; 8]);
assert_eq!(metadata.to_bytes(), bytes);
}
#[test]
fn parses_and_round_trips_captured_samples() {
let raw_samples = [
"81494c0021f0e2ff56ebffffb998ffff",
"bcaa89006e25f2ff2dd5f8fff7fdd6ff",
"cf2a8900947dfeffa1a2f3ffe04da8ff",
]
.into_iter()
.flat_map(|sample| hex::decode(sample).unwrap())
.collect::<Vec<_>>();
let mut metadata = LogMetadata::from_bytes(&hex::decode(CAPTURED_METADATA).unwrap()).unwrap();
metadata.sample_count = 3;
let log = OfflineLog::from_bytes(metadata, &raw_samples).unwrap();
assert_eq!(log.samples[0].voltage.get::<volt>(), 4.999_553);
assert_eq!(log.samples[0].current.get::<ampere>(), -1.904_607);
assert_eq!(log.samples[0].charge.get::<milliampere_hour>(), -5.29);
assert!((log.samples[0].energy.get::<milliwatt_hour>() + 26.439).abs() < 1e-12);
assert_eq!(log.samples[1].raw().charge_uah, -469_715);
assert_eq!(log.samples[1].raw().energy_uwh, -2_687_497);
assert_eq!(log.samples[2].raw().voltage_uv, 8_989_391);
assert_eq!(log.to_bytes(), raw_samples);
}
#[test]
fn rejects_wrong_metadata_and_log_sizes() {
assert!(LogMetadata::from_bytes(&[0; LOG_METADATA_SIZE - 1]).is_err());
let mut metadata = LogMetadata::from_bytes(&hex::decode(CAPTURED_METADATA).unwrap()).unwrap();
metadata.sample_count = 1;
assert!(OfflineLog::from_bytes(metadata, &[0; OFFLINE_LOG_SAMPLE_SIZE - 1]).is_err());
}
#[test]
fn rejects_data_from_the_wrong_catalog_entry() {
let mut metadata = LogMetadata::from_bytes(&hex::decode(CAPTURED_METADATA).unwrap()).unwrap();
metadata.sample_count = 1;
let wrong_sample = hex::decode("81494c0021f0e2ff56ebffffb998ffff").unwrap();
let error = OfflineLog::from_bytes(metadata, &wrong_sample).unwrap_err();
assert!(error.to_string().contains("final accumulators do not match"));
}
#[test]
fn parses_available_and_empty_captured_metadata_responses() {
use bytes::Bytes;
use crate::{Packet, RawPacket};
let available = hex::decode(concat!(
"4107c2020002000c",
"4130312e640000000000000000000000",
"450a09021027000050140000",
"a1a2f3ffe04da8ff000000000000000000000000"
))
.unwrap();
let packet = Packet::try_from(RawPacket::try_from(Bytes::from(available)).unwrap()).unwrap();
assert!(matches!(
packet.get_log_metadata(),
Some(LogMetadataResponse::Available(metadata)) if metadata.len() == 1 && metadata[0].sample_count == 521
));
let empty = hex::decode("4108c2ff00020000").unwrap();
let packet = Packet::try_from(RawPacket::try_from(Bytes::from(empty)).unwrap()).unwrap();
assert_eq!(packet.get_log_metadata(), Some(&LogMetadataResponse::Empty));
}
}