The three channels at a glance
| Channel | Purpose | Direction | Client (Dashboard) | Server | Port | URL / handshake | Auth | Rate profile |
|---|---|---|---|---|---|---|---|---|
| Socket.IO telemetry | Live flight telemetry | vehicle → UI | WebsocketService (socket.io-client) | Gateway root namespace | 5000 | wss://prod.skyhub.ai:5000/?token=<JWT> | JWT (HS256) in token query param | Throttled ~200 ms (5 Hz); diagnostics unthrottled |
| redispad commands | Gamepad / guided / camera / LAND (and ArUco + status back) | UI → vehicle | VehicleCommandService + ArucoOverlayService (native WebSocket) | WS Proxy (FastAPI) → Redis pub/sub | 7070 | wss://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 video | Live H264 camera | drone → UI | JanusService (janus.js / WebRTC) | Janus SFU (VideoRoom) | 8188 signaling; media over UDP | wss://prod.skyhub.ai:8188 | VideoRoom room_id + password/token | Real-time media (WebRTC) |
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.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().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.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.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.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.
VehicleCommandServicesends{ set_mode: 'LAND' },{ type: 'guided_control', command },{ type: 'camera_command', ... }, and rawGamepadData { buttons, axes, timestamp, front_ts }. It auto-reconnects ~1 s after a drop (unless intentionally disconnecting) and stays connected even with no drone selected.ArucoOverlayServiceopens a second, independent redispad socket to the same/redispad/{droneId}URL, filtered toaruco_trackingfor the precision-landing canvas overlay. So a selected drone has two redispad sockets open.
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.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.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).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.
WebsocketServicere-emits its cachedactiveSubscriptionsinside theconnecthandler on every reconnect, or the drone-selection UI goes silent. - Rosbridge (Gateway layer):
Connectiontracks everysubscribeframe in asubscriptionsset andSmartSocketreconnects with backoff (effectively 3 s then 10 s), replaying all tracked subscriptions inon_openso drone topics resume automatically. Full mechanics: Rosbridge Connection & 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
Three distinct auth models, three query-param names
Three distinct auth models, three query-param names
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.Socket.IO base URL is derived, not configured
Socket.IO base URL is derived, not configured
WebsocketService computes its base URL as environment.url.replace('/api/v1', ''). Any change to the REST path shape silently breaks the telemetry connection.CAMERA_STATUS and ARUCO_TRACKING can arrive on two channels
CAMERA_STATUS and ARUCO_TRACKING can arrive on two channels
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.
High-frequency handlers run outside the Angular zone
High-frequency handlers run outside the Angular zone
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.

