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
Input producers (GamepadService, the virtual joystick, keyboard, and the video window’s gimbal/camera buttons) never touch the socket directly. They all push a
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$:
1
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.2
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.3
Drone deselected
Ids are reset but the socket stays open. The WebSocket is deliberately kept alive even with no drone selected, and even across
ngOnDestroy — a “never disconnect” requirement. Do not add a teardown here.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$)
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:
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.

