DroneControlService keeps exactly one persistent rosbridge WebSocket per drone in an in-memory pool called drone_mapping. Every command dispatch and every telemetry subscription funnels through get_client(user_id, drone_id), which returns the pooled Connection or lazily builds one. This page covers how that pool is keyed and locked, where drone ownership is (and is not) enforced, how SITL and physical drones resolve to different WebSocket targets, and the precise startup order that must run before telemetry can ever flow.
This page is about who gets which connection and when. The Connection/SmartSocket internals — reconnect, backoff, keepalive, the outgoing queue, and the synchronous service-call RPC — live in Rosbridge Connection & Reconnect. The command methods that consume get_client are in DroneControlService & Rosbridge Dispatch.

The pool: drone_mapping

drone_mapping is a plain dict on the DroneControlService singleton, initialized empty in src/service/drone_control_service.py:47:
src/service/drone_control_service.py
def __init__(self, drone_service, mission_service, socketio_instance=None):
    self.drone_mapping = {}          # {drone_id: {"connection": Connection}}
    self.drone_service = drone_service
    self.mission_service = mission_service
    self.connection_lock = Lock()
    self.docker_ip = settings.DOCKER_HOST_IP
    self.socketio = socketio_instance  # None at construction — see "Startup wiring"
PropertyValue
Keydrone_id (int) — not (user_id, drone_id)
Value{"connection": Connection}
ScopeOne Connection per drone, shared across all users and all subscribers of that drone
GuardSingle connection_lock (threading.Lock) for the whole pool
EvictionNone. Entries persist until close() or an expiry-triggered reconnect. No idle timeout.
LifetimeProcess-local — see Multi-worker caveat
Because the key is the drone id alone, the very first caller to open a drone’s connection “wins” — every later caller (any user) reuses that same socket.

get_client / _get_client

get_client (src/service/drone_control_service.py:518-530) is a thin lock wrapper; all logic lives in _get_client (src/service/drone_control_service.py:532-581).

connection_lock semantics

A single global lock serializes all pool access — not one lock per drone:
src/service/drone_control_service.py
def get_client(self, user_id, drone_id, is_stop_call=False):
    is_locked = False
    try:
        is_locked = self.connection_lock.acquire(True, 5.0)   # blocking, 5s timeout
        if not is_locked:
            raise Exception("Connection lock timed out")
        return self._get_client(user_id, drone_id, is_stop_call)
    finally:
        if is_locked:
            self.connection_lock.release()
Consequences a future editor must respect:
  • The lock is held for the whole _get_client body, including the get_drone_by_id_and_user DB query and Connection.start_connection() on a cache miss. start_connection does not block on the WebSocket handshake — that runs on a background thread — but before spawning that thread it runs a synchronous _test_connection() HTTP reachability probe (requests.get, up to DRONE_REACHABILITY_TIMEOUT seconds each for connect and read, default 2s; connection.py:198,218-224). So both that probe and the DB lookup are blocking I/O executed under the lock: a slow DB lookup or an unreachable drone (the probe stalling ~2s+) blocks pooling for every drone, not just this one — and that reachability stall is a more likely trigger of Connection lock timed out than a slow DB lookup.
  • If the lock cannot be acquired within 5 seconds, get_client raises. Under heavy concurrent first-connects this surfaces as Connection lock timed out.
  • The lock protects the dict; it does not protect an individual Connection’s internal state (its outgoing queue, subscription set, and service-call maps have their own thread model).

Ownership is checked only on creation

Ownership enforcement lives entirely in the cache-miss branch:
src/service/drone_control_service.py
the_connection = self.drone_mapping.get(drone_id, {}).get("connection")

if the_connection and the_connection.is_expired():
    the_connection.start_connection()

# no need to check for drone ownership if the connection exists
if the_connection or is_stop_call:
    return the_connection

# no connection: check ownership before creating a new one
drone = self.drone_service.get_drone_by_id_and_user(drone_id, user_id)
if drone:
    ...
get_drone_by_id_and_user(drone_id, user_id) returns None (→ ResourceNotFoundException) when the drone is not owned by that user. But this only runs when the pool misses. If drone 42’s connection already exists — created earlier by its rightful owner — a later get_client(other_user, 42) returns the cached socket without any ownership re-check.
Ownership is a create-time gate, not a per-request one. Because drone_mapping is keyed by drone_id alone and cached connections skip the check, a second authenticated user can reuse a live connection for a drone they do not own. Telemetry rooms compound this: emissions target the drone-scoped room drone_{id}_dashboard (see _emit_dashboard_data), so anyone who joins it receives data. Any refactor of caching or telemetry rooms must preserve — or deliberately fix — this boundary. Related: Socket.IO Telemetry Streaming and VPN IP Authentication & Jumphost Routing.

The is_stop_call short-circuit

stop_* telemetry methods (e.g. stop_logs_data) call get_client(user_id, drone_id, is_stop_call=True). When is_stop_call is truthy, _get_client returns whatever is in the pool — possibly None — and never creates a connection:
src/service/drone_control_service.py
def stop_logs_data(self, user_id, drone_id):
    client = self.get_client(user_id, drone_id, True)
    if client:                       # None-guarded: nothing to unsubscribe from
        self.send_message(client, request_format.get_unsubscribe_msg("1", LOGS[0]))
This is deliberate: unsubscribing from a drone that has no live connection should be a no-op, not a reason to spin one up (and not a reason to run an ownership check). Callers of stop paths must always None-guard the result.

SITL vs physical IP resolution

The resolved WebSocket target depends on drone.type. Physical drones connect to their stored drone.ip; SITL drones store a Docker container name in drone.ip (and drone.mac), which must be translated at connect time (src/service/drone_control_service.py:549-570):
Drone typeConditiondrone_ip used for rosbridgePort
physicaldrone.ip (the WireGuard VPN address)drone.port
sitlREMOTE_DOCKER_ENABLED=truesettings.SITL_HOST (default <office-docker-host>, the office server over WireGuard)drone.port (9090 + n)
sitlREMOTE_DOCKER_ENABLED=false"host.docker.internal" (local Docker host)drone.port (9090 + n)
src/service/drone_control_service.py
if drone.type.value == "sitl":
    if not settings.ENABLE_SITL:
        raise ResourceWarning("SITL is not enabled")
    if settings.REMOTE_DOCKER_ENABLED:
        drone_ip = settings.SITL_HOST            # office server, reached via WireGuard
    else:
        drone_ip = "host.docker.internal"        # local host network
else:
    drone_ip = drone.ip                          # physical: real VPN IP
The drone.port for a SITL drone is derived arithmetically from its container number (9090 + n); the container name lives in drone.ip/drone.mac. Any code that assumes Drone.ip is a routable IP will break for SITL. The container/port allocation is owned by SITL Drone Lifecycle.
SITL_HOST falls back to DOCKER_HOST_IP and defaults to <office-docker-host> (src/application/settings.py:77). Whether the connection then goes direct or through the jumphost is a separate axis handled inside Connection via JUMPHOST_IP/JUMPHOST_PORT and the x-drone-ip/x-drone-port headers — see Rosbridge Connection & Reconnect.

Expiry-triggered reconnect and close

The pool never proactively evicts. Two things end a pooled connection’s life:
  • Expiry + reuse. On any get_client, if the pooled Connection.is_expired() (its SmartSocket gave up after MAX_RECONNECT_ATTEMPTS / MAX_RECONNECT_TIME), _get_client calls start_connection() on the same object in place and returns it — the drone_mapping entry is reused, not replaced.
  • Explicit close. close(user_id, drone_id) tears down and removes the entry:
src/service/drone_control_service.py
def close(self, user_id, drone_id):
    self.get_client(user_id, drone_id).close()
    self.drone_mapping.pop(drone_id)
close() calls get_client first, which — on a cache miss — will create a brand-new connection just to immediately close it (and run an ownership check that raises if the caller does not own the drone). It also pops by drone_id with no default, so closing a drone that is not pooled raises KeyError. Preserve these edges or guard them when refactoring teardown.

Startup wiring: the load-bearing order

Telemetry emission is gated on self.socketio being truthy, but the service is constructed without it. Three steps, in this exact order, must complete before any request is served, or telemetry callbacks are silently never attached.
1

Construct the service without SocketIO (app.py)

src/application/app.py:43 builds the singleton at import time. socketio_instance defaults to None:
src/application/app.py
drone_control_service = DroneControlService(generic_drone_service, generic_mission_service)
2

Create the SocketIO instance (main.py)

src/main.py:208 creates the gevent SocketIO server (optionally backed by a Redis message_queue):
src/main.py
socketio = SocketIO(
    app,
    async_mode="gevent",
    ping_timeout=settings.SOCKETIO_PING_TIMEOUT,
    ping_interval=settings.SOCKETIO_PING_INTERVAL,
    message_queue=settings.SOCKETIO_MESSAGE_QUEUE,
)
3

Inject SocketIO into routes and the service (main.py)

src/main.py:240 wires the handlers, then src/main.py:259 back-assigns the instance onto the singleton:
src/main.py
socket_routes.init_socket_routes(socketio, app_module.drone_control_service)
...
# This must be done BEFORE gunicorn workers start handling requests
app_module.drone_control_service.socketio = socketio

Why the order is load-bearing

At connection creation, _get_client decides once whether to attach the emit callback, based on the current value of self.socketio:
src/service/drone_control_service.py
socketio_callback = lambda topic, msg: self._emit_telemetry(drone_id, topic, msg)
the_connection = Connection(
    drone_ip=drone_ip,
    drone_port=drone.port,
    socketio_callback=socketio_callback if self.socketio else None,   # bound at creation
)
The callback is captured at Connection construction time and never re-evaluated per message (Connection.on_message just checks if self.socketio_callback). So:
  • If a Connection is ever created while self.socketio is still None, it is pooled with socketio_callback=None and will stay silent forever, even after step 3 assigns socketio. The only recovery is close() + recreate (there is a Connection.set_socketio_callback method, but DroneControlService never calls it to retrofit pooled connections).
  • This is why the comment at src/main.py:258 insists the assignment happens before gunicorn workers start handling requests — no get_client call may run before self.socketio is set.
Symptom → cause. Commands work (arming, takeoff, mode changes all succeed) but the UI receives no telemetry_data, with no errors in the log. The usual cause is a Connection that was pooled before socketio was attached, or self.socketio being falsy at creation time. Fix by restarting the worker (fresh pool) or close()-ing the affected drone so the next subscribe rebuilds it with a live callback.

Multi-worker caveat

drone_mapping is an in-memory dict on a per-process singleton. When the gateway runs multiple gevent/gunicorn workers, each worker keeps its own pool, so the same drone can hold one rosbridge Connection per worker. SOCKETIO_MESSAGE_QUEUE (an optional Redis URL, src/application/settings.py:82) exists precisely so a telemetry_data emitted from the worker that owns the drone’s rosbridge socket still reaches Socket.IO clients pinned to a different worker. Without it, telemetry only reaches clients that happen to share a worker with the drone’s connection. See Gateway Environment Variables and Startup, Validation & Composition Root.

Things a refactor must preserve

Changing the key to (user_id, drone_id) would create one rosbridge socket per user per drone — multiplying load on the drone’s rosbridge server and breaking the “shared telemetry room” model. If you re-key, you must also rethink room naming and ownership.
A cached connection is returned without re-checking ownership. Any per-message authorization must be added explicitly (it does not exist today).
socketio_callback is fixed when the Connection is built. Ensure self.socketio is truthy before any get_client, or retrofit via set_socketio_callback — otherwise a connection is permanently mute.
All pool access serializes on one lock. Keep the critical section cheap; do not add blocking I/O inside _get_client beyond what already runs there — the get_drone_by_id_and_user DB lookup and the synchronous _test_connection() reachability probe inside start_connection() (up to DRONE_REACHABILITY_TIMEOUT, default 2s) — or Connection lock timed out will start firing.
Drone.ip/Drone.mac hold the container name for SITL; translation to SITL_HOST or host.docker.internal happens only inside _get_client. Any new code path that opens a socket to a drone must replicate this branch.

Rosbridge Connection & Reconnect

Connection/SmartSocket internals: outgoing queue, subscription replay, keepalive, backoff, is_expired, and the synchronous service-call RPC.

DroneControlService & Rosbridge Dispatch

The command methods and telemetry emission (_emit_dashboard_data, yaw calculation) that consume get_client.

SITL Drone Lifecycle

Container numbering, 9090 + n port math, local vs remote Docker — the source of the SITL ip/port values this page resolves.

Startup, Validation & Composition Root

The full boot sequence in main.py/app.py that this page’s three wiring steps are part of.