use std::fmt;
use smallvec::SmallVec;
use crate::engine::format::plain::ParseError;
use crate::engine::format::timestamp::time_of_day;
use crate::engine::frame::RawFrame;
pub fn write_line<W: fmt::Write>(w: &mut W, frame: &RawFrame) -> fmt::Result {
let mut tod = time_of_day(frame.timestamp.as_deref());
tod.retain(|c| c != ':');
let sdp = ((frame.src as u32) << 12) | ((frame.dst as u32) << 4) | (frame.prio as u32);
write!(w, "A{tod} {sdp:05X} {pgn:05X}", pgn = frame.pgn)?;
if !frame.data.is_empty() {
w.write_char(' ')?;
for b in &frame.data {
write!(w, "{b:02X}")?;
}
}
Ok(())
}
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 mut toks = line.split_whitespace();
let ts_tok = toks.next().ok_or(ParseError::MissingTimestamp)?;
if !ts_tok.starts_with('A') || ts_tok.len() < 7 {
return Err(ParseError::BadInteger {
field: "timestamp",
value: ts_tok.to_string(),
offset: None,
});
}
let timestamp = format_actisense_timestamp(&ts_tok[1..])?;
let sdp_tok = toks.next().ok_or(ParseError::BadHeader {
expected: 3,
found: 1,
})?;
let sdp = u32::from_str_radix(sdp_tok, 16).map_err(|_| ParseError::BadInteger {
field: "sdp",
value: sdp_tok.to_string(),
offset: None,
})?;
let prio = (sdp & 0xf) as u8;
let dst = ((sdp >> 4) & 0xff) as u8;
let src = ((sdp >> 12) & 0xff) as u8;
let pgn_tok = toks.next().ok_or(ParseError::BadHeader {
expected: 3,
found: 2,
})?;
let pgn = u32::from_str_radix(pgn_tok, 16).map_err(|_| ParseError::BadInteger {
field: "pgn",
value: pgn_tok.to_string(),
offset: None,
})?;
let mut data: SmallVec<[u8; 8]> = SmallVec::new();
for t in toks {
let bytes = t.as_bytes();
if bytes.len() % 2 != 0 {
return Err(ParseError::BadHexByte {
index: data.len(),
value: t.to_string(),
offset: None,
});
}
for pair in bytes.chunks(2) {
let s = std::str::from_utf8(pair).map_err(|_| ParseError::BadHexByte {
index: data.len(),
value: t.to_string(),
offset: None,
})?;
let b = u8::from_str_radix(s, 16).map_err(|_| ParseError::BadHexByte {
index: data.len(),
value: s.to_string(),
offset: None,
})?;
data.push(b);
}
}
Ok(RawFrame {
timestamp: Some(timestamp),
prio,
pgn,
src,
dst,
data,
})
}
fn format_actisense_timestamp(rest: &str) -> Result<String, ParseError> {
let (h_str, mm_str, ss_str, ms_str) = if let Some((time, frac)) = rest.split_once('.') {
if time.len() < 6 {
return Err(ParseError::BadInteger {
field: "timestamp",
value: rest.to_string(),
offset: None,
});
}
(&time[..2], &time[2..4], &time[4..6], frac)
} else if rest.len() >= 6 {
(&rest[..2], &rest[2..4], &rest[4..6], "000")
} else {
return Err(ParseError::BadInteger {
field: "timestamp",
value: rest.to_string(),
offset: None,
});
};
let _h: u32 = h_str.parse().map_err(|_| ParseError::BadInteger {
field: "timestamp",
value: rest.to_string(),
offset: None,
})?;
let _m: u32 = mm_str.parse().map_err(|_| ParseError::BadInteger {
field: "timestamp",
value: rest.to_string(),
offset: None,
})?;
let _s: u32 = ss_str.parse().map_err(|_| ParseError::BadInteger {
field: "timestamp",
value: rest.to_string(),
offset: None,
})?;
let ms: u32 = ms_str.parse().map_err(|_| ParseError::BadInteger {
field: "timestamp",
value: rest.to_string(),
offset: None,
})?;
Ok(format!("{h_str}:{mm_str}:{ss_str},{:03}", ms % 1000))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_actisense_ascii_line() {
let line = "A173321.107 23FF7 1F119 01 02 03 04 05 06 07 08";
let f = parse_line(line).unwrap();
assert_eq!(f.timestamp.as_deref(), Some("17:33:21,107"));
assert_eq!(f.prio, 7);
assert_eq!(f.dst, 0xff);
assert_eq!(f.src, 0x23);
assert_eq!(f.pgn, 0x1f119);
assert_eq!(&f.data[..], &[1, 2, 3, 4, 5, 6, 7, 8]);
}
#[test]
fn packed_and_split_payloads_agree() {
let packed = parse_line("A173321.107 23FF7 1F119 0102030405060708").unwrap();
let split = parse_line("A173321.107 23FF7 1F119 01 02 03 04 05 06 07 08").unwrap();
let mixed = parse_line("A173321.107 23FF7 1F119 01020304 05060708").unwrap();
assert_eq!(packed.data, split.data);
assert_eq!(packed.data, mixed.data);
}
#[test]
fn sdp_unpacks_into_src_dst_prio() {
let f = parse_line("A000000.000 B1C6 1F119").unwrap();
assert_eq!((f.src, f.dst, f.prio), (0x0b, 0x1c, 6));
}
#[test]
fn timestamp_fraction_is_optional() {
let f = parse_line("A173321 23FF7 1F119").unwrap();
assert_eq!(f.timestamp.as_deref(), Some("17:33:21,000"));
}
#[test]
fn oversized_fraction_wraps_at_a_thousand() {
let f = parse_line("A173321.1234 23FF7 1F119").unwrap();
assert_eq!(f.timestamp.as_deref(), Some("17:33:21,234"));
}
#[test]
fn short_timestamp_is_rejected() {
for line in ["A1733 23FF7 1F119", "A17332 23FF7 1F119", "A 23FF7 1F119"] {
assert!(parse_line(line).is_err(), "expected error for {line}");
}
}
#[test]
fn non_numeric_time_is_rejected() {
assert!(matches!(
parse_line("A1A3321.107 23FF7 1F119"),
Err(ParseError::BadInteger {
field: "timestamp",
..
})
));
}
#[test]
fn non_numeric_fraction_is_rejected() {
assert!(matches!(
parse_line("A173321.abc 23FF7 1F119"),
Err(ParseError::BadInteger {
field: "timestamp",
..
})
));
}
#[test]
fn bad_sdp_or_pgn_names_its_field() {
assert!(matches!(
parse_line("A173321.107 ZZZZ 1F119"),
Err(ParseError::BadInteger { field: "sdp", .. })
));
assert!(matches!(
parse_line("A173321.107 23FF7 ZZZZZ"),
Err(ParseError::BadInteger { field: "pgn", .. })
));
}
#[test]
fn short_line_is_rejected() {
assert!(matches!(
parse_line("A173321.107"),
Err(ParseError::BadHeader { found: 1, .. })
));
assert!(matches!(
parse_line("A173321.107 23FF7"),
Err(ParseError::BadHeader { found: 2, .. })
));
}
#[test]
fn odd_length_payload_token_is_rejected() {
assert!(matches!(
parse_line("A173321.107 23FF7 1F119 010203040"),
Err(ParseError::BadHexByte { .. })
));
}
#[test]
fn non_hex_payload_is_rejected() {
assert!(matches!(
parse_line("A173321.107 23FF7 1F119 01ZZ"),
Err(ParseError::BadHexByte { .. })
));
}
#[test]
fn a_line_with_no_payload_parses() {
let f = parse_line("A173321.107 23FF7 1F119").unwrap();
assert!(f.data.is_empty());
assert_eq!(f.pgn, 0x1f119);
}
#[test]
fn empty_line_is_empty() {
assert!(matches!(parse_line(""), Err(ParseError::Empty)));
assert!(matches!(parse_line("\r\n"), Err(ParseError::Empty)));
}
#[test]
fn long_payload_round_trips() {
let data: SmallVec<[u8; 8]> = (0..=255u8).cycle().take(223).collect();
let frame = RawFrame {
timestamp: Some("2026-01-01T00:00:00.000Z".into()),
prio: 3,
pgn: 126996,
src: 1,
dst: 255,
data: data.clone(),
};
let mut line = String::new();
write_line(&mut line, &frame).unwrap();
assert_eq!(parse_line(&line).unwrap().data, data);
}
#[test]
fn rejects_missing_timestamp_prefix() {
let line = "173321.107 23FF7 1F119";
assert!(parse_line(line).is_err());
}
#[test]
fn write_line_round_trips_through_parse() {
let frame = RawFrame {
timestamp: Some("2026-01-01T17:33:21.107Z".into()),
prio: 7,
pgn: 0x1f119,
src: 0x23,
dst: 0xff,
data: [1, 2, 3, 4, 5, 6, 7, 8].into_iter().collect(),
};
let mut line = String::new();
write_line(&mut line, &frame).unwrap();
assert_eq!(line, "A173321.107 23FF7 1F119 0102030405060708");
let back = parse_line(&line).unwrap();
assert_eq!(
(back.prio, back.pgn, back.src, back.dst),
(7, 0x1f119, 0x23, 0xff)
);
assert_eq!(&back.data[..], &[1, 2, 3, 4, 5, 6, 7, 8]);
assert_eq!(back.timestamp.as_deref(), Some("17:33:21,107"));
}
#[test]
fn write_line_pads_the_header_fields() {
let frame = RawFrame::new(None, 6, 59904, 0, 255, [0x14, 0xf0, 0x01]);
let mut line = String::new();
write_line(&mut line, &frame).unwrap();
assert_eq!(line, "A000000.000 00FF6 0EA00 14F001");
let back = parse_line(&line).unwrap();
assert_eq!(
(back.prio, back.pgn, back.src, back.dst),
(6, 59904, 0, 255)
);
}
#[test]
fn write_line_empty_data() {
let frame = RawFrame {
timestamp: None,
prio: 3,
pgn: 0x1f119,
src: 1,
dst: 2,
data: SmallVec::new(),
};
let mut line = String::new();
write_line(&mut line, &frame).unwrap();
assert_eq!(line, "A000000.000 01023 1F119");
}
}