PX4 - Controls

Flight Stack

px4-high-level-flight-stack.pngPX4 High-Level Flight Stack
Guidance + Navigation + Control Algorithms for autonomous drones.

Estimator - Takes one or more sensor inputs, combines them, and computes vehicle state.

Controller - Takes setpoint and measurement/estimated state as input.

Mixer - Takes force commands and translates them into individual motor commands (specific to vehicle type).

Offboard Control Mode

It may be possible to directly control the drone with Offboard Control.

Offboard mode is used for controlling vehicle movement and attitude, by setting:

Requires 2 Hz "proof of life" signal or it will switch out of offboard mode after COM_OF_LOSS_T timeout.

In ROS 2, POL signal is through OffboardControlMode messages. Actual setpoint publish to uORB topics, like TrajectorySetpoint.

The fields are ordered in terms of priority => position takes precedence over velocity and later fields, velocity takes precedence over acceleration, and so on.

desired control quantity required estimate required message
position (NED) position TrajectorySetpoint
velocity (NED) velocity TrajectorySetpoint
acceleration (NED) velocity TrajectorySetpoint
attitude (FRD) None VehicleAttitudeSetpoint
body_rate (FRD) None VehicleRatesSetpoint
thrust and torque (FRD) None VehicleAttitudeSetpoint & VehicleTorqueSetpoint
direct motors and servos None ActuatorMotors & ActuatorServos

Position setpoint (position different from NaN). Non-NaN values of velocity and acceleration are used as feedforward terms for the inner loop controllers.

Velocity setpoint (velocity different from NaN and position set to NaN). Non-NaN values acceleration are used as feedforward terms for the inner loop controllers.

Acceleration setpoint (acceleration different from NaN and position and velocity set to NaN)

All values in NED system at m, m/s and m/s2 respectively.

quaternion q_d + thrust setpoint thrust_body. Non-NaN values of yaw_sp_move_rate are used as feedforward terms expressed in Earth frame and in rad/s.

Quaternion represents rotation between body FRD (front, right, down) and NED (north, east, down).

Thrust is in FRD and expressed as normalised [-1, 1] values.

xyz for thrust and xyz for torque.

All the values are in the drone body FRD frame and normalized in [-1, 1].

All the values normalized in [-1, 1]. For outputs that do not support negative values, negative entries map to NaN.

NaN maps to disarmed.

MAVLink vs ROS 2 Offboard Control

A ROS 2 Library and Interface for External PX4 Flight Modes ## Limitations of MAVLink method - Only a single app can control vehicle. - Requires MAVLink: hard to extend or access other data through this. - Limited integration with failsafe state machine & prearm checks. - Limited set of setpoint types. - Cannot specify displayed mode name in the GCS. ## Modes Component that can send one or more setpoints to control vehicle motion.

It is a passive component so it cannot activate other modes.

By itself, it can perform a number of tasks.

A mode can also replace internal PX4 mode.

It will also register with PX4 on startup.

Failsafe and Arming Checks

A mode has a set of requirement flags.

Non-essential falisafes can be temporarily deferred.

Unresponsive or crashed modes can trigger a failsafe.

Mode Executors

Can be used to change modes.

State machines that run through different modes, waiting for them to complete (similar to navigator module).

Only works while it is in charge,

Is transparent for the user.

Control Interfaces

The mode:

Mode space: What do I want to do? How do I do it? --> Control space: Control options?

ROS 2 mode streams set point for FMU controllers to track.

Example: Offboard with MAVLink API

Create Package

Control scripts for PX4 can be written in C++ or Python and launch as ROS 2 nodes. Here, we will only focus on the Python implementations.

The nodes that will control the drone will be added to a new ROS 2 package inside the pre-existing workspace made here. First, navigate to the workspace root:

# If the following workspace does not exist, refer to the hyperlinked page to create the workspace.
# This is important since packages relevant to controlling the PX4 are installed in this workspace.
cd ~/px4_ws

The source files for packages inside this workspace are in the /src directory. In here, we will create a new package called px4_control_py. This is where all the Python nodes will be added. This package will have a set of dependencies: rclpy, px4_msgs, std_msgs, sensor_msgs, geometry_msgs. This package will use the ament_python build system since the nodes are primarily in Python.

cd ./src
# Create package
ros2 pkg create --build-type ament_python px4_control_py --dependencies rclpy px4_msgs std_msgs sensor_msgs geometry_msgs

This should create a new folder inside the directory. Observe the directory tree:

L px4_ws/
   L build/
       L ...
   L install/
       L ...
   L log/
       L ...
   L src/
       L px4_msgs
       L px4_ros_coms
       L px4_control_py
           L px4_control_py
               L __init__.py
           L resource
           L test
           L package.xml
           L setup.cfg
           L setup.py

Writing a Control Node

The node will be a Python script, and will be located inside px4_control_py package.

touch ~/px4_ws/src/px4_control_py/px4_control_py/px4_control_node.py

Copy and paste the following code inside the script file:

# px4_control_node.py
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy, DurabilityPolicy
from px4_msgs.msg import OffboardControlMode, TrajectorySetpoint, VehicleCommand, VehicleLocalPosition, VehicleStatus


class OffboardControl(Node):
"""Node for controlling a vehicle in offboard mode."""

   def __init__(self) -> None:
       super().__init__('offboard_control_node')

       # Configure QoS profile for publishing and subscribing
       qos_profile = QoSProfile(
           reliability=ReliabilityPolicy.BEST_EFFORT,
           durability=DurabilityPolicy.TRANSIENT_LOCAL,
           history=HistoryPolicy.KEEP_LAST,
           depth=1
       )

       # Create publishers
       self.offboard_control_mode_publisher = self.create_publisher(
           OffboardControlMode, '/fmu/in/offboard_control_mode', qos_profile)
       self.trajectory_setpoint_publisher = self.create_publisher(
           TrajectorySetpoint, '/fmu/in/trajectory_setpoint', qos_profile)
       self.vehicle_command_publisher = self.create_publisher(
           VehicleCommand, '/fmu/in/vehicle_command', qos_profile)

       # Create subscribers
       self.vehicle_local_position_subscriber = self.create_subscription(
           VehicleLocalPosition, '/fmu/out/vehicle_local_position', self.vehicle_local_position_callback, qos_profile)
       self.vehicle_status_subscriber = self.create_subscription(
           VehicleStatus, '/fmu/out/vehicle_status', self.vehicle_status_callback, qos_profile)

       # Initialize variables
       self.offboard_setpoint_counter = 0
       self.vehicle_local_position = VehicleLocalPosition()
       self.vehicle_status = VehicleStatus()
       self.takeoff_height = -5.0

       # Create a timer to publish control commands
       self.timer = self.create_timer(0.1, self.timer_callback) # 10Hz frequency

   def vehicle_local_position_callback(self, vehicle_local_position):
       """Callback function for vehicle_local_position topic subscriber."""
       self.vehicle_local_position = vehicle_local_position

   def vehicle_status_callback(self, vehicle_status):
       """Callback function for vehicle_status topic subscriber."""
       self.vehicle_status = vehicle_status

   def arm(self):
       """Send an arm command to the vehicle."""
       self.publish_vehicle_command(
       VehicleCommand.VEHICLE_CMD_COMPONENT_ARM_DISARM, param1=1.0)
       self.get_logger().info('Arm command sent')

   def disarm(self):
       """Send a disarm command to the vehicle."""
       self.publish_vehicle_command(
       VehicleCommand.VEHICLE_CMD_COMPONENT_ARM_DISARM, param1=0.0)
       self.get_logger().info('Disarm command sent')

   def engage_offboard_mode(self):
       """Switch to offboard mode."""
       self.publish_vehicle_command(
       VehicleCommand.VEHICLE_CMD_DO_SET_MODE, param1=1.0, param2=6.0)
       self.get_logger().info("Switching to offboard mode")

   def land(self):
       """Switch to land mode."""
       self.publish_vehicle_command(VehicleCommand.VEHICLE_CMD_NAV_LAND)
       self.get_logger().info("Switching to land mode")

   def publish_offboard_control_heartbeat_signal(self):
       """Publish the offboard control mode."""
       msg = OffboardControlMode()
       msg.position = True
       msg.velocity = False
       msg.acceleration = False
       msg.attitude = False
       msg.body_rate = False
       msg.timestamp = int(self.get_clock().now().nanoseconds / 1000)
       self.offboard_control_mode_publisher.publish(msg)

   def publish_position_setpoint(self, x: float, y: float, z: float):
       """Publish the trajectory setpoint."""
       msg = TrajectorySetpoint()
       msg.position = [x, y, z]
       msg.yaw = 1.57079  # (90 degree)
       msg.timestamp = int(self.get_clock().now().nanoseconds / 1000)
       self.trajectory_setpoint_publisher.publish(msg)
       self.get_logger().info(f"Publishing position setpoints {[x, y, z]}")

   def publish_vehicle_command(self, command, **params) -> None:
       """Publish a vehicle command."""
       msg = VehicleCommand()
       msg.command = command
       msg.param1 = params.get("param1", 0.0)
       msg.param2 = params.get("param2", 0.0)
       msg.param3 = params.get("param3", 0.0)
       msg.param4 = params.get("param4", 0.0)
       msg.param5 = params.get("param5", 0.0)
       msg.param6 = params.get("param6", 0.0)
       msg.param7 = params.get("param7", 0.0)
       msg.target_system = 1
       msg.target_component = 1
       msg.source_system = 1
       msg.source_component = 1
       msg.from_external = True
       msg.timestamp = int(self.get_clock().now().nanoseconds / 1000)
       self.vehicle_command_publisher.publish(msg)

   def timer_callback(self) -> None:
       """Callback function for the timer."""
       self.publish_offboard_control_heartbeat_signal()

       if self.offboard_setpoint_counter == 10:
           self.engage_offboard_mode()
           self.arm()

       if self.vehicle_local_position.z > self.takeoff_height and self.vehicle_status.nav_state == VehicleStatus.NAVIGATION_STATE_OFFBOARD:
           self.publish_position_setpoint(0.0, 0.0, self.takeoff_height)

       elif self.vehicle_local_position.z <= self.takeoff_height:
           self.land()
           exit(0)

       if self.offboard_setpoint_counter < 11:
           self.offboard_setpoint_counter += 1

def main(args=None) -> None:
   print('Starting offboard control node...')
   rclpy.init(args=args)
   offboard_control = OffboardControl()
   rclpy.spin(offboard_control)
   offboard_control.destroy_node()
   rclpy.shutdown()

if __name__ == '__main__':
   try:
       main()
   except Exception as e:
       print(e)

This node will activate offboard control mode and publish a offboard control signal (self.publish_offboard_control_heartbeat_signal()) to the UAV at a 10Hz frequency to ensure that it doesn't leave the offboard control mode.

It will then arm the drone, cause it to takeoff to a height of 5m, and then land safely. Edit the following segment of the code to match your control needs:

def timer_callback(self) -> None:
   """Callback function for the timer."""
   self.publish_offboard_control_heartbeat_signal()

   if self.offboard_setpoint_counter == 10:
       self.engage_offboard_mode()
       self.arm()

   if self.vehicle_local_position.z > self.takeoff_height and self.vehicle_status.nav_state == VehicleStatus.NAVIGATION_STATE_OFFBOARD:
       self.publish_position_setpoint(0.0, 0.0, self.takeoff_height)

   elif self.vehicle_local_position.z <= self.takeoff_height:
       self.land()
       exit(0)

   if self.offboard_setpoint_counter < 11:
       self.offboard_setpoint_counter += 1

Now, all that is left is to build this node so that it can be launched.

Open in a text editor:
~/px4_ws/src/px4_control_py/setup.py

Edit it so that it looks like this:

# setup.py
from setuptools import find_packages, setup

package_name = 'px4_control_py'

setup(
   name=package_name,
   version='0.0.0',
   packages=find_packages(exclude=['test']),
   data_files=[
       ('share/ament_index/resource_index/packages',
       ['resource/' + package_name]),
       ('share/' + package_name, ['package.xml']),
   ],
   install_requires=['setuptools'],
   zip_safe=True,
   maintainer='name',
   maintainer_email='name@todo.todo',
   description='Package description',
   license='License declaration',
   tests_require=['pytest'],
   entry_points={
           'console_scripts': [
           'offboard_control_node = px4_control_py.px4_control_node:main',
       ],
   },
)

Finally, in a new terminal, run the following command:

# Build the new node and source the workspace
cd ~/px4_ws
colcon build
source install/local_setup.bash

Now, we can launch this node alongside our simulation. Run the following sequence of terminal command.

# in Terminal 1
cd ~/PX4-Autopilot
make px4_sitl gz_x500
# in Terminal 2
MicroXRCEAgent udp4 -p 8888
# in Terminal 3
source ~/px4_ws/install/local_setup.bash
ros2 run px4_control_py px4_control_node

Gimbal

Note

May also need to set COM_PREARM_MODE -> "Always"

For gimbal control in PX4 we need to enable it in mavlink shell:

gimbal start
gimbal primary-control 1 1

You also need to assign the required PWM motors to Gimbal (roll, pitch, yaw)
20251028140438-actuator_gimbal_settings.png
In case the gimbal control status does not sawy Input: Mavlink, you may need to edit params as follows:
20251028140422-mavlink_gimbal_params.png
From code, it can work with:

#!/usr/bin/env python3
import asyncio
from mavsdk import System
from mavsdk.action import ActionError
from mavsdk.gimbal import (GimbalError, ControlMode, GimbalMode, SendMode)

CONNECTION_STRING = "tcpout://192.168.200.2:5760"
GIMBAL_ID = 0

async def run():
    drone = System()
    print(f"Connecting to drone at {CONNECTION_STRING}...")
    await drone.connect(system_address=CONNECTION_STRING)

    print("Waiting for drone to connect...")
    async for state in drone.core.connection_state():
        if state.is_connected:
            print("Drone connected!")
            break

    print("Arming the vehicle...")
    try:
        await drone.action.arm()
        print("Vehicle ARMED.")
    except ActionError as e:
        print(f"Arming FAILED: {e}") 
        return

    print("--- Starting Gimbal Control Test ---")

    try:
        # --- 1. Take Control ---
        print("Taking primary control of gimbal...")
        await drone.gimbal.take_control(
            gimbal_id=GIMBAL_ID,
            control_mode=ControlMode.PRIMARY
        )
        await asyncio.sleep(1)

        # --- 2. Set Roll Angle (MAIN 7) ---
        await drone.gimbal.set_angles(
            gimbal_id=GIMBAL_ID,
            roll_deg=90.0,
            pitch_deg=0.0,
            yaw_deg=0.0,
            gimbal_mode=GimbalMode.YAW_LOCK,
            send_mode=SendMode.STREAM
        )
        await asyncio.sleep(4)

        # --- 3. Set Pitch Angle (MAIN 8) ---
        await drone.gimbal.set_angles(
            gimbal_id=GIMBAL_ID,
            roll_deg=0.0,
            pitch_deg=90.0,
            yaw_deg=0.0,
            gimbal_mode=GimbalMode.YAW_LOCK,
            send_mode=SendMode.STREAM
        )
        await asyncio.sleep(4)

        print("Resetting gimbal to 0, 0, 0...")
        await drone.gimbal.set_angles(
            gimbal_id=GIMBAL_ID,
            roll_deg=0.0,
            pitch_deg=0.0,
            yaw_deg=0.0,
            gimbal_mode=GimbalMode.YAW_LOCK,
            send_mode=SendMode.STREAM
        )
        await asyncio.sleep(2)

        print("Releasing gimbal control...")
        await drone.gimbal.release_control(gimbal_id=GIMBAL_ID)

    except GimbalError as e:
        print(f"Gimbal command failed: {e}")

    print("--- Test Complete. Disarming. ---")
    await drone.action.disarm()


if __name__ == "__main__":
    asyncio.run(run())