Module 4/5 · Weeks 10–12 · 30 h

Reading telemetry with pymavlink

DRT 114 Computer Programming

About 90 minDraft, awaiting reviewLast updated 26 September 2026

Lesson

By the end of this module you will be able to

  1. Explain the MAVLink 2 frame structure and read a header from raw bytes
  2. Read a telemetry file (.tlog) with pymavlink and count each message type
  3. Convert the units of HEARTBEAT, GLOBAL_POSITION_INT, SYS_STATUS and VFR_HUD correctly
  4. Find link outages from heartbeats and calculate packet loss from sequence numbers
  5. Export data to CSV and connect to SITL live safely

Prerequisites: DRT 114 module 3 · DRT 341 module 3 (MAVLink and the C2 link)

Why this matters

Drones running PX4 or ArduPilot report their state to the ground station over the MAVLink protocol, and most ground stations record every message they receive in a .tlog file. When something goes wrong, that file is the evidence of what the drone reported, when, and when the link dropped (DRT 342 module 4). Someone who can read it with a program analyses incidents faster and more precisely than by scrolling graphs in a packaged tool.

This module uses telemetry_sample.tlog, synthetic data from the same flight model as module 3, with a deliberate 8-second link outage.

Five steps: aircraft or SITL sends data over a UDP or serial link to a ground station, QGC or MAVProxy, which records a .tlog file; Python reads it with pymavlink to produce CSV, plots, link gaps and packet loss; a dashed line shows Python can also connect live over UDP
Figure 1 Telemetry path from aircraft to program

Every MAVLink 2 message starts with the byte 0xFD (MAVLink 1 uses 0xFE), followed by a header giving the length, sequence number, sender and message ID, then the payload and a checksum.

One MAVLink 2 frame, bytes left to right: STX 0xFD, LEN, INC FLAGS, CMP FLAGS, SEQ, SYS ID, COMP ID, three-byte MSG ID, PAYLOAD of 0 to 255 bytes, two-byte CRC and a 13-byte SIG; below, the 10-byte header tells who sent it, which message and which number, and the signature is present only when message signing is on
Figure 2 Structure of a MAVLink 2 frame

A .tlog stores each message after an 8-byte receive time (microseconds since 1970, big-endian). Reading the raw bytes once shows what the library does for us.

import struct
from datetime import datetime, timezone

with open("telemetry_sample.tlog", "rb") as f:
    raw = f.read(20)

ts_us = struct.unpack(">Q", raw[:8])[0]
print(datetime.fromtimestamp(ts_us / 1e6, tz=timezone.utc).isoformat())
print(" ".join(f"{b:02X}" for b in raw[8:20]))

stx, length, incompat, compat, seq, sysid, compid = raw[8:15]
msgid = int.from_bytes(raw[15:18], "little")
print(f"stx=0x{stx:02X} len={length} seq={seq} sysid={sysid} compid={compid} msgid={msgid}")
2026-03-14T02:30:00+00:00
FD 09 00 00 00 01 01 00 00 00 04 00
stx=0xFD len=9 seq=0 sysid=1 compid=1 msgid=0

Message ID 0 is HEARTBEAT, and the payload is 9 bytes because MAVLink 2 trims trailing zero bytes from the payload before sending.

pymavlink splits bytes into messages. mavutil.mavlink_connection() opens both files and live connections.

Watch out: blocking with files

When reading a file, use recv_match(blocking=False) and stop when it returns None. With blocking=True the program waits forever for a new message at the end of the file. blocking=True suits live connections where messages keep arriving, and should be given a timeout.

from collections import Counter
from pymavlink import mavutil


def read_all(path, msg_type=None):
    """Yield every message (optionally of one type) from a .tlog file."""
    link = mavutil.mavlink_connection(path)
    while True:
        msg = link.recv_match(type=msg_type, blocking=False)
        if msg is None:
            return
        yield msg


counts = Counter(msg.get_type() for msg in read_all("telemetry_sample.tlog"))
print(counts.most_common())
[('ATTITUDE', 3955), ('GLOBAL_POSITION_INT', 1582), ('VFR_HUD', 1582), ('HEARTBEAT', 396), ('SYS_STATUS', 396)]

Because read_all() uses yield, it is a generator: it hands out messages one at a time without holding the whole file in memory, which suits large logs.

Units of key messages

MAVLink stores numbers as integers in units that differ from everyday ones. Always convert, following the common message set documentation.

MessageFieldStored unitConvert to
HEARTBEAT (#0)type, autopilot, base_mode, custom_modeEnum codes and bitsVehicle type, autopilot, armed state, flight mode
SYS_STATUS (#1)voltage_batterymV÷ 1000 → V
current_batterycA÷ 100 → A
battery_remaining%−1 means unknown
ATTITUDE (#30)roll, pitch, yawrad× 180/π → degrees
GLOBAL_POSITION_INT (#33)lat, londegE7÷ 10⁷ → degrees
alt, relative_altmm÷ 1000 → m
vx, vy, vzcm/s÷ 100 → m/s
hdgcdeg÷ 100 → degrees
VFR_HUD (#74)groundspeed, alt, climbm/s, m, m/sUse as is

Example 1 Decoding a heartbeat and a position

hb = next(read_all("telemetry_sample.tlog", "HEARTBEAT"))
print("quadrotor:", hb.type == mavutil.mavlink.MAV_TYPE_QUADROTOR)
print("ArduPilot:", hb.autopilot == mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA)
print("mode:", mavutil.mode_string_v10(hb))
print("armed:", bool(hb.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED))

pos = next(m for m in read_all("telemetry_sample.tlog", "GLOBAL_POSITION_INT") if m.relative_alt > 29_000)
print(f"lat {pos.lat / 1e7:.6f}, lon {pos.lon / 1e7:.6f}")
print(f"height above home {pos.relative_alt / 1000:.1f} m, heading {pos.hdg / 100:.0f} deg")
print(f"climb rate {-pos.vz / 100:.1f} m/s")
quadrotor: True
ArduPilot: True
mode: GUIDED
armed: False
lat 14.099999, lon 100.599999
height above home 29.5 m, heading 90 deg
climb rate 2.5 m/s

In the NED frame vz is positive downwards, so flip its sign to get the climb rate (review frames in DRT 344 module 5). The armed state is one bit of base_mode, extracted with the & operator.

Every message has a sequence number from 0 to 255 that wraps around. A jump means packets were sent but not received. Heartbeats are typically sent about once per second on radio links (MAVLink says the rate depends on the channel and is not fixed by the protocol), so an unusually long gap between heartbeats reveals a link outage.

Example 2 Analysing link quality

t0 = prev_t = prev_seq = None
gaps, modes = [], []
received = lost = 0

for msg in read_all("telemetry_sample.tlog"):
    received += 1
    seq = msg.get_seq()
    if prev_seq is not None:
        lost += (seq - prev_seq - 1) % 256
    prev_seq = seq

    if msg.get_type() == "HEARTBEAT":
        t = msg._timestamp
        t0 = t if t0 is None else t0
        if prev_t is not None and t - prev_t > 3:
            gaps.append((round(prev_t - t0, 1), round(t - prev_t, 1)))
        prev_t = t
        mode = mavutil.mode_string_v10(msg)
        if not modes or modes[-1][1] != mode:
            modes.append((round(t - t0, 1), mode))

print("heartbeat gaps (start s, length s):", gaps)
print(f"lost {lost} of {received + lost} packets ({100 * lost / (received + lost):.2f}%)")
print("mode changes:", modes)
heartbeat gaps (start s, length s): [(149.0, 9.0)]
lost 160 of 8071 packets (1.98%)
mode changes: [(0.0, 'GUIDED'), (22.0, 'AUTO'), (352.0, 'RTL'), (387.0, 'LAND')]

There is a 9-second heartbeat gap after second 149, matching the model’s 8-second outage (heartbeats come every second, so the observed gap can be up to a second longer than the real outage), and the sequence numbers show 160 lost packets. Using % 256 counts correctly even when the sequence wraps from 255 to 0.

Exporting to CSV

import pandas as pd

rows = [
    {
        "time_s": m.time_boot_ms / 1000,
        "lat_deg": m.lat / 1e7,
        "lon_deg": m.lon / 1e7,
        "rel_alt_m": m.relative_alt / 1000,
    }
    for m in read_all("telemetry_sample.tlog", "GLOBAL_POSITION_INT")
]
position = pd.DataFrame(rows)
position.to_csv("position_from_tlog.csv", index=False)
print(position.shape, "max height", position["rel_alt_m"].max(), "m")
(1582, 4) max height 30.0 m

The file feeds straight into the module 3 tools. time_boot_ms counts from when the autopilot booted, not wall-clock time; for wall-clock time use _timestamp, recorded by the ground station.

Connecting to SITL live

When ready, connect to SITL (DRT 342 module 2). The same code works; only the source changes. ArduPilot SITL through MAVProxy outputs on UDP port 14550, and PX4 SITL sends to ground stations on UDP 14550 and to offboard programs on UDP 14540.

from pymavlink import mavutil

link = mavutil.mavlink_connection("udp:127.0.0.1:14550")
link.wait_heartbeat(timeout=10)
print("connected to system", link.target_system)

for _ in range(20):
    att = link.recv_match(type="ATTITUDE", blocking=True, timeout=2)
    if att is not None:
        print(f"roll {att.roll:.3f} rad, pitch {att.pitch:.3f} rad")

Safety

This lesson only reads telemetry. Sending commands to a drone through pymavlink must always be tried in SITL first, and a real aircraft’s MAVLink port must never be exposed to the internet, where anyone could send it commands.

Module lab

Lab: a link-quality report from a tlog

Follow lab guide L09 “Reading MAVLink telemetry from SITL” in the drone knowledge hub.

  1. Read telemetry_sample.tlog, count each message type, and calculate each type’s messages per second.
  2. Find link outages and packet loss, and report when the flight mode changes.
  3. Export position, battery voltage (from SYS_STATUS) and speed (from VFR_HUD) to CSV, and compare with module 3’s flight_log_sample.csv.
  4. If a computer can run SITL, connect live, fly a short mission, record a tlog and run the same program on it.

Common mistakes

Watch out

  • Using blocking=True on a file, so the program hangs at the end
  • Forgetting unit conversion, such as plotting degE7 lat directly or reading relative_alt as metres
  • Forgetting to flip vz in the NED frame
  • Counting packet loss without handling sequence wrap-around
  • Assuming heartbeats must arrive every second by standard, when that is only the usual practice on radio links
  • Exposing a MAVLink port to outside networks

Summary

  • A MAVLink 2 frame starts with 0xFD, and its header gives the length, sequence, sender and message ID
  • mavutil.mavlink_connection() opens both .tlog files and live links; read files with blocking=False
  • Always convert units: degE7 → degrees, mm → m, cm/s → m/s, cdeg → degrees
  • Heartbeat gaps reveal link outages, and sequence jumps count lost packets
  • Export to CSV for pandas analysis, and test with SITL before working with a real aircraft

Check your understanding

  1. What is the first byte of a MAVLink 2 frame, and of MAVLink 1?
  2. What latitude in degrees is lat = 140999986 in GLOBAL_POSITION_INT?
  3. How many volts is voltage_battery = 24310 in SYS_STATUS?
  4. The previous sequence number was 250 and the next message received has 3. How many packets were lost?
  5. Why does reading a .tlog with recv_match(blocking=True) hang?
Answers
  1. MAVLink 2 is 0xFD; MAVLink 1 is 0xFE
  2. degrees
  3. V
  4. packets
  5. At the end of the file it waits for a new message that will never come; use blocking=False and stop on None

Key formulas

MAVLink coordinate units
Packet loss from sequence numbers

Key references

  1. MAVLink Development Team. Packet serialization. MAVLink developer guide. link
  2. MAVLink Development Team. MAVLink common message set (common.xml). link
  3. ArduPilot and MAVLink contributors. (2026). pymavlink (Version 2.4.50) [Computer software]. PyPI. link
  4. ArduPilot Dev Team. SITL simulator (software in the loop). link
  5. PX4 Autopilot. PX4 user and developer guide. link
  6. MAVLink Development Team. MAVLink developer guide. link

Further reading

Study the assigned knowledge units in advance, review media and take the module quiz

In class / field

Lab or field practice from worksheets with a safety checklist

Learning evidence: Checked worksheets and quiz results

Module quiz

This is a formative self-check, not a graded exam

Knowledge domain: Communications, networks and IoT · Programming and digital technology