WebsocketService (src/app/services/websocket.service.ts) is the Dashboard’s telemetry ingress: a Socket.IO client that receives real-time vehicle data (GPS, diagnostics, altitude, logs, home position, camera and ArUco status) from the Gateway and pushes it into per-type RxJS Subjects that HUD, map, and video components subscribe to.
It is one of the four transport channels the Dashboard uses. Keep it mentally separate from the others (see Frontend ↔ Gateway Integration and Real-time Transport Channels):
| Channel | Service | Direction | Transport |
|---|---|---|---|
| Telemetry (this page) | WebsocketService | vehicle → UI | Socket.IO to Gateway root namespace |
| Commands / gamepad | VehicleCommandService | UI → vehicle | raw WS to redispad proxy — see Vehicle Commands |
| Video | JanusService | vehicle → UI | WebRTC — see App State & Video |
| CRUD / actions | DroneService etc. | UI ↔ Gateway | HTTP REST |
Connection handshake
connect() (src/app/services/websocket.service.ts:45) is private and lazily called by every subscribe method. It is idempotent — repeated calls reuse the live socket.
Reuse or drop the existing socket
If a socket exists and is
connected || active (still (re)connecting), connect() returns immediately. If it exists but has exhausted reconnect attempts, it is disconnect()-ed and nulled so a fresh one can be built.Read the JWT from localStorage
The access token is read from
localStorage.getItem('accessToken') — the same token maintained by AuthService. If there is no token, connect() silently returns (no anonymous telemetry).Derive the Socket.IO base URL
The REST base is stripped of its
/api/v1 suffix: environment.url.replace('/api/v1', ''). Socket.IO attaches to the Gateway root namespace on port 5000, not /api/v1.src/app/services/websocket.service.ts:64
token query parameter (?token=<jwt>), not an Authorization header — this is what the Gateway’s handle_connect decodes to establish the session. Contrast with the redispad channel, which uses access_token. Resolved base URLs per build:
| Env file | environment.url | Socket.IO base URL |
|---|---|---|
environment.ts (default) | http://localhost:5000/api/v1 | http://localhost:5000 |
environment.local.ts | http://localhost:5000/api/v1 | http://localhost:5000 |
environment.prod.ts | https://prod.skyhub.ai:5000/api/v1 | https://prod.skyhub.ai:5000 |
Stream types & subscribe / unsubscribe
There are three public subscribe methods, each emitting asubscribe_telemetry event for a different stream_type:
| Method | stream_type | Returns | Primary caller |
|---|---|---|---|
getAllData(droneId) | dashboard | void (fire-and-forget) | DroneStatusComponent.onChange (drone-status.component.ts:170) |
getGpsData(droneId) | gps | Subject (gpsSubject) | HomeComponent (home.component.ts:884) |
getLogsData(droneId) | logs | Subject (logsSubject) | TerminalComponent (terminal.component.ts:71) |
dashboard stream is the workhorse: on the Gateway side it fans out into GPS, GPS_RAW, rel_alt, /rosout, VFR_HUD, IMU, home, and /diagnostics subscriptions (see Gateway Socket.IO Telemetry), so a single getAllData() populates almost every Subject. In fact dashboard is currently the only stream_type that delivers data: getGpsData/getLogsData do emit their subscribe_telemetry events for the narrower gps/logs rooms, but the Gateway treats every topic as a dashboard topic and never emits to the drone_{id}_gps / drone_{id}_logs rooms (its per-stream emit path is unreachable dead code). So gpsSubject/logsSubject are fed exclusively by the dashboard fan-out — calling getGpsData/getLogsData on their own, without an active dashboard subscription, yields no telemetry.
Each subscribe call first connect()s, then either subscribes immediately (if socket.connected) or defers via socket.once('connect', ...).
subscribeToStream(droneId, streamType) (src/app/services/websocket.service.ts:182) tracks a de-duplication key `${droneId}_${streamType}` in the activeSubscriptions: Set<string>. A key already present is skipped, so re-selecting the same drone does not double-subscribe. The drone_id is parseInt-ed to a number in the emitted payload.
killAllActiveEventSource() (src/app/services/websocket.service.ts:320) is the teardown: it emits unsubscribe_telemetry for every tracked key, clears the Set, and disconnect()s + nulls the socket. DroneStatusComponent.onChange calls it before wiring a newly selected drone, so switching drones fully resets the stream.
killAllActiveEventSource, SSE_* aliases in telemetry.constant.ts, and environment.sseDebounceTime are SSE-era leftovers — telemetry was migrated from Server-Sent Events to Socket.IO but the names were kept for compatibility. They do not indicate any live SSE code.Routing incoming data: telemetry_data → Subjects
The Gateway emits one event, telemetry_data, with the shape { type, drone_id, data }. handleTelemetryData (src/app/services/websocket.service.ts:205) switches on data.type against TELEMETRY_DATA_TYPES (src/app/shared/constants/telemetry.constant.ts) and pushes a normalized payload into the matching Subject.
Routing keys on
data.type only — never on which room/stream requested it. In practice GPS only ever arrives via the dashboard fan-out (the dedicated gps room is never emitted to — see above), and it lands in gpsSubject regardless of which subscription triggered the fan-out. Components filter by drone_id, not by stream. This is why a component can subscribe to gpsSubject without ever calling getGpsData itself — the dashboard subscription is what actually feeds it.data.type | Target Subject | Emitted shape | Drone-id key |
|---|---|---|---|
GPS | gpsSubject | { ...data, droneId } | droneId (string) |
GPS_RAW | gpsRawSubject | { ...data, droneId } | droneId (string) |
HOME_POSITION | homePositionSubject | { ...data, droneId } | droneId (string) |
CAMERA_STATUS | cameraStatusSubject | { ...data, droneId } | droneId (string) |
ARUCO_TRACKING | arucoTrackingSubject | { ...data, droneId } | droneId (string) |
DIAGNOSTICS | diagnosticsSubject | { drone_id, status } (flattened) | drone_id (number) |
RELATIVE_ALT | relAltSubject | { drone_id, data: altValue } | drone_id (number) |
LOG | logsSubject | { ...data, drone_id } | drone_id (number) |
IMU_ORIENTATION | — | dropped | — |
VFR_HUD | — | dropped | — |
telemetry_rate* | telemetryRateSubject | raw payload | — |
telemetry_rate is a separate Socket.IO event (not a telemetry_data type) carrying an animation-rate hint.
Consumer filtering therefore differs by type. String-keyed streams compare event.droneId === String(this.selectedDrone?.id); number-keyed streams compare event.drone_id === this.selectedDrone?.id. Getting this wrong is the most common cause of “telemetry arrives but the HUD shows nothing.” Examples in drone-status.component.ts:214 (GPS, string) and :201 (rel-alt, number).
Subject → consumer map
| Subject | Consumed by |
|---|---|
gpsSubject | HomeComponent (3D marker — Map & Tracking), DroneStatusComponent (MSL altitude, accuracy) |
gpsRawSubject | DroneStatusComponent (RTK fix type: Fixed/Float/DGPS) |
diagnosticsSubject | DroneStatusComponent, MissionControlComponent, QuickButtonsComponent, ListDronesDialogComponent (battery, mode, system status) |
relAltSubject | DroneStatusComponent, HomeComponent, QuickButtonsComponent, TakeoffAltitudeDialogComponent |
logsSubject | TerminalComponent |
homePositionSubject | HomeComponent (home marker) |
cameraStatusSubject | VideoWindowComponent (recording state, zoom %) |
arucoTrackingSubject | no consumer — populated (websocket.service.ts:307) but nothing subscribes; the precision-landing overlay reads ArUco from ArucoOverlayService’s own socket instead (Video & App State) |
telemetryRateSubject | animation-rate consumers |
CAMERA_STATUS and ARUCO_TRACKING can arrive over both channels: here via Socket.IO and over the redispad WebSocket (ControllerDataSenderService forwards camera_status into this service’s cameraStatusSubject; ArucoOverlayService handles aruco_tracking on its own socket — see Vehicle Commands). Consumers may see interleaved/duplicate sources; do not assume a single origin.NgZone handling
High-frequency socket handlers run outside the Angular zone to avoid a change-detection storm. Only the final Subject emission re-enters the zone, viaemitInZone (src/app/services/websocket.service.ts:125):
src/app/services/websocket.service.ts:125
handleTelemetryData branch pushes through emitInZone, so template-bound subscribers still get change detection while raw socket traffic does not. Removing this pattern reintroduces heavy CD churn on every telemetry frame. This mirrors the other high-frequency loops (gamepad 60 Hz, ArUco per-frame) that also run runOutsideAngular.
Subscription replay on reconnect
Socket.IO loses room membership on a transport drop, but the client’sactiveSubscriptions Set survives. The connect handler (fired on both the first connect and every reconnect) replays every tracked subscription:
src/app/services/websocket.service.ts:81
Adding a new telemetry data type
Add the constant
Add the key to
TELEMETRY_DATA_TYPES in src/app/shared/constants/telemetry.constant.ts (the string must match exactly what the Gateway sets in telemetry_data.type).Add a switch branch
In
handleTelemetryData, add a case that reshapes data.data and pushes via emitInZone. Decide the drone-id key deliberately (droneId string vs drone_id number) and document it, since consumers filter on it.Wire the Gateway side
The data will not flow unless the Gateway emits it (into the
dashboard fan-out or a new stream). See Gateway Socket.IO Telemetry and DroneControlService.Debugging checklist
Telemetry never connects (but REST works)
Telemetry never connects (but REST works)
Check
environment.url still ends in /api/v1; the base URL is derived by stripping that suffix. Confirm localStorage.accessToken is present — connect() returns silently without it. Verify the Gateway is reachable on port 5000 root namespace and accepted the ?token= handshake.Data arrives but a component shows nothing
Data arrives but a component shows nothing
You are almost certainly filtering on the wrong drone-id key. GPS/GPS_RAW/HOME/CAMERA/ARUCO carry
droneId (string — compare with String(id)); DIAGNOSTICS/RELATIVE_ALT/LOG carry drone_id (number).Duplicated or interleaved camera/ArUco data
Duplicated or interleaved camera/ArUco data
CAMERA_STATUS / ARUCO_TRACKING legitimately arrive over both Socket.IO and redispad. Expected — see the note above.Telemetry stops after a network blip and never resumes
Telemetry stops after a network blip and never resumes
Socket.IO only retries
reconnectionAttempts: 5 times. After that the socket is dead; a fresh getAllData()/getGpsData() call (e.g. re-selecting the drone) will disconnect() the stale socket and build a new one.Related
Gateway Socket.IO Telemetry
The server side: handshake auth, room naming (
drone_{id}_{stream}), and the dashboard fan-out.DroneControlService
How rosbridge telemetry becomes
telemetry_data emissions (incl. server-side yaw).Vehicle Commands & Gamepad
The redispad channel — the other WebSocket, going UI → vehicle.
Real-time Transport Channels
All four channels side by side, their directions and auth.

