DocsTutorials
From simulation to a real drone
Fly the mission you wrote in simulation on a Crazyflie or a Tello.
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
| Simulation | Real drone | |
|---|---|---|
| Mission script | my_mission.py | The same, with smaller distances and speeds |
| Aerial platform | Multirotor simulator or Gazebo | as2_platform_crazyflie or as2_platform_tello |
| Position | Exact, from the simulator | From the drone's own sensors, or from motion capture |
| State estimator | Ground truth from the simulator | Raw odometry from the Crazyflie, or ground truth from motion capture |
| Clock | Simulated in Gazebo, with use_sim_time | The computer's |
| Motion controller and behaviors | PID speed controller, motion behaviors | The same, with gains and plugins for the drone |
| Namespace | drone0 | drone0, 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_crazyflieThe 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:
drone0:
platform:
ros__parameters:
uri: radio://0/80/2M/E7E7E7E701With 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:
/**:
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.bashIn a second terminal, start the ground station with the keyboard teleoperation:
./launch_ground_station.bash -tThe 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.pyThe 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:
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_idis stilldrone0, the namespace inconfig/config.yaml. Rename one, rename both.DroneInterfacehas nouse_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
tryandfinally.
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.pyThe 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.bashThis 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:
| Alert | Python API | Crazyflie | Tello |
|---|---|---|---|
Kill switch, -1 | send_emergency_killswitch_to_aircraft() | Emergency stop, motors off | Emergency command, motors off |
Emergency hover, -2 | send_emergency_hover_to_aircraft() | Motors off, the same as the kill switch | Hovers in place |
Emergency land, -3 | send_emergency_land_to_aircraft() | Nothing | Nothing |
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