Skip to content

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, and STATUSTEXT. 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 via set_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 via streams.py (MAV_CMD_SET_MESSAGE_INTERVAL); a legacy REQUEST_DATA_STREAM helper (request_data_streams) is also provided in streams.py for 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_ERRORerror, WARNINGwarn, 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.

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
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

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.

VisionPoseBridge

vision_bridge.py — indoor external-navigation feed. With MAVROS gone, the FCU's EKF3 still needs an external position source. When pose_source=VISION, PymavlinkTransport.start() constructs and starts this bridge automatically: it subscribes to MavlinkConfig.vision_pose_topic and forwards each received sample to the FCU as VISION_POSITION_ESTIMATE (ArduPilot Non-GPS Position Estimation wants ≥ 4 Hz). It also exposes the same pose as vision_pose so companion-side PID navigation works. This is the MAVLink-side equivalent of the MAVROS MavrosVisionRelay; the end-to-end indoor pipeline (Isaac VSLAM producer, both backends, FCU setup) is documented in Localization.

Which topic to point at (vision_pose_topic):

Scenario Topic Why
Real hardware the VSLAM output, /visual_slam/tracking/vo_pose_covariance (default) The bridge is the relay; subscribe directly to the estimator, no MAVROS involved.
Gazebo sim (indoor) /visual_slam/tracking/vo_pose_covariance gz_vision_source publishes Gazebo ground-truth pose on the same canonical topic, so the sim path matches real hardware. The MAVLINK_SITL_VISION_CONFIG preset sets this.

The forwarding rate equals the subscription rate — the bridge does not resample.

Warning: MavlinkConfig.vision_rate_hz exists but is not currently wired into the bridge; do not rely on it to throttle or pad the feed.

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 (VisionPoseBridge feeds the EKF automatically):

config = MavlinkConfig(
    pose_source=PoseSource.VISION,
    connection_string="/dev/ttyUSB0",
    vision_pose_topic="/visual_slam/tracking/vo_pose_covariance",
)
drone = DroneFactory.create("mavlink", config)

Ready-made presets (config.py): MAVLINK_SITL_CONFIG (SITL on tcp 5760), MAVLINK_SITL_GAZEBO_CONFIG (SITL+Gazebo on the secondary port tcp 5762, so a direct link can run alongside MAVROS on 5760), and MAVLINK_SITL_VISION_CONFIG (indoor, vision_pose_topic=/visual_slam/tracking/vo_pose_covariance).

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_to_mavros + MAVROS built-in VisionPoseBridge
Best for full ROS deployments, existing MAVROS tooling minimal companion stacks, single serial owner

Both expose the identical Drone API and capabilities.

References