Skip to content

Commit 075c81b

Browse files
authored
Merge pull request #5 from XR-Robotics/dev/galaxea
Dev/galaxea
2 parents 0c85f8e + 7c32e06 commit 075c81b

File tree

3 files changed

+278
-6
lines changed

3 files changed

+278
-6
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import threading
2+
3+
import rospy
4+
5+
from xrobotoolkit_teleop.hardware.galaxea_teleop_controller import GalaxeaDualA1XTeleopController
6+
from xrobotoolkit_teleop.utils.xr_client import XrClient
7+
8+
9+
def main():
10+
xr_client = XrClient()
11+
controller = GalaxeaDualA1XTeleopController(xr_client=xr_client)
12+
13+
stop_event = threading.Event()
14+
rospy.on_shutdown(lambda: stop_event.set())
15+
left_arm_thread = threading.Thread(
16+
target=controller.run_control_thread,
17+
args=(stop_event,),
18+
)
19+
20+
ik_thread = threading.Thread(
21+
target=controller.run_ik_thread,
22+
args=(stop_event,),
23+
)
24+
25+
left_arm_thread.start()
26+
ik_thread.start()
27+
28+
try:
29+
rospy.spin()
30+
except KeyboardInterrupt:
31+
print("Keyboard interrupt received, shutting down...")
32+
finally:
33+
stop_event.set()
34+
35+
left_arm_thread.join()
36+
ik_thread.join()
37+
38+
39+
if __name__ == "__main__":
40+
main()

xrobotoolkit_teleop/hardware/galaxea.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@
55

66

77
class A1XController:
8-
def __init__(self, rate_hz=100):
9-
rospy.init_node("a1x_controller")
10-
self.pub = rospy.Publisher("/motion_control/control_arm", motor_control, queue_size=1)
11-
self.gripper_pub = rospy.Publisher("/motion_control/control_gripper", motor_control, queue_size=1)
12-
self.sub = rospy.Subscriber("/hdas/feedback_arm", JointState, self.arm_state_callback)
8+
def __init__(
9+
self,
10+
arm_control_topic: str = "/motion_control/control_arm",
11+
gripper_control_topic: str = "/motion_control/control_gripper",
12+
arm_state_topic: str = "/hdas/feedback_arm",
13+
rate_hz: float = 100,
14+
):
15+
self.pub = rospy.Publisher(arm_control_topic, motor_control, queue_size=1)
16+
self.gripper_pub = rospy.Publisher(gripper_control_topic, motor_control, queue_size=1)
17+
self.sub = rospy.Subscriber(arm_state_topic, JointState, self.arm_state_callback)
1318
self.rate = rospy.Rate(rate_hz)
1419

1520
self.qpos = [0.0] * 6

xrobotoolkit_teleop/hardware/galaxea_teleop_controller.py

Lines changed: 228 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
# Default paths and configurations for a single right arm
2323
DEFAULT_SINGLE_A1X_URDF_PATH = os.path.join(ASSET_PATH, "galaxea/A1X/a1x.urdf")
24+
DEFAULT_DUAL_A1X_URDF_PATH = os.path.join(ASSET_PATH, "galaxea/A1X/dual_a1x.urdf")
2425
DEFAULT_SCALE_FACTOR = 1.0
2526
CONTROLLER_DEADZONE = 0.1
2627

@@ -39,6 +40,31 @@
3940
},
4041
}
4142

43+
DEFAULT_DUAL_END_EFFECTOR_CONFIG = {
44+
"right_arm": {
45+
"link_name": "right_gripper_link", # URDF link name for the single arm
46+
"pose_source": "right_controller",
47+
"control_trigger": "right_grip",
48+
"gripper_config": {
49+
"joint_name": "right_gripper_finger_joint1",
50+
"gripper_trigger": "right_trigger",
51+
"open_pos": -2.0,
52+
"close_pos": 0.0,
53+
},
54+
},
55+
"left_arm": {
56+
"link_name": "left_gripper_link", # URDF link name for the single arm
57+
"pose_source": "left_controller",
58+
"control_trigger": "left_grip",
59+
"gripper_config": {
60+
"joint_name": "left_gripper_finger_joint1",
61+
"gripper_trigger": "left_trigger",
62+
"open_pos": -2.0,
63+
"close_pos": 0.0,
64+
},
65+
},
66+
}
67+
4268

4369
class GalaxeaA1XTeleopController:
4470
def __init__(
@@ -59,7 +85,13 @@ def __init__(
5985
self.visualize_placo = visualize_placo
6086
self.end_effector_config = end_effector_config
6187

62-
self.right_controller = A1XController()
88+
rospy.init_node("galaxea_teleop_controller", anonymous=True)
89+
self.right_controller = A1XController(
90+
arm_control_topic="/motion_control/control_arm_right",
91+
gripper_control_topic="/motion_control/control_gripper_right",
92+
arm_state_topic="/hdas/feedback_arm_right",
93+
rate_hz=ros_rate_hz,
94+
)
6395

6496
self.placo_robot = placo.RobotWrapper(self.robot_urdf_path)
6597
self.solver = placo.KinematicsSolver(self.placo_robot)
@@ -205,3 +237,198 @@ def run_control_thread(self, stop_event: threading.Event):
205237
self.right_controller.publish_gripper_control()
206238
self.right_controller.rate.sleep()
207239
print("Galaxea teleop controller shutting down.")
240+
241+
242+
class GalaxeaDualA1XTeleopController:
243+
def __init__(
244+
self,
245+
xr_client: XrClient,
246+
robot_urdf_path: str = DEFAULT_DUAL_A1X_URDF_PATH,
247+
end_effector_config: dict = DEFAULT_DUAL_END_EFFECTOR_CONFIG,
248+
R_headset_world: np.ndarray = R_HEADSET_TO_WORLD,
249+
scale_factor: float = DEFAULT_SCALE_FACTOR,
250+
visualize_placo: bool = True,
251+
ros_rate_hz: int = 100,
252+
):
253+
254+
self.xr_client = xr_client
255+
self.robot_urdf_path = robot_urdf_path
256+
self.R_headset_world = R_headset_world
257+
self.scale_factor = scale_factor
258+
self.visualize_placo = visualize_placo
259+
self.end_effector_config = end_effector_config
260+
261+
rospy.init_node("galaxea_teleop_controller", anonymous=True)
262+
263+
self.left_controller = A1XController(
264+
arm_control_topic="/motion_control/control_arm_left",
265+
gripper_control_topic="/motion_control/control_gripper_left",
266+
arm_state_topic="/hdas/feedback_arm_left",
267+
rate_hz=ros_rate_hz,
268+
)
269+
270+
self.right_controller = A1XController(
271+
arm_control_topic="/motion_control/control_arm_right",
272+
gripper_control_topic="/motion_control/control_gripper_right",
273+
arm_state_topic="/hdas/feedback_arm_right",
274+
rate_hz=ros_rate_hz,
275+
)
276+
277+
self.placo_robot = placo.RobotWrapper(self.robot_urdf_path)
278+
self.solver = placo.KinematicsSolver(self.placo_robot)
279+
self.solver.dt = 1.0 / ros_rate_hz
280+
self.solver.mask_fbase(True)
281+
self.solver.add_kinetic_energy_regularization_task(1e-6)
282+
283+
self.effector_task = {}
284+
self.init_ee_xyz = {}
285+
self.init_ee_quat = {}
286+
self.init_controller_xyz = {}
287+
self.init_controller_quat = {}
288+
self.arm_active = {}
289+
for name, config in self.end_effector_config.items():
290+
self.effector_task[name] = self.solver.add_frame_task(config["link_name"], np.eye(4))
291+
self.effector_task[name].configure(f"{name}_frame", "soft", 1.0)
292+
self.solver.add_manipulability_task(config["link_name"], "both", 1.0).configure(
293+
f"{name}_manipulability", "soft", 5e-2
294+
)
295+
self.init_ee_xyz[name] = None
296+
self.init_ee_quat[name] = None
297+
self.init_controller_xyz[name] = None
298+
self.init_controller_quat[name] = None
299+
self.arm_active[name] = False
300+
301+
self.gripper_pos_target = {}
302+
for name, config in end_effector_config.items():
303+
if "gripper_config" in config:
304+
gripper_config = config["gripper_config"]
305+
self.gripper_pos_target[gripper_config["joint_name"]] = gripper_config["open_pos"]
306+
307+
print("Waiting for initial joint state from the Galaxea arm...")
308+
while not rospy.is_shutdown() and self.right_controller.timestamp == 0:
309+
rospy.sleep(0.1)
310+
print("Initial joint state received.")
311+
312+
self.placo_robot.state.q[7:13] = self.left_controller.qpos
313+
self.placo_robot.state.q[15:21] = self.right_controller.qpos
314+
315+
if self.visualize_placo:
316+
self.placo_robot.update_kinematics()
317+
self.placo_vis = robot_viz(self.placo_robot)
318+
time.sleep(0.5)
319+
webbrowser.open(self.placo_vis.viewer.url())
320+
self.placo_vis.display(self.placo_robot.state.q)
321+
for name, config in self.end_effector_config.items():
322+
robot_frame_viz(self.placo_robot, config["link_name"])
323+
frame_viz(f"vis_target_{name}", self.effector_task[name].T_world_frame)
324+
325+
def _process_xr_pose(self, xr_pose, arm_name: str):
326+
controller_xyz = np.array([xr_pose[0], xr_pose[1], xr_pose[2]])
327+
controller_quat = np.array([xr_pose[6], xr_pose[3], xr_pose[4], xr_pose[5]])
328+
329+
controller_xyz = self.R_headset_world @ controller_xyz
330+
R_transform = np.eye(4)
331+
R_transform[:3, :3] = self.R_headset_world
332+
R_quat = tf.quaternion_from_matrix(R_transform)
333+
controller_quat = tf.quaternion_multiply(
334+
tf.quaternion_multiply(R_quat, controller_quat), tf.quaternion_conjugate(R_quat)
335+
)
336+
337+
if self.init_controller_xyz[arm_name] is None:
338+
self.init_controller_xyz[arm_name] = controller_xyz.copy()
339+
self.init_controller_quat[arm_name] = controller_quat.copy()
340+
delta_xyz = np.zeros(3)
341+
delta_rot = np.array([0.0, 0.0, 0.0])
342+
else:
343+
delta_xyz = (controller_xyz - self.init_controller_xyz[arm_name]) * self.scale_factor
344+
delta_rot = quat_diff_as_angle_axis(self.init_controller_quat[arm_name], controller_quat)
345+
return delta_xyz, delta_rot
346+
347+
def update_ik(self):
348+
self.placo_robot.state.q[7:13] = self.left_controller.qpos
349+
self.placo_robot.state.q[15:21] = self.right_controller.qpos
350+
self.placo_robot.update_kinematics()
351+
352+
for arm_name, config in self.end_effector_config.items():
353+
xr_grip_val = self.xr_client.get_key_value_by_name(config["control_trigger"])
354+
self.arm_active[arm_name] = xr_grip_val > (1.0 - CONTROLLER_DEADZONE)
355+
356+
if self.arm_active[arm_name]:
357+
if self.init_ee_xyz[arm_name] is None:
358+
self.init_ee_xyz[arm_name] = self.placo_robot.get_T_world_frame(config["link_name"])[:3, 3]
359+
self.init_ee_quat[arm_name] = tf.quaternion_from_matrix(
360+
self.placo_robot.get_T_world_frame(config["link_name"])
361+
)
362+
print(f"{arm_name} activated.")
363+
364+
xr_pose = self.xr_client.get_pose_by_name(config["pose_source"])
365+
delta_xyz, delta_rot = self._process_xr_pose(xr_pose, arm_name)
366+
target_xyz, target_quat = apply_delta_pose(
367+
self.init_ee_xyz[arm_name], self.init_ee_quat[arm_name], delta_xyz, delta_rot
368+
)
369+
target_pose = tf.quaternion_matrix(target_quat)
370+
target_pose[:3, 3] = target_xyz
371+
self.effector_task[arm_name].T_world_frame = target_pose
372+
373+
else:
374+
if self.init_ee_xyz[arm_name] is not None:
375+
self.init_ee_xyz[arm_name] = None
376+
self.init_controller_xyz[arm_name] = None
377+
print(f"{arm_name} deactivated.")
378+
self.effector_task[arm_name].T_world_frame = self.placo_robot.get_T_world_frame(config["link_name"])
379+
380+
try:
381+
self.solver.solve(True)
382+
383+
if self.visualize_placo:
384+
self.placo_vis.display(self.placo_robot.state.q)
385+
for name in self.end_effector_config:
386+
frame_viz(f"vis_target_{name}", self.effector_task[name].T_world_frame)
387+
except RuntimeError as e:
388+
print(f"IK solver failed: {e}")
389+
except Exception as e:
390+
print(f"An unexpected error occurred in IK: {e}")
391+
392+
for arm_name, config in self.end_effector_config.items():
393+
if arm_name == "left_arm" and self.arm_active[arm_name]:
394+
self.left_controller.q_des = self.placo_robot.state.q[7:13].copy()
395+
if arm_name == "right_arm" and self.arm_active[arm_name]:
396+
self.right_controller.q_des = self.placo_robot.state.q[15:21].copy()
397+
# Update gripper position based on XR input
398+
if "gripper_config" in config:
399+
gripper_config = config["gripper_config"]
400+
trigger_value = self.xr_client.get_key_value_by_name(gripper_config["gripper_trigger"])
401+
gripper_pos = calc_parallel_gripper_position(
402+
gripper_config["open_pos"],
403+
gripper_config["close_pos"],
404+
trigger_value,
405+
)
406+
joint_name = gripper_config["joint_name"]
407+
self.gripper_pos_target[joint_name] = [gripper_pos]
408+
409+
self.left_controller.q_des_gripper = self.gripper_pos_target[
410+
self.end_effector_config["left_arm"]["gripper_config"]["joint_name"]
411+
]
412+
self.right_controller.q_des_gripper = self.gripper_pos_target[
413+
self.end_effector_config["right_arm"]["gripper_config"]["joint_name"]
414+
]
415+
print(self.right_controller.q_des_gripper)
416+
417+
def run_ik_thread(self, stop_event: threading.Event):
418+
print("Starting Galaxea A1X single arm teleop controller IK loop...")
419+
while not (rospy.is_shutdown() or stop_event.is_set()):
420+
start = time.time()
421+
self.update_ik()
422+
elapsed = time.time() - start
423+
time.sleep(max(0, (self.solver.dt - elapsed)))
424+
print("Galaxea teleop controller IK loop shutting down.")
425+
426+
def run_control_thread(self, stop_event: threading.Event):
427+
print("Starting Galaxea A1X single arm teleop controller loop...")
428+
while not (rospy.is_shutdown() or stop_event.is_set()):
429+
self.left_controller.publish_arm_control()
430+
self.left_controller.publish_gripper_control()
431+
self.right_controller.publish_arm_control()
432+
self.right_controller.publish_gripper_control()
433+
self.right_controller.rate.sleep()
434+
print("Galaxea teleop controller shutting down.")

0 commit comments

Comments
 (0)