DroneControlService (src/service/drone_control_service.py, ~1233 lines) is the single hub through which every drone command and every telemetry sample passes. It owns the pool of rosbridge WebSocket connections (one per drone), translates HTTP/Socket.IO calls into rosbridge protocol frames, and — as the callback target of each Connection — re-emits inbound MAVROS messages to browser clients over Socket.IO.
It is constructed once as a module-global singleton in src/application/app.py and injected into the drone route blueprints. Its socketio attribute is patched in after construction in src/main.py:259, which is load-bearing: _get_client only wires the emit callback when self.socketio is truthy (see Connection Pool & Startup Wiring).
This page covers the DroneControlService command/emit layer. The
Connection/SmartSocket internals it drives — the outgoing queue, subscription replay, reconnect/backoff, and the synchronous service-call mechanism — live in Rosbridge Connection & Reconnect. The HTTP action endpoints that call these methods are documented in Drone Management & Control Actions.Where it sits
The connection pool: get_client / _get_client
Every command and subscription method begins the same way:
src/service/drone_control_service.py
get_client is a thin lock wrapper around _get_client. The pool is a plain dict, self.drone_mapping, mapping drone_id -> {"connection": Connection}, guarded by a single process-wide self.connection_lock acquired with a 5-second timeout (get_client, line 518). A lock-acquire failure raises Exception("Connection lock timed out").
_get_client(user_id, drone_id, is_stop_call=False) (line 532) does four things:
Look up the pooled connection
the_connection = self.drone_mapping.get(drone_id, {}).get("connection"). If it exists but the_connection.is_expired() is true, it calls start_connection() to reconnect in place.Skip ownership on cache hit
If a connection already exists (or
is_stop_call is set), it is returned immediately — no ownership re-check. Ownership is verified only when a brand-new connection is created.Verify ownership on cache miss
drone = self.drone_service.get_drone_by_id_and_user(drone_id, user_id). A missing row raises ResourceNotFoundException.Resolve the target IP and open a Connection
Physical drones use
drone.ip. SITL drones store their container name in drone.ip, so it is translated (see below). A Connection is created with a socketio_callback closing over drone_id, then start_connection() is called and the connection is stored in the pool.SITL IP translation
SITL drones never store a real IP — bothDrone.ip and Drone.mac hold the Docker container name, and Drone.port is 9090 + container_number. _get_client rewrites the target at connect time:
| Drone type | REMOTE_DOCKER_ENABLED | rosbridge host used |
|---|---|---|
| physical | — | drone.ip |
| sitl | true | settings.SITL_HOST (office server, default <office-docker-host>, reached over WireGuard) |
| sitl | false | host.docker.internal |
settings.ENABLE_SITL is false, _get_client raises ResourceWarning("SITL is not enabled"). Container numbering and the 9090 + n port math are covered in SITL Drone Lifecycle.
Two dispatch styles: fire-and-forget vs. synchronous
Commands reach the drone through one of twoConnection calls:
self.send_message(client, frame)— enqueues the frame on the bounded outgoing queue (SEND_QUEUE_SIZE, default 30) and returns immediately. Used for mode changes, arming, takeoff/land,setpoint_rawpublishes, video-room topics, and allsubscribe/unsubscribeframes. No drone response is awaited.send_messageraisesWebSocketConnectionClosedExceptionif the client isNone.client.send_service_call_with_response(frame, timeout=...)— registers athreading.Event, enqueues acall_serviceframe, and blocks until the matchingservice_responsearrives or the timeout elapses, returning{"success", "values", "response"}. Used where the result matters: param set/get, mission push, and geofence push/clear/sync.
src/rosbridge/request_format.py (get_call_service_msg, get_subscribe_msg, get_publish_msg_without_data_field, get_advertise_msg, get_unsubscribe_msg).
Command method reference
Every method takes(user_id, drone_id, ...) and is reached from a POST /api/v1/drone/action/<name> route unless noted. MAVROS service/topic names come from src/utils/mavros_topics.py.
| Method | HTTP action | rosbridge target | MAVLink / mode | Style |
|---|---|---|---|---|
takeoff | takeoff | SET_MODE → arming → cmd/takeoff | GUIDED, arm, CommandTOL | fire-and-forget |
land | land | /mavros/set_mode | custom_mode=LAND | fire-and-forget |
mode_rtl | rtl | /mavros/set_mode | custom_mode=RTL | fire-and-forget |
mode_guided | guided | /mavros/set_mode | custom_mode=GUIDED | fire-and-forget |
set_mode | set_mode | /mavros/set_mode | arbitrary custom_mode | fire-and-forget |
arm_drone | arm, arm_drone | /mavros/cmd/command | CommandLong 400, param1=1.0 | fire-and-forget |
disarm_drone | disarm_drone | /mavros/cmd/command | CommandLong 400, param1=0.0 | fire-and-forget |
motor_test | motortest | /mavros/cmd/command | CommandLong 209 (DO_MOTOR_TEST) | fire-and-forget |
goto_gps_location | goto_gps_location | /mavros/setpoint_raw/global | GlobalPositionTarget, frame 6, type_mask 3576 | publish |
move | move | /mavros/setpoint_raw/local | PositionTarget, frame 8, type_mask 4088 | publish |
set_param | set_param | /mavros/param/set | ParamSetV2, force_set=True | sync (retry) |
set_params | set_params | loops set_param | — | sync |
get_params | (GET params) | /mavros/param/get_parameters | GetParameters (batch) | sync |
get_all_param_names | — | /mavros/param/list_parameters | ListParameters | sync |
pull_params | (POST /drone//params/pull) | /mavros/param/pull | ParamPull, force_pull=True | sync |
get_param | — | /rosapi/get_param | broken — see below | sync |
push_mission | push_mission | /mavros/mission/push | WaypointPush | sync |
push_geofence | push_geofence | /mavros/geofence/push | WaypointPush | sync |
clear_fence | clear_fence | /mavros/geofence/push (empty) | WaypointPush | sync |
sync_geofences | sync_geofences | /mavros/geofence/push | clear + upload all enabled | sync |
start_mission | start_mission | push + GUIDED + arm + takeoff | see caveat | mixed |
update_video_room | (arm route, SITL save) | /video_room_details | std_msgs/String | advertise + publish |
start/stop_video_stream | — | /video_room_state | START / STOP | advertise + publish |
update_video_source | video-source | /video_room_source | source string | advertise + publish |
Parameter handling
set_param chooses a rcl_interfaces/ParameterValue type — PARAMETER_INTEGER (2) for integers, PARAMETER_DOUBLE (3) for non-integer floats — sets force_set=True to bypass FCU checks, and retries up to max_retries=5 when MAVROS answers "does not exist" (services still initializing). get_params batches all names through GetParameters in one call (roughly 1 s vs. minutes of per-param rosapi calls) and decodes each value by its type tag.
Mission upload (push_mission)
push_mission (line 784) loads MissionPoints via mission_service.get_mission_points(...), converts each with wp.to_mavlink_waypoint(), and — if the first waypoint’s command is not 22 — prepends a synthetic TAKEOFF (frame 3, command 22, x_lat=0.0, y_long=0.0, z_alt=takeoff_alt). The altitude is custom_takeoff_altitude if given, else the first waypoint’s z_alt (fallback 10.0). It then calls /mavros/mission/push and asserts values["wp_transfered"] == len(mavlink_waypoints), raising on a partial transfer. On success it persists drone.mission_id via drone_service.update(...).
The waypoint list is uploaded as stored, plus the TAKEOFF prepend — there is no reversed-return-path, RTL, or speed-command auto-generation. The description in the repo’s root
CLAUDE.md is stale on this point. Waypoint conversion (to_mavlink_waypoint, lat→x_lat, lng→y_long, altitude→z_alt) is detailed in Mission & Geofence MAVLink Format.start_mission and the AUTO-switch caveat
start_mission(user_id, drone_id, takeoff_altitude=None) (line 615) uploads the assigned mission, then sets GUIDED, arms, and issues a GUIDED takeoff (with time.sleep pauses between steps). It deliberately stops there.
Geofences
push_geofence uploads one geofence’s points; sync_geofences clears the fence and re-uploads all enabled geofences as one fence list. Both resolve the per-point MAVLink command with get_fence_command(type, fence_type) and, for polygons, set param1 to the vertex count. geofence_service and get_fence_command are imported lazily inside the methods to avoid an import cycle — preserve that. Fence uploads go through the dedicated /mavros/geofence/push service (PUSH_FENCE), not the mission service.
Video-room topic publishing
Because rosbridge refuses to publish to a topic with no local publisher, the three video methods advertise the topic type first, then publish astd_msgs/String:
update_video_room→ advertise + publish JSON room details on/video_room_detailsstart_video_stream/stop_video_stream→ publish"START"/"STOP"on/video_room_stateupdate_video_source→ publish the source string on/video_room_source
VideoRoom, id == drone.id) and the physical-vs-SITL bootstrapping difference live in Janus Video Rooms & On-Drone Video Control.
Telemetry emission and dashboard yaw
When aConnection receives an op=publish frame it appends the message to queues[topic] and invokes the socketio_callback, which is _emit_telemetry(drone_id, topic, msg) (line 1109). That method:
- Returns immediately if
self.socketiois unset (telemetry is dark untilmain.py:259runs). - Maps the topic to a stream type via
_get_stream_type_from_topic. - Routes to
_emit_dashboard_dataif_is_dashboard_topic(topic), else to_emit_to_stream_subscribers.
_emit_dashboard_data emits a telemetry_data event {type, drone_id, data} to room drone_{id}_dashboard. It unwraps std_msgs/Float64 for RELATIVE_ALT, and on GPS frames it injects a computed yaw into the payload:
- Priority 1 — latest
VFR_HUD.headingfromconnection.queues(same source Mission Planner uses). - Priority 2 — compute yaw from the latest IMU quaternion:
atan2(2(wz+xy), 1-2(y²+z²))in degrees, then convert ENU → compass heading with(90 - yaw) % 360.
GPS, GPS_RAW, LOGS (/rosout), RELATIVE_ALT, DIAGNOSTICS, VFR_HUD, IMU_ORIENTATION, HOME_POSITION — is fanned out by _subscribe_dashboard in src/routes/socket_routes.py. Diagnostics is subscribed with throttle_rate=0 (already ~1 Hz); everything else uses TELEMETRY_THROTTLE_RATE (default 200 ms). The Socket.IO handshake and subscribe_telemetry handlers are documented in Socket.IO Telemetry Streaming.
Config that governs dispatch
| Setting | Default | Effect |
|---|---|---|
ROSBRIDGE_SERVICE_TIMEOUT | 30.0 | Timeout for mission/geofence sync service calls |
TELEMETRY_THROTTLE_RATE | 200 | Default rosbridge throttle_rate (ms); diagnostics forced to 0 |
SEND_QUEUE_SIZE | 30 | Bound on the per-Connection outgoing queue |
REQUEUE | false | Re-enqueue an outgoing frame if ws.send() fails |
ENABLE_SITL | true | Gates SITL connections in _get_client |
REMOTE_DOCKER_ENABLED | false | Selects SITL_HOST vs. host.docker.internal for SITL rosbridge |
SITL_HOST / DOCKER_HOST_IP | <office-docker-host> | Remote SITL rosbridge host (WireGuard office server) |
JUMPHOST_IP / JUMPHOST_PORT | "" / 9090 | If set, connect via jumphost using x-drone-ip/x-drone-port headers |
DO_UNSUBSCRIBE | true | Whether unsubscribe actually sends rosbridge unsubscribe frames |
Next: Connection & reconnect internals
How the
Connection/SmartSocket outgoing queue, subscription replay, keepalive, and reconnect/give-up (is_expired) work under this dispatch layer.
