/). 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().
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).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().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.
| Setting | Env var | Default | Notes |
|---|---|---|---|
async_mode | — | gevent | Cooperative concurrency for the WebSocket workers. |
ping_timeout | SOCKETIO_PING_TIMEOUT | 60 (s) | Drop the client if no pong within this window. |
ping_interval | SOCKETIO_PING_INTERVAL | 25 (s) | Heartbeat cadence. |
message_queue | SOCKETIO_MESSAGE_QUEUE | None | Optional Redis URL to fan emits across multiple gunicorn workers. |
cors_allowed_origins | — | * locally, else the configured origins | See src/main.py:210. |
Client → Server events
| Event | Payload | Effect |
|---|---|---|
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. |
disconnect | — | Room membership is dropped automatically; no rosbridge unsubscribe is issued. |
The target drone’s database id. This value alone determines the room name — see the security note below.
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).
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).
stream_type | Room joined | Rosbridge topics subscribed | Actually delivers? |
|---|---|---|---|
gps | drone_{id}_gps | GPS | No — see gotcha |
logs | drone_{id}_logs | LOGS (/rosout) | No |
relalt | drone_{id}_relalt | RELATIVE_ALT | No |
system | drone_{id}_system | DIAGNOSTICS (/diagnostics) | No |
dashboard | drone_{id}_dashboard | GPS, GPS_RAW, HOME, REL_ALT, LOGS, VFR_HUD, IMU, DIAGNOSTICS | Yes |
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 constant | MAVROS topic | ROS type | Purpose |
|---|---|---|---|
RELATIVE_ALT | /mavros/global_position/rel_alt | std_msgs/msg/Float64 | Relative altitude |
LOGS | /rosout | rcl_interfaces/msg/Log | ROS log lines |
GPS | /mavros/global_position/global | sensor_msgs/msg/NavSatFix | Position (yaw injected server-side) |
GPS_RAW | /mavros/gpsstatus/gps1/raw | mavros_msgs/msg/GPSRAW | Raw fix / RTK status |
HOME_POSITION | /mavros/home_position/home | mavros_msgs/msg/HomePosition | Home point |
VFR_HUD | /mavros/vfr_hud | mavros_msgs/msg/VFR_HUD | Heading source for yaw |
IMU_ORIENTATION | /mavros/imu/data | sensor_msgs/msg/Imu | Yaw fallback quaternion |
DIAGNOSTICS | /diagnostics | diagnostic_msgs/msg/DiagnosticArray | Mode / system status |
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
| Event | Payload | When |
|---|---|---|
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. |
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:
type | data shape |
|---|---|
GPS | NavSatFix object, augmented with an injected yaw (degrees). |
GPS_RAW | GPSRAW object (fix type, satellites, DOP). |
RELATIVE_ALT | A bare float (the std_msgs/Float64 .data is unwrapped). |
LOG | rcl_interfaces/Log object (note: singular LOG, not logs). |
VFR_HUD | VFR_HUD object. |
IMU_ORIENTATION | Imu object. |
HOME_POSITION | HomePosition object. |
DIAGNOSTICS | DiagnosticArray 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 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.
