Telemetry Commands (101-166)
These commands provide real-time flight data including status, sensors, RC inputs, and motor outputs.
MSP_STATUS (101)
Query flight controller status including sensor availability, flight modes, and system load.
Response
| Offset | Type | Field | Description |
|---|---|---|---|
| 0-1 | U16 | cycle_time | PID loop time in µs |
| 2-3 | U16 | i2c_error_count | I2C bus errors |
| 4-5 | U16 | sensor_flags | Active sensor bitmask |
| 6-9 | U32 | flight_mode_flags | Active flight mode flags |
| 10 | U8 | current_pid_profile | Active PID profile index |
| 11-12 | U16 | system_load | CPU load percentage × 10 |
Sensor Flags
| Bit | Sensor |
|---|---|
| 0 | Accelerometer |
| 1 | Barometer |
| 2 | Magnetometer |
| 3 | GPS |
| 4 | Rangefinder |
| 5 | Gyroscope |
| 6 | Optical Flow |
bitflags::bitflags! {
#[derive(Debug, Clone, Copy)]
pub struct SensorFlags: u16 {
const ACC = 1 << 0;
const BARO = 1 << 1;
const MAG = 1 << 2;
const GPS = 1 << 3;
const RANGEFINDER = 1 << 4;
const GYRO = 1 << 5;
const OPTICAL_FLOW = 1 << 6;
}
}
#[derive(Debug, Clone, Copy)]
pub struct MspStatus {
pub cycle_time: u16,
pub i2c_error_count: u16,
pub sensors: SensorFlags,
pub flight_mode_flags: u32,
pub current_pid_profile: u8,
pub system_load: u16,
}
impl MspStatus {
pub const COMMAND: u8 = 101;
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 13 {
return None;
}
Some(MspStatus {
cycle_time: u16::from_le_bytes([data[0], data[1]]),
i2c_error_count: u16::from_le_bytes([data[2], data[3]]),
sensors: SensorFlags::from_bits_truncate(
u16::from_le_bytes([data[4], data[5]])
),
flight_mode_flags: u32::from_le_bytes([data[6], data[7], data[8], data[9]]),
current_pid_profile: data[10],
system_load: u16::from_le_bytes([data[11], data[12]]),
})
}
pub fn system_load_percent(&self) -> f32 {
self.system_load as f32 / 10.0
}
pub fn pid_loop_frequency(&self) -> u32 {
if self.cycle_time > 0 {
1_000_000 / self.cycle_time as u32
} else {
0
}
}
}
MSP_RAW_IMU (102)
Query raw sensor data from accelerometer, gyroscope, and magnetometer.
Response
| Offset | Type | Field | Description |
|---|---|---|---|
| 0-1 | I16 | acc_x | Accelerometer X (raw) |
| 2-3 | I16 | acc_y | Accelerometer Y (raw) |
| 4-5 | I16 | acc_z | Accelerometer Z (raw) |
| 6-7 | I16 | gyro_x | Gyroscope X (°/s × 4.096) |
| 8-9 | I16 | gyro_y | Gyroscope Y |
| 10-11 | I16 | gyro_z | Gyroscope Z |
| 12-13 | I16 | mag_x | Magnetometer X |
| 14-15 | I16 | mag_y | Magnetometer Y |
| 16-17 | I16 | mag_z | Magnetometer Z |
Units
- Accelerometer: Raw ADC units, scale depends on configuration (typically ±8g)
- Gyroscope: Degrees/second × 4.096 (divide by 4.096 to get °/s)
- Magnetometer: Raw units, scale depends on sensor
#[derive(Debug, Clone, Copy)]
pub struct MspRawImu {
pub acc: [i16; 3],
pub gyro: [i16; 3],
pub mag: [i16; 3],
}
impl MspRawImu {
pub const COMMAND: u8 = 102;
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 18 {
return None;
}
let read_i16 = |offset: usize| -> i16 {
i16::from_le_bytes([data[offset], data[offset + 1]])
};
Some(MspRawImu {
acc: [read_i16(0), read_i16(2), read_i16(4)],
gyro: [read_i16(6), read_i16(8), read_i16(10)],
mag: [read_i16(12), read_i16(14), read_i16(16)],
})
}
/// Get gyroscope values in degrees per second
pub fn gyro_dps(&self) -> [f32; 3] {
[
self.gyro[0] as f32 / 4.096,
self.gyro[1] as f32 / 4.096,
self.gyro[2] as f32 / 4.096,
]
}
}
MSP_MOTOR (104)
Query current motor output values.
Response
8 motor values, each as U16 (little-endian):
| Offset | Type | Field | Description |
|---|---|---|---|
| 0-1 | U16 | motor_1 | Motor 1 PWM (1000-2000) |
| 2-3 | U16 | motor_2 | Motor 2 PWM |
| ... | ... | ... | ... |
| 14-15 | U16 | motor_8 | Motor 8 PWM |
#[derive(Debug, Clone, Copy)]
pub struct MspMotor {
pub motors: [u16; 8],
}
impl MspMotor {
pub const COMMAND: u8 = 104;
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 16 {
return None;
}
let mut motors = [0u16; 8];
for i in 0..8 {
motors[i] = u16::from_le_bytes([data[i * 2], data[i * 2 + 1]]);
}
Some(MspMotor { motors })
}
/// Get motor throttle percentage (0-100%)
pub fn throttle_percent(&self, motor_index: usize) -> f32 {
let value = self.motors.get(motor_index).copied().unwrap_or(1000);
((value.saturating_sub(1000)) as f32 / 10.0).min(100.0)
}
}
MSP_RC (105)
Query RC channel input values.
Response
Variable length, typically 8-18 channels × 2 bytes each:
| Offset | Type | Field | Description |
|---|---|---|---|
| 0-1 | U16 | channel_1 | Roll (1000-2000 µs) |
| 2-3 | U16 | channel_2 | Pitch |
| 4-5 | U16 | channel_3 | Throttle |
| 6-7 | U16 | channel_4 | Yaw |
| 8-9 | U16 | channel_5 | AUX1 |
| ... | ... | ... | Additional AUX channels |
#[derive(Debug, Clone)]
pub struct MspRc {
pub channels: Vec<u16>,
}
impl MspRc {
pub const COMMAND: u8 = 105;
pub fn from_bytes(data: &[u8]) -> Self {
let channel_count = data.len() / 2;
let mut channels = Vec::with_capacity(channel_count);
for i in 0..channel_count {
let value = u16::from_le_bytes([data[i * 2], data[i * 2 + 1]]);
channels.push(value);
}
MspRc { channels }
}
pub fn roll(&self) -> u16 {
self.channels.get(0).copied().unwrap_or(1500)
}
pub fn pitch(&self) -> u16 {
self.channels.get(1).copied().unwrap_or(1500)
}
pub fn throttle(&self) -> u16 {
self.channels.get(2).copied().unwrap_or(1000)
}
pub fn yaw(&self) -> u16 {
self.channels.get(3).copied().unwrap_or(1500)
}
pub fn aux(&self, index: usize) -> u16 {
self.channels.get(4 + index).copied().unwrap_or(1000)
}
}
MSP_ATTITUDE (108)
Query aircraft orientation angles.
Response
| Offset | Type | Field | Description |
|---|---|---|---|
| 0-1 | I16 | roll | Roll angle × 10 (degrees) |
| 2-3 | I16 | pitch | Pitch angle × 10 (degrees) |
| 4-5 | I16 | yaw | Heading × 1 (degrees, 0-360) |
#[derive(Debug, Clone, Copy)]
pub struct MspAttitude {
pub roll: i16, // Degrees × 10
pub pitch: i16, // Degrees × 10
pub yaw: i16, // Degrees (0-360)
}
impl MspAttitude {
pub const COMMAND: u8 = 108;
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 6 {
return None;
}
Some(MspAttitude {
roll: i16::from_le_bytes([data[0], data[1]]),
pitch: i16::from_le_bytes([data[2], data[3]]),
yaw: i16::from_le_bytes([data[4], data[5]]),
})
}
pub fn roll_degrees(&self) -> f32 {
self.roll as f32 / 10.0
}
pub fn pitch_degrees(&self) -> f32 {
self.pitch as f32 / 10.0
}
pub fn yaw_degrees(&self) -> f32 {
self.yaw as f32
}
}
MSP_ALTITUDE (109)
Query altitude and vertical speed.
Response
| Offset | Type | Field | Description |
|---|---|---|---|
| 0-3 | I32 | altitude | Altitude in cm |
| 4-5 | I16 | vario | Vertical speed in cm/s |
#[derive(Debug, Clone, Copy)]
pub struct MspAltitude {
pub altitude_cm: i32,
pub vario_cms: i16,
}
impl MspAltitude {
pub const COMMAND: u8 = 109;
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 6 {
return None;
}
Some(MspAltitude {
altitude_cm: i32::from_le_bytes([data[0], data[1], data[2], data[3]]),
vario_cms: i16::from_le_bytes([data[4], data[5]]),
})
}
pub fn altitude_meters(&self) -> f32 {
self.altitude_cm as f32 / 100.0
}
pub fn vario_ms(&self) -> f32 {
self.vario_cms as f32 / 100.0
}
}
MSP_ANALOG (110)
Query battery voltage, current draw, and RSSI.
Response
| Offset | Type | Field | Description |
|---|---|---|---|
| 0 | U8 | vbat_legacy | Battery voltage (legacy, deprecated) |
| 1-2 | U16 | mah_drawn | Consumed capacity in mAh |
| 3-4 | U16 | rssi | RSSI value (0-1023) |
| 5-6 | I16 | amperage | Current in 0.01A steps |
| 7-8 | U16 | vbat | Battery voltage in 0.01V steps |
#[derive(Debug, Clone, Copy)]
pub struct MspAnalog {
pub mah_drawn: u16,
pub rssi: u16,
pub amperage: i16, // In 0.01A units
pub vbat: u16, // In 0.01V units
}
impl MspAnalog {
pub const COMMAND: u8 = 110;
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 9 {
return None;
}
Some(MspAnalog {
mah_drawn: u16::from_le_bytes([data[1], data[2]]),
rssi: u16::from_le_bytes([data[3], data[4]]),
amperage: i16::from_le_bytes([data[5], data[6]]),
vbat: u16::from_le_bytes([data[7], data[8]]),
})
}
pub fn voltage(&self) -> f32 {
self.vbat as f32 / 100.0
}
pub fn current_amps(&self) -> f32 {
self.amperage as f32 / 100.0
}
pub fn rssi_percent(&self) -> f32 {
(self.rssi as f32 / 1023.0) * 100.0
}
}
MSP_PID (112)
Query PID coefficients for all controller items.
Response
Array of PID tuples (typically 8-10 items):
| Offset | Type | Field | Description |
|---|---|---|---|
| 0 | U8 | P | Proportional gain |
| 1 | U8 | I | Integral gain |
| 2 | U8 | D | Derivative gain |
PID Items
| Index | Item |
|---|---|
| 0 | Roll |
| 1 | Pitch |
| 2 | Yaw |
| 3 | Alt (if applicable) |
| 4 | Pos (if applicable) |
| 5 | PosR (if applicable) |
| 6 | NavR (if applicable) |
| 7 | Level |
| 8 | Mag (if applicable) |
| 9 | Vel (if applicable) |
#[derive(Debug, Clone, Copy)]
pub struct PidCoefficients {
pub p: u8,
pub i: u8,
pub d: u8,
}
#[derive(Debug, Clone)]
pub struct MspPid {
pub items: Vec<PidCoefficients>,
}
impl MspPid {
pub const COMMAND: u8 = 112;
pub fn from_bytes(data: &[u8]) -> Self {
let item_count = data.len() / 3;
let mut items = Vec::with_capacity(item_count);
for i in 0..item_count {
items.push(PidCoefficients {
p: data[i * 3],
i: data[i * 3 + 1],
d: data[i * 3 + 2],
});
}
MspPid { items }
}
pub fn roll(&self) -> Option<&PidCoefficients> {
self.items.get(0)
}
pub fn pitch(&self) -> Option<&PidCoefficients> {
self.items.get(1)
}
pub fn yaw(&self) -> Option<&PidCoefficients> {
self.items.get(2)
}
}
MSP_RAW_GPS (106)
Query raw GPS data.
Response
| Offset | Type | Field | Description |
|---|---|---|---|
| 0 | U8 | fix_type | 0=none, 1=fix, 2=DGPS |
| 1 | U8 | num_sat | Satellite count |
| 2-5 | I32 | lat | Latitude × 10^7 |
| 6-9 | I32 | lon | Longitude × 10^7 |
| 10-11 | I16 | alt | Altitude in meters |
| 12-13 | U16 | speed | Speed in cm/s |
| 14-15 | U16 | ground_course | Course × 10 (degrees) |
#[derive(Debug, Clone, Copy)]
pub struct MspRawGps {
pub fix_type: u8,
pub num_sat: u8,
pub lat: i32, // Degrees × 10^7
pub lon: i32, // Degrees × 10^7
pub alt_meters: i16,
pub speed_cms: u16,
pub ground_course: u16, // Degrees × 10
}
impl MspRawGps {
pub const COMMAND: u8 = 106;
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 16 {
return None;
}
Some(MspRawGps {
fix_type: data[0],
num_sat: data[1],
lat: i32::from_le_bytes([data[2], data[3], data[4], data[5]]),
lon: i32::from_le_bytes([data[6], data[7], data[8], data[9]]),
alt_meters: i16::from_le_bytes([data[10], data[11]]),
speed_cms: u16::from_le_bytes([data[12], data[13]]),
ground_course: u16::from_le_bytes([data[14], data[15]]),
})
}
pub fn latitude(&self) -> f64 {
self.lat as f64 / 10_000_000.0
}
pub fn longitude(&self) -> f64 {
self.lon as f64 / 10_000_000.0
}
pub fn speed_ms(&self) -> f32 {
self.speed_cms as f32 / 100.0
}
pub fn has_fix(&self) -> bool {
self.fix_type >= 1
}
}
MSP_BATTERY_STATE (130)
Query detailed battery state.
Response
| Offset | Type | Field | Description |
|---|---|---|---|
| 0 | U8 | cell_count | Detected cell count |
| 1-2 | U16 | capacity | Battery capacity mAh |
| 3 | U8 | voltage_legacy | Legacy voltage |
| 4-5 | U16 | mah_drawn | Consumed mAh |
| 6-7 | I16 | amperage | Current in 0.01A |
| 8 | U8 | state | Battery state |
| 9-10 | U16 | voltage | Voltage in 0.01V |
Battery States
| Value | State |
|---|---|
| 0 | OK |
| 1 | WARNING |
| 2 | CRITICAL |
| 3 | NOT_PRESENT |
| 4 | INIT |
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(u8)]
pub enum BatteryState {
Ok = 0,
Warning = 1,
Critical = 2,
NotPresent = 3,
Init = 4,
}
#[derive(Debug, Clone, Copy)]
pub struct MspBatteryState {
pub cell_count: u8,
pub capacity_mah: u16,
pub mah_drawn: u16,
pub amperage: i16,
pub state: u8,
pub voltage: u16,
}
impl MspBatteryState {
pub const COMMAND: u8 = 130;
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 11 {
return None;
}
Some(MspBatteryState {
cell_count: data[0],
capacity_mah: u16::from_le_bytes([data[1], data[2]]),
mah_drawn: u16::from_le_bytes([data[4], data[5]]),
amperage: i16::from_le_bytes([data[6], data[7]]),
state: data[8],
voltage: u16::from_le_bytes([data[9], data[10]]),
})
}
pub fn voltage_volts(&self) -> f32 {
self.voltage as f32 / 100.0
}
pub fn cell_voltage(&self) -> f32 {
if self.cell_count > 0 {
self.voltage_volts() / self.cell_count as f32
} else {
0.0
}
}
pub fn remaining_percent(&self) -> f32 {
if self.capacity_mah > 0 {
((self.capacity_mah - self.mah_drawn) as f32 / self.capacity_mah as f32) * 100.0
} else {
0.0
}
}
}
Telemetry Polling Example
use std::io::{Read, Write};
use std::time::{Duration, Instant};
/// Continuous telemetry polling loop
fn telemetry_loop<T: Read + Write>(port: &mut T) -> Result<(), &'static str> {
let commands = [
(101, "STATUS"),
(108, "ATTITUDE"),
(110, "ANALOG"),
(105, "RC"),
];
let poll_interval = Duration::from_millis(20); // 50 Hz
loop {
let start = Instant::now();
for (cmd, name) in &commands {
let checksum = 0 ^ cmd;
let request = [b'$', b'M', b'<', 0, *cmd, checksum];
port.write_all(&request).map_err(|_| "Write failed")?;
let mut buf = [0u8; 64];
let n = port.read(&mut buf).map_err(|_| "Read failed")?;
if n >= 6 && &buf[0..3] == b"$M>" {
let size = buf[3] as usize;
let payload = &buf[5..5 + size];
match *cmd {
108 => {
if let Some(att) = MspAttitude::from_bytes(payload) {
println!("Roll: {:.1}° Pitch: {:.1}° Yaw: {:.0}°",
att.roll_degrees(), att.pitch_degrees(), att.yaw_degrees());
}
}
110 => {
if let Some(analog) = MspAnalog::from_bytes(payload) {
println!("Battery: {:.2}V {:.1}A",
analog.voltage(), analog.current_amps());
}
}
_ => {}
}
}
}
let elapsed = start.elapsed();
if elapsed < poll_interval {
std::thread::sleep(poll_interval - elapsed);
}
}
}