Skip to main content

moteus_protocol/
fdcanusb.rs

1// Copyright 2026 mjbots Robotic Systems, LLC.  info@mjbots.com
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Allocation-free codec for the fdcanusb text line protocol.
16//!
17//! The fdcanusb USB-to-CAN-FD adapter uses a simple line-oriented text
18//! protocol:
19//!
20//! - Send: `can send <id-hex> <data-hex> <flags>\n`
21//! - Receive: `rcv <id-hex> <data-hex> <flags>`
22//! - `OK` acknowledgment after each send, `ERR ...` on failure
23//!
24//! moteus controllers can also speak this protocol directly over a TTL
25//! UART (see `docs/integration/uart.md`), optionally protected by a
26//! CRC-8 checksum appended to each line as ` *XX`.
27//!
28//! This module contains the stateless parts of that protocol — line
29//! encoding, parsing, and checksum computation — in a `no_std`,
30//! allocation-free form suitable for embedded hosts.  Buffers are
31//! provided by the caller; [`MAX_LINE_LENGTH`] bounds the worst-case
32//! line size.  The stateful pieces (timeouts, retries, and the policy
33//! for when to enable checksums) live in the `moteus` crate's
34//! transports.
35
36use crate::frame::CanFdFrame;
37
38/// The maximum length of an encoded or received protocol line.
39///
40/// The worst case is a `can send` line carrying a 64-byte CAN-FD
41/// payload addressed with a 29-bit extended arbitration ID (which is
42/// used whenever a CAN prefix is configured):
43///
44/// `can send ` (9) + 8 id digits + space + 128 data digits + space +
45/// `EBF` flags (3) + ` *XX` checksum (4) + newline = 155 bytes.
46pub const MAX_LINE_LENGTH: usize = 160;
47
48/// CRC-8 lookup table for polynomial 0x97, indexed by nybble.
49const CRC8_TABLE: [u8; 16] = [
50    0x00, 0x97, 0xb9, 0x2e, 0xe5, 0x72, 0x5c, 0xcb, //
51    0x5d, 0xca, 0xe4, 0x73, 0xb8, 0x2f, 0x01, 0x96,
52];
53
54/// Computes the CRC-8 of `data` using polynomial 0x97, nybble-at-a-time.
55///
56/// This is the checksum used by the fdcanusb text protocol when
57/// communicating with a moteus controller over UART.
58pub const fn compute_crc8(data: &[u8]) -> u8 {
59    let mut crc = 0u8;
60    let mut i = 0;
61    while i < data.len() {
62        let b = data[i];
63        crc = CRC8_TABLE[((crc >> 4) ^ (b >> 4)) as usize & 0x0f] ^ (crc << 4);
64        crc = CRC8_TABLE[((crc >> 4) ^ (b & 0x0f)) as usize & 0x0f] ^ (crc << 4);
65        i += 1;
66    }
67    crc
68}
69
70/// Options controlling [`encode_can_send`].
71#[derive(Debug, Clone, Copy, Default)]
72pub struct EncodeOptions {
73    /// Force bit rate switching off even if the frame requests it.
74    pub disable_brs: bool,
75    /// Append a ` *XX` CRC-8 checksum to the line.
76    pub checksum: bool,
77}
78
79/// Errors from [`encode_can_send`].
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum EncodeError {
82    /// The provided buffer was too small.  A buffer of
83    /// [`MAX_LINE_LENGTH`] bytes is always sufficient.
84    BufferTooSmall,
85}
86
87/// One parsed line of the fdcanusb protocol, as classified by
88/// [`parse_line`].
89#[derive(Debug, Clone, PartialEq)]
90pub enum Line<'a> {
91    /// An `OK` acknowledgment.
92    Ok,
93    /// A received CAN frame (`rcv ...`).
94    Rcv(CanFdFrame),
95    /// An error report from the device (`ERR ...`).
96    Err(&'a [u8]),
97    /// Anything else, including empty and malformed `rcv` lines.
98    Other(&'a [u8]),
99}
100
101struct Writer<'a> {
102    buf: &'a mut [u8],
103    pos: usize,
104}
105
106impl Writer<'_> {
107    fn write(&mut self, data: &[u8]) -> Result<(), EncodeError> {
108        if self.pos + data.len() > self.buf.len() {
109            return Err(EncodeError::BufferTooSmall);
110        }
111        self.buf[self.pos..self.pos + data.len()].copy_from_slice(data);
112        self.pos += data.len();
113        Ok(())
114    }
115
116    /// Writes a byte as two uppercase hex digits.
117    fn write_hex_byte(&mut self, value: u8) -> Result<(), EncodeError> {
118        const HEX: &[u8; 16] = b"0123456789ABCDEF";
119        self.write(&[HEX[(value >> 4) as usize], HEX[(value & 0x0f) as usize]])
120    }
121
122    /// Writes an arbitration ID as lowercase hex with a minimum width
123    /// of 4 digits.  29-bit extended IDs (used when a CAN prefix is
124    /// configured) produce up to 8 digits.
125    fn write_hex_id(&mut self, value: u32) -> Result<(), EncodeError> {
126        const HEX: &[u8; 16] = b"0123456789abcdef";
127        let digits = {
128            let mut n = 4;
129            while n < 8 && (value >> (4 * n)) != 0 {
130                n += 1;
131            }
132            n
133        };
134        for i in (0..digits).rev() {
135            self.write(&[HEX[((value >> (4 * i)) & 0x0f) as usize]])?;
136        }
137        Ok(())
138    }
139}
140
141/// Encodes a CAN-FD frame as a `can send` line into `buf`, returning
142/// the encoded length.
143///
144/// The payload is padded to a valid CAN-FD DLC size with 0x50 (NOP)
145/// bytes.  A buffer of [`MAX_LINE_LENGTH`] bytes is always large
146/// enough.
147pub fn encode_can_send(
148    frame: &CanFdFrame,
149    options: &EncodeOptions,
150    buf: &mut [u8],
151) -> Result<usize, EncodeError> {
152    let mut w = Writer { buf, pos: 0 };
153
154    w.write(b"can send ")?;
155    w.write_hex_id(frame.arbitration_id)?;
156    w.write(b" ")?;
157
158    for b in frame.payload() {
159        w.write_hex_byte(*b)?;
160    }
161    // Pad to a valid CAN-FD DLC size.
162    let on_wire_size = CanFdFrame::round_up_dlc(frame.size as usize);
163    for _ in frame.size as usize..on_wire_size {
164        w.write(b"50")?;
165    }
166
167    w.write(b" ")?;
168    if frame.brs_enabled() && !options.disable_brs {
169        w.write(b"B")?;
170    } else {
171        w.write(b"b")?;
172    }
173    if frame.fdcan_enabled() {
174        w.write(b"F")?;
175    }
176
177    if options.checksum {
178        // The CRC covers the line content including the space that
179        // separates it from the '*'.
180        w.write(b" ")?;
181        let crc = compute_crc8(&w.buf[..w.pos]);
182        w.write(b"*")?;
183        w.write_hex_byte(crc)?;
184    }
185
186    w.write(b"\n")?;
187
188    Ok(w.pos)
189}
190
191/// Strips and validates a trailing ` *XX` checksum from a line.
192///
193/// Returns `(content, had_checksum, checksum_valid)`:
194/// - `content`: the line with the checksum and any trailing spaces
195///   removed (unchanged if no checksum was found)
196/// - `had_checksum`: a `*XX` pattern was found at the end of the line
197/// - `checksum_valid`: the checksum matched (only meaningful when
198///   `had_checksum`)
199///
200/// The CRC is computed over everything before the `*`, including any
201/// trailing space.
202pub fn strip_checksum(line: &[u8]) -> (&[u8], bool, bool) {
203    // The checksum must be exactly '*' followed by two hex digits at
204    // the very end of the line.
205    if line.len() < 3 || line[line.len() - 3] != b'*' {
206        return (line, false, false);
207    }
208
209    let star_pos = line.len() - 3;
210    let claimed = {
211        let hi = parse_hex_digit(line[star_pos + 1]);
212        let lo = parse_hex_digit(line[star_pos + 2]);
213        match (hi, lo) {
214            (Some(hi), Some(lo)) => (hi << 4) | lo,
215            _ => return (line, false, false),
216        }
217    };
218
219    let computed = compute_crc8(&line[..star_pos]);
220
221    let mut content = &line[..star_pos];
222    while let [head @ .., b' '] = content {
223        content = head;
224    }
225
226    (content, true, claimed == computed)
227}
228
229/// Classifies one checksum-stripped protocol line.
230///
231/// Callers are responsible for line assembly (splitting the input on
232/// `\r`/`\n`) and for checksum handling via [`strip_checksum`].
233pub fn parse_line(line: &[u8]) -> Line<'_> {
234    let line = trim(line);
235
236    if line.starts_with(b"OK") {
237        return Line::Ok;
238    }
239    if line.starts_with(b"rcv") {
240        if let Some(frame) = parse_rcv(line) {
241            return Line::Rcv(frame);
242        }
243        return Line::Other(line);
244    }
245    if line.starts_with(b"ERR") {
246        return Line::Err(line);
247    }
248    Line::Other(line)
249}
250
251/// Parses a received frame line: `rcv <id-hex> <data-hex> [E] [B] [F]`.
252///
253/// When the device responds with no data, the data field is empty
254/// (`rcv 0100  e B F`), so fields are split on *single* spaces and
255/// empty fields are preserved.
256///
257/// Returns `None` if the line is not a well-formed `rcv` line.
258pub fn parse_rcv(line: &[u8]) -> Option<CanFdFrame> {
259    let line = trim(line);
260    let mut fields = line.split(|&b| b == b' ');
261
262    if fields.next() != Some(b"rcv".as_slice()) {
263        return None;
264    }
265
266    let arbitration_id = parse_hex_u32(fields.next()?)?;
267
268    let mut frame = CanFdFrame::new();
269    frame.arbitration_id = arbitration_id;
270
271    let data = fields.next()?;
272    if data.len() % 2 != 0 || data.len() > 2 * frame.data.len() {
273        return None;
274    }
275    for (i, pair) in data.chunks_exact(2).enumerate() {
276        frame.data[i] = ((parse_hex_digit(pair[0])? as u8) << 4) | parse_hex_digit(pair[1])? as u8;
277    }
278    frame.size = (data.len() / 2) as u8;
279
280    for flag in fields {
281        match flag {
282            b"E" => frame.arbitration_id |= 0x80000000, // Extended ID marker
283            b"B" => frame.set_brs(true),
284            b"F" => frame.set_fdcan(true),
285            _ => {}
286        }
287    }
288
289    Some(frame)
290}
291
292/// Returns true if an `ERR` line indicates that the device requires
293/// checksums (case-insensitive search for "checksum").
294pub fn is_checksum_error(line: &[u8]) -> bool {
295    const NEEDLE: &[u8] = b"checksum";
296    line.windows(NEEDLE.len())
297        .any(|w| w.eq_ignore_ascii_case(NEEDLE))
298}
299
300fn trim(mut line: &[u8]) -> &[u8] {
301    while let [b, rest @ ..] = line {
302        if b.is_ascii_whitespace() {
303            line = rest;
304        } else {
305            break;
306        }
307    }
308    while let [rest @ .., b] = line {
309        if b.is_ascii_whitespace() {
310            line = rest;
311        } else {
312            break;
313        }
314    }
315    line
316}
317
318fn parse_hex_digit(c: u8) -> Option<u8> {
319    match c {
320        b'0'..=b'9' => Some(c - b'0'),
321        b'a'..=b'f' => Some(c - b'a' + 10),
322        b'A'..=b'F' => Some(c - b'A' + 10),
323        _ => None,
324    }
325}
326
327fn parse_hex_u32(digits: &[u8]) -> Option<u32> {
328    if digits.is_empty() || digits.len() > 8 {
329        return None;
330    }
331    let mut value = 0u32;
332    for &c in digits {
333        value = (value << 4) | parse_hex_digit(c)? as u32;
334    }
335    Some(value)
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    // Test vectors verified against the python crcmod package; they
343    // match lib/cpp/mjbots/moteus/test/moteus_test.cc and
344    // lib/python/moteus/test/fdcanusb_test.py.
345    #[test]
346    fn test_compute_crc8() {
347        assert_eq!(compute_crc8(b""), 0x00);
348        assert_eq!(compute_crc8(b"OK "), 0xBD);
349        assert_eq!(compute_crc8(b"can send 0001 20 "), 0xAE);
350        assert_eq!(compute_crc8(b"rcv 0502 1234 "), 0x3C);
351        assert_eq!(compute_crc8(b"rcv 00000100 230b0a00 "), 0xD2);
352
353        // Different data produces different CRCs.
354        assert_eq!(compute_crc8(b"rcv 00000100 00 "), 0x3E);
355        assert_eq!(compute_crc8(b"rcv 00000100 01 "), 0xED);
356        assert_eq!(compute_crc8(b"rcv 00000100 ff "), 0x57);
357    }
358
359    fn make_frame(id: u32, data: &[u8]) -> CanFdFrame {
360        let mut frame = CanFdFrame::new();
361        frame.arbitration_id = id;
362        frame.data[..data.len()].copy_from_slice(data);
363        frame.size = data.len() as u8;
364        frame
365    }
366
367    fn encode<'a>(frame: &CanFdFrame, options: &EncodeOptions, buf: &'a mut [u8]) -> &'a [u8] {
368        let len = encode_can_send(frame, options, buf).unwrap();
369        &buf[..len]
370    }
371
372    #[test]
373    fn test_encode_basic() {
374        let frame = make_frame(0x8001, &[0x01, 0x00, 0x0A]);
375        let mut buf = [0u8; MAX_LINE_LENGTH];
376        assert_eq!(
377            encode(&frame, &EncodeOptions::default(), &mut buf),
378            b"can send 8001 01000A BF\n"
379        );
380    }
381
382    #[test]
383    fn test_encode_dlc_padding() {
384        // Sizes above 8 bytes round up to the next valid CAN-FD DLC,
385        // padded with 0x50 (NOP) bytes.
386        let frame = make_frame(0x0001, &[0x11; 9]);
387        let mut buf = [0u8; MAX_LINE_LENGTH];
388        assert_eq!(
389            encode(&frame, &EncodeOptions::default(), &mut buf),
390            b"can send 0001 111111111111111111505050 BF\n"
391        );
392    }
393
394    #[test]
395    fn test_encode_disable_brs() {
396        let frame = make_frame(0x8001, &[0x01]);
397        let mut buf = [0u8; MAX_LINE_LENGTH];
398        assert_eq!(
399            encode(
400                &frame,
401                &EncodeOptions {
402                    disable_brs: true,
403                    ..Default::default()
404                },
405                &mut buf
406            ),
407            b"can send 8001 01 bF\n"
408        );
409    }
410
411    #[test]
412    fn test_encode_checksum() {
413        let frame = make_frame(0x0001, &[0x20]);
414        let mut buf = [0u8; MAX_LINE_LENGTH];
415        let line = encode(
416            &frame,
417            &EncodeOptions {
418                disable_brs: true,
419                checksum: true,
420            },
421            &mut buf,
422        );
423        // The line ends with " *XX\n" where the CRC covers everything
424        // before the '*', including the trailing space.
425        assert!(line.starts_with(b"can send 0001 20 bF *"));
426        assert!(line.ends_with(b"\n"));
427
428        // The receive side validates and strips the checksum.
429        let (content, had, valid) = strip_checksum(&line[..line.len() - 1]);
430        assert_eq!(content, b"can send 0001 20 bF");
431        assert!(had);
432        assert!(valid);
433    }
434
435    #[test]
436    fn test_encode_extended_id() {
437        // With a CAN prefix configured, the arbitration ID exceeds 16
438        // bits and must be emitted with more than 4 hex digits.
439        let frame = make_frame(0x12345, &[0xFF]);
440        let mut buf = [0u8; MAX_LINE_LENGTH];
441        assert_eq!(
442            encode(&frame, &EncodeOptions::default(), &mut buf),
443            b"can send 12345 FF BF\n"
444        );
445
446        let frame = make_frame(0x1234_5678, &[]);
447        let mut buf = [0u8; MAX_LINE_LENGTH];
448        assert_eq!(
449            encode(&frame, &EncodeOptions::default(), &mut buf),
450            b"can send 12345678  BF\n"
451        );
452    }
453
454    #[test]
455    fn test_encode_buffer_too_small() {
456        let frame = make_frame(0x8001, &[0x01]);
457        let mut buf = [0u8; 8];
458        assert_eq!(
459            encode_can_send(&frame, &EncodeOptions::default(), &mut buf),
460            Err(EncodeError::BufferTooSmall)
461        );
462    }
463
464    #[test]
465    fn test_max_line_length_is_sufficient() {
466        let frame = make_frame(0x1FFF_FFFF, &[0xAB; 64]);
467        let mut buf = [0u8; MAX_LINE_LENGTH];
468        let len = encode_can_send(
469            &frame,
470            &EncodeOptions {
471                disable_brs: false,
472                checksum: true,
473            },
474            &mut buf,
475        )
476        .unwrap();
477        assert!(len <= MAX_LINE_LENGTH);
478    }
479
480    #[test]
481    fn test_strip_checksum_valid() {
482        let (content, had, valid) = strip_checksum(b"OK *BD");
483        assert_eq!(content, b"OK");
484        assert!(had);
485        assert!(valid);
486    }
487
488    #[test]
489    fn test_strip_checksum_invalid() {
490        let (content, had, valid) = strip_checksum(b"OK *FF");
491        assert_eq!(content, b"OK");
492        assert!(had);
493        assert!(!valid);
494    }
495
496    #[test]
497    fn test_strip_checksum_absent() {
498        let (content, had, valid) = strip_checksum(b"OK");
499        assert_eq!(content, b"OK");
500        assert!(!had);
501        assert!(!valid);
502
503        // '*' not in the right position is not a checksum.
504        let (content, had, _) = strip_checksum(b"rcv 0001 2a *1");
505        assert_eq!(content, b"rcv 0001 2a *1");
506        assert!(!had);
507
508        // Non-hex digits after '*' are not a checksum.
509        let (content, had, _) = strip_checksum(b"some *zz");
510        assert_eq!(content, b"some *zz");
511        assert!(!had);
512    }
513
514    #[test]
515    fn test_parse_line() {
516        assert_eq!(parse_line(b"OK"), Line::Ok);
517        assert_eq!(parse_line(b"OK\r\n"), Line::Ok);
518        assert!(matches!(parse_line(b"rcv 0502 1234"), Line::Rcv(_)));
519        assert!(matches!(parse_line(b"ERR unknown command"), Line::Err(_)));
520        assert!(matches!(parse_line(b""), Line::Other(_)));
521        // Malformed rcv lines are Other, not Err.
522        assert!(matches!(parse_line(b"rcv zz"), Line::Other(_)));
523    }
524
525    #[test]
526    fn test_parse_rcv() {
527        let frame = parse_rcv(b"rcv 8001 01000A B F").unwrap();
528        assert_eq!(frame.arbitration_id, 0x8001);
529        assert_eq!(frame.payload(), &[0x01, 0x00, 0x0A]);
530        assert!(frame.brs_enabled());
531        assert!(frame.fdcan_enabled());
532    }
533
534    #[test]
535    fn test_parse_rcv_extended_id() {
536        let frame = parse_rcv(b"rcv 00010001 20").unwrap();
537        assert_eq!(frame.arbitration_id, 0x00010001);
538
539        let frame = parse_rcv(b"rcv 0100 20 E").unwrap();
540        assert_eq!(frame.arbitration_id, 0x80000100);
541    }
542
543    #[test]
544    fn test_parse_rcv_empty_data() {
545        // An empty data field is preserved by single-space splitting.
546        let frame = parse_rcv(b"rcv 0100  e B F").unwrap();
547        assert_eq!(frame.arbitration_id, 0x0100);
548        assert_eq!(frame.size, 0);
549    }
550
551    #[test]
552    fn test_parse_rcv_malformed() {
553        assert!(parse_rcv(b"rcv").is_none());
554        assert!(parse_rcv(b"rcv 0100").is_none());
555        assert!(parse_rcv(b"rcv zz 00").is_none());
556        assert!(parse_rcv(b"rcv 0100 0").is_none()); // Odd hex length
557        assert!(parse_rcv(b"OK").is_none());
558    }
559
560    #[test]
561    fn test_is_checksum_error() {
562        assert!(is_checksum_error(b"ERR checksum required"));
563        assert!(is_checksum_error(b"ERR invalid Checksum"));
564        assert!(!is_checksum_error(b"ERR unknown command"));
565    }
566
567    #[test]
568    fn test_encode_parse_round_trip() {
569        let frame = make_frame(0x0542, &[0x12, 0x34]);
570        let mut buf = [0u8; MAX_LINE_LENGTH];
571        let len = encode_can_send(&frame, &EncodeOptions::default(), &mut buf).unwrap();
572
573        // Re-interpret the encoded line as if it were received:
574        // replace the "can send " prefix with "rcv " and drop the
575        // trailing newline.
576        let content = &buf[b"can send ".len()..len - 1];
577        let mut line = [0u8; MAX_LINE_LENGTH];
578        line[..4].copy_from_slice(b"rcv ");
579        line[4..4 + content.len()].copy_from_slice(content);
580
581        let parsed = parse_rcv(&line[..4 + content.len()]).unwrap();
582        assert_eq!(parsed.arbitration_id, 0x0542);
583        assert_eq!(parsed.payload(), &[0x12, 0x34]);
584        assert!(parsed.brs_enabled());
585        assert!(parsed.fdcan_enabled());
586    }
587}