Skip to content

ROS 2 Integration

ROS 2 packages for Lyrical (the default), Jazzy and Humble. lyrebird_controller follows PX4’s real uXRCE-DDS topic convention as closely as DJI’s telemetry/command surface allows: telemetry is grouped into typed messages under fmu/out/..., and discrete actions are dispatched through one fmu/in/vehicle_command topic keyed by a MAV_CMD-style command id — the same shape as PX4’s real VehicleCommand/MAVLink COMMAND_LONG. A developer who already knows PX4’s ROS 2 API should recognize the topic layout, the command vocabulary, and most message field names immediately.

Every command id and message field below was checked against MAVLink’s common.xml and PX4-Autopilot’s real msg/versioned/*.msg sources, not approximated from memory. Where DJI telemetry has no PX4 equivalent to reuse (gimbal/camera status, LRF), the message is Lyrebird’s own but keeps PX4’s snake_case naming style.

It checks for new telemetry at 20 Hz but publishes only when the drone has actually sent a new snapshot, so the topic rate follows the aircraft’s telemetry interval (default ~2 Hz) rather than repeating each sample.

The topics and message names follow PX4, but the messages are lyrebird_msgs, not px4_msgs. In ROS 2 the package is part of the type, so a node written against px4_msgs/msg/VehicleCommand does not connect to Lyrebird’s lyrebird_msgs/msg/VehicleCommand, even on the same topic name and with the same fields. How close each message is:

Lyrebird messageCompared with px4_msgs
VehicleCommandNearly identical: same fields and the same MAV_CMD ids (take-off 22, RTL 20, gimbal 1000, …). PX4 also has confirmation. Lyrebird adds VEHICLE_CMD_LYREBIRD_* ids in MAVLink’s user range for DJI-only actions.
VehicleCommandAckNearly identical: same result codes. Lyrebird adds seq, the go-to number that mission_result reports arrival under.
VehicleGlobalPosition, HomePosition, BatteryStatus, ManualControlSetpointSubsets with PX4’s field names (lat/lon/alt, valid_hpos, remaining, time_remaining_s, roll/pitch/yaw/throttle). Many PX4 fields are absent; DJI-specific ones are added.
TrajectorySetpointDifferent meaning. PX4’s is a local NED position/velocity setpoint streamed in offboard mode; Lyrebird’s is a single GPS go-to (latitude, longitude, altitude, speed, yaw mode) that the aircraft flies on its own.
VehicleLocalPositionDifferent: velocity, heading and height above take-off, but no x/y/z position; attitude as JSON text.
VehicleStatusDifferent: DJI’s flight-mode name as text, the manual-override latch and take-off readiness, instead of PX4’s numeric nav_state and arming_state.
OffboardControlMode, VehicleOdometry, VehicleAttitudeNot provided. There is no arming and no offboard mode to enter.
MissionResult, GimbalStatus, CameraStatusLyrebird’s own, for what PX4 has no equivalent of (go-to arrival latches, DJI gimbal and camera).

So PX4 ROS 2 code needs porting, not just new imports: the command path ports almost directly, the setpoint and status logic does not. GroundStation/examples/ros2_flight.py flies take-off, a go-to and return home with these messages.

Real px4_msgs compatibility can be added later if it is needed: an adapter node that speaks the real px4_msgs types (pinned to one PX4 release, since those definitions change between releases) could map the nearly identical messages directly. Running PX4 offboard code unchanged would also need PX4’s offboard model emulated on DJI: arming, OffboardControlMode, local NED setpoints streamed at 10 Hz or more, and nav_state.

GroundStation/ROS/
├── lyrebird_msgs/ # PX4-DDS-style .msg definitions (VehicleCommand, BatteryStatus, ...)
├── lyrebird_controller/ # Main control + telemetry node
│ ├── controller.py # DjiNode: dispatches fmu/in/vehicle_command, publishes fmu/out/*
│ ├── topics.py # Central topic name / QoS / legacy-remap registry
│ └── dji_interface.py
├── lyrebird_videofeed/ # RTSP video feed -> sensor_msgs/Image on fmu/out/video
└── lyrebird_bringup/
├── fleet_auto_discovery.launch.py # one FleetAutoDiscoveryManager, re-scans + settings/ports for the whole fleet
├── swarm_connection.launch.py
└── config/parameters.yaml

lyrebird_controller/fleet_auto_discovery.py also ships its own config/fleet_settings.yaml, loaded by FleetAutoDiscoveryManager at startup — see Fleet auto-scaling below.

lyrebird_controller/topics.py is the single source of truth for every topic name, type, and QoS profile — both controller.py and the ros_monitor container’s dashboard bridge import it, instead of each maintaining its own hand-written topic list.

Exactly as before: each drone gets its own ROS namespace at launch (e.g. /mini1/), with fmu/out/... and fmu/in/... nested inside it — e.g. /mini1/fmu/out/battery_status — matching PX4’s own per-vehicle namespacing pattern.

fleet_auto_discovery.launch.py doesn’t just launch whatever it finds at startup and stop looking. It runs one FleetAutoDiscoveryManager node (lyrebird_controller/fleet_auto_discovery.py), which rescans the network on a timer (discovery_period_sec, default 30s) for the life of the launch, and spins up a namespaced lyrebird_controller_<name> DjiNode for any drone that wasn’t there yet — no restart of the ROS 2 stack needed to pick up a drone powered on after launch. Every DjiNode it creates lives inside this one process, sharing one MultiThreadedExecutor, since the fleet size isn’t known ahead of time and a fixed set of ros2 launch Node actions can’t be declared for it.

Because they share one process, the manager creates one shared MAVLink UDP listener for the whole fleet. Each aircraft is registered provisionally by its discovered IP and name, then bound to its MAVLink sysid when the first autopilot heartbeat arrives. sysid is used for wire routing; the discovered name still determines the ROS namespace (for example /mini1/) and is never inferred from a numeric system id. Duplicate active system ids are rejected, while a stale route can be rebound when an aircraft reconnects. On connecting, each drone is also pushed the settings in lyrebird_controller/config/fleet_settings.yaml — the same settings the app’s cockpit settings menu edits, plus which transport (HTTP/MAVLink/both) to use and an optional auto-assigned rthAltitude range spaced across the fleet — so a fleet can be brought up with consistent settings without opening the app on each aircraft.

This replaced an older mechanism (auto_discovery_native.launch.py, one ros2 launch Node action per drone via a rescanning TimerAction) that could not assign MAVLink ports or push any settings, since each drone got its own OS process rather than sharing one.

Point a launch file at a different copy of this file via the fleet_settings_file ROS parameter to run a different profile per deployment (e.g. ros2 launch lyrebird_bringup fleet_auto_discovery.launch.py fleet_settings_file:=/path/to/fleet_settings.yaml). Any key below can also be set the normal ROS way (a launch file’s own parameters=[...], or --ros-args -p), which always wins over the file. Comment out a key to leave that setting alone on the drone instead of overwriting it.

Top-level (ground-station-side, not pushed to the drone):

KeyDefaultMeaning
transportbothWhich wire every discovered drone is commanded over: http | mavlink | both. Independent of everything below — this is how the ground station talks to the aircraft, not an app setting. Keep it both (MAVLink carries what it can, HTTP fills the rest) unless you have a specific reason to run one wire only.
discovery_period_sec30.0Seconds between rescans for newly joined drones.
discovery_timeout_sec5.0How long each scan waits for answers.
mavlink_port14551One shared MAVLink listen port for the whole Lyrebird fleet. The bundled value leaves QGroundControl’s usual 14550 available on the same host. mavlink_port_base remains a backwards-compatible alias. Otherwise comes from LB_MAVLINK_PORT.
mavlink_peer_port14550The common UDP port every aircraft listens on for commands. Otherwise comes from LB_MAVLINK_PEER_PORT.

fleet_settings (pushed to every drone on connection — the same values the app’s cockpit settings menu edits):

KeyDefaultValid values
maxFlightHeight120Meters AGL flight ceiling.
maxFlightDistance500Meters, max distance from the home point.
distanceLimitEnabledtruetrue | false — whether maxFlightDistance is enforced.
videoSourcedronedrone — the aircraft’s camera is the only source; the phone-camera and Mock MP4 test sources were removed. The key is kept because existing clients and the LB_VIDEO_SRC parameter read it.
streamingModewebrtcwebrtc | rtmp | rtsp | agora | gb28181 — webrtc means WebRTC via WHIP push (DJI SDK naming, not a separate “whip” mode).
webrtcResolutionautoauto | 1080p | 720p | 480p (only used when streamingMode: webrtc).
webrtcFps205 | 10 | 15 | 20 | 25 | 30 (only used when streamingMode: webrtc).
detectionsEnabledfalsetrue | false — on-board object detection.
detectionSourcenonenone | dji_onboard | yolo_on_phone (only used when detectionsEnabled: true).
edgeConfidenceThreshold0.300.10-0.70 in 0.05 steps — minimum confidence for a detection to be reported.
rcControlModeusajp | usa | ch | custom — RC stick mapping.
mediamtxServer(unset)MediaMTX relay server URL, only used by some streaming modes (an rtsp relay or gb28181 bridging). Leave unset to use the phone’s own local server.
surfaceH264Encoderfalsetrue | false — experimental: encode WebRTC video straight from a DJI SDK surface instead of the default encoder. Takes effect on the phone’s next app restart, not immediately.

rth_altitude_range (optional; overrides rthAltitude in fleet_settings when present):

KeyMeaning
minRTH altitude (meters) for the first discovered drone.
maxCeiling: no drone is assigned above this.
stepMeters added per logical drone slot, independent of MAVLink ports, capped at max once the fleet outgrows the range.

drone_settings maps a drone’s discovered name (e.g. mini3) to its own settings dict, overriding fleet_settings/rth_altitude_range for that drone only — e.g. {"mini3": {"rthAltitude": 60, "droneName": "Mini 3", "mavlinkSystemId": 1}}. droneName and mavlinkSystemId are deliberately not fleet-wide keys: the former is human identity and the latter is a unique manual vehicle assignment. Manual IDs are 1..99; omitted IDs use the aircraft’s serial-derived automatic range 100..254. Both can also be changed in the app.

TopicTypeBody
fmu/in/vehicle_commandlyrebird_msgs/VehicleCommandcommand id + param1..param7, see table below
fmu/in/trajectory_setpointlyrebird_msgs/TrajectorySetpointlatitude, longitude, altitude, yaw, speed, yaw_mode
fmu/in/manual_control_setpointlyrebird_msgs/ManualControlSetpointroll, pitch, throttle, yaw ∈ [-1,1] (virtual stick)
fmu/in/goto_trajectory_dji_nativeString"(speed, [(lat,lon,alt),...])"
fmu/in/set_settingString"key=value" (webapp setting keys)
fmu/in/download_mediaStringon-camera file name

Real MAV_CMD ids (from MAVLink common.xml / PX4’s VehicleCommand.msg) are reused wherever the semantics match, so command: 22 really is MAV_CMD_NAV_TAKEOFF:

CommandidParams
VEHICLE_CMD_NAV_TAKEOFF22—
VEHICLE_CMD_NAV_LAND21—
VEHICLE_CMD_NAV_RETURN_TO_LAUNCH20—
VEHICLE_CMD_CONDITION_YAW115param1=yaw angle (deg)
VEHICLE_CMD_DO_CHANGE_ALTITUDE186param1=altitude (m)
VEHICLE_CMD_DO_GIMBAL_MANAGER_PITCHYAW1000param1=pitch (deg), param2=yaw (deg); NaN = unset (send one axis at a time by NaN-ing the other)
VEHICLE_CMD_SET_CAMERA_ZOOM531param2=zoom ratio
VEHICLE_CMD_VIDEO_START_CAPTURE2500—
VEHICLE_CMD_VIDEO_STOP_CAPTURE2501—
VEHICLE_CMD_IMAGE_START_CAPTURE2000—

DJI-only actions with no MAV_CMD equivalent use VEHICLE_CMD_LYREBIRD_* ids in MAVLink’s own documented user-command range (31000-31999), so they can never collide with a real MAV_CMD:

CommandidParams
VEHICLE_CMD_LYREBIRD_ABORT_MISSION31015—
VEHICLE_CMD_LYREBIRD_ABORT_ALL31016—
VEHICLE_CMD_LYREBIRD_ENABLE_VIRTUAL_STICK31017—
VEHICLE_CMD_LYREBIRD_ABORT_DJI_NATIVE_MISSION31018—
VEHICLE_CMD_LYREBIRD_DEACTIVATE_MANUAL_OVERRIDE31019—
VEHICLE_CMD_LYREBIRD_GIMBAL_REL_PITCH31020param1=degrees relative to current pitch
VEHICLE_CMD_LYREBIRD_GIMBAL_REL_YAW31021param1=degrees relative to current yaw
VEHICLE_CMD_LYREBIRD_SET_RTH_ALTITUDE31022param1=altitude (m)
VEHICLE_CMD_LYREBIRD_CAPTURE_TEMPERATURE31023—
VEHICLE_CMD_LYREBIRD_LIST_MEDIA31024—
VEHICLE_CMD_LYREBIRD_LRF_MEASURE31025—
VEHICLE_CMD_LYREBIRD_DROP31026—

Camera, media, and LRF commands block for as long as the aircraft takes to answer (up to 120 s for a download), so they run on a single-worker thread pool and answer asynchronously on their own fmu/out/camera/* and fmu/out/lrf_* result topics rather than stalling the telemetry loop.

TopicTypeReplaces (old flat topics)
fmu/out/vehicle_command_ackVehicleCommandAckcommand_ack/waypoint_seq, command_ack/yaw_seq, command_ack/altitude_seq
fmu/out/vehicle_global_positionVehicleGlobalPositionlocation, satellite_count
fmu/out/home_positionHomePositionhome_location, home_set
fmu/out/vehicle_local_positionVehicleLocalPositionspeed, speed_vector, heading, altitude, attitude
fmu/out/battery_statusBatteryStatusbattery_level, distance_to_home, remaining_flight_time, time_needed_to_go_home, time_needed_to_land, time_to_landing_spot, max_radius_can_fly_and_go_home, battery_needed_to_go_home, battery_needed_to_land
fmu/out/vehicle_statusVehicleStatusflight_mode, manual_override_active, ready_to_takeoff, takeoff_block_reason
fmu/out/mission_resultMissionResultwaypoint_reached, intermediary_waypoint_reached, altitude_reached, yaw_reached, waypoint_seq, altitude_seq, yaw_seq
fmu/out/gimbal_statusGimbalStatusgimbal_attitude, gimbal_joint_attitude, gimbal_yaw, gimbal_pitch
fmu/out/camera_statusCameraStatuszoom_fl, hybrid_fl, optical_fl, zoom_ratio, camera/is_recording, camera/thermal_max_temp
fmu/out/camera/capture_resultStringcamera/capture_result (unchanged payload)
fmu/out/camera/media_listStringcamera/media_list (unchanged payload)
fmu/out/camera/download_resultStringcamera/download_result (unchanged payload)
fmu/out/lrf_targetNavSatFixlrf/target (unchanged type)
fmu/out/lrf_measurementStringlrf/measurement (unchanged type)
fmu/out/settingsStringstate/settings (unchanged type)
fmu/out/videosensor_msgs/Imagevideo_frames (in lyrebird_videofeed)

Field names inside each message reuse PX4’s real field names wherever DJI telemetry can actually populate them (e.g. remaining/time_remaining_s on BatteryStatus, lat/lon/alt on VehicleGlobalPosition), and simply omit PX4 fields we have no data for rather than fabricating values. VehicleStatus.flight_mode stays a free-form string of DJI’s own mode names — it does not claim compatibility with PX4’s numeric nav_state enum, since the mode sets aren’t equivalent.

Matches PX4’s own rationale: fmu/out/* telemetry uses the best-effort/volatile “sensor data” profile (depth 5) since occasional missed samples are fine and low latency matters more; fmu/in/* commands/setpoints and fmu/out/vehicle_command_ack use a reliable profile (depth 10) since those must arrive.

legacy_topics:=true on either bringup launch file restores the old flat topic names for anything whose message type didn’t change in this redesign (video, LRF, settings, capture/media/download results, and the three remaining string-payload command topics) via a plain ROS2 topic remap, at no extra runtime cost. It does not restore the ~20 topics that consolidated into vehicle_command, trajectory_setpoint, manual_control_setpoint, or any of the bundled telemetry messages above — a remap can only rename a topic, it can’t split one new struct message back out into several old differently-typed scalar topics. Consumers of those old topics need to migrate to the new ones; see lyrebird_controller/topics.py for the exact remap list.

Docker (single-drone, auto-discovery):

From the repository root (the image installs the Python side from pyproject.toml and uv.lock, which live there):

Terminal window
docker build -t lyrebird-ros -f GroundStation/Dockerfile .
docker run --rm --network=host lyrebird-ros

The image is based on ros:lyrical-ros-base (ROS 2 Lyrical, the current LTS) with CycloneDDS, cv-bridge, vision-opencv, image-transport, plus the Python client and pymavlink. Jazzy and the legacy Humble build from the same Dockerfile with --build-arg ROS_DISTRO=jazzy (or humble), or ROS_DISTRO=jazzy ./GroundStation/run_docker.sh.

numpy and OpenCV in the image are the distribution’s own packages, the ones its cv_bridge is compiled against: a newer numpy installed over them breaks every image conversion.

DistroUbuntuPythonnumpy / OpenCV (from the distro)
Lyrical (default)26.043.142.3 / 4.10
Jazzy24.043.121.26 / 4.6
Humble22.043.101.21 / 4.5

Manual multi-drone launch:

Terminal window
cd GroundStation/ROS
colcon build --symlink-install && source install/setup.bash
ros2 launch lyrebird_bringup fleet_auto_discovery.launch.py
# or, to also expose the pure-rename legacy topics:
ros2 launch lyrebird_bringup fleet_auto_discovery.launch.py legacy_topics:=true
# or, to use a fleet_settings.yaml other than lyrebird_controller's bundled default:
ros2 launch lyrebird_bringup fleet_auto_discovery.launch.py fleet_settings_file:=/path/to/fleet_settings.yaml
# Example commands (namespace is the drone's own name, e.g. "mini1")
ros2 topic pub /mini1/fmu/in/vehicle_command lyrebird_msgs/msg/VehicleCommand \
"{command: 22}" # MAV_CMD_NAV_TAKEOFF
ros2 topic pub /mini1/fmu/in/trajectory_setpoint lyrebird_msgs/msg/TrajectorySetpoint \
"{latitude: 49.306254, longitude: 4.593728, altitude: 20.0, yaw: 90.0, speed: 5.0, yaw_mode: 0}"