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):
ChannelServiceDirectionTransport
Telemetry (this page)WebsocketServicevehicle → UISocket.IO to Gateway root namespace
Commands / gamepadVehicleCommandServiceUI → vehicleraw WS to redispad proxy — see Vehicle Commands
VideoJanusServicevehicle → UIWebRTC — see App State & Video
CRUD / actionsDroneService etc.UI ↔ GatewayHTTP REST
WebsocketService only receives telemetry. Sending anything to the vehicle (arm, mode, gamepad, LAND) goes through the REST API or the redispad WebSocket — never through this service. The comment block at the top of the file exists to stop refactors from merging the two.

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.
1

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.
2

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).
3

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.
4

Open the socket outside the Angular zone

All of io(...) and its handler registration run inside ngZone.runOutsideAngular(...) so socket.io internals and per-message handling do not trigger change detection.
src/app/services/websocket.service.ts:64
const baseUrl = environment.url.replace('/api/v1', ''); // http://localhost:5000

this.ngZone.runOutsideAngular(() => {
  const socket = io(baseUrl, {
    transports: ['websocket'],   // websocket only, no long-polling fallback
    query: { token },            // JWT passed as ?token=<jwt>
    reconnection: true,
    reconnectionDelay: 1000,
    reconnectionAttempts: 5,
  });
  // ...handlers registered here
});
The JWT travels in the 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 fileenvironment.urlSocket.IO base URL
environment.ts (default)http://localhost:5000/api/v1http://localhost:5000
environment.local.tshttp://localhost:5000/api/v1http://localhost:5000
environment.prod.tshttps://prod.skyhub.ai:5000/api/v1https://prod.skyhub.ai:5000
The Socket.IO URL is derived by string-replacing /api/v1. Any change to the shape of environment.url (e.g. a different API path prefix) will silently break telemetry while leaving REST working. If telemetry never connects but REST does, check this derivation first.

Stream types & subscribe / unsubscribe

There are three public subscribe methods, each emitting a subscribe_telemetry event for a different stream_type:
Methodstream_typeReturnsPrimary caller
getAllData(droneId)dashboardvoid (fire-and-forget)DroneStatusComponent.onChange (drone-status.component.ts:170)
getGpsData(droneId)gpsSubject (gpsSubject)HomeComponent (home.component.ts:884)
getLogsData(droneId)logsSubject (logsSubject)TerminalComponent (terminal.component.ts:71)
The 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.
Each branch reshapes the payload. Note the drone-id key inconsistency — this is a real gotcha when adding a type or writing a consumer filter:
data.typeTarget SubjectEmitted shapeDrone-id key
GPSgpsSubject{ ...data, droneId }droneId (string)
GPS_RAWgpsRawSubject{ ...data, droneId }droneId (string)
HOME_POSITIONhomePositionSubject{ ...data, droneId }droneId (string)
CAMERA_STATUScameraStatusSubject{ ...data, droneId }droneId (string)
ARUCO_TRACKINGarucoTrackingSubject{ ...data, droneId }droneId (string)
DIAGNOSTICSdiagnosticsSubject{ drone_id, status } (flattened)drone_id (number)
RELATIVE_ALTrelAltSubject{ drone_id, data: altValue }drone_id (number)
LOGlogsSubject{ ...data, drone_id }drone_id (number)
IMU_ORIENTATIONdropped
VFR_HUDdropped
telemetry_rate*telemetryRateSubjectraw payload
*telemetry_rate is a separate Socket.IO event (not a telemetry_data type) carrying an animation-rate hint.
IMU_ORIENTATION and VFR_HUD arrive but are intentionally not re-emitted. The Gateway already computes the vehicle’s yaw/heading server-side (VFR_HUD heading first, else IMU quaternion → compass) and injects it into the GPS payload. Re-emitting these client-side would be redundant. Do not “fix” the empty case branches by wiring them to Subjects.
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

SubjectConsumed by
gpsSubjectHomeComponent (3D marker — Map & Tracking), DroneStatusComponent (MSL altitude, accuracy)
gpsRawSubjectDroneStatusComponent (RTK fix type: Fixed/Float/DGPS)
diagnosticsSubjectDroneStatusComponent, MissionControlComponent, QuickButtonsComponent, ListDronesDialogComponent (battery, mode, system status)
relAltSubjectDroneStatusComponent, HomeComponent, QuickButtonsComponent, TakeoffAltitudeDialogComponent
logsSubjectTerminalComponent
homePositionSubjectHomeComponent (home marker)
cameraStatusSubjectVideoWindowComponent (recording state, zoom %)
arucoTrackingSubjectno consumer — populated (websocket.service.ts:307) but nothing subscribes; the precision-landing overlay reads ArUco from ArucoOverlayService’s own socket instead (Video & App State)
telemetryRateSubjectanimation-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, via emitInZone (src/app/services/websocket.service.ts:125):
src/app/services/websocket.service.ts:125
private emitInZone(subject: Subject<any>, value: any): void {
  this.ngZone.run(() => subject.next(value));
}
Every 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’s activeSubscriptions 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
socket.on('connect', () => {
  // Server loses subscriptions on a transport drop - replay them
  this.activeSubscriptions.forEach((subscription) => {
    const [droneId, streamType] = subscription.split('_');
    socket.emit('subscribe_telemetry', {
      drone_id: parseInt(droneId, 10),
      stream_type: streamType,
    });
  });
});
This replay is what keeps the HUD alive across a flaky connection. It relies on the subscription key format `${droneId}_${streamType}` splitting cleanly on _ — since droneId is numeric and streamType is a single token (dashboard/gps/logs), split('_') yields exactly two parts. A stream type containing an underscore would corrupt the replayed drone_id. Preserve the key format if you add a stream type.

Adding a new telemetry data type

1

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).
2

Declare a Subject

Add a new Subject<any>() field on WebsocketService.
3

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.
4

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

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.
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).
CAMERA_STATUS / ARUCO_TRACKING legitimately arrive over both Socket.IO and redispad. Expected — see the note above.
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.

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.