Skip to main content

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) │
└──────────────────────────────────────────────────────────────────┘
OffsetSizeFieldDescription
01Header[0]$ (0x24)
11Header[1]M (0x4D)
21Direction< request / > response / ! error
31SizePayload length (0-255)
41CommandCommand ID (0-254)
5NPayloadN bytes of data (N = Size)
5+N1ChecksumXOR 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] │ │
└──────────────────────────────────────────────────────────────────────────┘
OffsetSizeFieldDescription
0-12Header$M
21Direction< or >
31Command255 (MSP_V2_FRAME marker)
41Size255 (JUMBO flag)
5-62Actual SizeReal payload size (little-endian u16)
7NPayloadN bytes of data
7+N1ChecksumXOR 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] │ │
└──────────────────────────────────────────────────────────────────────────┘
OffsetSizeFieldDescription
0-12Header$X
21Direction< or > or !
31FlagsReserved (usually 0)
4-52Command16-bit command ID (little-endian)
6-72SizePayload length (little-endian)
8NPayloadN bytes of data
8+N1CRC8CRC8-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) │ │ │ │
└──────────────────────────────────────────────────────────────────────────────────┘
OffsetSizeFieldDescription
0-12Header$M (V1 envelope)
21Direction< or >
31Command255 (MSP_V2_FRAME marker)
41SizeOuter payload size
51V2 FlagsMSPv2 flags
6-72V2 Command16-bit command (little-endian)
8-92V2 SizeInner payload size (little-endian)
10NPayloadInner payload data
10+N1CRC8CRC8-DVB-S2 of inner content
11+N1XORXOR 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​

FormatHeaderMax PayloadChecksumUse Case
MSPv1$M255 bytesXORSimple commands, legacy support
MSPv1 JUMBO$M65535 bytesXORLarge data (blackbox, fonts)
MSPv2$X65535 bytesCRC8Modern, all new commands
MSPv2/V1$M65535 bytesCRC8+XORV2 features with V1 compatibility