canboat 9.1.0

NMEA 2000 decoding, encoding and live-bus access: the CANboat PGN database as a library, plus the canboat CLI (convert / interface / server / tui).
Documentation
// (C) 2009-2026, Kees Verruijt, Harlingen, The Netherlands.

//! Actisense N2K ASCII line format.
//!
//! Example line:
//!
//! ```text
//!   A173321.107 23FF7 1F119 01 02 ...
//! ```
//!
//! Layout (whitespace-separated):
//!   1. `A<HHMMSS.mmm>` — timestamp (UTC time-of-day; no date).
//!   2. `<SDP>` in hex — encoded as `(src << 12) | (dst << 4) | prio`.
//!   3. `<PGN>` in hex.
//!   4. Subsequent tokens are hex data bytes.
//!
//! Mirrors `parseRawFormatActisenseN2KAscii` in canboat/common/parse.c.

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;

/// Encode `frame` as an Actisense N2K-ASCII line (no trailing newline):
/// `A<HHMMSS.mmm> <SDP:05X> <PGN:05X> <contiguous upper-case hex>`, where
/// `SDP = (src << 12) | (dst << 4) | prio`. The inverse of [`parse_line`];
/// data bytes are packed as one hex run, as canboat emits them.
///
/// Both header fields are five digits, as Actisense specifies them: a
/// W2K-2 (firmware 3.095) drops a line to send whose `SSDDP` or `PPPPP`
/// is shorter, such as an ISO Request from address 0 (`00FF6 0EA00`).
pub fn write_line<W: fmt::Write>(w: &mut W, frame: &RawFrame) -> fmt::Result {
    // Actisense's time token has no colons: HHMMSS.mmm.
    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();

    // Token 1: A + 6-digit time, optionally with .mmm fraction.
    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..])?;

    // Token 2: SDP packed hex.
    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;

    // Token 3: PGN.
    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,
    })?;

    // Remaining tokens carry payload data. Actisense packs the bytes
    // as one contiguous hex string (e.g. "3F9FDCFFFFFFFFFF") so each
    // remaining token is split into 2-char hex pairs. Multiple tokens
    // are concatenated to be permissive.
    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,
    })
}

/// Convert Actisense's `HHMMSS[.mmm]` to canboat's `HH:MM:SS,mmm`
/// style.
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,
    })?;
    // canboat formats with comma + 3-digit milliseconds.
    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() {
        // Pulled from canboat/common/parse.c documentation. Synthetic:
        //   time 17:33:21.107, src 0x23, dst 0xff, prio 0x7,
        //   pgn 0x1f119 = 127257.
        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]);
    }

    /// Actisense packs the payload as one contiguous hex run; the
    /// parser also accepts it split across tokens, so both shapes of
    /// the same message decode alike.
    #[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);
    }

    /// SDP packs src, dst and prio into one hex word.
    #[test]
    fn sdp_unpacks_into_src_dst_prio() {
        // src 0x0B, dst 0x1C, prio 6 -> (0x0B << 12) | (0x1C << 4) | 6.
        let f = parse_line("A000000.000 B1C6 1F119").unwrap();
        assert_eq!((f.src, f.dst, f.prio), (0x0b, 0x1c, 6));
    }

    /// The millisecond fraction is optional; without it the stamp
    /// reads `,000`.
    #[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"));
    }

    /// A fraction of more than three digits wraps at a thousand, as
    /// canboat's `%03u` of `ms % 1000` does.
    #[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"));
    }

    /// A time token too short to hold HHMMSS is rejected rather than
    /// sliced out of bounds.
    #[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}");
        }
    }

    /// Non-numeric HH/MM/SS digits are rejected — the time is decimal
    /// even though the rest of the line is hex.
    #[test]
    fn non_numeric_time_is_rejected() {
        assert!(matches!(
            parse_line("A1A3321.107 23FF7 1F119"),
            Err(ParseError::BadInteger {
                field: "timestamp",
                ..
            })
        ));
    }

    /// A non-numeric fraction is rejected too.
    #[test]
    fn non_numeric_fraction_is_rejected() {
        assert!(matches!(
            parse_line("A173321.abc 23FF7 1F119"),
            Err(ParseError::BadInteger {
                field: "timestamp",
                ..
            })
        ));
    }

    /// SDP and PGN are hex; a bad token names the field it came from.
    #[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", .. })
        ));
    }

    /// A line that stops before SDP or PGN is a header error.
    #[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, .. })
        ));
    }

    /// An odd-length payload token can't be split into bytes.
    #[test]
    fn odd_length_payload_token_is_rejected() {
        assert!(matches!(
            parse_line("A173321.107 23FF7 1F119 010203040"),
            Err(ParseError::BadHexByte { .. })
        ));
    }

    /// A non-hex payload digit is an error rather than a zero byte.
    #[test]
    fn non_hex_payload_is_rejected() {
        assert!(matches!(
            parse_line("A173321.107 23FF7 1F119 01ZZ"),
            Err(ParseError::BadHexByte { .. })
        ));
    }

    /// A header-only line is a valid zero-length frame.
    #[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);
    }

    /// Blank lines are `Empty`, which callers skip.
    #[test]
    fn empty_line_is_empty() {
        assert!(matches!(parse_line(""), Err(ParseError::Empty)));
        assert!(matches!(parse_line("\r\n"), Err(ParseError::Empty)));
    }

    /// A fast-packet-sized payload survives the writer and comes back
    /// byte-identical, so long frames aren't silently clipped.
    #[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]);
        // Timestamp survives as the same instant (parser stores comma form).
        assert_eq!(back.timestamp.as_deref(), Some("17:33:21,107"));
    }

    /// What a W2K-2 takes to send: an ISO Request from address 0, five
    /// digits each for the header fields.
    #[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");
    }
}