While a drone is live, the Dashboard holds three concurrent, deliberately-separate real-time connections open at once — plus ordinary HTTP REST for CRUD and actions. Each channel has a different direction, a different backend service, a different port, and a different auth mechanism. They look superficially alike (“it’s all WebSockets”), and that resemblance is the trap: merging any two of them breaks latency guarantees, auth boundaries, or the video plane. This page is the map of what is what, so you never wire a command into the telemetry socket or assume video flows through the Gateway.
These channels must not be conflated. Telemetry (Socket.IO) is read-only vehicle→UI; commands (redispad) are UI→vehicle over a separate proxy the Gateway does not own; video (Janus) is out-of-band WebRTC that never touches the Gateway’s data path. Comments in websocket.service.ts and vehicle-command.service.ts explicitly stress keeping them distinct.

The three channels at a glance

ChannelPurposeDirectionClient (Dashboard)ServerPortURL / handshakeAuthRate profile
Socket.IO telemetryLive flight telemetryvehicle → UIWebsocketService (socket.io-client)Gateway root namespace5000wss://prod.skyhub.ai:5000/?token=<JWT>JWT (HS256) in token query paramThrottled ~200 ms (5 Hz); diagnostics unthrottled
redispad commandsGamepad / guided / camera / LAND (and ArUco + status back)UI → vehicleVehicleCommandService + ArucoOverlayService (native WebSocket)WS Proxy (FastAPI) → Redis pub/sub7070wss://prod.skyhub.ai:7070/redispad/<droneId>?access_token=<JWT>access_token sent but not validated server-side — proxy resolves by drone_id only (open auth gap)Sampled ~60 Hz, low-latency
Janus WebRTC videoLive H264 cameradrone → UIJanusService (janus.js / WebRTC)Janus SFU (VideoRoom)8188 signaling; media over UDPwss://prod.skyhub.ai:8188VideoRoom room_id + password/tokenReal-time media (WebRTC)
There is a fourth, internal real-time plane you never see from the browser: the Gateway ↔ drone rosbridge WebSocket on port 9090. Telemetry does not come from the drone to the browser directly — the Gateway subscribes to MAVROS topics over rosbridge and re-emits them into Socket.IO rooms. See Rosbridge Connection & Reconnect for that plane’s internals.

Channel 1 — Socket.IO telemetry (Gateway :5000)

The only channel that carries live vehicle state to the UI. It is a Socket.IO connection to the Gateway’s root namespace, not the REST API.
1

Connect with a JWT in the query string

WebsocketService.connect() derives the base URL by stripping /api/v1 from environment.url, then opens io(baseUrl, { query: { token }, transports: ['websocket'], reconnectionAttempts: 5 }). On the server, handle_connect in src/routes/socket_routes.py:31 reads request.args.get("token"), decodes it HS256 (jwt.decode(token, jwt_secret_key, algorithms=["HS256"])), stores session["user_id"], and emits connected. A missing/expired/invalid token triggers error + disconnect().
2

Subscribe to a stream, join a room

The client emits subscribe_telemetry { drone_id, stream_type }. The handler at src/routes/socket_routes.py:91 joins room drone_{drone_id}_{stream_type} and kicks off rosbridge subscriptions. For stream_type: 'dashboard', _subscribe_dashboard (socket_routes.py:211) fans out to GPS, GPS_RAW, rel_alt, /rosout logs, VFR_HUD, IMU orientation, home position, and /diagnostics.
3

Receive telemetry_data

Each rosbridge publish reaches Connection.on_message, which invokes the socketio callback → DroneControlService._emit_telemetry (src/service/drone_control_service.py:1109) → _emit_dashboard_data, which emits telemetry_data { type, drone_id, data } to drone_{id}_dashboard. The client routes by data.type into per-stream RxJS Subjects.
Only the dashboard stream actually delivers data. _emit_telemetry routes to _emit_dashboard_data for every mapped topic (its dashboard-topic set equals the full topic map), so _emit_to_stream_subscribers (drone_control_service.py:1207) — which serves the single-stream rooms gps/logs/relalt/system — is never reached for real data. A client that subscribes to a single stream joins drone_{id}_gps but data only ever emits to drone_{id}_dashboard, so it receives nothing. Treat single-stream subscription as dead code until deliberately fixed.
Room naming is drone-scoped, not user-scoped. Rooms are drone_{drone_id}_{stream_type} (socket_routes.py:123), shared by every subscriber of that drone — not the user_{id}_drone_{id}_... pattern the older CLAUDE.md/README claims. Ownership is verified once, when the pooled rosbridge connection is first created (get_drone_by_id_and_user in _get_client), and not per telemetry message. See Authentication & Security Model.
The dashboard stream also does one piece of server-side work: it injects a computed yaw into GPS payloads — VFR_HUD heading first, else an IMU-quaternion → compass conversion (90 - atan2(...)) % 360 (drone_control_service.py:1174). IMU_ORIENTATION and VFR_HUD are consumed for this and are not re-emitted to the client as their own types. Full detail: Socket.IO Telemetry Streaming (server) and Real-time Telemetry Client (client).

Channel 2 — redispad commands (WS Proxy :7070)

Manual control does not go through the Gateway. The Dashboard opens a raw WebSocket to a separate service — the WebSocket Gamepad Proxy (FastAPI) — at ${ws_proxy}/redispad/{droneId}?access_token=<JWT>. The proxy resolves the drone IP from PostgreSQL and relays over Redis pub/sub: inbound commands to channel {drone_ip}:gamepad_input, and status/overlay back on {drone_ip}:output and {drone_ip}:aruco_tracking. This is the low-latency path, kept independent from rosbridge so a 60 Hz stick stream never contends with mission/telemetry traffic.
  • VehicleCommandService sends { set_mode: 'LAND' }, { type: 'guided_control', command }, { type: 'camera_command', ... }, and raw GamepadData { buttons, axes, timestamp, front_ts }. It auto-reconnects ~1 s after a drop (unless intentionally disconnecting) and stays connected even with no drone selected.
  • ArucoOverlayService opens a second, independent redispad socket to the same /redispad/{droneId} URL, filtered to aruco_tracking for the precision-landing canvas overlay. So a selected drone has two redispad sockets open.
Axes movement is silently dropped unless guided control is enabled. ControllerDataSenderService only forwards stick/axes frames when GuidedControlService reports guided control on; buttons and guided/camera commands always pass. On the drone, movement becomes GUIDED-frame velocity setpoints (SET_POSITION_TARGET_LOCAL_NED) with a 0.5 s dead-man timeout — deliberately replacing dangerous RC_CHANNELS_OVERRIDE. Removing this gate would send raw stick input to a vehicle not in GUIDED. See Vehicle Commands & Gamepad and Guided Velocity Control & Safety.
The WS Proxy performs no server-side token validation on either endpoint. The Dashboard sends ?access_token=<JWT>, but the proxy’s main.py ignores it: neither /redispad/{id} (websocket_redis_proxy) nor the older direct /gamepad/{id} (websocket_gamepad_direct) reads or verifies the access_token, decodes a JWT, or checks ownership. Both simply websocket.accept() and resolve the drone IP by drone_id from PostgreSQL (get_drone_ip_by_id) — the user_id column is selected but never compared to the caller. So both paths share the same open auth gap; access_token is not an enforced auth mechanism here. The Dashboard uses /redispad exclusively.

Channel 3 — Janus WebRTC video (:8188)

Video is out-of-band: it never traverses the Gateway’s telemetry or command data path. Only control of the video room is REST/rosbridge; the media itself is WebRTC drone→Janus→UI.
1

Room creation (control plane, via Gateway)

On drone creation or GET /api/v1/video_room/{drone_id}/start?update=true, the Gateway’s VideoService creates a Janus VideoRoom and publishes the room_id / password / token to the drone over the rosbridge topic /video_room_details. See Janus Video Rooms.
2

Ingest (drone → WHIP → Janus)

The on-drone (or SITL) GStreamer pipeline HW-encodes H264 and whipsinks it to the WHIP ingest server, which registers it as a Janus VideoRoom publisher over Janus’ own WebSocket API (ws://janus:8188). See Video Streaming (RTSP → WHIP/WebRTC).
3

Subscribe (UI ← Janus)

JanusService connects to environment.janusGatewayUrl (wss://prod.skyhub.ai:8188), joins the same room with the token, and attaches remote WebRTC tracks to a <video> element. ICE uses stun:stun.l.google.com:19302; UDP media relays through the jumphost. See App State & Video and the Janus SFU.
The ArUco precision-landing box is not burned into the video — tracking is published to Redis {ip}:aruco_tracking, arrives over Channel 2 (redispad), and is drawn as a canvas overlay on top of the <video>. This is why a marker box can lag or freeze independently of the video frames.

Reconnection & subscription replay

Both realtime streaming channels lose server-side state on a transport drop and must replay their subscriptions — and they do so at two independent layers:
  • Socket.IO (browser layer): the server loses room membership when the transport drops. WebsocketService re-emits its cached activeSubscriptions inside the connect handler on every reconnect, or the drone-selection UI goes silent.
  • Rosbridge (Gateway layer): Connection tracks every subscribe frame in a subscriptions set and SmartSocket reconnects with backoff (effectively 3 s then 10 s), replaying all tracked subscriptions in on_open so drone topics resume automatically. Full mechanics: Rosbridge Connection & Reconnect.
The rosbridge subscription set is add-onlyunsubscribe frames are never removed from it, so a reconnect can re-enable a stream a client had explicitly unsubscribed from. Keep this in mind when reasoning about “phantom” telemetry after a drone reconnect.
VehicleCommandService (Channel 2) also auto-reconnects (~1 s), guarding against stale sockets by capturing the socket in each handler closure. Janus (Channel 3) does not auto-reload on a dead session; VideoWindowComponent resets local state so “Start Video” can re-initialize.

Why they stay separate — gotchas to preserve

Telemetry uses ?token= (Socket.IO handshake, session[user_id]); redispad sends ?access_token= but the WS Proxy does not validate it (open auth gap — it resolves by drone_id only); video uses a VideoRoom password/token. REST uses a Bearer header. They are not interchangeable. See Authentication & Security Model.
WebsocketService computes its base URL as environment.url.replace('/api/v1', ''). Any change to the REST path shape silently breaks the telemetry connection.
The Socket.IO client heartbeat (SOCKETIO_PING_TIMEOUT=60 / SOCKETIO_PING_INTERVAL=25), the rosbridge WS ping (ping_interval=3 / ping_timeout=2, connection.py:191), and TCP keepalive (KEEPIDLE=3, KEEPINTVL=3, KEEPCNT=2) are independent. Don’t conflate them when tuning stability.
Both can come over Socket.IO telemetry and over redispad, so consumers may see interleaved/duplicate sources. This is intentional dual-sourcing, not a bug.
Socket.IO telemetry handlers, the 60 Hz gamepad poll, joystick 80 ms polling, and per-frame ArUco rendering all run via NgZone.runOutsideAngular and re-enter only via emitInZone. Losing this pattern reintroduces heavy change-detection churn.

Where to go next

Rosbridge Connection

The internal :9090 plane, SmartSocket reconnect/backoff, and subscription replay.

Connection Pool

drone_mapping pooling, ownership checks, SITL vs physical IP resolution, startup wiring.

Telemetry (Server)

Socket.IO events, dashboard fan-out, yaw computation, room semantics.

Telemetry (Client)

WebsocketService: subscribe/replay and RxJS Subject routing.

Vehicle Commands

redispad WebSocket, guided-control gating, gamepad button map.

WS Proxy

FastAPI relay, /gamepad vs /redispad, Redis channel contract.

Video Rooms

Janus room creation and on-drone video control.

Janus SFU

The WebRTC SFU that fans camera streams to viewers.

Cross-System Flows

End-to-end command, telemetry, and video sequences.