Frame Structure
This page details the byte-level structure of all MSP frame formats.
MSPv1 Standard Frame
The original MSP frame format, suitable for payloads up to 255 bytes.
┌──────────────────────────────────────────────────────────────────┐
│ Byte 0 │ Byte 1 │ Byte 2 │ Byte 3 │ Byte 4 │ Byte 5+ │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ '$' │ 'M' │ '<' │ size │ cmd │ payload │
│ (0x24) │ (0x4D) │ or │ (0-255) │ (0-255) │ [size] │
│ │ │ '>' │ │ │ bytes │
├──────────┴──────────┴──────────┴──────────┴──────────┴──────────┤
│ + checksum (1 byte) │
└──────────────────────────────────────────────────────────────────┘
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | Header[0] | $ (0x24) |
| 1 | 1 | Header[1] | M (0x4D) |
| 2 | 1 | Direction | < request / > response / ! error |
| 3 | 1 | Size | Payload length (0-255) |
| 4 | 1 | Command | Command ID (0-254) |
| 5 | N | Payload | N bytes of data (N = Size) |
| 5+N | 1 | Checksum | XOR checksum |
MSPv1 Frame Implementation
/// MSPv1 frame structure
pub struct MspV1Frame {
pub direction: u8,
pub command: u8,
pub payload: Vec<u8>,
}
impl MspV1Frame {
/// Calculate XOR checksum for MSPv1
fn calculate_checksum(size: u8, command: u8, payload: &[u8]) -> u8 {
let mut checksum = size ^ command;
for byte in payload {
checksum ^= byte;
}
checksum
}
/// Serialize frame to bytes (for sending)
pub fn to_bytes(&self) -> Vec<u8> {
let size = self.payload.len() as u8;
let mut frame = Vec::with_capacity(6 + self.payload.len());
// Header
frame.push(b'$');
frame.push(b'M');
frame.push(self.direction);
// Size and command
frame.push(size);
frame.push(self.command);
// Payload
frame.extend_from_slice(&self.payload);
// Checksum
let checksum = Self::calculate_checksum(size, self.command, &self.payload);
frame.push(checksum);
frame
}
/// Parse frame from bytes (for receiving)
pub fn from_bytes(data: &[u8]) -> Result<Self, &'static str> {
if data.len() < 6 {
return Err("Frame too short");
}
// Verify header
if data[0] != b'$' || data[1] != b'M' {
return Err("Invalid header");
}
let direction = data[2];
let size = data[3] as usize;
let command = data[4];
if data.len() < 6 + size {
return Err("Incomplete payload");
}
let payload = data[5..5 + size].to_vec();
let received_checksum = data[5 + size];
// Verify checksum
let expected_checksum = Self::calculate_checksum(size as u8, command, &payload);
if received_checksum != expected_checksum {
return Err("Checksum mismatch");
}
Ok(MspV1Frame {
direction,
command,
payload,
})
}
/// Create a request frame (host to FC)
pub fn request(command: u8, payload: Vec<u8>) -> Self {
MspV1Frame {
direction: b'<',
command,
payload,
}
}
}
MSPv1 JUMBO Frame
For payloads exceeding 255 bytes, MSPv1 uses a JUMBO frame format.
┌──────────────────────────────────────────────────────────────────────────┐
│ Byte 0-1 │ Byte 2 │ Byte 3 │ Byte 4 │ Byte 5-6 │ Byte 7+ │ Last byte │
├──────────┼────────┼────────┼────────┼────────────┼─────────┼────────────┤
│ "$M" │ dir │ 255 │ 255 │ actual_sz │ payload │ checksum │
│ │ │ (cmd) │ (flag) │ (u16 LE) │ [size] │ │
└──────────────────────────────────────────────────────────────────────────┘
| Offset | Size | Field | Description |
|---|---|---|---|
| 0-1 | 2 | Header | $M |
| 2 | 1 | Direction | < or > |
| 3 | 1 | Command | 255 (MSP_V2_FRAME marker) |
| 4 | 1 | Size | 255 (JUMBO flag) |
| 5-6 | 2 | Actual Size | Real payload size (little-endian u16) |
| 7 | N | Payload | N bytes of data |
| 7+N | 1 | Checksum | XOR checksum |
/// MSPv1 JUMBO frame builder
pub fn build_jumbo_frame(command: u8, payload: &[u8]) -> Vec<u8> {
let actual_size = payload.len() as u16;
let mut frame = Vec::with_capacity(8 + payload.len());
// Header
frame.push(b'$');
frame.push(b'M');
frame.push(b'<');
// JUMBO markers
frame.push(255); // Command placeholder (MSP_V2_FRAME)
frame.push(255); // Size = 255 indicates JUMBO
// Actual size (little-endian)
frame.push((actual_size & 0xFF) as u8);
frame.push((actual_size >> 8) as u8);
// Real command
frame.push(command);
// Payload
frame.extend_from_slice(payload);
// Checksum (XOR of all bytes after header)
let mut checksum: u8 = 255 ^ 255; // cmd ^ size
checksum ^= (actual_size & 0xFF) as u8;
checksum ^= (actual_size >> 8) as u8;
checksum ^= command;
for byte in payload {
checksum ^= byte;
}
frame.push(checksum);
frame
}
MSPv2 Native Frame
The modern MSP format with improved features.
┌──────────────────────────────────────────────────────────────────────────┐
│ Byte 0-1 │ Byte 2 │ Byte 3 │ Byte 4-5 │ Byte 6-7 │ Byte 8+ │ Last byte │
├──────────┼────────┼────────┼──────────┼──────────┼─────────┼────────────┤
│ "$X" │ dir │ flags │ cmd_id │ size │ payload │ crc8 │
│ │ │ │ (u16 LE) │ (u16 LE) │ [size] │ │
└──────────────────────────────────────────────────────────────────────────┘
| Offset | Size | Field | Description |
|---|---|---|---|
| 0-1 | 2 | Header | $X |
| 2 | 1 | Direction | < or > or ! |
| 3 | 1 | Flags | Reserved (usually 0) |
| 4-5 | 2 | Command | 16-bit command ID (little-endian) |
| 6-7 | 2 | Size | Payload length (little-endian) |
| 8 | N | Payload | N bytes of data |
| 8+N | 1 | CRC8 | CRC8-DVB-S2 checksum |
MSPv2 Frame Implementation
/// MSPv2 frame structure
pub struct MspV2Frame {
pub direction: u8,
pub flags: u8,
pub command: u16,
pub payload: Vec<u8>,
}
impl MspV2Frame {
/// CRC8-DVB-S2 lookup table
const CRC8_TABLE: [u8; 256] = Self::generate_crc8_table();
/// Generate CRC8-DVB-S2 lookup table at compile time
const fn generate_crc8_table() -> [u8; 256] {
let mut table = [0u8; 256];
let mut i = 0usize;
while i < 256 {
let mut crc = i as u8;
let mut j = 0;
while j < 8 {
if crc & 0x80 != 0 {
crc = (crc << 1) ^ 0xD5;
} else {
crc <<= 1;
}
j += 1;
}
table[i] = crc;
i += 1;
}
table
}
/// Calculate CRC8-DVB-S2
fn calculate_crc8(data: &[u8]) -> u8 {
let mut crc: u8 = 0;
for byte in data {
crc = Self::CRC8_TABLE[(crc ^ byte) as usize];
}
crc
}
/// Serialize frame to bytes
pub fn to_bytes(&self) -> Vec<u8> {
let size = self.payload.len() as u16;
let mut frame = Vec::with_capacity(9 + self.payload.len());
// Header
frame.push(b'$');
frame.push(b'X');
frame.push(self.direction);
// Flags
frame.push(self.flags);
// Command (little-endian)
frame.push((self.command & 0xFF) as u8);
frame.push((self.command >> 8) as u8);
// Size (little-endian)
frame.push((size & 0xFF) as u8);
frame.push((size >> 8) as u8);
// Payload
frame.extend_from_slice(&self.payload);
// CRC8 (covers flags, command, size, and payload)
let crc_data = &frame[3..]; // From flags to end of payload
let crc = Self::calculate_crc8(crc_data);
frame.push(crc);
frame
}
/// Parse frame from bytes
pub fn from_bytes(data: &[u8]) -> Result<Self, &'static str> {
if data.len() < 9 {
return Err("Frame too short");
}
if data[0] != b'$' || data[1] != b'X' {
return Err("Invalid header");
}
let direction = data[2];
let flags = data[3];
let command = u16::from_le_bytes([data[4], data[5]]);
let size = u16::from_le_bytes([data[6], data[7]]) as usize;
if data.len() < 9 + size {
return Err("Incomplete payload");
}
let payload = data[8..8 + size].to_vec();
let received_crc = data[8 + size];
// Verify CRC
let expected_crc = Self::calculate_crc8(&data[3..8 + size]);
if received_crc != expected_crc {
return Err("CRC mismatch");
}
Ok(MspV2Frame {
direction,
flags,
command,
payload,
})
}
/// Create a request frame
pub fn request(command: u16, payload: Vec<u8>) -> Self {
MspV2Frame {
direction: b'<',
flags: 0,
command,
payload,
}
}
}
MSPv2 Over V1 Frame
Wraps MSPv2 content in an MSPv1 envelope for backwards compatibility.
┌──────────────────────────────────────────────────────────────────────────────────┐
│ MSPv1 Outer Envelope MSPv2 Inner Content │
├──────────────────────────────────────────────────────────────────────────────────┤
│ $M │ dir │ cmd=255 │ size │ flags │ v2_cmd │ v2_size │ payload │ crc8 │ xor_sum │
│ │ │ │ │ │ (u16) │ (u16) │ │ │ │
└──────────────────────────────────────────────────────────────────────────────────┘
| Offset | Size | Field | Description |
|---|---|---|---|
| 0-1 | 2 | Header | $M (V1 envelope) |
| 2 | 1 | Direction | < or > |
| 3 | 1 | Command | 255 (MSP_V2_FRAME marker) |
| 4 | 1 | Size | Outer payload size |
| 5 | 1 | V2 Flags | MSPv2 flags |
| 6-7 | 2 | V2 Command | 16-bit command (little-endian) |
| 8-9 | 2 | V2 Size | Inner payload size (little-endian) |
| 10 | N | Payload | Inner payload data |
| 10+N | 1 | CRC8 | CRC8-DVB-S2 of inner content |
| 11+N | 1 | XOR | XOR checksum of outer envelope |
/// MSPv2 over V1 frame builder
pub fn build_v2_over_v1_frame(command: u16, payload: &[u8]) -> Vec<u8> {
let inner_size = payload.len() as u16;
let outer_size = 1 + 2 + 2 + payload.len() + 1; // flags + cmd + size + payload + crc
let mut frame = Vec::with_capacity(6 + outer_size);
// V1 Outer Header
frame.push(b'$');
frame.push(b'M');
frame.push(b'<');
frame.push(255); // MSP_V2_FRAME
frame.push(outer_size as u8);
// V2 Inner Content
frame.push(0); // flags
// V2 Command (little-endian)
frame.push((command & 0xFF) as u8);
frame.push((command >> 8) as u8);
// V2 Size (little-endian)
frame.push((inner_size & 0xFF) as u8);
frame.push((inner_size >> 8) as u8);
// Payload
frame.extend_from_slice(payload);
// CRC8 of inner content (from flags to end of payload)
let crc = crc8_dvb_s2(&frame[5..]);
frame.push(crc);
// XOR checksum of outer envelope (cmd, size, and inner content including CRC)
let mut xor: u8 = 255 ^ outer_size as u8;
for byte in &frame[5..] {
xor ^= byte;
}
frame.push(xor);
frame
}
/// CRC8-DVB-S2 calculation
fn crc8_dvb_s2(data: &[u8]) -> u8 {
let mut crc: u8 = 0;
for byte in data {
crc ^= byte;
for _ in 0..8 {
if crc & 0x80 != 0 {
crc = (crc << 1) ^ 0xD5;
} else {
crc <<= 1;
}
}
}
crc
}
Frame Detection State Machine
For parsing incoming data streams:
/// MSP parser state machine
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ParserState {
Idle,
HeaderM, // Received '$'
HeaderVersion, // Received 'M' or 'X'
Direction, // Received direction byte
SizeV1, // V1: waiting for size
CommandV1, // V1: waiting for command
PayloadV1, // V1: receiving payload
ChecksumV1, // V1: waiting for checksum
FlagsV2, // V2: waiting for flags
CommandV2Low, // V2: command low byte
CommandV2High, // V2: command high byte
SizeV2Low, // V2: size low byte
SizeV2High, // V2: size high byte
PayloadV2, // V2: receiving payload
Crc8V2, // V2: waiting for CRC
}
pub struct MspParser {
state: ParserState,
version: Option<MspVersion>,
direction: u8,
command: u16,
size: u16,
payload: Vec<u8>,
checksum: u8,
}
impl MspParser {
pub fn new() -> Self {
MspParser {
state: ParserState::Idle,
version: None,
direction: 0,
command: 0,
size: 0,
payload: Vec::new(),
checksum: 0,
}
}
/// Process one byte, returns Some(frame) when complete
pub fn parse_byte(&mut self, byte: u8) -> Option<MspFrame> {
match self.state {
ParserState::Idle => {
if byte == b'$' {
self.state = ParserState::HeaderM;
}
}
ParserState::HeaderM => {
match byte {
b'M' => {
self.version = Some(MspVersion::V1);
self.state = ParserState::Direction;
}
b'X' => {
self.version = Some(MspVersion::V2);
self.state = ParserState::Direction;
}
_ => self.reset(),
}
}
ParserState::Direction => {
self.direction = byte;
match self.version {
Some(MspVersion::V1) => self.state = ParserState::SizeV1,
Some(MspVersion::V2) => self.state = ParserState::FlagsV2,
None => self.reset(),
}
}
// ... additional states handled similarly
_ => {}
}
None
}
fn reset(&mut self) {
self.state = ParserState::Idle;
self.version = None;
self.payload.clear();
}
}
/// Generic MSP frame (either V1 or V2)
pub enum MspFrame {
V1(MspV1Frame),
V2(MspV2Frame),
}
Summary Table
| Format | Header | Max Payload | Checksum | Use Case |
|---|---|---|---|---|
| MSPv1 | $M | 255 bytes | XOR | Simple commands, legacy support |
| MSPv1 JUMBO | $M | 65535 bytes | XOR | Large data (blackbox, fonts) |
| MSPv2 | $X | 65535 bytes | CRC8 | Modern, all new commands |
| MSPv2/V1 | $M | 65535 bytes | CRC8+XOR | V2 features with V1 compatibility |