use smallvec::SmallVec;
use crate::engine::format::plain::ParseError;
use crate::engine::frame::RawFrame;
#[derive(Debug, Clone)]
pub enum IkonvertLine {
Frame(RawFrame),
Control(String),
Other,
}
pub fn parse_line(line: &str) -> Result<IkonvertLine, ParseError> {
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() {
return Err(ParseError::Empty);
}
if let Some(rest) = line.strip_prefix("!PDGY,") {
return parse_n2k_line(rest).map(IkonvertLine::Frame);
}
if let Some(rest) = line.strip_prefix("$PDGY,") {
return Ok(IkonvertLine::Control(rest.to_string()));
}
Ok(IkonvertLine::Other)
}
fn parse_n2k_line(rest: &str) -> Result<RawFrame, ParseError> {
let mut it = rest.splitn(6, ',');
let pgn = parse_int::<u32>(it.next(), "pgn")?;
let prio = parse_int::<u8>(it.next(), "prio")?;
let src = parse_int::<u8>(it.next(), "src")?;
let dst = parse_int::<u8>(it.next(), "dst")?;
let ts = it.next().ok_or(ParseError::BadHeader {
expected: 6,
found: 4,
})?;
let b64 = it.next().ok_or(ParseError::BadHeader {
expected: 6,
found: 5,
})?;
let data_vec = b64_decode(b64).ok_or(ParseError::BadHexByte {
index: 0,
value: b64.to_string(),
offset: None,
})?;
let data: SmallVec<[u8; 8]> = data_vec.into_iter().collect();
Ok(RawFrame {
timestamp: Some(ts.to_string()),
prio,
pgn,
src,
dst,
data,
})
}
fn parse_int<T: std::str::FromStr>(
raw: Option<&str>,
field: &'static str,
) -> Result<T, ParseError> {
let s = raw.ok_or(ParseError::BadHeader {
expected: 6,
found: 0,
})?;
s.trim().parse().map_err(|_| ParseError::BadInteger {
field,
value: s.to_string(),
offset: None,
})
}
pub(crate) fn b64_encode(bytes: &[u8], out: &mut String) {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut i = 0;
while i + 3 <= bytes.len() {
let n =
(u32::from(bytes[i]) << 16) | (u32::from(bytes[i + 1]) << 8) | u32::from(bytes[i + 2]);
out.push(TABLE[((n >> 18) & 63) as usize] as char);
out.push(TABLE[((n >> 12) & 63) as usize] as char);
out.push(TABLE[((n >> 6) & 63) as usize] as char);
out.push(TABLE[(n & 63) as usize] as char);
i += 3;
}
match bytes.len() - i {
0 => {}
1 => {
let n = u32::from(bytes[i]) << 16;
out.push(TABLE[((n >> 18) & 63) as usize] as char);
out.push(TABLE[((n >> 12) & 63) as usize] as char);
out.push('=');
out.push('=');
}
2 => {
let n = (u32::from(bytes[i]) << 16) | (u32::from(bytes[i + 1]) << 8);
out.push(TABLE[((n >> 18) & 63) as usize] as char);
out.push(TABLE[((n >> 12) & 63) as usize] as char);
out.push(TABLE[((n >> 6) & 63) as usize] as char);
out.push('=');
}
_ => unreachable!(),
}
}
pub fn encode_tx_frame(frame: &crate::engine::frame::RawFrame) -> String {
let mut out = String::with_capacity(32 + frame.data.len() * 4 / 3 + 4);
out.push_str("!PDGY,");
out.push_str(&frame.pgn.to_string());
out.push(',');
out.push_str(&frame.dst.to_string());
out.push(',');
b64_encode(&frame.data, &mut out);
out.push_str("\r\n");
out
}
pub const IKONVERT_BEM: u32 = 0x40100;
#[derive(Debug, Clone, Copy, Default)]
pub struct NetworkStatus {
pub load_pct: Option<u8>,
pub errors: Option<u32>,
pub device_count: Option<u8>,
pub uptime_s: Option<u32>,
pub gateway_addr: u8,
pub rejected_tx: Option<u32>,
}
pub fn build_network_status(status: NetworkStatus, timestamp: Option<String>) -> RawFrame {
let mut data = [0xffu8; 15];
if let Some(v) = status.load_pct {
data[0] = v;
}
if let Some(v) = status.errors {
data[1..5].copy_from_slice(&v.to_le_bytes());
}
if let Some(v) = status.device_count {
data[5] = v;
}
if let Some(v) = status.uptime_s {
data[6..10].copy_from_slice(&v.to_le_bytes());
}
data[10] = status.gateway_addr;
if let Some(v) = status.rejected_tx {
data[11..15].copy_from_slice(&v.to_le_bytes());
}
RawFrame {
timestamp,
prio: 7,
pgn: IKONVERT_BEM,
src: status.gateway_addr,
dst: 255,
data: data.into_iter().collect(),
}
}
pub fn synthesize_network_status(body: &str, timestamp: String) -> Option<RawFrame> {
let rest = body.strip_prefix("000000,")?;
if rest.split(',').all(str::is_empty) {
return None;
}
let mut fields = rest.split(',');
let load = parse_status_field(fields.next()).unwrap_or(0xff);
let errors = parse_status_field(fields.next()).unwrap_or(-1);
let count = parse_status_field(fields.next()).unwrap_or(0);
let uptime = parse_status_field(fields.next()).unwrap_or(0);
let addr = parse_status_field(fields.next()).unwrap_or(0);
let rejected = parse_status_field(fields.next()).unwrap_or(0);
Some(build_network_status(
NetworkStatus {
load_pct: Some(load as u8),
errors: Some(errors as u32),
device_count: (count != 0).then_some(count as u8),
uptime_s: (uptime != 0).then_some(uptime as u32),
gateway_addr: addr as u8,
rejected_tx: (rejected != 0).then_some(rejected as u32),
},
Some(timestamp),
))
}
fn parse_status_field(s: Option<&str>) -> Option<i32> {
let s = s?;
if s.is_empty() {
return None;
}
s.parse().ok()
}
pub const TX_ONLINE_ALL: &str = "$PDGY,N2NET_INIT,ALL\r\n";
pub const TX_ONLINE_NORMAL: &str = "$PDGY,N2NET_INIT,NORMAL\r\n";
pub const TX_OFFLINE: &str = "$PDGY,N2NET_OFFLINE\r\n";
pub const TX_LIMIT_OFF: &str = "$PDGY,TX_LIMIT,OFF\r\n";
fn b64_decode(s: &str) -> Option<Vec<u8>> {
let s = s.trim();
if s.is_empty() {
return Some(Vec::new());
}
let bytes = s.as_bytes();
if !bytes.len().is_multiple_of(4) {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
let mut group = [0u8; 4];
let mut pad = 0;
for chunk in bytes.chunks(4) {
for (i, b) in chunk.iter().enumerate() {
group[i] = match *b {
b'A'..=b'Z' => *b - b'A',
b'a'..=b'z' => *b - b'a' + 26,
b'0'..=b'9' => *b - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' => {
pad += 1;
0
}
_ => return None,
};
}
let combined = (u32::from(group[0]) << 18)
| (u32::from(group[1]) << 12)
| (u32::from(group[2]) << 6)
| u32::from(group[3]);
out.push(((combined >> 16) & 0xff) as u8);
out.push(((combined >> 8) & 0xff) as u8);
out.push((combined & 0xff) as u8);
}
if pad > 0 {
out.truncate(out.len() - pad);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn b64_round_trip_known_vectors() {
assert_eq!(b64_decode("").unwrap(), b"");
assert_eq!(b64_decode("Zg==").unwrap(), b"f");
assert_eq!(b64_decode("Zm8=").unwrap(), b"fo");
assert_eq!(b64_decode("Zm9v").unwrap(), b"foo");
assert_eq!(b64_decode("Zm9vYg==").unwrap(), b"foob");
assert_eq!(b64_decode("Zm9vYmE=").unwrap(), b"fooba");
assert_eq!(b64_decode("Zm9vYmFy").unwrap(), b"foobar");
}
#[test]
fn parses_pdgy_frame() {
let line = "!PDGY,127257,3,35,255,12.345,AQID";
let ev = parse_line(line).unwrap();
match ev {
IkonvertLine::Frame(f) => {
assert_eq!(f.pgn, 127257);
assert_eq!(f.prio, 3);
assert_eq!(f.src, 35);
assert_eq!(f.dst, 255);
assert_eq!(f.timestamp.as_deref(), Some("12.345"));
assert_eq!(&f.data[..], &[0x01, 0x02, 0x03]);
}
other => panic!("expected Frame, got {other:?}"),
}
}
#[test]
fn classifies_control_sentence() {
let ev = parse_line("$PDGY,,000000,,,,,").unwrap();
assert!(matches!(ev, IkonvertLine::Control(_)));
}
#[test]
fn tx_frame_format_matches_canboat() {
use crate::engine::frame::RawFrame;
let frame = RawFrame {
timestamp: None,
prio: 6,
pgn: 60928,
src: 0,
dst: 255,
data: smallvec::smallvec![0x01, 0x02, 0x03],
};
let line = encode_tx_frame(&frame);
assert_eq!(line, "!PDGY,60928,255,AQID\r\n");
}
#[test]
fn synthesizes_network_status_from_populated_heartbeat() {
let f = synthesize_network_status("000000,38,1,38,753,2,0", "ts".into()).unwrap();
assert_eq!(f.pgn, IKONVERT_BEM);
assert_eq!(f.prio, 7);
assert_eq!(f.src, 2, "src tracks the gateway address field");
assert_eq!(f.dst, 255);
assert_eq!(f.data.len(), 15);
assert_eq!(
f.data.as_slice(),
&[
38, 0x01, 0x00, 0x00, 0x00, 38, 0xf1, 0x02, 0x00, 0x00, 2, 0xff, 0xff, 0xff, 0xff, ]
);
}
#[test]
fn keep_alive_heartbeat_does_not_synthesize() {
assert!(synthesize_network_status("000000,,,,,,", "ts".into()).is_none());
assert!(synthesize_network_status("000000,,,,,,,", "ts".into()).is_none());
}
#[test]
fn non_heartbeat_body_does_not_synthesize() {
assert!(synthesize_network_status("ACK,whatever", "ts".into()).is_none());
assert!(synthesize_network_status("TEXT,banner", "ts".into()).is_none());
}
#[test]
fn build_network_status_writes_provided_fields_and_keeps_sentinel_for_none() {
let f = build_network_status(
NetworkStatus {
load_pct: Some(42),
errors: Some(0x0102_0304),
device_count: Some(7),
uptime_s: None, gateway_addr: 17,
rejected_tx: Some(0xdead_beef),
},
None,
);
assert_eq!(f.pgn, IKONVERT_BEM);
assert_eq!(f.src, 17, "src mirrors gateway_addr");
assert_eq!(f.dst, 255);
assert_eq!(f.prio, 7);
let d = &f.data[..];
assert_eq!(d.len(), 15);
assert_eq!(d[0], 42, "load_pct");
assert_eq!(&d[1..5], &0x0102_0304u32.to_le_bytes());
assert_eq!(d[5], 7, "device_count");
assert_eq!(&d[6..10], &[0xff, 0xff, 0xff, 0xff]);
assert_eq!(d[10], 17, "gateway_addr");
assert_eq!(&d[11..15], &0xdead_beefu32.to_le_bytes());
}
#[test]
fn build_network_status_all_none_is_all_sentinel_except_gateway_addr() {
let f = build_network_status(
NetworkStatus {
gateway_addr: 0,
..Default::default()
},
None,
);
for (i, b) in f.data.iter().enumerate() {
let expected = if i == 10 { 0 } else { 0xff };
assert_eq!(
*b, expected,
"byte {i}: got {b:#04x}, expected {expected:#04x}",
);
}
}
#[test]
fn b64_encode_round_trips_known_vectors() {
let mut out = String::new();
b64_encode(b"foobar", &mut out);
assert_eq!(out, "Zm9vYmFy");
out.clear();
b64_encode(b"fooba", &mut out);
assert_eq!(out, "Zm9vYmE=");
out.clear();
b64_encode(b"foob", &mut out);
assert_eq!(out, "Zm9vYg==");
out.clear();
b64_encode(b"", &mut out);
assert_eq!(out, "");
}
}