use smallvec::SmallVec;
use crate::engine::format::plain::ParseError;
use crate::engine::format::timestamp::days_to_ymd;
use crate::engine::frame::{FASTPACKET_MAX_SIZE, RawFrame};
pub fn parse_line(line: &str) -> Result<RawFrame, ParseError> {
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() {
return Err(ParseError::Empty);
}
let rest = line
.strip_prefix("$PCDIN,")
.ok_or(ParseError::MissingTimestamp)?;
let mut iter = rest.splitn(4, ',');
let pgn_tok = iter.next().ok_or(ParseError::BadHeader {
expected: 4,
found: 1,
})?;
let tstamp_tok = iter.next().ok_or(ParseError::BadHeader {
expected: 4,
found: 2,
})?;
let src_tok = iter.next().ok_or(ParseError::BadHeader {
expected: 4,
found: 3,
})?;
let data_tok = iter.next().ok_or(ParseError::BadHeader {
expected: 4,
found: 4,
})?;
let pgn = u32::from_str_radix(pgn_tok, 16).map_err(|_| ParseError::BadInteger {
field: "pgn",
value: pgn_tok.to_string(),
offset: None,
})?;
let tstamp_hex: String = tstamp_tok
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.collect();
let tstamp_ms = u64::from_str_radix(&tstamp_hex, 16).map_err(|_| ParseError::BadInteger {
field: "tstamp",
value: tstamp_tok.to_string(),
offset: None,
})?;
let src = u8::from_str_radix(src_tok, 16).map_err(|_| ParseError::BadInteger {
field: "src",
value: src_tok.to_string(),
offset: None,
})?;
let data_str = data_tok.split('*').next().unwrap_or("");
if data_str.len() % 2 != 0 {
return Err(ParseError::BadHexByte {
index: data_str.len() / 2,
value: data_str.chars().last().unwrap_or(' ').to_string(),
offset: None,
});
}
let mut data: SmallVec<[u8; 8]> = SmallVec::new();
for (i, chunk) in data_str.as_bytes().chunks(2).enumerate() {
if i >= FASTPACKET_MAX_SIZE {
break;
}
let s = std::str::from_utf8(chunk).map_err(|_| ParseError::BadHexByte {
index: i,
value: String::from_utf8_lossy(chunk).into_owned(),
offset: None,
})?;
let b = u8::from_str_radix(s, 16).map_err(|_| ParseError::BadHexByte {
index: i,
value: s.to_string(),
offset: None,
})?;
data.push(b);
}
Ok(RawFrame {
timestamp: Some(format_chetco_timestamp(tstamp_ms)),
prio: 0,
pgn,
src,
dst: 0xff,
data,
})
}
fn format_chetco_timestamp(tstamp_ms: u64) -> String {
let secs = (tstamp_ms / 1000) as i64;
let ms = tstamp_ms % 1000;
let days = secs.div_euclid(86_400);
let day_seconds = secs.rem_euclid(86_400) as u32;
let h = day_seconds / 3600;
let m = (day_seconds / 60) % 60;
let s = day_seconds % 60;
let (year, mo, d) = days_to_ymd(days);
format!("{year:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02},{ms:03}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_chetco_sentence() {
let line = "$PCDIN,01F801,FB12345,07,A0B1C2D3E4F506*55";
let f = parse_line(line).unwrap();
assert_eq!(f.pgn, 0x1f801);
assert_eq!(f.src, 0x07);
assert_eq!(f.dst, 0xff);
assert_eq!(f.prio, 0);
assert_eq!(
f.data.as_slice(),
&[0xa0, 0xb1, 0xc2, 0xd3, 0xe4, 0xf5, 0x06]
);
assert_eq!(f.timestamp.as_deref(), Some("1970-01-04T01:07:47,141"));
}
#[test]
fn checksum_suffix_is_not_payload() {
let with = parse_line("$PCDIN,01F801,0,07,A0B1*55").unwrap();
let without = parse_line("$PCDIN,01F801,0,07,A0B1").unwrap();
assert_eq!(with.data.as_slice(), &[0xa0, 0xb1]);
assert_eq!(with.data, without.data);
}
#[test]
fn trailing_marker_on_the_timestamp_is_tolerated() {
let plain = parse_line("$PCDIN,01F801,089C77D,07,A0*55").unwrap();
let marked = parse_line("$PCDIN,01F801,089C77D!,07,A0*55").unwrap();
assert_eq!(plain.timestamp, marked.timestamp);
}
#[test]
fn timestamp_without_hex_is_rejected() {
assert!(matches!(
parse_line("$PCDIN,01F801,!!!,07,A0*55"),
Err(ParseError::BadInteger {
field: "tstamp",
..
})
));
}
#[test]
fn non_hex_pgn_or_src_is_rejected() {
assert!(matches!(
parse_line("$PCDIN,ZZZZZZ,0,07,A0*55"),
Err(ParseError::BadInteger { field: "pgn", .. })
));
assert!(matches!(
parse_line("$PCDIN,01F801,0,ZZ,A0*55"),
Err(ParseError::BadInteger { field: "src", .. })
));
}
#[test]
fn odd_length_data_is_rejected() {
assert!(matches!(
parse_line("$PCDIN,01F801,0,07,A0B*55"),
Err(ParseError::BadHexByte { .. })
));
}
#[test]
fn non_hex_data_digit_is_rejected() {
assert!(matches!(
parse_line("$PCDIN,01F801,0,07,A0ZZ*55"),
Err(ParseError::BadHexByte { .. })
));
}
#[test]
fn data_is_clamped_to_the_fast_packet_ceiling() {
let hex = "ab".repeat(FASTPACKET_MAX_SIZE + 5);
let f = parse_line(&format!("$PCDIN,01F801,0,07,{hex}*55")).unwrap();
assert_eq!(f.data.len(), FASTPACKET_MAX_SIZE);
}
#[test]
fn empty_data_block_parses() {
let f = parse_line("$PCDIN,01F801,0,07,*55").unwrap();
assert!(f.data.is_empty());
}
#[test]
fn short_sentence_is_rejected() {
for line in ["$PCDIN,01F801", "$PCDIN,01F801,0", "$PCDIN,01F801,0,07"] {
assert!(
matches!(parse_line(line), Err(ParseError::BadHeader { .. })),
"expected BadHeader for {line}"
);
}
}
#[test]
fn empty_line_is_empty() {
assert!(matches!(parse_line(""), Err(ParseError::Empty)));
assert!(matches!(parse_line("\r\n"), Err(ParseError::Empty)));
}
#[test]
fn timestamp_rolls_over_days() {
assert_eq!(format_chetco_timestamp(0), "1970-01-01T00:00:00,000");
assert_eq!(
format_chetco_timestamp(90_061_500),
"1970-01-02T01:01:01,500"
);
}
#[test]
fn rejects_missing_prefix() {
assert!(parse_line("BOGUS,01,02").is_err());
}
}