/). 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 thetoken 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}).Socket.IO server configuration
TheSocketIO 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.
Client → Server events
int
required
The target drone’s database id. This value alone determines the room name — see the security note below.
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 check
Subscription joins a drone-scoped room, built purely from the payload:room_name = f"drone_{drone_id}_{stream_type}" (src/routes/socket_routes.py:153). The emission side targets the exact same string family — drone_{drone_id}_dashboard (src/service/drone_control_service.py:1605) and drone_{drone_id}_{suffix} (src/service/drone_control_service.py:1637).
The per-drone alert room drone_{drone_id}_alerts is joined at the same point, once ownership is verified. Alerts are not tied to a single stream — the fleet map subscribes gps/relalt while the status panel uses dashboard — so they get their own room. It is not left on unsubscribe; flask-socketio clears all rooms on disconnect.
Stream types
Everystream_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).
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.
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 asdata.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_hudmessage exists, use itsheadingfield (0–360°, the same value Mission Planner shows). - Priority 2 — IMU quaternion. Otherwise convert the latest
/mavros/imu/dataorientation to yaw and map ENU → compass withyaw = (90 - degrees(atan2(...))) % 360. This is the SITL fallback.
src/service/drone_control_service.py:1192
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.
Server → Client events
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:
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 callsstop_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_dashboardsubscribes toHOME_POSITIONbut_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 inDroneControlService, but there is none. The drone-side subscription outlives the socket until the pooled connection expires. See Connection Pool & Startup Wiring.
Troubleshooting
I get connected but no telemetry_data
I get connected but no telemetry_data
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....subscribe_telemetry returns a generic error
subscribe_telemetry returns a generic error
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.Telemetry keeps flowing after I unsubscribe
Telemetry keeps flowing after I unsubscribe
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.A revoked (logged-out) token still connects
A revoked (logged-out) token still connects
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.Related pages
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.
