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
client = self.get_client(user_id, drone_id)
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 — both Drone.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 typeREMOTE_DOCKER_ENABLEDrosbridge host used
physicaldrone.ip
sitltruesettings.SITL_HOST (office server, default <office-docker-host>, reached over WireGuard)
sitlfalsehost.docker.internal
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.
The pool is keyed by drone_id only, not (user_id, drone_id). A cached Connection is shared by every caller of that drone, and ownership is enforced only at creation time. Any command that reuses an already-open connection is dispatched without re-checking that user_id still owns the drone. Telemetry rooms are likewise per-drone (drone_{id}_dashboard), not per-user. Preserve the ownership check on connection creation, and do not assume it protects individual commands.

Two dispatch styles: fire-and-forget vs. synchronous

Commands reach the drone through one of two Connection 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_raw publishes, video-room topics, and all subscribe/unsubscribe frames. No drone response is awaited. send_message raises WebSocketConnectionClosedException if the client is None.
  • client.send_service_call_with_response(frame, timeout=...) — registers a threading.Event, enqueues a call_service frame, and blocks until the matching service_response arrives or the timeout elapses, returning {"success", "values", "response"}. Used where the result matters: param set/get, mission push, and geofence push/clear/sync.
Frames are built by src/rosbridge/request_format.py (get_call_service_msg, get_subscribe_msg, get_publish_msg_without_data_field, get_advertise_msg, get_unsubscribe_msg).
Nearly every call_service frame is built with the hardcoded id "1" (get_call_service_msg("1", ...)). send_service_call_with_response keys its pending_service_calls/service_responses on that id, so two concurrent synchronous service calls on the same Connection can overwrite each other’s response. It works today only because callers are sequential and block on their own response. See the collision note in Rosbridge Connection & Reconnect.

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.
MethodHTTP actionrosbridge targetMAVLink / modeStyle
takeofftakeoffSET_MODEarmingcmd/takeoffGUIDED, arm, CommandTOLfire-and-forget
landland/mavros/set_modecustom_mode=LANDfire-and-forget
mode_rtlrtl/mavros/set_modecustom_mode=RTLfire-and-forget
mode_guidedguided/mavros/set_modecustom_mode=GUIDEDfire-and-forget
set_modeset_mode/mavros/set_modearbitrary custom_modefire-and-forget
arm_dronearm, arm_drone/mavros/cmd/commandCommandLong 400, param1=1.0fire-and-forget
disarm_dronedisarm_drone/mavros/cmd/commandCommandLong 400, param1=0.0fire-and-forget
motor_testmotortest/mavros/cmd/commandCommandLong 209 (DO_MOTOR_TEST)fire-and-forget
goto_gps_locationgoto_gps_location/mavros/setpoint_raw/globalGlobalPositionTarget, frame 6, type_mask 3576publish
movemove/mavros/setpoint_raw/localPositionTarget, frame 8, type_mask 4088publish
set_paramset_param/mavros/param/setParamSetV2, force_set=Truesync (retry)
set_paramsset_paramsloops set_paramsync
get_params(GET params)/mavros/param/get_parametersGetParameters (batch)sync
get_all_param_names/mavros/param/list_parametersListParameterssync
pull_params(POST /drone//params/pull)/mavros/param/pullParamPull, force_pull=Truesync
get_param/rosapi/get_parambroken — see belowsync
push_missionpush_mission/mavros/mission/pushWaypointPushsync
push_geofencepush_geofence/mavros/geofence/pushWaypointPushsync
clear_fenceclear_fence/mavros/geofence/push (empty)WaypointPushsync
sync_geofencessync_geofences/mavros/geofence/pushclear + upload all enabledsync
start_missionstart_missionpush + GUIDED + arm + takeoffsee caveatmixed
update_video_room(arm route, SITL save)/video_room_detailsstd_msgs/Stringadvertise + publish
start/stop_video_stream/video_room_stateSTART / STOPadvertise + publish
update_video_sourcevideo-source/video_room_sourcesource stringadvertise + 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.
get_param (line 186) is dead — it raises NameError at runtime. It references ROSAPI_GET_PARAM[0], but that symbol is not in the import block at the top of drone_control_service.py (only PARAM_GET, PARAM_LIST, PARAM_PULL, PARAM_SET are imported). ROSAPI_GET_PARAM is defined in src/utils/mavros_topics.py:31 but never imported here, so any call blows up before hitting rosbridge. No route wires get_param today (routes use get_params/pull_params), so it is latent. A refactor must add the import or delete the method.

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 22prepends 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.
start_mission never switches the vehicle to AUTO. It relies on the Dashboard to monitor relative altitude and flip to AUTO once the takeoff altitude is reached. A server-side helper, _monitor_takeoff_and_switch_to_auto (line 676), exists and would do this by polling client.queues[rel_alt], but start_mission does not call it — it is an unused alternate path. If you move the AUTO switch server-side, wire that helper in; do not assume the mission flies itself after start_mission returns.

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 a std_msgs/String:
  • update_video_room → advertise + publish JSON room details on /video_room_details
  • start_video_stream / stop_video_stream → publish "START" / "STOP" on /video_room_state
  • update_video_source → publish the source string on /video_room_source
The room-creation side (Janus 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 a Connection 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:
  1. Returns immediately if self.socketio is unset (telemetry is dark until main.py:259 runs).
  2. Maps the topic to a stream type via _get_stream_type_from_topic.
  3. Routes to _emit_dashboard_data if _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.heading from connection.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.
The dashboard topic set — 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.
Single-stream emission is effectively dead code. _emit_to_stream_subscribers targets rooms drone_{id}_{gps|logs|relalt|system|home}, but its condition (not _is_dashboard_topic) is never true for real data — every mapped topic is also a dashboard topic. A client that subscribes to gps/logs/relalt/system joins drone_{id}_{stream} yet receives nothing, because data is only ever emitted to drone_{id}_dashboard. Only the dashboard stream works end-to-end. Also note: these room names are drone_{id}_{stream} (per-drone), not the user_{user_id}_drone_{drone_id}_{stream_type} scheme described in the root CLAUDE.md — trust socket_routes.py and this service for the real names.

Config that governs dispatch

SettingDefaultEffect
ROSBRIDGE_SERVICE_TIMEOUT30.0Timeout for mission/geofence sync service calls
TELEMETRY_THROTTLE_RATE200Default rosbridge throttle_rate (ms); diagnostics forced to 0
SEND_QUEUE_SIZE30Bound on the per-Connection outgoing queue
REQUEUEfalseRe-enqueue an outgoing frame if ws.send() fails
ENABLE_SITLtrueGates SITL connections in _get_client
REMOTE_DOCKER_ENABLEDfalseSelects 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"" / 9090If set, connect via jumphost using x-drone-ip/x-drone-port headers
DO_UNSUBSCRIBEtrueWhether unsubscribe actually sends rosbridge unsubscribe frames
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.