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 agent half of the skyhub container (source tree still docker/gamepad/, mounted at /app):
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 agent 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 agent 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 agent 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:142) 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
11Bound but inert — logs Person tracking is not available on this buildR3
1215Gimbal pitch up / down, yaw left / rightD-pad
Mode-switch buttons (03) go straight to mavlink.set_mode(). Button 10 (toggle recording) is handled in-process by the agent’s SIYI path. dock_start / dock_stop are not buttons — they arrive as commands and are handled by the ROS half’s aruco_landing module (see /drone-os/robotics/detection-and-landing).

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).
SIYI camera control — recording, photo, zoom (buttons 5, 6, 7, 10) — was silently dead on every vehicle before 0.2.0: deduplicating SiyiController into the shared skyhub_common package left message_router importing the deleted path behind a warn-only except ImportError, so HAS_SIYI was False and the buttons no-oped. Fixed — the controller is imported from skyhub_common.siyi_controller.

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.

ROS & Agent Module Systems

The ModuleBase lifecycle GuidedControlModule plugs into.