WebSocket (not Socket.IO, not HTTP) that connects to a separate Redis-backed proxy — environment.ws_proxy, port 7070 — which is not the Gateway. Manual gamepad/keyboard/joystick input, guided-control enable/disable, a quick-LAND command, camera/gimbal commands, and the ArUco precision-landing overlay all ride this one proxy.
The three services
| Service | File | Role |
|---|---|---|
VehicleCommandService | src/app/services/vehicle-command.service.ts | Owns the redispad WebSocket. connect/disconnect/send/land, auto-reconnect, stale-socket guarding, message$ for inbound replies. |
ControllerDataSenderService | src/app/services/controller-data-sender.service.ts | App-level glue. Wires selectedDrone$ → connect/reconnect, controllerData$ → send (with the axes gate), and routes inbound message$ to the right subjects. |
GuidedControlService | src/app/services/guided-control.service.ts | The safety gate. Enables/disables/queries vehicle-side “guided control” over redispad and tracks guidedControlEnabled$. |
GamepadData-shaped payload into AppStateService.controllerData$, and ControllerDataSenderService is the single subscriber that forwards it.
Connecting: VehicleCommandService
connect(droneId) builds the URL and opens a native WebSocket (vehicle-command.service.ts:63):
src/app/services/vehicle-command.service.ts
- Access token, not
token. The JWT comes fromauthService.getAccessToken()and is passed as theaccess_tokenquery param. If there is no token,connectlogs and returns without opening a socket. - Stale-socket guarding. The freshly created socket is captured in a local
const socket, and every handler (onopen/onmessage/onerror/onclose) begins withif (socket !== this.socket) return;. This makes late events from a replaced socket no-ops after a reconnect or drone switch. - Auto-reconnect.
onclosereconnects after 1000ms tocurrentDroneId— unlesshasConnectionError(anonerrorfired) orisDisconnecting(an intentionaldisconnect()) is set. AWebSocketerror shows a toast, setshasConnectionError, closes the socket, and suppresses the reconnect. send(data)JSON-stringifies only whenisConnected && socket.readyState === WebSocket.OPEN; otherwise the frame is silently dropped.land()is a convenience that sends{ set_mode: 'LAND' }— an intentionally simple, always-allowed emergency command.- Inbound.
onmessageparses JSON and pushes it ontomessageSubject(message$); non-JSON is ignored.
Connection lifecycle (ControllerDataSenderService)
init() is called once from AppComponent.ngOnInit (app.component.ts:47) and also kicks off guidedControlService.init(). It subscribes to selectedDrone$:
First drone selected
If the socket is not already connected (and not mid-connect), it calls
vehicleCommandService.connect(droneId), records lastConnectedDroneId, and shows a “WebSocket connected” toast. An isConnecting guard is cleared after 1000ms.Drone switched
If
lastConnectedDroneId !== droneId, it disconnect()s, then connect()s the new drone after a 100ms gap so the old socket’s late events cannot bleed into the new connection.ArucoOverlayService opens a second, independent WebSocket to the same /redispad/{droneId} URL. So a single selected drone typically has two redispad sockets open — one for commands (VehicleCommandService) and one for the ArUco overlay. This is by design.The guided-control gate (the safety model)
Raw stick/axis input is never sent to a vehicle unless the operator has explicitly enabled guided control and the vehicle is in GUIDED mode. The gate lives inControllerDataSenderService’s controllerData$ subscriber (controller-data-sender.service.ts:96):
src/app/services/controller-data-sender.service.ts
Enable / disable handshake
GuidedControlService sends { type: 'guided_control', command: 'enable' | 'disable' | 'status' } over redispad and waits for a guided_control_status reply. Both enable() and disable() return a Promise<boolean>:
- If the socket is not connected, they toast an error and resolve
falseimmediately. - On a reply they resolve based on
status.successandstatus.enabled_by_user. - If no reply arrives within 3000ms, success is assumed — the optimistic-fallback resolves
trueand flipsguidedControlEnabledSubject. This “assume-success” backwards-compat behavior means a silent proxy looks like a successful enable/disable.
manual-control-dialog.component.ts — onRealGamepad() / onVirtualJoystick() both await guidedControlService.enable() before activating controls) and the disconnect-confirmation dialog (disconnect-confirmation-dialog.component.ts — await guidedControlService.disable()).
Auto-disable on mode change
init() subscribes to currentMode$. When the vehicle leaves GUIDED while guided control is enabled, the frontend disables it locally, clears the gamepad-active flag, and toasts — so a mode switch on the vehicle (or a mission taking over) automatically stops stick input from being forwarded (guided-control.service.ts:65).
Inbound guided_control_status frames are fanned out in ControllerDataSenderService: they call guidedControlService.handleWebSocketMessage(data) and sync appStateService.setGuidedControlEnabled(data.enabled_by_user). camera_status frames are forwarded to WebsocketService.cameraStatusSubject (the same subject the telemetry channel also feeds — camera status can arrive on either channel).
Message contract over redispad
Everything is JSON. The proxy relays outbound frames to Redis{drone_ip}:gamepad_input and surfaces vehicle replies back on the socket — see WebSocket Gamepad Proxy and Redis Message Bus for the drone side.
- UI → vehicle (send)
- vehicle → UI (message$)
| Frame | Shape | Sent by |
|---|---|---|
| Gamepad/joystick | { buttons: {[id]: number}, axes: number[], timestamp, front_ts } | GamepadService / virtual joystick via controllerData$ |
| Guided control | { type: 'guided_control', command: 'enable' | 'disable' | 'status' } | GuidedControlService |
| Camera / gimbal | { type: 'camera_command', command, pressed, value?, source: 'frontend', timestamp } | VideoWindowComponent via controllerData$ |
| Quick land | { set_mode: 'LAND' } | VehicleCommandService.land() |
| Video status query | { type: 'video_stream_status_request' } | VideoWindowComponent |
GamepadData and the axes gate
GamepadData (gamepad.service.ts:5) is the universal control payload for physical gamepad, keyboard, and virtual joysticks alike:
src/app/services/gamepad.service.ts
GamepadService polls at ~60Hz (refreshRate = 17) outside the Angular zone, applies a 0.1 deadzone, and omits axes entirely unless there is significant stick or keyboard movement (shouldIncludeAxes). That empty-axes convention is what lets pure button frames sail through the gate while movement frames are held back. Keyboard drives a progressive-acceleration model onto axes[0] (yaw) or the rover throttle button 7; W/S/A/D/Q map to gimbal buttons 12/13/14/15/8 (W=12 pitch-up, S=13 pitch-down, A=14 yaw-left, D=15 yaw-right, Q=8 center) only while the video is fullscreen; R1 (5) is the photo shutter. The full button contract and gimbal mapping live in Video, Gimbal & Manual Flight Control.
ArUco overlay: same proxy, second socket
ArucoOverlayService (src/app/services/aruco-overlay.service.ts) opens its own redispad socket to receive aruco_tracking frames and renders marker detections onto a <canvas> laid over the live video — the tracking is not burned into the video stream.
- Connected from
VideoWindowComponentatvideo-window.component.ts:215, and skipped for SITL drones (selectedDrone.type?.toLowerCase() !== 'sitl'). - Runs entirely outside the Angular zone (per-frame messages must not trigger change detection).
- Auto-reconnects after 2000ms; pauses processing while the video is hidden (a
0×0getBoundingClientRectsetsoverlayVisible = false); and clears the overlay afterdataStaleTimeoutMsof no data.
Configuration
environment.ws_proxy selects the redispad endpoint:
| Env file | ws_proxy |
|---|---|
environment.ts (default dev) | wss://prod.skyhub.ai:7070 |
environment.local.ts | ws://localhost:7070 |
environment.aws-dev.ts | wss://ws_proxy.skyhub-dev.internal:7070 |
environment.prod.ts / environment.e2e.ts | wss://prod.skyhub.ai:7070 |
Debugging checklist
Commands not reaching the vehicle
Commands not reaching the vehicle
Confirm
VehicleCommandService.isSocketConnected() is true and the socket URL uses access_token= (not token=). Check the JWT is present via authService.getAccessToken(). Remember send() is a no-op when the socket is not OPEN.Reconnect storms or no reconnect
Reconnect storms or no reconnect
onerror sets hasConnectionError, which suppresses the 1s reconnect (intentional). An intentional disconnect() sets isDisconnecting. If a switch to a new drone seems to lose events, that is the stale-socket guard (socket !== this.socket) correctly ignoring the old socket.ArUco overlay blank
ArUco overlay blank
Overlay is skipped for SITL drones. It also pauses while the video element is hidden (
0×0) and clears after the stale-data timeout. It is a separate socket from commands.Related pages
Real-time Telemetry Client
The opposite direction — Socket.IO telemetry vehicle → UI (a different channel, different auth param).
Video, Gimbal & Manual Flight
Full gamepad button map, gimbal/zoom/record commands, and the manual-control UI.
WebSocket Gamepad Proxy
The redispad proxy itself: Redis channel routing to the drone.
Guided Velocity Control & Safety
The on-drone half of the gate — GUIDED velocity setpoints and the dead-man timeout.

