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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
// (C) 2009-2026, Kees Verruijt, Harlingen, The Netherlands.

//! Canboat PLAIN / FAST line format.
//!
//! ```text
//! <timestamp>,<prio>,<pgn>,<src>,<dst>,<len>,<hex0>,<hex1>,...,<hexN>
//! ```
//!
//! `len` is the payload byte count (`0..=223`). When `len <= 8` the
//! line is conventionally called PLAIN (a single CAN frame); otherwise
//! it is FAST (a pre-coalesced fast-packet payload). Both share the
//! same syntax — this module parses and writes both.

use std::fmt;

use smallvec::SmallVec;

use crate::engine::frame::{RAWFRAME_MAX_SIZE, RawFrame};

/// Byte → hex nibble lookup. `0xff` sentinel for non-hex bytes.
/// Used by the PLAIN/FAST payload decoder to skip the cost of
/// `u8::from_str_radix` + tokenisation per byte.
const HEX_NIBBLE: [u8; 256] = {
    let mut t = [0xffu8; 256];
    let mut i = 0u8;
    while i < 10 {
        t[(b'0' + i) as usize] = i;
        i += 1;
    }
    let mut i = 0u8;
    while i < 6 {
        t[(b'a' + i) as usize] = 10 + i;
        t[(b'A' + i) as usize] = 10 + i;
        i += 1;
    }
    t
};

#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParseError {
    #[error("line is empty")]
    Empty,
    #[error("missing timestamp separator")]
    MissingTimestamp,
    #[error("expected {expected} comma-separated header fields, found {found}")]
    #[non_exhaustive]
    BadHeader { expected: usize, found: usize },
    #[error("malformed integer in field {field}: {value:?}")]
    #[non_exhaustive]
    BadInteger {
        field: &'static str,
        value: String,
        /// Byte offset within the original line where the field
        /// started. `None` when the producing parser doesn't track
        /// offsets (the non-PLAIN format parsers — airmar, seasmart,
        /// ydwg02, etc. — use `str::Split` and don't bother).
        offset: Option<usize>,
    },
    #[error("declared length {len} exceeds max {max}", max = RAWFRAME_MAX_SIZE)]
    #[non_exhaustive]
    LengthTooLarge { len: usize },
    #[error("expected {expected} hex bytes, found {found}")]
    #[non_exhaustive]
    BadPayloadCount { expected: usize, found: usize },
    #[error("malformed hex byte {value:?} at index {index}")]
    #[non_exhaustive]
    BadHexByte {
        index: usize,
        value: String,
        offset: Option<usize>,
    },
    #[error("line ends after the length field; no payload section")]
    MissingPayload,
}

impl ParseError {
    /// Byte offset within the line where the parse failed, when the
    /// producing parser tracks it. Callers can use this to render a
    /// caret-style pointer beneath the offending line.
    pub fn byte_offset(&self) -> Option<usize> {
        match self {
            ParseError::BadInteger { offset, .. } | ParseError::BadHexByte { offset, .. } => {
                *offset
            }
            _ => None,
        }
    }
}

/// Parse one PLAIN/FAST line.
///
/// The input may have a trailing newline; it is ignored. The timestamp
/// is preserved verbatim (whatever appears before the first comma).
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);
    }

    // Split off the timestamp (everything up to the first comma).
    let (ts, rest) = line.split_once(',').ok_or(ParseError::MissingTimestamp)?;
    let timestamp = if ts.is_empty() {
        None
    } else {
        Some(ts.to_string())
    };

    // Byte offset of `rest` within the original `line` — used to
    // translate `cursor` (which is relative to `rest`) into a
    // line-relative offset on errors.
    let rest_base = ts.len() + 1;

    // Header: prio, pgn, src, dst, len. Parse the five tokens by
    // walking byte-by-byte rather than using `str::Split` so we can
    // recover the byte offset of the payload start without paying
    // for an extra slice/iterator chain.
    let rest_bytes = rest.as_bytes();
    let mut cursor = 0usize;
    let prio = take_uint_until_comma::<u8>(rest_bytes, &mut cursor, rest_base, "prio")?;
    let pgn = take_uint_until_comma::<u32>(rest_bytes, &mut cursor, rest_base, "pgn")?;
    let src = take_uint_until_comma::<u8>(rest_bytes, &mut cursor, rest_base, "src")?;
    let dst = take_uint_until_comma::<u8>(rest_bytes, &mut cursor, rest_base, "dst")?;
    let len = take_uint_until_comma::<usize>(rest_bytes, &mut cursor, rest_base, "len")?;

    if len > RAWFRAME_MAX_SIZE {
        return Err(ParseError::LengthTooLarge { len });
    }

    // canboat requires a sixth comma — the one terminating `len` and
    // opening the payload — whatever `len` itself says
    // (`findOccurrence(p, ',', 6)` in `parseRawFormatFast`,
    // common/parse.c). Captures contain lines that stop right after the
    // length, `2020-08-22T13:52:36.981Z,0,16712965,255,0,0`, and the C
    // rejects them outright with "cannot find sixth comma". Accepting
    // them produced a zero-byte frame that reached the decoder and came
    // out as a fields-less record for a PGN that does not exist.
    if cursor == 0 || rest_bytes[cursor - 1] != b',' {
        return Err(ParseError::MissingPayload);
    }

    // Payload: exactly `len` hex bytes, comma-separated. Fast path:
    // walk the remaining bytes ourselves, decoding two hex nibbles
    // per byte via a precomputed table. Beats
    // `from_str_radix(tok.trim(), 16)` per byte by roughly 3× —
    // canboat samples spend the largest chunk of their parse time
    // here.
    let payload_start = cursor;
    let payload_bytes = &rest_bytes[cursor..];
    let mut data: SmallVec<[u8; 8]> = SmallVec::with_capacity(len);
    let mut i = 0usize;
    let mut decoded = 0usize;
    while decoded < len && i < payload_bytes.len() {
        // Tolerate leading whitespace inside a token (canboat samples
        // occasionally use ` 04` style).
        while i < payload_bytes.len() && payload_bytes[i] == b' ' {
            i += 1;
        }
        if i + 2 > payload_bytes.len() {
            return Err(ParseError::BadHexByte {
                index: decoded,
                value: String::from_utf8_lossy(&payload_bytes[i..]).into_owned(),
                offset: Some(rest_base + payload_start + i),
            });
        }
        let hi = HEX_NIBBLE[payload_bytes[i] as usize];
        let lo = HEX_NIBBLE[payload_bytes[i + 1] as usize];
        if hi == 0xff || lo == 0xff {
            return Err(ParseError::BadHexByte {
                index: decoded,
                value: String::from_utf8_lossy(&payload_bytes[i..i + 2]).into_owned(),
                offset: Some(rest_base + payload_start + i),
            });
        }
        data.push((hi << 4) | lo);
        decoded += 1;
        i += 2;
        // Skip the separator. Trailing whitespace allowed for canboat
        // samples like `... 03 ,04`.
        while i < payload_bytes.len() && payload_bytes[i] == b' ' {
            i += 1;
        }
        if i < payload_bytes.len() && payload_bytes[i] == b',' {
            i += 1;
        }
    }

    if data.len() != len {
        return Err(ParseError::BadPayloadCount {
            expected: len,
            found: data.len(),
        });
    }

    Ok(RawFrame {
        timestamp,
        prio,
        pgn,
        src,
        dst,
        data,
    })
}

/// Read a decimal unsigned integer up to the next comma in `bytes`,
/// advancing `cursor` past the integer and the comma. Skips a
/// leading space, matching the canboat sample style `01, 02`.
///
/// `base` is the offset of `bytes[0]` within the original line, so
/// any returned `BadInteger` carries a *line-relative* byte offset
/// for caret-style error rendering.
fn take_uint_until_comma<T>(
    bytes: &[u8],
    cursor: &mut usize,
    base: usize,
    field: &'static str,
) -> Result<T, ParseError>
where
    T: TryFrom<u64>,
{
    while *cursor < bytes.len() && bytes[*cursor] == b' ' {
        *cursor += 1;
    }
    let start = *cursor;
    let mut acc: u64 = 0;
    let mut saw_digit = false;
    while *cursor < bytes.len() {
        let b = bytes[*cursor];
        if b.is_ascii_digit() {
            acc = acc * 10 + (b - b'0') as u64;
            saw_digit = true;
            *cursor += 1;
            continue;
        }
        break;
    }
    if !saw_digit {
        return Err(ParseError::BadInteger {
            field,
            value: String::from_utf8_lossy(&bytes[start..*cursor]).into_owned(),
            offset: Some(base + start),
        });
    }
    // Skip trailing whitespace then the comma.
    while *cursor < bytes.len() && bytes[*cursor] == b' ' {
        *cursor += 1;
    }
    if *cursor < bytes.len() && bytes[*cursor] == b',' {
        *cursor += 1;
    }
    T::try_from(acc).map_err(|_| ParseError::BadInteger {
        field,
        value: acc.to_string(),
        offset: Some(base + start),
    })
}

/// Write one PLAIN/FAST line to `w`. The timestamp is canonicalised via
/// [`normalize_timestamp`](super::normalize_timestamp) (empty if `None`)
/// so a comma-millis value never corrupts the comma-delimited line. Hex
/// bytes are lowercase, two digits each.
///
/// The output never includes a trailing newline; the caller decides.
pub fn write_line<W: fmt::Write>(w: &mut W, frame: &RawFrame) -> fmt::Result {
    if let Some(ts) = &frame.timestamp {
        w.write_str(&super::normalize_timestamp(ts))?;
    }
    write!(
        w,
        ",{prio},{pgn},{src},{dst},{len}",
        prio = frame.prio,
        pgn = frame.pgn,
        src = frame.src,
        dst = frame.dst,
        len = frame.data.len(),
    )?;
    for b in &frame.data {
        write!(w, ",{:02x}", b)?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_plain_8byte_frame() {
        let line = "2011-04-25-06:25:03.603,3,129029,36,255,8,e6,f1,3a,80,9c,c6,0d,b3";
        let f = parse_line(line).unwrap();
        assert_eq!(f.timestamp.as_deref(), Some("2011-04-25-06:25:03.603"));
        assert_eq!(f.prio, 3);
        assert_eq!(f.pgn, 129029);
        assert_eq!(f.src, 36);
        assert_eq!(f.dst, 255);
        assert_eq!(
            &f.data[..],
            &[0xe6, 0xf1, 0x3a, 0x80, 0x9c, 0xc6, 0x0d, 0xb3]
        );
    }

    #[test]
    fn parses_fast_43byte_payload() {
        let mut line = String::from("2011-04-25-06:25:03.603,3,129029,36,255,43");
        for i in 0..43 {
            line.push_str(&format!(",{:02x}", i as u8));
        }
        let f = parse_line(&line).unwrap();
        assert_eq!(f.data.len(), 43);
        assert_eq!(f.data[0], 0);
        assert_eq!(f.data[42], 42);
    }

    #[test]
    fn writes_round_trip() {
        // A canonical timestamp round-trips unchanged.
        let frame = RawFrame::new(
            Some("2011-04-25T06:25:03.603Z".into()),
            3,
            129029,
            36,
            255,
            vec![0xe6, 0xf1, 0x3a, 0x80, 0x9c, 0xc6, 0x0d, 0xb3],
        );
        let mut out = String::new();
        write_line(&mut out, &frame).unwrap();
        let again = parse_line(&out).unwrap();
        assert_eq!(again, frame);
    }

    #[test]
    fn write_canonicalises_classic_timestamp() {
        // The classic canboat log form (dash before the time, no `Z`) is
        // rewritten to ISO-8601 UTC on emit.
        let frame = RawFrame::new(
            Some("2011-04-25-06:25:03.603".into()),
            3,
            129029,
            36,
            255,
            vec![0xe6, 0xf1, 0x3a, 0x80, 0x9c, 0xc6, 0x0d, 0xb3],
        );
        let mut out = String::new();
        write_line(&mut out, &frame).unwrap();
        assert!(out.starts_with("2011-04-25T06:25:03.603Z,"), "got {out}");
    }

    #[test]
    fn rejects_empty_line() {
        assert!(matches!(parse_line(""), Err(ParseError::Empty)));
    }

    #[test]
    fn rejects_oversized_len() {
        // RAWFRAME_MAX_SIZE is 255 * 7 = 1785 (ISO-TP maximum).
        let line = "ts,3,129029,36,255,1786";
        assert!(matches!(
            parse_line(line),
            Err(ParseError::LengthTooLarge { .. })
        ));
        // 250 declared bytes is in range since ISO-TP support. The line
        // stops after the length, so it is rejected for having no
        // payload section at all — the C's "cannot find sixth comma" —
        // before the byte count is ever counted.
        let line = "ts,3,129029,36,255,250";
        assert!(matches!(parse_line(line), Err(ParseError::MissingPayload)));
    }

    #[test]
    fn rejects_a_line_that_stops_after_the_length() {
        // Real capture lines (samples/susteranna2020.raw and friends)
        // that carry no data at all. canboat rejects them outright;
        // accepting them yielded a zero-byte frame that decoded into a
        // fields-less record for a PGN that does not exist.
        let line = "2020-08-22T13:52:36.981Z,0,16712965,255,0,0";
        assert!(matches!(parse_line(line), Err(ParseError::MissingPayload)));
        // A declared-zero-length line *with* the payload comma is still
        // accepted, matching the C's parser.
        let ok = parse_line("2020-08-22T13:52:36.981Z,0,127250,255,0,0,").expect("parses");
        assert!(ok.data.is_empty());
    }

    #[test]
    fn rejects_short_payload() {
        // Declared 8 bytes, only 3 supplied.
        let line = "ts,3,129029,36,255,8,01,02,03";
        assert!(matches!(
            parse_line(line),
            Err(ParseError::BadPayloadCount {
                expected: 8,
                found: 3
            })
        ));
    }

    #[test]
    fn empty_timestamp_field() {
        let line = ",3,129029,36,255,1,ff";
        let f = parse_line(line).unwrap();
        assert!(f.timestamp.is_none());
        assert_eq!(&f.data[..], &[0xff]);
    }
}