What MAVROS is and why you need it
Remember the architecture from the roadmap:
ROS 2 nodes MAVROS ArduPilot SITL
(our code) ←——————————————→ (flight controller)
translator
The problem: ROS 2 and ArduPilot speak different languages.
- ROS 2 uses its own message types:
geometry_msgs/PoseStamped,sensor_msgs/Imu, and so on. - ArduPilot speaks MAVLink — a binary protocol designed specifically for drones. Not JSON, not HTTP — its own byte format, very compact and fast. A typical MAVLink heartbeat packet is just 17 bytes; a position update is around 28. The equivalent JSON would be 150–200 bytes once you add field names and structure overhead. Over a flaky 433 MHz radio link with maybe 19 kbit/s of real throughput, those bytes matter.
MAVROS is a ROS 2 translator node. It sits in the middle and does two things:
ArduPilot MAVROS Our code
───────── ──────── ─────────
MAVLink packet ──────→ translates ──────→ ROS 2 topic
"altitude 1.5 m" into PoseStamped /mavros/local_position/pose
ROS 2 topic ←────── translates ←────── MAVLink packet
/mavros/cmd/arming into CMD_COMPONENT_ARM "arm throttle"
Analogy: you’ve landed in Japan and don’t speak Japanese. MAVROS is your translator. You tell it “take off to 2 meters” in your language (ROS 2), it translates that into Japanese (MAVLink), and passes it to the pilot (ArduPilot). The catch: a real translator can paraphrase if needed — MAVROS does a strict byte-level translation. If your ROS 2 message has fields MAVLink doesn’t know about, they’re silently dropped. Worth knowing when something doesn’t reach the autopilot.
Key MAVROS topics
Read (ArduPilot → our code):
| Topic | What it contains |
|---|---|
/mavros/state |
armed, mode, connected |
/mavros/local_position/pose |
XYZ position + orientation |
/mavros/imu/data |
IMU data |
/mavros/battery |
battery charge |
Here’s what /mavros/local_position/pose actually looks like when the drone is hovering at 1.5 m near the origin:
header:
frame_id: "map"
stamp: {sec: 1715432891, nanosec: 234567890}
pose:
position: {x: 0.03, y: -0.01, z: 1.504}
orientation: {x: 0.0, y: 0.0, z: 0.0, w: 1.0}
Those come in at ~30 Hz by default. If your control loop needs more, crank it up through the MAVROS streamrate params (/mavros/setpoint_raw/stream_rate and friends) — 100 Hz is reasonable on a wired link, much less so over telemetry radio.
Write (our code → ArduPilot):
| Topic | What it does |
|---|---|
/mavros/setpoint_position/local |
fly to this XYZ point |
/mavros/setpoint_velocity/cmd_vel |
fly at this velocity |
/mavros/cmd/arming |
enable/disable motors |
/mavros/set_mode |
switch mode (GUIDED, LOITER, …) |
Heads up: setpoint topics need to be continuously published at ≥2 Hz, otherwise ArduPilot falls back to LOITER (it assumes the control link died). A single pub --once call won’t fly the drone anywhere — you need a timer-driven publisher that keeps republishing, even when the setpoint isn’t changing.
Why not just MAVProxy?
MAVProxy is a console for humans. You type takeoff 2 by hand. MAVROS is for programmatic control — your Python script publishes to a topic, MAVROS translates, ArduPilot executes. No human in the middle, no fragile terminal interaction.
In practice you often run both side-by-side during early bring-up: MAVProxy in one terminal for manual safety overrides (mode LAND is one keystroke away), MAVROS feeding the autonomy stack in another. Once the policy is stable, MAVProxy drops out.
Install MAVROS
deactivate # if a venv is active
sudo apt install ros-jazzy-mavros ros-jazzy-mavros-extras -y
After the install — mandatory step, without it MAVROS won’t start:
sudo ros2 run mavros install_geographiclib_datasets.sh
What this does: downloads ~200 MB of GeographicLib data files used for converting between geographic coordinate systems (WGS84 ↔ local frames). Skip it and MAVROS will spit unable to find geoid_height_table.txt at startup and refuse to publish position data. The download is one-time per machine; you don’t redo it for new sessions.
If sudo doesn’t pass ROS through:
sudo bash /opt/ros/jazzy/lib/mavros/install_geographiclib_datasets.sh
Test
Terminal 1 — Gazebo (should already be running with -r)
Terminal 2 — SITL (should already be running)
Terminal 3 — MAVROS:
source /opt/ros/jazzy/setup.bash
ros2 launch mavros apm.launch.py fcu_url:=udp://:14550@
If it doesn’t find the launch file, check:
ls /opt/ros/jazzy/share/mavros/launch/
# apm_config.yaml multi_uas.launch px4.launch
# apm.launch node.launch px4_pluginlists.yaml
# apm_pluginlists.yaml px4_config.yaml test_compose.launch.py
source /opt/ros/jazzy/setup.bash
ros2 launch mavros apm.launch fcu_url:=udp://:14550@
What we check — 5–10 seconds later, in a new terminal:
source /opt/ros/jazzy/setup.bash
ros2 topic echo /mavros/state
What you should see:
connected: true
armed: false
mode: GUIDED
If connected: false — MAVROS can’t reach SITL. Three usual causes:
- SITL isn’t actually running. Check terminal 2 for
EKF3 IMU0 is using GPS-style chatter; if it’s silent, SITL crashed or never started. - Wrong UDP port.
udp://:14550@assumes SITL is sending to 14550 (the default). If you launched SITL with--out=127.0.0.1:14551, point MAVROS at 14551 in thefcu_url. - Firewall blocking localhost UDP. Rare on a clean Ubuntu install, but worth a
sudo ufw statuscheck if everything else looks right.
If armed: false but you want to arm: that’s the next page — arming and switching to GUIDED is a separate dance (pre-arm checks, EKF origin set, GPS lock or EK3_SRC1_POSXY=6 for non-GPS mode). Don’t try to publish setpoints to a disarmed drone — they queue up and snap into action the moment arming succeeds, which is rarely what you wanted.