The redispad channel is the Dashboard’s low-latency UI → vehicle command path. It is a raw WebSocket (not Socket.IO, not HTTP) that connects to a separate Redis-backed proxyenvironment.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.
Do not conflate redispad with the two other real-time channels. Telemetry (vehicle → UI) is Socket.IO to the Gateway root namespace with the JWT in a token query param — see Real-time Telemetry Client. redispad (UI → vehicle) is a raw WebSocket to a distinct proxy with the JWT in an access_token query param. Video is WebRTC to Janus. A refactor must keep all three distinct — comments in vehicle-command.service.ts and websocket.service.ts stress this. See Frontend ↔ Gateway Integration for the four-channel map.

The three services

ServiceFileRole
VehicleCommandServicesrc/app/services/vehicle-command.service.tsOwns the redispad WebSocket. connect/disconnect/send/land, auto-reconnect, stale-socket guarding, message$ for inbound replies.
ControllerDataSenderServicesrc/app/services/controller-data-sender.service.tsApp-level glue. Wires selectedDrone$ → connect/reconnect, controllerData$send (with the axes gate), and routes inbound message$ to the right subjects.
GuidedControlServicesrc/app/services/guided-control.service.tsThe safety gate. Enables/disables/queries vehicle-side “guided control” over redispad and tracks guidedControlEnabled$.
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
const wsUrl = `${environment.ws_proxy}/redispad/${droneId}?access_token=${encodeURIComponent(accessToken)}`;
const socket = new WebSocket(wsUrl);
Key behaviors to preserve:
  • Access token, not token. The JWT comes from authService.getAccessToken() and is passed as the access_token query param. If there is no token, connect logs 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 with if (socket !== this.socket) return;. This makes late events from a replaced socket no-ops after a reconnect or drone switch.
  • Auto-reconnect. onclose reconnects after 1000ms to currentDroneIdunless hasConnectionError (an onerror fired) or isDisconnecting (an intentional disconnect()) is set. A WebSocket error shows a toast, sets hasConnectionError, closes the socket, and suppresses the reconnect.
  • send(data) JSON-stringifies only when isConnected && 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. onmessage parses JSON and pushes it onto messageSubject (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 in ControllerDataSenderService’s controllerData$ subscriber (controller-data-sender.service.ts:96):
src/app/services/controller-data-sender.service.ts
const hasAxesMovement =
  data.axes && Array.isArray(data.axes) && data.axes.some((v: number) => Math.abs(v) > 0.1);

// Only send axes movement data if guided control is enabled.
// Always allow button commands and guided_control commands through.
if (hasAxesMovement && !this.appStateService.isGuidedControlEnabled()) {
  return; // frame dropped
}
this.vehicleCommandService.send(data);
Axes are silently dropped unless guided control is enabled. Buttons, guided_control, and camera_command frames always pass. Removing this gate would stream raw joystick axes to a vehicle that is not in guided velocity mode — the on-drone safe-control layer is the second line of defense, but the UI gate is the first. Preserve it.

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 false immediately.
  • On a reply they resolve based on status.success and status.enabled_by_user.
  • If no reply arrives within 3000ms, success is assumed — the optimistic-fallback resolves true and flips guidedControlEnabledSubject. This “assume-success” backwards-compat behavior means a silent proxy looks like a successful enable/disable.
The UI entry points are the manual-control dialog (manual-control-dialog.component.tsonRealGamepad() / onVirtualJoystick() both await guidedControlService.enable() before activating controls) and the disconnect-confirmation dialog (disconnect-confirmation-dialog.component.tsawait 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.
FrameShapeSent 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
export interface GamepadData {
  buttons: { [key: string]: number };
  axes: ReadonlyArray<number> | Float32Array;
  timestamp: number;
  front_ts: number;
}
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 VideoWindowComponent at video-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×0 getBoundingClientRect sets overlayVisible = false); and clears the overlay after dataStaleTimeoutMs of no data.
Because it is a separate socket, ArUco data flowing (or not) is independent of command connectivity — a working overlay does not imply commands are reaching the vehicle, and vice-versa.

Configuration

environment.ws_proxy selects the redispad endpoint:
Env filews_proxy
environment.ts (default dev)wss://prod.skyhub.ai:7070
environment.local.tsws://localhost:7070
environment.aws-dev.tswss://ws_proxy.skyhub-dev.internal:7070
environment.prod.ts / environment.e2e.tswss://prod.skyhub.ai:7070
The default environment.ts mixes a localhost REST url with a prod ws_proxy. Running the plain ng serve (default config) points telemetry/REST at localhost but sends commands and video at production. Use environment.local.ts for a fully-local stack.

Debugging checklist

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.
That is the guided-control gate. Verify AppStateService.isGuidedControlEnabled() is true and the vehicle is in GUIDED — leaving GUIDED auto-disables it. enable() must have resolved (or hit its 3s assume-success timeout).
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.
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.

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.