Aerostack2

DocsTutorials

From simulation to a real drone

Fly the mission you wrote in simulation on a Crazyflie or a Tello.

Authored by
  • @alvgaona

In this tutorial you take the mission from A mission in Python to a real drone, a Bitcraze Crazyflie or a Ryze Tello. The mission script barely changes. What changes is the platform you launch, where the drone's position comes from, and how careful you have to be.

A real drone can hurt you

Fly in a closed space with a net or nobody inside, with propeller guards on. Keep the first flights low and slow, and keep a way to stop the motors at hand. Read Stopping in an emergency before the first flight.

Before you start

  • You've done A mission in Python. This tutorial reuses its my_mission.py, and Move to Gazebo helps too.
  • The drone's platform is installed. The Crazyflie and Ryze Tello pages explain how to build it and set up the drone. The Crazyflie platform builds on ROS 2 Humble only.
  • The drone can tell where it is. A Crazyflie needs a Flow deck, a Lighthouse system or motion capture. A Tello measures its height and speed but not its position, so it needs motion capture.
  • tmux and tmuxinator are installed, as in the Quickstart.

These projects have no Pixi environment. Run their commands in a terminal where Aerostack2 and the platform are sourced.

What changes and what stays

SimulationReal drone
Mission scriptmy_mission.pyThe same, with smaller distances and speeds
Aerial platformMultirotor simulator or Gazeboas2_platform_crazyflie or as2_platform_tello
PositionExact, from the simulatorFrom the drone's own sensors, or from motion capture
State estimatorGround truth from the simulatorRaw odometry from the Crazyflie, or ground truth from motion capture
ClockSimulated in Gazebo, with use_sim_timeThe computer's
Motion controller and behaviorsPID speed controller, motion behaviorsThe same, with gains and plugins for the drone
Namespacedrone0drone0, from the project's config/config.yaml

Get the project

Each drone has a ready-made project, laid out like the Quickstart's:

git clone https://github.com/aerostack2/project_crazyflie.git
cd project_crazyflie

The windows of the tmux session are the same as in simulation. Only the platform window runs something else, the driver that talks to the drone.

Tell the project about your drone

Everything for the drones is in config/config.yaml. Its first part applies to every drone, and the end has one section per drone, named after its namespace.

Set the radio address of your Crazyflie under drone0:

config/config.yaml (excerpt)
drone0:
  platform:
    ros__parameters:
      uri: radio://0/80/2M/E7E7E7E701

With a Flow deck or Lighthouse, the drone's own estimate is good enough to fly on. The project already passes it on with the raw odometry estimator:

config/config.yaml (excerpt)
/**:
  state_estimator:
    ros__parameters:
      plugin_name: "raw_odometry"

For motion capture instead, use the ground_truth settings shown for the Tello and turn on external_odom, as the Crazyflie page explains.

Notice what isn't there. There's no use_sim_time, so every node runs on the computer's clock, and no base_frame_id, so the drone's body frame is the usual drone0/base_link. The motion controller is the PID speed controller, as in simulation, with gains for this drone in config/pid_speed_controller.yaml.

Start the drone and the ground station

Turn the drone on, put it on the floor where the mission starts, and start its stack:

./launch_as2.bash

In a second terminal, start the ground station with the keyboard teleoperation:

./launch_ground_station.bash -t

The Tello's ground station also starts the OptiTrack driver from mocap4ros2, with the server address in config_ground_station/mocap4r2_optitrack.yaml, and -m leaves it out. The Crazyflie's does it the other way round, and only starts it with -m. Add -v to either for RViz.

Watch the platform window of the drone's session. The Crazyflie platform waits until the radio link connects, and the Tello platform stops if the Tello doesn't answer.

Check where the drone thinks it is

Run state.py from A mission in Python. It doesn't arm or fly. Run it again after moving the drone by hand a little, then put it back:

python3 state.py

The position should follow the drone, in metres, with z close to 0 on the floor. Positions are in earth, like in simulation, but where its origin lies now depends on the source. Every point of the mission is measured from there, so know where it is before you send the drone anywhere.

If the position doesn't change, don't fly. The estimator isn't getting the drone's position.

Fly by hand first

Click the keyboard teleoperation window and press t to take off. Move the drone a step with the arrows, then press l to land. This checks the whole stack with you in control, as in Fly by hand.

Shrink the mission

Copy my_mission.py into the project folder. Keep its structure, with the flight in fly and the landing in finally, but make it fit your flying space. The simulated mission flies 4 m across and starts a flight toward a point 10 m away, which won't fit indoors. A 1 × 1 m square at 1 m, at 0.3 m/s:

my_mission.py
import rclpy
from as2_python_api.drone_interface import DroneInterface


def fly(drone: DroneInterface) -> None:
    """Fly the mission. Raises an error if a behavior fails."""
    if not drone.arm() or not drone.offboard():
        raise RuntimeError("Couldn't arm or switch to offboard")

    if not drone.takeoff(height=1.0, speed=0.3):
        raise RuntimeError("Take off failed")

    # A 1 x 1 m square at 1 m, slowly
    drone.go_to(1.0, 0.0, 1.0, speed=0.3)
    drone.go_to.go_to_point_path_facing([1.0, 1.0, 1.0], speed=0.3)
    path = [[0.0, 1.0, 1.0], [0.0, 0.0, 1.0]]
    drone.follow_path.follow_path_with_path_facing(path, speed=0.3)


rclpy.init()
drone = DroneInterface(drone_id="drone0")

try:
    fly(drone)
finally:
    # Whatever happened, land and hand control back
    drone.land(speed=0.3)
    drone.manual()
    drone.shutdown()
    rclpy.shutdown()
  • drone_id is still drone0, the namespace in config/config.yaml. Rename one, rename both.
  • DroneInterface has no use_sim_time=True. Without a simulation there's no simulated clock, so leave it out, which is the default.
  • Everything else is the same API, the same behaviors and the same try and finally.

The script runs unchanged in the multirotor simulator, so fly it there first.

Fly it

Stand clear, with the keyboard teleoperation window ready, and run it:

python3 my_mission.py

The drone takes off, flies the square and lands where it started. If something goes wrong, use the emergency stop below rather than waiting for the script.

Stop everything

Land the drone first. Then:

./stop_tmuxinator.bash

This closes the drone and ground station sessions. Turn the drone off before picking it up.

Stopping in an emergency

The kill switch stops the motors at once, wherever the drone is. In the keyboard teleoperation window it's the Delete key. From a terminal:

ros2 topic pub --once /drone0/alert_event as2_msgs/msg/AlertEvent "{alert: -1}"

The platform handles the alerts on alert_event, and what happens depends on the drone:

AlertPython APICrazyflieTello
Kill switch, -1send_emergency_killswitch_to_aircraft()Emergency stop, motors offEmergency command, motors off
Emergency hover, -2send_emergency_hover_to_aircraft()Motors off, the same as the kill switchHovers in place
Emergency land, -3send_emergency_land_to_aircraft()NothingNothing

On a Crazyflie, stopping the platform means stopping the motors, so an emergency hover drops it too. The positive alerts, sent by send_emergency_hover() and send_emergency_land(), aren't acted on by any platform.

After either of the first two, the platform stays in its emergency state, and sends its stop command instead of the mission's commands. Restart the drone's stack before flying again. Try the kill switch in simulation first, so you know it works from your keyboard before you need it.

Next steps

Last updated on November 3, 2026

On this page

Last updated on November 3, 2026