use crate::{Error, Result};
pub const LOAD_TELEMETRY_COMMAND: [u8; 8] = [0x00, 0xC9, 0x10, 0x02, 0x01, 0x01, 0x75, 0xD6];
pub const LOAD_TIMESTAMP_COMMAND: [u8; 8] = [0x00, 0xC0, 0x10, 0x02, 0x00, 0x04, 0x38, 0xEF];
pub const LOAD_BATTERY_VOLTAGE_COMMAND: [u8; 5] = [0x00, 0xC0, 0x00, 0x21, 0xE7];
pub const ALL_DIAGNOSIS_COMMANDS: [&[u8]; 4] = [
&LOAD_TELEMETRY_COMMAND,
&LOAD_TIMESTAMP_COMMAND,
&LOAD_TELEMETRY_COMMAND,
&LOAD_BATTERY_VOLTAGE_COMMAND,
];
const TELEMETRY_HEADER: [u8; 2] = [0x90, 0x22];
const TIMESTAMP_HEADER: [u8; 2] = [0x80, 0x02];
const BATTERY_VOLTAGE_HEADER: [u8; 2] = [0x88, 0x21];
const TAG_PUFF_COUNT: u8 = 0x8E;
const TAG_DAY_COUNTER: u8 = 0x17;
#[derive(Debug, Default, Clone, PartialEq)]
pub struct DiagnosticData {
pub total_smoking_count: Option<u16>,
pub days_used: Option<u16>,
pub battery_voltage: Option<f32>,
}
impl DiagnosticData {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
struct TelemetryBlock {
value: u16,
tag: u8,
}
impl TelemetryBlock {
const SIZE: usize = 8;
fn parse(bytes: &[u8]) -> Result<Self> {
if bytes.len() < Self::SIZE {
return Err(Error::ProtocolDecode("invalid telemetry block: too short".to_string()));
}
Ok(Self { value: u16::from_le_bytes([bytes[4], bytes[5]]), tag: bytes[7] })
}
}
#[derive(Debug, Default, Clone)]
pub(crate) struct DiagnosticDataBuilder {
inner: DiagnosticData,
}
impl DiagnosticDataBuilder {
pub fn parse(mut self, bytes: &[u8]) -> Result<Self> {
if bytes.len() < 4 {
return Err(Error::ProtocolDecode(
"diagnosis response too short for header".to_string(),
));
}
match [bytes[2], bytes[3]] {
TELEMETRY_HEADER => self.parse_telemetry(bytes)?,
TIMESTAMP_HEADER => self.parse_timestamp(bytes)?,
BATTERY_VOLTAGE_HEADER => self.parse_battery_voltage(bytes)?,
_ => {} }
Ok(self)
}
#[must_use]
pub fn build(self) -> DiagnosticData {
self.inner
}
fn parse_telemetry(&mut self, bytes: &[u8]) -> Result<()> {
if bytes.len() < 6 {
return Err(Error::ProtocolDecode(
"invalid telemetry frame: too short for marker".to_string(),
));
}
let marker = u16::from_le_bytes([bytes[4], bytes[5]]);
if marker != 0x0101 {
return Err(Error::ProtocolDecode("invalid telemetry frame: wrong marker".to_string()));
}
let length_byte = bytes[3] as usize;
let num_blocks = length_byte.saturating_sub(2) / TelemetryBlock::SIZE;
let blocks_start: usize = 6;
let required = blocks_start + num_blocks * TelemetryBlock::SIZE;
if bytes.len() < required {
return Err(Error::ProtocolDecode(format!(
"invalid telemetry frame: expected {required} bytes, got {}",
bytes.len()
)));
}
for i in 0..num_blocks {
let offset = blocks_start + i * TelemetryBlock::SIZE;
let block = TelemetryBlock::parse(&bytes[offset..offset + TelemetryBlock::SIZE])?;
match block.tag {
TAG_PUFF_COUNT => self.inner.total_smoking_count = Some(block.value),
TAG_DAY_COUNTER => self.inner.days_used = Some(block.value),
_ => {}
}
}
Ok(())
}
fn parse_timestamp(&mut self, bytes: &[u8]) -> Result<()> {
if bytes.len() < 6 {
return Err(Error::ProtocolDecode("invalid timestamp frame: too short".to_string()));
}
self.inner.days_used = Some(u16::from_le_bytes([bytes[4], bytes[5]]));
Ok(())
}
fn parse_battery_voltage(&mut self, bytes: &[u8]) -> Result<()> {
if bytes.len() < 7 {
return Err(Error::ProtocolDecode(
"invalid battery voltage frame: too short".to_string(),
));
}
let raw = u16::from_le_bytes([bytes[5], bytes[6]]);
self.inner.battery_voltage = Some(f32::from(raw) / 1000.0);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{
ALL_DIAGNOSIS_COMMANDS, DiagnosticData, DiagnosticDataBuilder,
LOAD_BATTERY_VOLTAGE_COMMAND, LOAD_TELEMETRY_COMMAND, LOAD_TIMESTAMP_COMMAND,
};
#[test]
fn keeps_load_commands_stable() {
assert_eq!(LOAD_TELEMETRY_COMMAND, [0x00, 0xC9, 0x10, 0x02, 0x01, 0x01, 0x75, 0xD6]);
assert_eq!(LOAD_TIMESTAMP_COMMAND, [0x00, 0xC0, 0x10, 0x02, 0x00, 0x04, 0x38, 0xEF]);
assert_eq!(LOAD_BATTERY_VOLTAGE_COMMAND, [0x00, 0xC0, 0x00, 0x21, 0xE7]);
}
#[test]
fn diagnosis_commands_contains_four_entries() {
assert_eq!(ALL_DIAGNOSIS_COMMANDS.len(), 4);
}
#[test]
fn parses_battery_voltage_frame() {
let bytes = [0x00, 0x08, 0x88, 0x21, 0x00, 0xA8, 0x10, 0x00, 0x00];
let result = DiagnosticDataBuilder::default().parse(&bytes).unwrap().build();
assert_eq!(result.battery_voltage, Some(4264_f32 / 1000.0));
}
#[test]
fn parses_timestamp_frame() {
let bytes = [0x00, 0x08, 0x80, 0x02, 0x1E, 0x00, 0x00, 0x00];
let result = DiagnosticDataBuilder::default().parse(&bytes).unwrap().build();
assert_eq!(result.days_used, Some(0x001E)); }
#[test]
fn ignores_unknown_header_frames() {
let bytes = [0x00, 0x08, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00];
let result = DiagnosticDataBuilder::default().parse(&bytes).unwrap().build();
assert_eq!(result, DiagnosticData::default());
}
#[test]
fn rejects_too_short_frame() {
let error = DiagnosticDataBuilder::default().parse(&[0x00, 0x08, 0x88]);
assert!(error.is_err());
}
#[test]
fn rejects_too_short_battery_voltage_frame() {
let bytes = [0x00, 0x08, 0x88, 0x21, 0x00, 0xA8];
let error = DiagnosticDataBuilder::default().parse(&bytes);
assert!(error.is_err());
}
#[test]
fn rejects_too_short_timestamp_frame() {
let bytes = [0x00, 0x08, 0x80, 0x02, 0x1E];
let error = DiagnosticDataBuilder::default().parse(&bytes);
assert!(error.is_err());
}
#[test]
fn builder_accumulates_across_multiple_frames() {
let timestamp_bytes = [0x00, 0x08, 0x80, 0x02, 0x0A, 0x00, 0x00, 0x00];
let battery_bytes = [0x00, 0x08, 0x88, 0x21, 0x00, 0xE8, 0x0F, 0x00, 0x00];
let result = DiagnosticDataBuilder::default()
.parse(×tamp_bytes)
.unwrap()
.parse(&battery_bytes)
.unwrap()
.build();
assert_eq!(result.days_used, Some(10));
assert_eq!(result.battery_voltage, Some(4072_f32 / 1000.0));
assert_eq!(result.total_smoking_count, None);
}
}