Skip to main content

Switch controllers without the FSM

mode_manager is the production path for changing controllers, but sometimes you want raw control — debugging, calibration, scripted tests, or just verifying the underlying controller_manager service. This how-to walks the mode FSM via direct ros2 control calls.

No gamepad? This is your mode-switch path.

Humanoid Control ships no keyboard control for the FSM — mode_manager reacts to a gamepad (/joy) only. On a dev or headless host without one, the ros2 control calls on this page are the supported way to drive modes by hand. (There used to be a planned termios keyboard reader; it was dropped in favour of this CLI path.)

Why bypass mode_manager

Use caseWhy FSM is in the way
CalibrationThe FSM auto-DAMPs on safety events; sometimes you want to manually drive state through faults.
Verifying a new controller pluginYou want to load + activate it directly, not register it as an FSM mode.
Recording sysid tracesThe FSM transitions add unmeasurable delay; manual switches are more reproducible.
Debugging a controller's on_activateDirect control + log inspection without the FSM's request_mode retry chatter.

The FSM doesn't enforce its rules at the controller_manager layer — the controller_manager just sees switch_controller service calls. So you can call them directly without any FSM in the loop.

Disable the FSM in the launch

Easiest: pass enable_mode_manager:=false so mode_manager isn't spawned at all:

ros2 launch humanoid_bringup_lite real.launch.py enable_mode_manager:=false

Now zero_torque_controller is active (the spawner set it active), and the four other controllers are loaded as inactive. No FSM watches /safety_status, no /joy is required. The operator drives every transition.

The four basic transitions

The commands below are interactive ros2 control / ros2 topic calls — open a second terminal and pixi shell into the workspace so ros2 is on PATH:

cd humanoid_control_ws
pixi shell

ZERO_TORQUE → DAMPING

ros2 control switch_controllers \
--deactivate zero_torque_controller \
--activate damping_controller

The robot becomes "compliant against velocity but no position holding". Pushing the arm by hand will move it; let go and it stops without oscillating.

DAMPING → STANDBY

STANDBY has two poses, each a separately spawned instance of the same plugin: standby_controller_a (Pose A) and standby_controller_b (Pose B). Activate whichever pose you want:

ros2 control switch_controllers \
--deactivate damping_controller \
--activate standby_controller_a

Use --activate standby_controller_b instead for Pose B.

The motors will move. Standby ramps K_p / K_d from 0 to the target gains during segment 0, then interpolates to the piano-ready pose during segment 1. Total runtime ~4 seconds. Support the arms or have a clear workspace.

Watch the state topic for the pose you activated (one per instance) for is_finished: true:

ros2 topic echo /standby_controller_a/state

STANDBY → REMOTE (or LOCOMOTION)

ros2 control switch_controllers \
--deactivate standby_controller_a \
--activate remote_policy_controller

(Deactivate whichever standby instance is active — standby_controller_a or standby_controller_b.)

remote_policy_controller (humanoid_control/RemotePolicyController) is the System 1/2 external-command ingress: it immediately starts looking for MITCommand on /remote_policy_controller/command. Without a publisher it'll trip its stale-command policy (passive by default → a damped hold: zero stiffness, high damping like DAMPING, holding live position) within 100 ms. To use this for real, start a non-real-time MITCommand source first — gravity compensation (Lite-Gravity-Compensation) today, VLA / manipulation later. This controller is not fed by any learned policy; learned policies run in-process in rl_policy_controller.

rl_policy_controller (humanoid_control/RLPolicyController) is not spawned by real.launch.py — it is loaded inactive by the prepare→spawn policy launch (ros2 launch humanoid_control_policy lite_policy.launch.py checkpoint_file:=<path>), which runs prepare to resolve the ONNX + .mcap motion bag and emit the parameter overlay. Once that launch has spawned it, you can activate it by hand the same way as below.

Anything → ZERO_TORQUE (always end here)

ros2 control switch_controllers \
--deactivate <whatever_is_active> \
--activate zero_torque_controller

Before Ctrl+C-ing the launch, transition back to zero_torque. The plugin's on_deactivate will send Disable to every motor when the launch tears down, but landing at zero_torque first means there's no risk of a non-zero command in flight at the moment of shutdown.

Inspecting state

# Which controllers are loaded, and which are active?
ros2 control list_controllers
# Expected after first transition:
# damping_controller humanoid_control/DampingController active
# zero_torque_controller humanoid_control/ZeroTorqueController inactive
# joint_state_broadcaster joint_state_broadcaster/... active
# standby_controller_a humanoid_control/StandbyController inactive
# standby_controller_b humanoid_control/StandbyController inactive
# remote_policy_controller humanoid_control/RemotePolicyController inactive

# What hardware components are up?
ros2 control list_hardware_components
# Expected for Lite:
# LiteLeftArm active
# LiteRightArm active

# What interfaces is the active controller claiming?
ros2 control list_controllers --verbose

Useful one-liners

# Quick switch macro (drop into your bashrc)
ros2cs () {
ros2 control switch_controllers --deactivate "$1" --activate "$2"
}
ros2cs zero_torque_controller damping_controller

# Force a strict switch (fail if either controller is in the wrong state)
ros2 control switch_controllers \
--deactivate damping_controller \
--activate standby_controller_a \
--strict

What's the FSM doing differently?

OperationFSM (mode_manager)Raw ros2 control
Gate LOAD on current stateYes — rejects from non-DAMPINGNo — happy to go ZERO_TORQUE → STANDBY directly
Gate START_* on is_finishedYesNo
Auto-DAMP on /safety_statusYesNo — you have to script it
Publish /control_modeYesNo — list_controllers is your only state view
React to /joyYesNo

When you're done debugging, re-enable mode_manager before operating in production. Its gates and the auto-DAMP path are real safety properties; the convenience of bypassing them is for the operator who's watching the robot, not for unattended use.

See also