Skip to main content

ados_protocol/
msp.rs

1//! MSP (MultiWii Serial Protocol) v1 + v2 codec.
2//!
3//! The agent's MAVLink router already exposes a raw MSP byte pipe on
4//! `/run/ados/msp.sock` (it forwards bytes to and from a Betaflight / iNav /
5//! KISS flight controller's serial link), but nothing in the tree could build
6//! or parse an MSP frame. This module is that codec: the wire framing for both
7//! MSP versions plus the one command a companion needs to fly a rate-control FC,
8//! `MSP_SET_RAW_RC` (200), with the stick scaling the GCS already standardized.
9//!
10//! Two frame formats, both little-endian:
11//!
12//! * **MSPv1** `$M<`: `'$' 'M' <dir> <size:u8> <cmd:u8> <payload...> <crc:u8>`
13//!   where `crc` is the XOR of `size`, `cmd`, and every payload byte. `size` is
14//!   the payload length, so a v1 command id and a single payload byte cannot
15//!   exceed 255. `<dir>` is `<` for a request to the FC, `>` for a reply, `!`
16//!   for an error reply.
17//! * **MSPv2** `$X<`: `'$' 'X' <dir> <flag:u8> <cmd:u16 LE> <size:u16 LE>
18//!   <payload...> <crc:u8>` where `crc` is CRC8/DVB-S2 (poly `0xD5`) accumulated
19//!   over `flag`, the two command bytes, the two size bytes, and every payload
20//!   byte. v2 lifts the 8-bit command/size ceiling (needed for the full command
21//!   space and larger settings payloads).
22//!
23//! The codec is transport-agnostic: it produces and consumes byte buffers, and
24//! the plugin host / MSP client move those over the socket. It never opens the
25//! socket or the FC itself.
26
27/// `MSP_SET_RAW_RC` — write raw RC channel values (PWM microseconds) to the FC.
28/// Payload is N little-endian `u16` channels. This is the command a companion
29/// uses to fly an FC in a rate mode (ACRO), the FC treating the values as if
30/// they came from a receiver.
31pub const MSP_SET_RAW_RC: u16 = 200;
32
33/// The v1 direction/marker byte for a request to the FC (`<`).
34pub const DIR_TO_FC: u8 = b'<';
35/// The v1 marker for a normal reply from the FC (`>`).
36pub const DIR_FROM_FC: u8 = b'>';
37/// The v1 marker for an error reply from the FC (`!`).
38pub const DIR_ERROR: u8 = b'!';
39
40/// Accumulate one byte into a CRC8/DVB-S2 running value (polynomial `0xD5`).
41/// Seed with `0` and fold every byte of the v2 header+payload.
42pub fn crc8_dvb_s2(mut crc: u8, byte: u8) -> u8 {
43    crc ^= byte;
44    for _ in 0..8 {
45        if crc & 0x80 != 0 {
46            crc = (crc << 1) ^ 0xD5;
47        } else {
48            crc <<= 1;
49        }
50    }
51    crc
52}
53
54/// CRC8/DVB-S2 over a slice, seeded at 0.
55pub fn crc8_dvb_s2_buf(data: &[u8]) -> u8 {
56    data.iter().fold(0u8, |c, &b| crc8_dvb_s2(c, b))
57}
58
59/// Encode an MSPv1 request `$M<`. `cmd` and `payload.len()` must each fit in a
60/// byte; a command or payload that does not is a v2-only frame, so this returns
61/// `None` rather than truncating (a silent truncation would corrupt the FC
62/// command — fail loudly instead).
63pub fn encode_v1(cmd: u16, payload: &[u8]) -> Option<Vec<u8>> {
64    if cmd > u8::MAX as u16 || payload.len() > u8::MAX as usize {
65        return None;
66    }
67    let size = payload.len() as u8;
68    let cmd = cmd as u8;
69    let mut out = Vec::with_capacity(6 + payload.len());
70    out.extend_from_slice(&[b'$', b'M', DIR_TO_FC, size, cmd]);
71    out.extend_from_slice(payload);
72    let mut crc = size ^ cmd;
73    for &b in payload {
74        crc ^= b;
75    }
76    out.push(crc);
77    Some(out)
78}
79
80/// Encode an MSPv2 request `$X<` with `flag = 0` (the common case).
81pub fn encode_v2(cmd: u16, payload: &[u8]) -> Vec<u8> {
82    encode_v2_flagged(0, cmd, payload)
83}
84
85/// Encode an MSPv2 request `$X<` with an explicit flag byte.
86pub fn encode_v2_flagged(flag: u8, cmd: u16, payload: &[u8]) -> Vec<u8> {
87    let size = payload.len() as u16;
88    let mut out = Vec::with_capacity(9 + payload.len());
89    out.extend_from_slice(&[b'$', b'X', DIR_TO_FC]);
90    // The CRC covers flag, cmd (LE), size (LE), and the payload — everything
91    // after the direction byte except the CRC itself.
92    let mut header = Vec::with_capacity(5);
93    header.push(flag);
94    header.extend_from_slice(&cmd.to_le_bytes());
95    header.extend_from_slice(&size.to_le_bytes());
96    out.extend_from_slice(&header);
97    out.extend_from_slice(payload);
98    let mut crc = crc8_dvb_s2_buf(&header);
99    for &b in payload {
100        crc = crc8_dvb_s2(crc, b);
101    }
102    out.push(crc);
103    out
104}
105
106/// A decoded MSP frame: its command id, direction/marker byte, and payload.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct MspFrame {
109    pub cmd: u16,
110    /// The direction byte: [`DIR_TO_FC`], [`DIR_FROM_FC`], or [`DIR_ERROR`].
111    pub dir: u8,
112    pub payload: Vec<u8>,
113    /// The MSP version the frame was encoded in (1 or 2).
114    pub version: u8,
115}
116
117/// Why a byte buffer could not be decoded as a single complete MSP frame.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum MspDecodeError {
120    /// Fewer bytes than the smallest valid frame of its kind.
121    TooShort,
122    /// The `$M` / `$X` preamble or the version byte was not recognized.
123    BadPreamble,
124    /// The trailing CRC did not match the computed CRC (corrupt frame).
125    BadCrc { expected: u8, found: u8 },
126}
127
128/// Decode exactly one MSP frame from the START of `buf` (v1 or v2, auto-detected
129/// from the `$M` / `$X` preamble). Returns the frame and the number of bytes it
130/// consumed, so a caller streaming from the socket can advance past it. Verifies
131/// the CRC and fails closed on a mismatch — a corrupt FC reply must never be
132/// handed on as if it were valid (a status surface reports verified
133/// data).
134pub fn decode_frame(buf: &[u8]) -> Result<(MspFrame, usize), MspDecodeError> {
135    if buf.len() < 3 {
136        return Err(MspDecodeError::TooShort);
137    }
138    if buf[0] != b'$' {
139        return Err(MspDecodeError::BadPreamble);
140    }
141    match buf[1] {
142        b'M' => decode_v1(buf),
143        b'X' => decode_v2(buf),
144        _ => Err(MspDecodeError::BadPreamble),
145    }
146}
147
148fn decode_v1(buf: &[u8]) -> Result<(MspFrame, usize), MspDecodeError> {
149    // '$' 'M' dir size cmd payload... crc  -> min 6 bytes (empty payload)
150    if buf.len() < 6 {
151        return Err(MspDecodeError::TooShort);
152    }
153    let dir = buf[2];
154    let size = buf[3] as usize;
155    let cmd = buf[4] as u16;
156    let total = 6 + size;
157    if buf.len() < total {
158        return Err(MspDecodeError::TooShort);
159    }
160    let payload = &buf[5..5 + size];
161    let mut crc = buf[3] ^ buf[4];
162    for &b in payload {
163        crc ^= b;
164    }
165    let found = buf[total - 1];
166    if crc != found {
167        return Err(MspDecodeError::BadCrc {
168            expected: crc,
169            found,
170        });
171    }
172    Ok((
173        MspFrame {
174            cmd,
175            dir,
176            payload: payload.to_vec(),
177            version: 1,
178        },
179        total,
180    ))
181}
182
183fn decode_v2(buf: &[u8]) -> Result<(MspFrame, usize), MspDecodeError> {
184    // '$' 'X' dir flag cmd_lo cmd_hi size_lo size_hi payload... crc -> min 9
185    if buf.len() < 9 {
186        return Err(MspDecodeError::TooShort);
187    }
188    let dir = buf[2];
189    let flag = buf[3];
190    let cmd = u16::from_le_bytes([buf[4], buf[5]]);
191    let size = u16::from_le_bytes([buf[6], buf[7]]) as usize;
192    let total = 9 + size;
193    if buf.len() < total {
194        return Err(MspDecodeError::TooShort);
195    }
196    let payload = &buf[8..8 + size];
197    let mut crc = crc8_dvb_s2_buf(&buf[3..8]); // flag + cmd + size
198    let _ = flag; // documented; covered by the header slice above
199    for &b in payload {
200        crc = crc8_dvb_s2(crc, b);
201    }
202    let found = buf[total - 1];
203    if crc != found {
204        return Err(MspDecodeError::BadCrc {
205            expected: crc,
206            found,
207        });
208    }
209    Ok((
210        MspFrame {
211            cmd,
212            dir,
213            payload: payload.to_vec(),
214            version: 2,
215        },
216        total,
217    ))
218}
219
220/// Map a bipolar stick axis (roll / pitch / yaw), `-1.0..=1.0`, to a PWM value
221/// `1000..=2000` with centre `1500`. Out-of-range inputs clamp. This matches the
222/// GCS `bipolarToPwm` mapping so a companion and the GCS scale sticks identically.
223pub fn bipolar_to_pwm(v: f32) -> u16 {
224    let clamped = v.clamp(-1.0, 1.0);
225    (1500.0 + clamped * 500.0).round() as u16
226}
227
228/// Map a throttle input, `0.0..=1.0`, to a PWM value `1000..=2000` with idle at
229/// `1000` (NOT centre — a throttle at rest is idle, not mid-stick). Matches the
230/// GCS `throttleToPwm` mapping.
231pub fn throttle_to_pwm(t: f32) -> u16 {
232    let clamped = t.clamp(0.0, 1.0);
233    (1000.0 + clamped * 1000.0).round() as u16
234}
235
236/// Encode an `MSP_SET_RAW_RC` command carrying `channels` PWM microsecond values
237/// as little-endian `u16`s. `v2` selects the framing (v2 is the safe default for
238/// a modern FC; v1 is offered for a legacy target). A v1 encode of more than 127
239/// channels overflows the byte size field and returns `None`.
240pub fn set_raw_rc(channels: &[u16], v2: bool) -> Option<Vec<u8>> {
241    let mut payload = Vec::with_capacity(channels.len() * 2);
242    for &ch in channels {
243        payload.extend_from_slice(&ch.to_le_bytes());
244    }
245    if v2 {
246        Some(encode_v2(MSP_SET_RAW_RC, &payload))
247    } else {
248        encode_v1(MSP_SET_RAW_RC, &payload)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    // ---- Golden byte vectors: the codec is the interop boundary, so pin the
257    // exact bytes rather than only round-tripping (a symmetric bug in encode +
258    // decode would pass a round-trip and still be wrong on the wire).
259
260    #[test]
261    fn crc8_dvb_s2_matches_known_values() {
262        // DVB-S2 CRC8 of a single 0x00 is 0x00; of {0x00,0x00} is 0x00.
263        assert_eq!(crc8_dvb_s2_buf(&[0x00]), 0x00);
264        // A single 0x01 through the poly 0xD5.
265        assert_eq!(crc8_dvb_s2_buf(&[0x01]), 0xD5);
266    }
267
268    #[test]
269    fn v1_msp_status_request_golden() {
270        // MSP_STATUS (101) with no payload: $ M < 00 65 crc, crc = 0x00 ^ 0x65.
271        let frame = encode_v1(101, &[]).unwrap();
272        assert_eq!(frame, vec![b'$', b'M', b'<', 0x00, 0x65, 0x65]);
273    }
274
275    #[test]
276    fn v1_with_payload_golden() {
277        // cmd 200, payload [0xE8,0x03] (=1000 LE). size=2.
278        // crc = 2 ^ 200 ^ 0xE8 ^ 0x03.
279        let frame = encode_v1(200, &[0xE8, 0x03]).unwrap();
280        let crc = 2u8 ^ 200 ^ 0xE8 ^ 0x03;
281        assert_eq!(frame, vec![b'$', b'M', b'<', 0x02, 0xC8, 0xE8, 0x03, crc]);
282    }
283
284    #[test]
285    fn v1_rejects_oversize() {
286        assert!(encode_v1(256, &[]).is_none()); // cmd > 255
287        assert!(encode_v1(1, &vec![0u8; 256]).is_none()); // payload > 255
288    }
289
290    #[test]
291    fn v2_msp_status_request_golden() {
292        // $ X < flag=00 cmd=65 00 size=00 00 crc. The CRC 0xCA is from an
293        // INDEPENDENT CRC8/DVB-S2 oracle (verified against the canonical
294        // "123456789" -> 0xBC check value), not this module's own function, so a
295        // symmetric encode/crc bug cannot pass.
296        let frame = encode_v2(101, &[]);
297        assert_eq!(
298            frame,
299            vec![b'$', b'X', b'<', 0x00, 0x65, 0x00, 0x00, 0x00, 0xCA]
300        );
301    }
302
303    #[test]
304    fn set_raw_rc_v2_golden() {
305        // Two channels at 1500 (0xDC 0x05) each. cmd=200 (0xC8 0x00), size=4.
306        // Full frame incl. the CRC 0xB4 from the independent oracle above.
307        let bytes = set_raw_rc(&[1500, 1500], true).unwrap();
308        assert_eq!(
309            bytes,
310            vec![b'$', b'X', b'<', 0x00, 0xC8, 0x00, 0x04, 0x00, 0xDC, 0x05, 0xDC, 0x05, 0xB4]
311        );
312    }
313
314    #[test]
315    fn decode_roundtrips_v1_and_v2() {
316        for &v2 in &[false, true] {
317            let bytes = set_raw_rc(&[1000, 1500, 2000, 1500], v2).unwrap();
318            let (frame, consumed) = decode_frame(&bytes).unwrap();
319            assert_eq!(consumed, bytes.len());
320            assert_eq!(frame.cmd, MSP_SET_RAW_RC);
321            assert_eq!(frame.dir, DIR_TO_FC);
322            assert_eq!(frame.version, if v2 { 2 } else { 1 });
323            // Payload decodes back to the channel values.
324            let (pairs, rest) = frame.payload.as_chunks::<2>();
325            assert!(rest.is_empty(), "an RC payload is whole u16 channels");
326            let chans: Vec<u16> = pairs.iter().copied().map(u16::from_le_bytes).collect();
327            assert_eq!(chans, vec![1000, 1500, 2000, 1500]);
328        }
329    }
330
331    #[test]
332    fn decode_rejects_a_corrupt_crc() {
333        let mut bytes = set_raw_rc(&[1500], true).unwrap();
334        let last = bytes.len() - 1;
335        bytes[last] ^= 0xFF; // corrupt the CRC
336        assert!(matches!(
337            decode_frame(&bytes),
338            Err(MspDecodeError::BadCrc { .. })
339        ));
340        // v1 too.
341        let mut v1 = set_raw_rc(&[1500], false).unwrap();
342        let l = v1.len() - 1;
343        v1[l] ^= 0xFF;
344        assert!(matches!(
345            decode_frame(&v1),
346            Err(MspDecodeError::BadCrc { .. })
347        ));
348    }
349
350    #[test]
351    fn decode_reports_short_and_bad_preamble() {
352        assert_eq!(decode_frame(b"$"), Err(MspDecodeError::TooShort));
353        assert_eq!(
354            decode_frame(&[b'$', b'Z', b'<', 0, 0, 0]),
355            Err(MspDecodeError::BadPreamble)
356        );
357        // A v2 frame missing its payload+crc is short, not corrupt.
358        assert_eq!(
359            decode_frame(&[b'$', b'X', b'<', 0, 0xC8, 0, 4, 0]),
360            Err(MspDecodeError::TooShort)
361        );
362    }
363
364    #[test]
365    fn stick_scaling_matches_the_gcs() {
366        // Bipolar: centre, extremes, clamp.
367        assert_eq!(bipolar_to_pwm(0.0), 1500);
368        assert_eq!(bipolar_to_pwm(1.0), 2000);
369        assert_eq!(bipolar_to_pwm(-1.0), 1000);
370        assert_eq!(bipolar_to_pwm(2.0), 2000); // clamp high
371        assert_eq!(bipolar_to_pwm(-2.0), 1000); // clamp low
372        assert_eq!(bipolar_to_pwm(0.5), 1750);
373        // Throttle: idle at 0, NOT centre. A throttle scaled to mid-stick would
374        // arm a motor at half power the instant the channel opened.
375        assert_eq!(throttle_to_pwm(0.0), 1000);
376        assert_eq!(throttle_to_pwm(1.0), 2000);
377        assert_eq!(throttle_to_pwm(0.5), 1500);
378        assert_eq!(throttle_to_pwm(-0.1), 1000); // clamp
379        assert_eq!(throttle_to_pwm(1.1), 2000); // clamp
380    }
381}