The Gateway pushes real-time drone telemetry to the UI over a single Flask-SocketIO namespace (the root /). HTTP handles auth and commands; Socket.IO handles the firehose of GPS, altitude, heading, logs, and diagnostics. A client authenticates once in the handshake, then subscribes to per-drone telemetry rooms; the Gateway fans those subscriptions out to MAVROS topics over the pooled rosbridge connection and re-emits every incoming message back to the room. The two files that define this contract are src/routes/socket_routes.py (handshake + subscription handlers) and src/service/drone_control_service.py (the emission side). This page is the authoritative description of the wire contract — including a routing quirk that makes four of the five stream types silently deliver nothing.
This page documents the server side of the socket. For the Angular client (RxJS Subject routing, reconnect replay of activeSubscriptions) see Real-time Telemetry Client. For how the underlying drone WebSocket is pooled and reconnected, see Rosbridge Connection & Reconnect. For how this fits alongside the video and gamepad transports, see Real-time Transport Channels.

Connection handshake

The client opens a Socket.IO connection to the Gateway root namespace with a JWT in the token query parameter. handle_connect (src/routes/socket_routes.py:30) decodes it with jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"]), reads the sub claim, and stores it in the Flask session as user_id (src/routes/socket_routes.py:55). On success it emits connected; on any failure it emits error and calls disconnect().
1

Client opens the socket with ?token=JWT

Base URL is the Gateway origin without the /api/v1 suffix (the Socket.IO namespace lives at the root path, not under the HTTP blueprint prefix).
2

Server decodes the JWT (HS256)

request.args.get("token")jwt.decode(...). A missing token, an expired token (ExpiredSignatureError), or a token with no sub all result in an error emit followed by disconnect().
3

Server stores user_id in the session and emits connected

session["user_id"] = user_id, then emit("connected", {"status": "success", "user_id": user_id}).
import { io } from "socket.io-client";

// Gateway origin WITHOUT /api/v1 — the socket namespace is the root path
const socket = io("https://gateway.example.com", {
  query: { token: "<jwt-access-token>" },   // ?token=<jwt>
  transports: ["websocket"],
});

socket.on("connected", ({ user_id }) => {
  socket.emit("subscribe_telemetry", { drone_id: 42, stream_type: "dashboard" });
});
socket.on("telemetry_data", (msg) => console.log(msg.type, msg.data));
socket.on("error", (e) => console.error("socket error:", e.message));
import socketio

sio = socketio.Client()

@sio.on("connected")
def _(data):
    sio.emit("subscribe_telemetry", {"drone_id": 42, "stream_type": "dashboard"})

@sio.on("telemetry_data")
def _(msg):
    print(msg["type"], msg["data"])

sio.connect("https://gateway.example.com?token=<jwt-access-token>",
            transports=["websocket"])
sio.wait()
Socket auth has two sharp edges a future editor must know about:
  1. The token is read only from the query string (request.args.get("token")), despite the code comment claiming an “auth dict (preferred method)”. Passing the JWT in a Bearer header or in the Socket.IO auth payload will not authenticate the socket.
  2. The handshake does a raw jwt.decode and never consults the HTTP logout BLOCKLIST. A token that was revoked via DELETE /api/v1/logout still authenticates a new socket connection until it naturally expires. See Authentication & JWT Lifecycle for the blocklist’s limits.

Socket.IO server configuration

The SocketIO instance is built in src/main.py:208 and wired to the handlers at src/main.py:240; the socketio reference is injected onto DroneControlService post-init at src/main.py:259.
SettingEnv varDefaultNotes
async_modegeventCooperative concurrency for the WebSocket workers.
ping_timeoutSOCKETIO_PING_TIMEOUT60 (s)Drop the client if no pong within this window.
ping_intervalSOCKETIO_PING_INTERVAL25 (s)Heartbeat cadence.
message_queueSOCKETIO_MESSAGE_QUEUENoneOptional Redis URL to fan emits across multiple gunicorn workers.
cors_allowed_origins* locally, else the configured originsSee src/main.py:210.
When you run more than one Gateway worker, you must set SOCKETIO_MESSAGE_QUEUE to a shared Redis URL. Rosbridge callbacks emit from whichever worker holds the drone’s pooled connection, but the subscribing client may be pinned to a different worker. Without the Redis backplane, telemetry_data is emitted into a room the client never joined on that worker, and the UI sees a live socket with no data. See Environment Variables.

Client → Server events

EventPayloadEffect
connect?token=<jwt> (query)Authenticate; stores user_id in session; emits connected.
subscribe_telemetry{ drone_id, stream_type }Joins room drone_{drone_id}_{stream_type} and starts the rosbridge topic subscriptions.
unsubscribe_telemetry{ drone_id, stream_type }Leaves the room; stops rosbridge topics only if DO_UNSUBSCRIBE.
disconnectRoom membership is dropped automatically; no rosbridge unsubscribe is issued.
drone_id
int
required
The target drone’s database id. This value alone determines the room name — see the security note below.
stream_type
string
required
One of gps, logs, relalt, system, or dashboard. In practice only dashboard delivers data (see The single-stream gotcha).

Room naming and the ownership gap

Subscription joins a drone-scoped room, built purely from the payload: room_name = f"drone_{drone_id}_{stream_type}" (src/routes/socket_routes.py:123). The emission side targets the exact same string family — drone_{drone_id}_dashboard (src/service/drone_control_service.py:1204) and drone_{drone_id}_{suffix} (src/service/drone_control_service.py:1231).
The room name contains no user_id. CLAUDE.md documents a user_{user_id}_drone_{drone_id}_{stream_type} scheme — that is wrong; trust the code. The room is drone_{id}_{stream} and there is no ownership check on subscribe_telemetry.Ownership is verified in exactly one place: when _get_client has to create a new rosbridge connection, it calls get_drone_by_id_and_user(drone_id, user_id) and raises if the drone is not the caller’s (src/service/drone_control_service.py:549). But join_room(...) runs first and unconditionally (src/routes/socket_routes.py:124), and a cached connection is returned with no re-check. Consequence: if any user already has a live stream open for a drone, a second authenticated client that guesses that drone_id can join drone_{id}_dashboard and receive that drone’s telemetry. A refactor must not change the exact room string (or telemetry stops flowing) and should ideally add an ownership check on subscribe.

Stream types

Every stream_type maps to a set of MAVROS topics and a room. The mapping lives in handle_subscribe_telemetry and _subscribe_dashboard (src/routes/socket_routes.py).
stream_typeRoom joinedRosbridge topics subscribedActually delivers?
gpsdrone_{id}_gpsGPSNo — see gotcha
logsdrone_{id}_logsLOGS (/rosout)No
relaltdrone_{id}_relaltRELATIVE_ALTNo
systemdrone_{id}_systemDIAGNOSTICS (/diagnostics)No
dashboarddrone_{id}_dashboardGPS, GPS_RAW, HOME, REL_ALT, LOGS, VFR_HUD, IMU, DIAGNOSTICSYes

Dashboard fan-out

stream_type: "dashboard" is the real telemetry channel. _subscribe_dashboard (src/routes/socket_routes.py:211) issues eight rosbridge subscriptions through the pooled connection. Topic strings and types are defined in src/utils/mavros_topics.py.
Topic constantMAVROS topicROS typePurpose
RELATIVE_ALT/mavros/global_position/rel_altstd_msgs/msg/Float64Relative altitude
LOGS/rosoutrcl_interfaces/msg/LogROS log lines
GPS/mavros/global_position/globalsensor_msgs/msg/NavSatFixPosition (yaw injected server-side)
GPS_RAW/mavros/gpsstatus/gps1/rawmavros_msgs/msg/GPSRAWRaw fix / RTK status
HOME_POSITION/mavros/home_position/homemavros_msgs/msg/HomePositionHome point
VFR_HUD/mavros/vfr_hudmavros_msgs/msg/VFR_HUDHeading source for yaw
IMU_ORIENTATION/mavros/imu/datasensor_msgs/msg/ImuYaw fallback quaternion
DIAGNOSTICS/diagnosticsdiagnostic_msgs/msg/DiagnosticArrayMode / system status
All subscriptions are throttled to TELEMETRY_THROTTLE_RATE (default 200 ms, src/application/settings.py:145) via request_format.get_subscribe_msg. The one exception is diagnostics: start_system_data passes throttle_rate=0 (unthrottled) because /diagnostics already publishes at ~1 Hz and carries critical mode/status (src/service/drone_control_service.py:482).

Server-side yaw injection

The Gateway computes a compass heading server-side and injects it into every GPS payload as data.yaw, so the UI can rotate the map marker without doing quaternion math. Logic is in _emit_dashboard_data (src/service/drone_control_service.py:1159), reading from the connection’s per-topic message buffers (connection.queues):
  • Priority 1 — VFR_HUD heading. If a buffered /mavros/vfr_hud message exists, use its heading field (0–360°, the same value Mission Planner shows).
  • Priority 2 — IMU quaternion. Otherwise convert the latest /mavros/imu/data orientation to yaw and map ENU → compass with yaw = (90 - degrees(atan2(...))) % 360. This is the SITL fallback.
src/service/drone_control_service.py:1192
# Yaw (z-axis) from IMU quaternion, then ENU frame -> compass heading
siny_cosp = 2 * (w * z + x * y)
cosy_cosp = 1 - 2 * (y * y + z * z)
yaw = math.atan2(siny_cosp, cosy_cosp) * 180 / math.pi
yaw = (90 - yaw) % 360
payload["data"]["yaw"] = yaw

The single-stream gotcha (only dashboard delivers)

The single-stream types (gps, logs, relalt, system) are effectively dead: you can subscribe and get subscription_confirmed, the rosbridge topic is subscribed, but no telemetry_data ever reaches your room. The reason is a routing mismatch in _emit_telemetry (src/service/drone_control_service.py:1109). For every incoming message it checks _is_dashboard_topic(topic). That predicate’s topic list (GPS, GPS_RAW, LOGS, RELATIVE_ALT, DIAGNOSTICS, VFR_HUD, IMU, HOME) is a superset of every topic a single-stream subscription can request. So the branch is always taken → the message is routed to _emit_dashboard_data → emitted only to drone_{id}_dashboard. The single-stream helper _emit_to_stream_subscribers (:1207), which would emit to drone_{id}_gps etc., is never reached.
Always subscribe with stream_type: "dashboard". It is the only value that produces telemetry_data. Route the incoming messages by their type field on the client. Do not delete _emit_to_stream_subscribers in a cleanup pass without also re-pointing _is_dashboard_topic — the single-stream rooms are the intended target if the routing is ever fixed.

Server → Client events

EventPayloadWhen
connected{ status: "success", user_id }JWT accepted in the handshake.
subscription_confirmed{ status: "subscribed" | "unsubscribed", stream_type, drone_id }After a subscribe/unsubscribe completes.
telemetry_data{ type, drone_id, data }Each throttled rosbridge message, emitted to the drone’s room.
error{ message }Auth failure, missing fields, unknown stream_type, or a handler exception.
The type field on telemetry_data is the internal stream name from _get_stream_type_from_topic (src/service/drone_control_service.py:1131). On the dashboard room you will receive these type values:
typedata shape
GPSNavSatFix object, augmented with an injected yaw (degrees).
GPS_RAWGPSRAW object (fix type, satellites, DOP).
RELATIVE_ALTA bare float (the std_msgs/Float64 .data is unwrapped).
LOGrcl_interfaces/Log object (note: singular LOG, not logs).
VFR_HUDVFR_HUD object.
IMU_ORIENTATIONImu object.
HOME_POSITIONHomePosition object.
DIAGNOSTICSDiagnosticArray object.

Unsubscribe and DO_UNSUBSCRIBE

unsubscribe_telemetry (src/routes/socket_routes.py:147) always calls leave_room(...) so the client stops receiving emits. Whether it also tells the drone to stop publishing is gated by the DO_UNSUBSCRIBE env var (default true, src/application/settings.py:83):
  • DO_UNSUBSCRIBE=true — dashboard unsubscribe calls _unsubscribe_dashboard, which sends rosbridge unsubscribe frames for each topic; single-stream unsubscribe calls stop_topic_data.
  • DO_UNSUBSCRIBE=false — the client leaves the room but the rosbridge subscription stays open. The drone keeps streaming to the pooled connection (and to anyone still in the room). Use this to keep the drone-side subscription warm across UI reconnects at the cost of extra drone traffic.
Two asymmetries in the unsubscribe path are worth preserving deliberately or fixing on purpose:
  • _subscribe_dashboard subscribes to HOME_POSITION but _unsubscribe_dashboard (src/routes/socket_routes.py:249) does not unsubscribe it — the home-position subscription leaks on unsubscribe.
  • disconnect (src/routes/socket_routes.py:78) does no rosbridge cleanup at all; the comment claims reference counting exists in DroneControlService, but there is none. The drone-side subscription outlives the socket until the pooled connection expires. See Connection Pool & Startup Wiring.

Troubleshooting

Almost always one of: (1) you subscribed with a single stream type (gps/logs/relalt/system) — switch to dashboard; (2) multiple Gateway workers without SOCKETIO_MESSAGE_QUEUE, so emits land on a different worker; or (3) the rosbridge connection to the drone isn’t up (physical drone offline / SITL container not healthy) so no topic messages arrive at all. Check the Gateway logs for No existing connection for drone {id}, creating new connection....
The handler wraps the subscription in a try/except and emits {"message": "Subscription error"} on any exception. The most common underlying cause is a ResourceNotFoundException from get_drone_by_id_and_user — the drone_id doesn’t exist or isn’t owned by the token’s user and no connection was already cached. Missing drone_id/stream_type yields a more specific Missing drone_id or stream_type.
DO_UNSUBSCRIBE=false leaves the drone-side subscription open, and disconnect never unsubscribes. Set DO_UNSUBSCRIBE=true and issue an explicit unsubscribe_telemetry before disconnecting. Note the home-position topic leaks even then.
Expected with the current code: the socket handshake does not check the HTTP BLOCKLIST. Revocation only takes effect when the short-lived access token expires. See Authentication & JWT Lifecycle.

HTTP API Overview & Auth Models

The three auth models, the /api/v1 base path, and the response envelope.

Drone Management & Control Actions

The HTTP commands (arm/takeoff/mode/mission) that drive what telemetry reports.

DroneControlService & Rosbridge Dispatch

The connection pool, get_client ownership check, and the emission internals.

Real-time Telemetry Client

How the Angular Dashboard consumes telemetry_data and replays subscriptions.