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:
1
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.2
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.3
Verify ownership on cache miss
drone = self.drone_service.get_drone_by_id_and_user(drone_id, user_id). A missing row raises ResourceNotFoundException.4
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:
If a SITL drone is requested while
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.
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
Jumphost routing and VPN source-IP trust are covered in VPN IP Authentication & Jumphost Routing; the full variable list is in Gateway Environment Variables.
Next: Connection & reconnect internals
How the
Connection/SmartSocket outgoing queue, subscription replay, keepalive, and reconnect/give-up (is_expired) work under this dispatch layer.
