Skip to main content

Controllers + FSM

Five controller_interface::ControllerInterface plugins, one standalone rclcpp::Node orchestrator. Only one mode controller is active at a time; joint_state_broadcaster runs always.

FSM summary

See Concepts → Architecture for the annotated version including safety-fault edges.

Plugin-by-plugin

humanoid_control/ZeroTorqueController

Role: startup default; safer fault fallback.

Claims: position, velocity, effort, stiffness, damping on every joint.

Writes every tick: 0 to all 5 command interfaces.

Parameters:

ParamTypeDefaultDescription
jointsstring[]Required. Joint names to claim (must match URDF).

humanoid_control/DampingController

Role: compliant fail-safe. The robot stays soft under gravity but resists velocity.

Mechanics: on on_activate, captures the current joint positions into captured_position_. Every tick writes:

InterfaceValue
positioncaptured_position_[i]
velocity0
effort0
stiffness0
dampingdamping_value_[i]

With stiffness = 0, no restoring force; with damping > 0, viscous resistance.

Parameters:

ParamTypeDefaultDescription
jointsstring[]Required.
dampingfloat64[][]Per-joint damping. Empty → use damping_scalar.
damping_scalarfloat641.0Used when damping is empty.

humanoid_control/StandbyController

Role: linearly interpolate joint positions through a pose sequence; ramp K_p / K_d from 0 to target gains during the first segment so activation never snaps.

Instances: spawned twicestandby_controller_a (Pose A, loaded by L1+A / /humanoid_control/mode/load_a) and standby_controller_b (Pose B, loaded by L1+B / /humanoid_control/mode/load_b). Same plugin class (and same standby_controller.cpp), different pose parameters in the bringup YAML.

Parameters:

ParamTypeDescription
jointsstring[]Required.
target_stiffnessfloat64[]Per-joint target K_p when ramp finishes.
target_dampingfloat64[]Per-joint target K_d.
segment_durationsfloat64[]Seconds per pose segment. Length determines how many pose segments are expected.
pose_segment_<i>float64[]Per-segment target pose vector; one parameter per segment index in [0, len(segment_durations)). Each is a per-joint position array sized to len(joints).

Publishes: ~/state (humanoid_control_msgs/StandbyState) with TRANSIENT_LOCAL QoS so mode_manager sees is_finished even on late join — i.e. /standby_controller_a/state and /standby_controller_b/state, one per instance. Watch the one matching the pose you loaded.

How the bundled config interpolates

humanoid_control_lite_controllers.yaml configures both instances with two segments each: pose_segment_0 is the zero-pose (where the robot starts) and pose_segment_1 is that instance's target pose — standby_controller_a and standby_controller_b differ only in this final pose (Pose A vs Pose B). A LOAD_A / LOAD_B intent therefore animates the arms from zero to the chosen pose over two 2-second segments while ramping K_p / K_d from 0 to the target gains during the first segment.

fallback_controllers: ["damping_controller"] is set on the controller-manager side so any non-OK return_type from update() auto-deactivates Standby and activates Damping.

humanoid_control/RLPolicyController

Role: in-process ONNX inference — this is the System 0 path that runs every learned policy (tracking / piano / locomotion). Each RT update() it packs the observation (ObservationManager), runs inference (OnnxPolicy), reads the motion reference from the preloaded .mcap (ReferenceProvider), maps the action across the full articulation (ActionMapper), and writes the five MIT command interfaces — never leaving the RT thread. Policies differ only by the loaded .onnx + .mcap; the ONNX task_type metadata selects the term set.

Its parameters come from the rl_policy_controller overlay that humanoid_control_policy prepare (or pianist_policy prepare) transcodes from the ONNX custom_metadata_map — they are not hand-written:

ParamTypeDescription
jointsstring[]Full articulation list.
action_joint_namesstring[]Subset the policy emits actions for; the rest are pinned to position=0.
observation_namesstring[]The flat observation vector, term by term (resolved by ObservationManager).
body_namesstring[]Reference-tracked bodies (for motion_body_* terms).
default_joint_positionfloat64[]q_default in obs scaling and pos = q_default + scale * a.
action_scalefloat64[]Per-action-joint scale.
stiffness, dampingfloat64[]Per-joint MIT gains written every tick.
policy_checkpointstringPath to the resolved .onnx.
motion_filestringPath to the .mcap motion bag loaded at on_configure.
observation_dim, action_dimintONNX I/O sizes.
ONNX runtime is opt-in

OnnxPolicy (onnxruntime C++) is built only when onnxruntime is found at build time — the conda onnxruntime-cpp package, pinned in pixi.toml. Without it the controller falls back to PlaceholderPolicy (zeros) — useful for smoke-testing the FSM and the observation/reference plumbing without a real inference dependency. The contract (PolicyMetadata → overlay) is identical either way. See Policy runner.

humanoid_control/RemotePolicyController

Role: the System 1/2 external-command ingress (kept, unchanged). A non-real-time source publishes MITCommand over DDS to ~/command (RELIABLE QoS depth 4); the controller validates joint order and hands off via realtime_tools::RealtimeBuffer to the RT update(), with arrival-time staleness gating. It is not used by the learned policies anymore — those run in-process in RLPolicyController.

Parameters:

ParamTypeDefaultDescription
jointsstring[]Required.
stale_command_policystringpassivepassive or hold. passive is a damped hold — zero stiffness (kP=0) and high damping (kD = damping_scalar) while holding the live joint position, exactly like DAMPING mode; applied both on entering REMOTE before the first command and on stale dropouts (it no longer goes fully limp). hold is unchanged.
stale_command_timeout_msint100Staleness window measured against the message's arrival time at the subscription callback, not against MITCommand.header.stamp. Publisher clock skew is irrelevant.
dampingfloat64[][]Per-joint K_d for the passive damped hold. Empty → use damping_scalar.
damping_scalarfloat646.0Damping used when damping is empty — matches the DAMPING mode's damping.

The controller rejects any MITCommand whose joint_names doesn't match its claimed order, or whose array lengths don't all match joints.size().

Today the producer is the gravity-compensation runner (Lite-Gravity-Compensation — raw CycloneDDS, no rclpy); next it will be VLA / manipulation. These are deliberately out-of-process: slower, deliberative, and tolerant of the DDS-hop latency. A producer depends on lite_sdk2 (the humanoid_control_msgs types generated by humanoid_control_msgs_dds plus the DDS channel layer) rather than hand-writing message mirrors — see Talk to Humanoid Control from Python. See Policy runner for how the in-process learned-policy path relates.

mode_manager (executable)

NOT a controller plugin — a regular rclcpp::Node compiled as the humanoid_controllers/mode_manager executable.

InputTopic / sourcePurpose
Gamepad/joy (sensor_msgs/Joy)DAMP / LOAD / START_LOCOMOTION / START_REMOTE / QUIT intents
Standby done/standby_controller_a/state or /standby_controller_b/state (StandbyState)gate the START intents on is_finished (watch the loaded pose's instance)
Safety/safety_status (SafetyStatus)auto-fall to DAMPING on non-OK
Trigger services/humanoid_control/mode/{damp,load_a,load_b,start_remote,start_locomotion,quit} (std_srvs/Trigger)same intents from the command line
OutputTopicPurpose
FSM state/control_mode (ControlMode)50 Hz telemetry
Mode switch/controller_manager/switch_controllerthe actual transition

Joy bindings (Xbox-layout defaults — remap via the joy.* params):

ButtonsIntentTarget
X (2)DAMPdamping_controller
L1+A (4+0)LOAD_Astandby_controller_a (Pose A)
L1+B (4+1)LOAD_Bstandby_controller_b (Pose B)
R1+A (5+0)START_LOCOMOTIONrl_policy_controller
R1+B (5+1)START_REMOTEremote_policy_controller
BACK (6)QUITrclcpp::shutdown()

The two LOAD combos load different poses (standby_controller_a vs standby_controller_b); the two START combos pick the policy. The two axes are independent — from either standby pose you can start either policy (R1+A → LOCOMOTION, R1+B → REMOTE), so L1+A → R1+B is just as valid as L1+A → R1+A. The only constraint is that you cannot switch A↔B directly: LOAD is admissible only from DAMPING, so to change pose you DAMP first, then load the other pose.

Parameters:

ParamTypeDefaultDescription
tick_rate_hzfloat6450.0timer rate
controller_managerstring/controller_managerCM namespace
joy.damp_buttonint2DAMP button index
joy.quit_buttonint6QUIT button index
joy.load_combo_aint[][4, 0]LOAD_A combo (L1+A → Pose A)
joy.load_combo_bint[][4, 1]LOAD_B combo (L1+B → Pose B)
joy.start_combo_locomotionint[][5, 0]START_LOCOMOTION combo (R1+A)
joy.start_combo_remoteint[][5, 1]START_REMOTE combo (R1+B)

Spawn order (in launch)

The launch spawns zero_torque_controller active independently — so even if mode_manager dies, the robot is in the safe state.