Manual flight on SkyCore does not use RC_CHANNELS_OVERRIDE. Operator stick input is converted into SET_POSITION_TARGET_LOCAL_NED body-frame velocity setpoints and sent to ArduPilot only when the vehicle is in GUIDED mode and control has been explicitly enabled. This is a deliberate safety decision: with RC override, a zero value means minimum throttle (a crash); with a GUIDED velocity setpoint, zero means hold position. Every part of this subsystem is built around that one property. The whole path lives in the on-drone gamepad / ws_proxy service:
FileResponsibility
docker/gamepad/src/modules/guided_control/controller.pyGuidedVelocityController — builds and sends the MAVLink setpoint, owns the state machine, mode-guards, timeout
docker/gamepad/src/modules/guided_control/module.pyGuidedControlModule — lifecycle, config → limits, 20 Hz rate limit, 10 Hz watchdog thread
docker/gamepad/src/modules/guided_control/mapper.pyGamepadMapper — axes → body-frame VelocityCommand with dead-zone + scaling
docker/gamepad/src/core/message_router.pyroutes guided_control / velocity_command / raw gamepad frames
docker/gamepad/src/shared/config.pyGUIDED_* limits + ROVER_KEYBINDS button map
This page is the safety model. For how a frame reaches the drone (Dashboard → {ws_proxy}/redispad/{droneId} → Redis {ip}:gamepad_inputMessageRouter) see /dashboard/vehicle-commands and /ecosystem/ws-proxy. For the module framework this plugs into, see /drone-os/module-system.

Command path

The gamepad service connects to the flight controller over MAVLink UDP udpout:127.0.0.1:14777 through the single mavp2p router — see /drone-os/mavlink-topology.

Why velocity setpoints, not RC override

RC_CHANNELS_OVERRIDE (removed)

Zero value = minimum throttle = crash. Fights the RC transmitter. No mode awareness.

GUIDED velocity (current)

Zero velocity = hold position. Ignored outside GUIDED. Loss of input → vehicle stops and holds.
The setpoint is a SET_POSITION_TARGET_LOCAL_NED message with MAV_FRAME_BODY_NED (frame 8) so forward/right/down are relative to the vehicle’s heading. The type mask ignores position, acceleration, force and absolute yaw, and uses only velocity + yaw-rate:
docker/gamepad/src/modules/guided_control/controller.py
FRAME_BODY_NED = 8
# IGNORE position(0-2) + accel(6-8) + force(9) + yaw(10); USE velocity(3-5) + yaw_rate(11)
TYPE_MASK_VELOCITY_YAW_RATE = 0b0000_0111_1100_0111  # = 0x07C7 = 1991
Body-frame axes: vx = forward(+)/back(-), vy = right(+)/left(-), vz = down(+)/up(-) (NED), yaw_rate = CW(+)/CCW(-) in rad/s (_send_velocity_raw, controller.py:279).

Enable / disable protocol

Movement is a two-step handshake. The UI must explicitly enable control, and it only succeeds in GUIDED mode.
1

Put the vehicle in GUIDED

Press GUIDED (gamepad Triangle → button 3) or send a set_mode command. enable() reads the live mode via the injected mode_getter (mavlink_module.get_flight_mode) and refuses otherwise.
2

Enable velocity control

Send {"type": "guided_control", "command": "enable"}. On success the module sets _enabled_by_user = True, sends one zero-velocity setpoint for a clean start, and broadcasts a guided_control_state message to WebSocket clients.
3

Stream axes frames

Now axes[] frames are accepted. The Dashboard only sends axes while control is enabled, and the drone-side process_gamepad_input() also drops them unless _enabled_by_user is set — a double gate.
4

Disable

Send {"type": "guided_control", "command": "disable"} (or leave GUIDED). A stop command is sent and _enabled_by_user returns to False.
The enable/disable/status handlers return a status dict (module.py:131) and publish it to the Redis output channel as guided_control_status. Send {"type":"guided_control","command":"status"} to query current state without changing it.
enable returns {"success": false, ...} (not an error) when the vehicle is not in GUIDED — the message is Vehicle must be in GUIDED mode (currently: <mode>). Callers must check success, not just the absence of an error.

Control state machine

GuidedVelocityController (controller.py:56) has three states. The on_state_change callback bubbles every transition up to GuidedControlModule._handle_state_change, which auto-clears _enabled_by_user on any drop to DISABLED and broadcasts the new state.
  • DISABLED — no setpoints sent; incoming velocity commands are ignored.
  • ENABLED — actively sending clamped setpoints.
  • STOPPED — was enabled but input stalled; a zero-velocity hold is sent and the vehicle holds. Resumes to ENABLED automatically when frames arrive again.

Velocity limits & dead-zone

Limits come from GamepadConfig (config.py:113) and are read into VelocityLimits / GamepadMapper in module.py:60. Values clamp on the drone side regardless of what the UI sends.
Env varDefaultMeaning
GUIDED_MAX_HORIZONTAL_SPEED3.0Max forward/back/strafe (m/s)
GUIDED_MAX_VERTICAL_SPEED2.0Max up/down (m/s)
GUIDED_MAX_YAW_RATE0.5Max rotation (rad/s)
GUIDED_COMMAND_TIMEOUT0.5Seconds of no input before STOPPED
GUIDED_DEAD_ZONE0.15Stick dead-zone (0–1)
The VelocityLimits dataclass defaults (5.0 / 2.5 / 1.0) are looser than the config, but they are never used in production — set_connection() always overrides them with the GUIDED_* config values (3.0 / 2.0 / 0.5). Edit the env vars / config, not the dataclass defaults.
Dead-zone is applied with rescaling (mapper.py:165): input below the threshold returns 0.0, and the remaining dead_zone..1.0 range is stretched back to 0..1 so there is no output step at the edge of the dead-zone.

Stick → velocity mapping (drone)

The gamepad service hardwires VEHICLE_TYPE = 2 (quadrotor) in config.py:110, so GamepadMapper.map_from_existing_format uses the drone mapping (is_rover = False). The production frame layout:
axes[]StickMapped to
axes[0]Left stick XYaw rate (× GUIDED_MAX_YAW_RATE)
axes[1]Left stick YAltitude / down (× GUIDED_MAX_VERTICAL_SPEED)
axes[2]Right stick XStrafe right (× GUIDED_MAX_HORIZONTAL_SPEED)
axes[3]Right stick YForward (inverted, × GUIDED_MAX_HORIZONTAL_SPEED)
GamepadMapper supports a rover mapping (is_rover=True: left-stick throttle + steering, no strafe/vertical), but because GamepadConfig.VEHICLE_TYPE is a hardcoded constant (not read from env), the gamepad service is always in drone mode. A refactor that wants real rover control must make VEHICLE_TYPE configurable — changing only the SITL VEHICLE_TYPE env var does not reach this mapper.

20 Hz send + 10 Hz watchdog

Two independent timers protect the link:
  • 20 Hz send rate limitGuidedControlModule (module.py:56) drops frames faster than 1/20 s, so an over-eager 60 Hz UI stream is throttled to a steady 20 Hz of setpoints.
  • 10 Hz timeout watchdog — a daemon thread (_timeout_loop, module.py:280) calls check_timeout() every 100 ms. If no command has arrived for command_timeout (0.5 s), it sends a zero-velocity setpoint and moves to STOPPED. This is what actually protects against a dropped uplink or a hung UI.
set_velocity_body() accepts a dead_man_active flag that sends zero velocity when False, but the current gamepad/velocity routes always pass it True. In practice the timeout watchdog, not the dead-man flag, is the continuous-input safety net. Preserve the watchdog if you touch this loop.

The three safety guards a refactor MUST preserve

These are load-bearing. Removing or weakening any one turns safe hold-in-place behaviour into an uncommanded-motion hazard.
  1. Enabled AND GUIDED-only. Setpoints are sent only when _enabled_by_user is True and the vehicle is in GUIDED. enable() refuses outside GUIDED (controller.py:178).
  2. Timeout watchdog. The 10 Hz check_timeout() loop forces a zero-velocity hold after GUIDED_COMMAND_TIMEOUT of silence (controller.py:255, module.py:280).
  3. Auto-disable on mode change. set_velocity_body() re-verifies GUIDED before sending; any change away from GUIDED calls disable() and clears _enabled_by_user (controller.py:224, module.py:267). The RC transmitter’s mode switch therefore always wins.
Underpinning all three is the design invariant that zero velocity = hold, not throttle-to-zero. Keep the message type (SET_POSITION_TARGET_LOCAL_NED), the body frame (8) and the type mask (0x07C7) intact — do not reintroduce RC_CHANNELS_OVERRIDE.

Gamepad button / axis map

Buttons are dispatched through ROVER_KEYBINDS (config.py:123) in MessageRouter._handle_gamepad_input. Unlike axes, button commands are always processed regardless of guided-control state.
ButtonActionPhysical input
0Set mode RTLCross (X)
1Set mode AUTOCircle (O)
2Set mode POSHOLDSquare
3Set mode GUIDEDTriangle
4Toggle gimbal modeL1 (300 ms debounce)
5Take photoR1
6Zoom out (proportional)L2 trigger / axes[4]
7Zoom in (proportional)R2 trigger / axes[5]
8Center gimbal + autofocusShare
9AutofocusOptions
10Toggle recordingL3
11Track person (Isaac SLAM)R3
1215Gimbal pitch up / down, yaw left / rightD-pad
Mode-switch buttons (03) go straight to mavlink.set_mode(). Buttons 10/11 and dock are advisory here and handled by the Isaac SLAM / detection & docking services.

Gimbal-modifier mode (L1)

L1 (button 4) toggles a modifier that repurposes inputs — a non-obvious dual mapping worth preserving:
InputNormalGimbal mode (L1 on)
Right stickForward/back + strafe (movement)Gimbal yaw + pitch (proportional)
D-padGimbal pitch/yawForward/back + strafe (synthetic axes → movement)
Left stickYaw + altitudeYaw + altitude (unchanged)
In gimbal mode the router synthesizes an axes frame from the D-pad and feeds it to process_gamepad_input() so movement still works (message_router.py:706). Gimbal control is additionally locked during ArUco precision landing — the aruco_landing source sets _gimbal_locked and operator gimbal commands are silently dropped until dock_stop or disarm (see /drone-os/robotics/detection-and-landing).

Vehicle Commands & Gamepad

The UI side: 60 Hz sampling, redispad WebSocket, guided-enable state sync.

MAVLink Routing (mavp2p)

How port 14777 setpoints reach the FCU through the single MAVLink hub.

Redis Message Bus

The {ip}:gamepad_input / output channels that carry these messages.

Core & Gamepad Module Systems

The ModuleBase lifecycle GuidedControlModule plugs into.