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):
| File | Responsibility |
|---|---|
docker/gamepad/src/modules/guided_control/controller.py | GuidedVelocityController — builds and sends the MAVLink setpoint, owns the state machine, mode-guards, timeout |
docker/gamepad/src/modules/guided_control/module.py | GuidedControlModule — lifecycle, config → limits, 20 Hz rate limit, 10 Hz watchdog thread |
docker/gamepad/src/modules/guided_control/mapper.py | GamepadMapper — axes → body-frame VelocityCommand with dead-zone + scaling |
docker/gamepad/src/core/message_router.py | routes guided_control / velocity_command / raw gamepad frames |
docker/gamepad/src/shared/config.py | GUIDED_* 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_input → MessageRouter) 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 UDPudpout: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.
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
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.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.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.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.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.
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 fromGamepadConfig (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 var | Default | Meaning |
|---|---|---|
GUIDED_MAX_HORIZONTAL_SPEED | 3.0 | Max forward/back/strafe (m/s) |
GUIDED_MAX_VERTICAL_SPEED | 2.0 | Max up/down (m/s) |
GUIDED_MAX_YAW_RATE | 0.5 | Max rotation (rad/s) |
GUIDED_COMMAND_TIMEOUT | 0.5 | Seconds of no input before STOPPED |
GUIDED_DEAD_ZONE | 0.15 | Stick 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.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 hardwiresVEHICLE_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[] | Stick | Mapped to |
|---|---|---|
axes[0] | Left stick X | Yaw rate (× GUIDED_MAX_YAW_RATE) |
axes[1] | Left stick Y | Altitude / down (× GUIDED_MAX_VERTICAL_SPEED) |
axes[2] | Right stick X | Strafe right (× GUIDED_MAX_HORIZONTAL_SPEED) |
axes[3] | Right stick Y | Forward (inverted, × GUIDED_MAX_HORIZONTAL_SPEED) |
20 Hz send + 10 Hz watchdog
Two independent timers protect the link:- 20 Hz send rate limit —
GuidedControlModule(module.py:56) drops frames faster than1/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) callscheck_timeout()every 100 ms. If no command has arrived forcommand_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
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 throughROVER_KEYBINDS (config.py:142) in
MessageRouter._handle_gamepad_input. Unlike axes, button commands are always processed
regardless of guided-control state.
| Button | Action | Physical input |
|---|---|---|
0 | Set mode RTL | Cross (X) |
1 | Set mode AUTO | Circle (O) |
2 | Set mode POSHOLD | Square |
3 | Set mode GUIDED | Triangle |
4 | Toggle gimbal mode | L1 (300 ms debounce) |
5 | Take photo | R1 |
6 | Zoom out (proportional) | L2 trigger / axes[4] |
7 | Zoom in (proportional) | R2 trigger / axes[5] |
8 | Center gimbal + autofocus | Share |
9 | Autofocus | Options |
10 | Toggle recording | L3 |
11 | Bound but inert — logs Person tracking is not available on this build | R3 |
12–15 | Gimbal pitch up / down, yaw left / right | D-pad |
0–3) 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 (button4) toggles a modifier that repurposes inputs — a non-obvious dual mapping worth
preserving:
| Input | Normal | Gimbal mode (L1 on) |
|---|---|---|
| Right stick | Forward/back + strafe (movement) | Gimbal yaw + pitch (proportional) |
| D-pad | Gimbal pitch/yaw | Forward/back + strafe (synthetic axes → movement) |
| Left stick | Yaw + altitude | Yaw + altitude (unchanged) |
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.Related
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.
