Direct MAVLink Control¶
A direct pymavlink control path for ArduPilot and PX4 vehicles, for cases where the MAVROS bridge is unavailable, undesired, or insufficient (lighter companion stack, custom plugins, or a single owner of the FCU serial port).
MavlinkDrone is the same ArduPilot vehicle as MavrosDrone, reached over a different transport. Both subclass the shared ArduPilotDrone core, so all flight/navigation logic is identical — only the wire plumbing differs.
PymavlinkTransport is firmware-neutral: an injected MavlinkModeCodec isolates the one firmware difference (flight-mode encode/decode). ArduPilotModeCodec (default, SET_MODE + pymavlink mode_mapping()) backs MavlinkDrone; Px4ModeCodec (MAV_CMD_DO_SET_MODE with PX4 (main, sub) modes) backs Px4MavlinkDrone. Telemetry, setpoints, arming, params and stream rates are shared unchanged.
classDiagram
class VehicleDrone {
<<abstract>>
+takeoff() land() move_to() move_to_gps() rtl()
+arm()* set_mode() set_param() get_altitude()
}
class ArduPilotDrone
class Px4Drone
class VehicleTransport {
<<abstract>>
+state local_pose vision_pose gps heading rel_alt rangefinder distance_sensors
+arm() set_mode() command_takeoff() set_param()
+send_velocity_target() send_local_target() send_global_target()
}
class MavrosTransport
class PymavlinkTransport
class MavlinkModeCodec {
<<abstract>>
}
class ArduPilotModeCodec
class Px4ModeCodec
VehicleDrone o-- VehicleTransport
VehicleDrone <|-- ArduPilotDrone
VehicleDrone <|-- Px4Drone
VehicleTransport <|.. MavrosTransport
VehicleTransport <|.. PymavlinkTransport
ArduPilotDrone <|-- MavrosDrone
ArduPilotDrone <|-- MavlinkDrone
Px4Drone <|-- Px4MavlinkDrone
MavlinkDrone ..> PymavlinkTransport : builds
Px4MavlinkDrone ..> PymavlinkTransport : builds
PymavlinkTransport o-- MavlinkConnection
PymavlinkTransport o-- MavlinkModeCodec
MavlinkModeCodec <|.. ArduPilotModeCodec
MavlinkModeCodec <|.. Px4ModeCodec
Components¶
MavlinkConnection¶
Thin wrapper around mavutil.mavlink_connection in connection.py. Opens a single MAVLink endpoint, performs the heartbeat handshake to discover target_system/target_component, and exposes the raw connection via .master. Adds a send_lock: a single endpoint must have one RX reader but may have many senders (setpoints, heartbeat, rangefinder, vision bridge), and mav.*_send is not thread-safe, so every sender serializes through this lock.
PymavlinkTransport¶
transport.py — the VehicleTransport implementation that owns the FCU link directly.
- RX: a ROS timer on the drone's node drains
recv_match(blocking=False)and dispatches each message through a handler table. Decoded types:HEARTBEAT,GLOBAL_POSITION_INT,LOCAL_POSITION_NED,ATTITUDE,RANGEFINDER/DISTANCE_SENSOR,PARAM_VALUE,COMMAND_ACK, andSTATUSTEXT. This keeps the concurrency model identical to MAVROS — telemetry updates on the executor thread, blocking flight calls read it on the user thread. - TX: a 1 Hz heartbeat timer announces the companion; commands go via
command_long/set_mode/param_set; setpoints viaset_position_target_local_ned/set_position_target_global_int. - Frames: the core speaks ENU/FLU; the transport converts to the wire's NED/FRD on egress and back to ENU on ingest (exactly what MAVROS does internally).
- Streams: on
start()it requests message intervals viastreams.py(MAV_CMD_SET_MESSAGE_INTERVAL); a legacyREQUEST_DATA_STREAMhelper (request_data_streams) is also provided instreams.pyfor older firmware but is not called automatically.
STATUSTEXT surfacing¶
MAVROS forwards FCU STATUSTEXT to /rosout; the direct transport has no such relay, so _on_statustext logs FCU text on the drone's ROS logger at a matching severity (MAV_SEVERITY_ERROR → error, WARNING → warn, else info). This surfaces the actual reason a command was rejected — most usefully PreArm: ... failures — which would otherwise be invisible over a direct link. Consecutive identical messages are de-duplicated.
Command acknowledgements¶
arm(), disarm(), and command_takeoff() send COMMAND_LONG with want_ack=True and wait up to 3 s for COMMAND_ACK. Missing ACK or a result other than ACCEPTED / IN_PROGRESS returns False. The ArduPilot/PX4 vehicle layer then also requires is_armed to become true after arm — ACK alone is not enough. Takeoff/land settle semantics (including FCU STANDBY after land) live in the vehicle core.
Parameter confirmation¶
set_param clears any cached value, sends PARAM_SET, then waits up to 0.5 s for the FCU's PARAM_VALUE echo and verifies the echoed value matches (within tolerance) before returning True/logging the confirmation. ArduPilot echoes a known parameter within a few milliseconds and stays silent for an unknown one, so the short timeout keeps alias probing (4.6 WPNAV_* → 4.8 WP_*) responsive without false negatives on a fast link. Unlike the MAVROS service result (which only confirms the request was accepted), this confirms the value actually took.
Distance sensors¶
Each DISTANCE_SENSOR message is decoded into a DistanceReading and stored by sensor id in a copy-on-write map, exposed as distance_sensors (and get_distance(orientation) on the drone). The downward sensor also updates rangefinder. Every reported orientation is collected automatically, with no SDK-side configuration beyond the FCU rangefinder/proximity setup. See the vehicle core README for the data model.
Stream rates¶
streams.py requests these per-message rates on start() (override via MavlinkConfig.stream_rates). ArduPilot Non-GPS guidance wants pose ≥ 4 Hz; the defaults request more so PID navigation has fresh feedback.
| Message | Rate (Hz) |
|---|---|
HEARTBEAT |
1 |
SYS_STATUS |
2 |
EXTENDED_SYS_STATE |
2 |
ATTITUDE |
20 |
GLOBAL_POSITION_INT |
10 |
LOCAL_POSITION_NED |
20 |
GPS_RAW_INT |
5 |
RANGEFINDER |
10 |
DISTANCE_SENSOR |
10 |
VFR_HUD |
5 |
HOME_POSITION |
1 |
EXTENDED_SYS_STATE carries landed_state (same primary input as MAVSDK in_air). ArduPilot does not stream it unless requested; see Takeoff and Landing.
A rate <= 0 disables a stream. GPS_RAW_INT and a few others are requested for completeness even though position is taken from GLOBAL_POSITION_INT/LOCAL_POSITION_NED.
The single-RX-reader rule is what lets a RangefinderPublisher and a VisionPoseBridge share the same MavlinkConnection safely — they only send, through the lock.
Vision pose (indoor)¶
vision_bridge.py — GPS-denied external navigation. With
pose_source=VISION, PymavlinkTransport always fills vision_pose from
vision_pose_topic for companion PID. Who sends
VISION_POSITION_ESTIMATE
to the FCU (ArduPilot Non-GPS Position Estimation
wants ≥ 4 Hz) is controlled by auto_vision_feed:
auto_vision_feed |
FCU feed | Companion pose |
|---|---|---|
False (default) |
Separate vision_pose_node backend:=mavlink on another MAVLink endpoint |
VisionPoseSubscriber |
True |
Mission-owned VisionPoseBridge (optional vision_send_speed → VisionSpeedBridge) |
same bridge on_pose |
Never run both senders on the same endpoint. End-to-end pipeline (producer, backends, FCU setup): Localization.
Which topic to point at (vision_pose_topic):
| Scenario | Topic | Why |
|---|---|---|
| Real hardware | VSLAM output, /visual_slam/tracking/vo_pose_covariance (default) |
Subscribe directly to the estimator |
| Gazebo sim (indoor) | same | gz_vision_source republishes ground truth on the canonical topic (MAVLINK_SITL_VISION_CONFIG) |
Forwarding rate equals the subscription rate. vision_rate_hz is not wired — do not rely on it to throttle.
Quick start¶
Outdoor / SITL over TCP:
from nectar.control import DroneFactory, MavlinkConfig, PoseSource
config = MavlinkConfig(connection_string="tcp:127.0.0.1:5760", expect_lidar=False)
drone = DroneFactory.create("mavlink", config)
drone.takeoff(altitude=2.0)
drone.move_to(x=2.0, y=1.0, z=0.0, precision=0.2)
drone.rtl(land=True)
Real hardware over serial (Jetson ↔ Pixhawk):
config = MavlinkConfig(connection_string="/dev/ttyTHS1", baud=921600)
drone = DroneFactory.create("mavlink", config)
Indoor, vision-based (external feeder by default):
config = MavlinkConfig(
pose_source=PoseSource.VISION,
connection_string="/dev/ttyUSB0", # or a router UDP endpoint for the mission
vision_pose_topic="/visual_slam/tracking/vo_pose_covariance",
)
drone = DroneFactory.create("mavlink", config)
Ready-made presets (config.py): MAVLINK_SITL_CONFIG (tcp 5760),
MAVLINK_SITL_GAZEBO_CONFIG / MAVLINK_SITL_VISION_CONFIG (mission on SERIAL1 tcp 5762;
indoor feeder on SERIAL0 tcp 5760 when mavros:=false).
Connection string format¶
Unlike MavrosConfig (MAVROS fcu_url), MavlinkConfig.connection_string is pymavlink-native:
| Form | Example |
|---|---|
| TCP | tcp:127.0.0.1:5760 |
| UDP listener | udp:127.0.0.1:14551 |
| UDP sender | udpout:192.168.1.10:14550 |
| Serial | /dev/ttyUSB0 (+ baud=) |
Sharing the connection with a rangefinder¶
from nectar.sensors.rangefinder_publisher import RangefinderPublisher
pub = RangefinderPublisher(sensor, drone.connection) # shares the lock
pub.start()
When to use which transport¶
MavrosDrone ("mavros") |
MavlinkDrone ("mavlink") |
|
|---|---|---|
| Link | requires a running mavros_node |
owns the FCU endpoint directly |
| Deps | ROS MAVROS stack | pymavlink only |
| Indoor vision feed | vision_pose → MAVROS |
external vision_pose_node (default) or auto_vision_feed=True |
| Best for | full ROS deployments, existing MAVROS tooling | minimal companion stacks, single serial owner |
Both expose the identical Drone API and capabilities.
References¶
- pymavlink documentation
- MAVLink common messages
- ArduPilot Copter commands in Guided mode
- ArduPilot Non-GPS position estimation
- mavlink-router — fan out the FCU's serial to multiple endpoints