CoolFace
Modelpublic

lerobot/unitree-g1-mujoco

sourceHugging Faceupdated 9d agoView on Hugging Face
13likes164downloads
existing_files.patch593 linesDownload Raw Back to root
1--- a/README.md2+++ b/README.md3@@ -1,5 +1,129 @@4-# MuJoCo Sim for Unitree G15+---6+tags:7+  - lerobot8+  - mujoco9+  - unitree-g110+---11 12-Standalone MuJoCo physics simulator for the Unitree G1 robot, adapted from gr00t_wbc. Currently supports G1_29dof.13+# Unitree G1 MuJoCo model for LeRobot14 15-set use joystick to 1 to control the robot +The default model has two **Dex1-1 parallel grippers** and three published cameras.16+The original articulated hands, URDF, MJCF and meshes remain included.17+This is the model repository loaded dynamically by LeRobot through `env.py`.18+19+The existing `make_env()` entry point, 29 body motor commands, body state DDS20+topics, simulation stepping and ZMQ image message format are preserved.21+MuJoCo loads `assets/scene_33dof.xml`, which includes the gripper MJCF derived22+from the supplied URDF. The portable URDF is also included at23+`assets/g1_29dof_with_dex1_1.urdf`; runtime cameras and actuators are defined in MJCF.24+25+## Choose the end effectors26+27+`config.yaml` defaults to `END_EFFECTOR: grippers`. Set it to `hands` to use the28+original seven-joint hands. Scene, finger counts and effort limits follow that29+selection automatically. Both modes have all three camera streams.30+31+| Selection | Runtime scene | Actuated joints |32+| --- | --- | --- |33+| `grippers` (default) | `assets/scene_33dof.xml` | 29 body + 2 fingers per gripper |34+| `hands` | `assets/scene_hands_cameras.xml` | 29 body + 7 joints per hand |35+36+Direct users of this repository can also call:37+38+```python39+from env import make_env40+41+if __name__ == "__main__":42+    env = make_env(end_effector="hands")  # Omit the argument for grippers.43+    try:44+        env.reset()45+        while True:46+            env.step()  # Body commands continue to arrive over DDS.47+    finally:48+        env.close()49+```50+51+The original `assets/scene_43dof.xml`, `assets/g1_29dof_with_hand.xml`,52+`assets/g1_body29_hand14.urdf` and no-hand model are retained unchanged.53+54+## Cameras55+56+All three streams are enabled by default on **`tcp://127.0.0.1:5555`**, with57+640 × 480 images and the existing approximately 30 Hz publishing setting.58+59+| Stream name | Mount |60+| --- | --- |61+| `head_camera` | Existing head camera |62+| `left_wrist_cam` | Left gripper base / wrist |63+| `right_wrist_cam` | Right gripper base / wrist |64+65+Each stream is advertised through the same top-level JPEG key and nested66+`images` / `timestamps` entries as `head_camera`. The existing67+`view_cameras_live.py` discovers the names from those messages. They are also68+listed in `env.camera_configs`, `env.camera_names` and `env.metadata["cameras"]`.69+The wrist cameras move with their respective wrists, with 95° vertical field of70+view and the approximate extrinsics from the HIW-500 model builder.71+72+LeRobot's ZMQ cameras require explicit client configuration, just as the head73+camera does. Pass this dictionary as `UnitreeG1Config(..., cameras=cameras)`:74+75+```python76+from lerobot.cameras.zmq.configuration_zmq import ZMQCameraConfig77+78+cameras = {79+    name: ZMQCameraConfig(80+        server_address="127.0.0.1", port=5555, camera_name=name,81+        width=640, height=480, fps=30,82+    )83+    for name in ("head_camera", "left_wrist_cam", "right_wrist_cam")84+}85+```86+87+An equivalent camera configuration is in `lerobot_cameras.json`.88+`make_env(cameras=["head_camera"])` selects a subset; `publish_images=False`89+disables publishing. `onscreen=False` disables the viewer for headless use.90+91+## Gripper control92+93+Dex1 fingers use force actuators, driven by the existing bridge's external PD94+controller. They start open at 0.0245 m. To preserve the simulator's hand95+transport, the first two motor entries on `rt/dex3/left/cmd` and96+`rt/dex3/right/cmd` command fingers 1 and 2, with matching state topics.97+Gripper `q` is in metres and `tau` is in newtons; each finger is limited to 20 N.98+Command both fingers to the same position for symmetric opening or closing.99+Original hand mode retains all seven rotational command entries per side.100+101+The simulation lower limit is -0.023 m, following HIW-500's mesh closure trim.102+The source and portable URDF retain the official -0.020 m lower limit.103+Run `python build_gripper_model.py --official-limits` to use that limit in MJCF104+too; it leaves approximately 5.88 mm between the supplied finger pads.105+This model update does not add finger actions to LeRobot's 29-body-motor106+`UnitreeG1` action schema.107+108+## Development and checks109+110+Install the existing Unitree SDK2 / CycloneDDS prerequisites, then111+`python -m pip install -r requirements.txt`. Regenerate the variants with112+`python build_gripper_model.py`.113+114+```bash115+MUJOCO_GL=egl python -m unittest discover -s tests -v116+MUJOCO_GL=egl python tests/smoke_live.py117+MUJOCO_GL=egl python tests/smoke_live.py --end-effector hands118+```119+120+The regression suite uses real MuJoCo for both variants, motor/observation121+mapping, force-controlled closure, wrist camera motion, rendering and camera122+publisher shared-memory buffers. It isolates DDS with a test double.123+`smoke_live.py` additionally requires the real Gymnasium, Unitree SDK2 and124+ZMQ/OpenCV dependencies and checks the live environment and transports.125+See `VALIDATION.md` for what was executed for this update.126+127+## Sources128+129+- Base model/runtime: [lerobot/unitree-g1-mujoco](https://huggingface.co/lerobot/unitree-g1-mujoco/tree/a38dc8617f0fca51b38e9354dc58ee35ad850fb5).130+- Dex1-1 URDF, meshes and wrist-camera geometry: [Hxxxz0/HIW-500-controoler](https://github.com/Hxxxz0/HIW-500-controoler/tree/c69d89d88bb51774fe9a3684b90d9ff1abf801da).131+- LeRobot loading and camera configuration checked against [main at b6ec006](https://github.com/huggingface/lerobot/tree/b6ec0060779550c0a157ae34feb89e0cf86012a8).132+133+The original Dex1 source URDF, Apache 2.0 license and attribution notice are in134+`reference/hiw500/`. Unitree mesh assets retain their upstream terms.135--- a/config.yaml136+++ b/config.yaml137@@ -1,6 +1,7 @@138 # Robot Configuration139 ROBOT_TYPE: 'g1_29dof'140-ROBOT_SCENE: "assets/scene_43dof.xml"141+END_EFFECTOR: "grippers"  # "grippers" (Dex1-1) or "hands" (original Dex3)142+ROBOT_SCENE: "assets/scene_33dof.xml"  # Selected automatically from END_EFFECTOR143 144 # DDS Communication145 DOMAIN_ID: 0146@@ -25,6 +26,7 @@147 ENABLE_ONSCREEN: true148 ENABLE_OFFSCREEN: false149 MP_START_METHOD: "spawn"150+CAMERAS: ["head_camera", "left_wrist_cam", "right_wrist_cam"]151 152 # Sensors153 USE_SENSOR: False154@@ -33,16 +35,17 @@155 # Robot Dimensions156 NUM_MOTORS: 29157 NUM_JOINTS: 29158-NUM_HAND_MOTORS: 7159-NUM_HAND_JOINTS: 7160+NUM_HAND_MOTORS: 2161+NUM_HAND_JOINTS: 2162 163-# Torque Limits (Nm) - 29 body + 14 hand = 43 total164+# Selected automatically per mode: 29 body + 4 gripper = 33 total.165+# Revolute joint torque limits in Nm; prismatic finger force limits in N.166 motor_effort_limit_list: [167     88.0, 88.0, 88.0, 139.0, 50.0, 50.0,   # left leg168     88.0, 88.0, 88.0, 139.0, 50.0, 50.0,   # right leg169     88.0, 50.0, 50.0,                       # waist170     25.0, 25.0, 25.0, 25.0, 25.0, 5.0, 5.0, # left arm171-    2.45, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7,    # left hand172+    20.0, 20.0,                            # left gripper173     25.0, 25.0, 25.0, 25.0, 25.0, 5.0, 5.0, # right arm174-    2.45, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7     # right hand175+    20.0, 20.0                             # right gripper176 ]177--- a/env.py178+++ b/env.py179@@ -5,12 +5,18 @@180 import numpy as np181 from huggingface_hub import snapshot_download182 import yaml183-snapshot_download("lerobot/unitree-g1-mujoco")184+# LeRobot initially fetches only env.py. Hydrate its matching Hub snapshot;185+# a complete local checkout can also be used without another network request.186+_repo_dir = Path(__file__).parent187+if not (_repo_dir / "assets/scene_33dof.xml").is_file():188+    _revision = _repo_dir.name if _repo_dir.parent.name == "snapshots" else None189+    snapshot_download("lerobot/unitree-g1-mujoco", revision=_revision)190 191 # Ensure sim module is importable192 sys.path.insert(0, str(Path(__file__).parent))193 194 from sim.simulator_factory import SimulatorFactory, init_channel195+from sim.model_config import select_end_effector196 197 198 def make_env(n_envs=1, use_async_envs=False, **kwargs):199@@ -23,6 +29,8 @@200             - publish_images: bool, whether to publish camera images via ZMQ201             - camera_port: int, ZMQ port for camera images202             - cameras: list of camera names203+            - end_effector: "grippers" (default) or "hands"204+            - onscreen: bool, override the interactive viewer setting205     """206     repo_dir = Path(__file__).parent207     208@@ -30,11 +38,14 @@209     config_path = repo_dir / "config.yaml"210     with open(config_path) as f:211         config = yaml.safe_load(f)212+    config = select_end_effector(config, kwargs.get("end_effector"))213     214     # Configure cameras if requested215     publish_images = kwargs.get("publish_images", True)216     camera_port = kwargs.get("camera_port", 5555)217-    cameras = kwargs.get("cameras", ["head_camera"])218+    cameras = kwargs.get("cameras")219+    if cameras is None:220+        cameras = config["CAMERAS"]221     222     enable_offscreen = publish_images or config.get("ENABLE_OFFSCREEN", False)223     camera_configs = {}224@@ -49,7 +60,7 @@225     simulator = SimulatorFactory.create_simulator(226         config=config,227         env_name="default",228-        onscreen=config.get("ENABLE_ONSCREEN", True),229+        onscreen=kwargs.get("onscreen", config.get("ENABLE_ONSCREEN", True)),230         offscreen=enable_offscreen,231         camera_configs=camera_configs,232     )233@@ -65,6 +76,10 @@234             self.sim_env = sim.sim_env235             self.step_count = 0236             self.camera_configs = cam_configs237+            self.camera_names = tuple(cam_configs)238+            self.camera_port = cam_port239+            self.end_effector = config["END_EFFECTOR"]240+            self.metadata = {"render_modes": ["human"], "cameras": self.camera_names}241             242             # Get timing from config243             self.sim_dt = config["SIMULATE_DT"]244@@ -77,7 +92,7 @@245                     start_method=config.get("MP_START_METHOD", "spawn"),246                     camera_port=cam_port247                 )248-                print(f"Camera images publishing on tcp://localhost:{cam_port}")249+                print(f"Camera images publishing on tcp://localhost:{cam_port}: {', '.join(self.camera_names)}")250             251             # Define spaces252             num_joints = config.get("NUM_MOTORS", 29)253@@ -123,7 +138,7 @@254                 obs_dict.get("body_tau_est", np.zeros(29)),255                 obs_dict.get("floating_base_pose", np.zeros(7))[:4],256                 obs_dict.get("floating_base_vel", np.zeros(6))[:3],257-                obs_dict.get("floating_base_acc", np.zeros(3)),258+                obs_dict.get("floating_base_acc", np.zeros(3))[:3],259             ]).astype(np.float32)260             return obs261 262--- a/requirements.txt263+++ b/requirements.txt264@@ -1,8 +1,13 @@265-mujoco>=3.0.0266+mujoco>=3.3.0267 numpy>=1.24.0268 pyyaml>=6.0269 unitree-sdk2py>=1.0.0270 loguru>=0.7.0271+gymnasium>=1.0.0272+huggingface_hub>=0.25.0273+pygame>=2.5.0274+termcolor>=2.0.0275+scipy>=1.10.0276 277 # Camera publishing dependencies278 opencv-python>=4.8.0279@@ -10,4 +15,3 @@280 msgpack>=1.0.0281 msgpack-numpy>=0.4.8282 matplotlib>=3.5.0  # For live camera viewer283-284--- a/run_sim.py285+++ b/run_sim.py286@@ -7,6 +7,7 @@287 288 import yaml289 from sim.simulator_factory import SimulatorFactory, init_channel290+from sim.model_config import select_end_effector291 292 def main(n_envs=1, use_async_envs: bool = False, 293              publish_images=True, camera_port=5555, cameras=None, **kwargs):294@@ -15,6 +16,7 @@295     config_path = Path(__file__).parent / "config.yaml"296     with open(config_path) as f:297         config = yaml.safe_load(f)298+    config = select_end_effector(config, kwargs.get("end_effector"))299     300     # Override config with default values301     enable_offscreen = publish_images or config.get("ENABLE_OFFSCREEN", False)302@@ -22,7 +24,7 @@303     # Configure cameras if requested304     camera_configs = {}305     if enable_offscreen:306-        camera_list = cameras or ["head_camera"]307+        camera_list = config["CAMERAS"] if cameras is None else cameras308         for cam_name in camera_list:309             camera_configs[cam_name] = {"height": 480, "width": 640}310         print(f"📷 Cameras: {', '.join(camera_list)} → ZMQ port {camera_port}")311@@ -36,7 +38,7 @@312     sim = SimulatorFactory.create_simulator(313         config=config,314         env_name="default",315-        onscreen=config.get("ENABLE_ONSCREEN", True),316+        onscreen=kwargs.get("onscreen", config.get("ENABLE_ONSCREEN", True)),317         offscreen=enable_offscreen,318         camera_configs=camera_configs,319     )320@@ -63,4 +65,3 @@321 322 if __name__ == "__main__":323     main()324-325--- a/sim/base_sim.py326+++ b/sim/base_sim.py327@@ -13,7 +13,7 @@328     HAS_RCLPY = True329 except ImportError:330     HAS_RCLPY = False331-    print("Warning: rclpy not found. Camera image publishing will be disabled.")332+    print("ROS 2 integration unavailable; camera images use the ZMQ publisher.")333 from unitree_sdk2py.core.channel import ChannelFactoryInitialize334 import yaml335 import os336@@ -21,6 +21,7 @@337 from .metric_utils import check_contact338 from .sim_utils import get_subtree_body_names339 from .unitree_sdk2py_bridge import ElasticBand, UnitreeSdk2Bridge340+from .model_config import select_end_effector341 342 GR00T_WBC_ROOT = Path(__file__).resolve().parent.parent  # Points to mujoco_sim_g1/343 344@@ -52,7 +53,6 @@345         self.num_hand_dof = self.config["NUM_HAND_JOINTS"]346         self.sim_dt = self.config["SIMULATE_DT"]347         self.obs = None348-        self.torques = np.zeros(self.num_body_dof + self.num_hand_dof * 2)349         self.torque_limit = np.array(self.config["motor_effort_limit_list"])350         self.camera_configs = camera_configs351         352@@ -131,7 +131,8 @@353             self.viewer.cam.distance = 2.0  # Distance from camera to target354             self.viewer.cam.lookat = np.array([0, 0, 0.5])  # Point the camera is looking at355 356-        # Note that the actuator order is the same as the joint order in the mujoco model.357+        # Body DDS indices exclude the end effectors. Map each scalar joint358+        # explicitly: actuator order need not match the model's joint order.359         self.body_joint_index = []360         self.left_hand_index = []361         self.right_hand_index = []362@@ -144,23 +145,41 @@363                 ]364             ):365                 self.body_joint_index.append(i)366-            elif "left_hand" in name:367+            elif "left_hand" in name or name.startswith("left_dex1_finger_joint_"):368                 self.left_hand_index.append(i)369-            elif "right_hand" in name:370+            elif "right_hand" in name or name.startswith("right_dex1_finger_joint_"):371                 self.right_hand_index.append(i)372 373         assert len(self.body_joint_index) == self.config["NUM_JOINTS"], \374             f"Expected {self.config['NUM_JOINTS']} body joints, got {len(self.body_joint_index)}"375-        # Hand joints are optional (some models don't have hands)376-        if self.config.get("NUM_HAND_JOINTS", 0) > 0:377-            expected_hands = self.config["NUM_HAND_JOINTS"]378-            if len(self.left_hand_index) != expected_hands or len(self.right_hand_index) != expected_hands:379-                print(f"Warning: Expected {expected_hands} hand joints, got left={len(self.left_hand_index)}, right={len(self.right_hand_index)}")380-                print("Continuing without hands...")381-382-        self.body_joint_index = np.array(self.body_joint_index)383-        self.left_hand_index = np.array(self.left_hand_index)384-        self.right_hand_index = np.array(self.right_hand_index)385+        expected_hands = self.config.get("NUM_HAND_JOINTS", 0)386+        if len(self.left_hand_index) != expected_hands or len(self.right_hand_index) != expected_hands:387+            raise ValueError(f"Expected {expected_hands} joints per end effector, got left={len(self.left_hand_index)}, right={len(self.right_hand_index)}")388+389+        for prefix, attribute in (("body", "body_joint_index"), ("left_hand", "left_hand_index"), ("right_hand", "right_hand_index")):390+            joint_ids = np.asarray(getattr(self, attribute), dtype=int)391+            setattr(self, attribute, joint_ids)392+            setattr(self, prefix + "_qpos_index", self.mj_model.jnt_qposadr[joint_ids])393+            setattr(self, prefix + "_dof_index", self.mj_model.jnt_dofadr[joint_ids])394+            actuator_ids = []395+            for joint_id in joint_ids:396+                matches = np.flatnonzero(397+                    (self.mj_model.actuator_trntype == mujoco.mjtTrn.mjTRN_JOINT)398+                    & (self.mj_model.actuator_trnid[:, 0] == joint_id)399+                )400+                if len(matches) != 1:401+                    raise ValueError(f"Expected one actuator for {self.mj_model.joint(joint_id).name}, got {len(matches)}")402+                actuator_ids.append(matches[0])403+            setattr(self, prefix + "_actuator_index", np.asarray(actuator_ids, dtype=int))404+        self.torques = np.zeros(self.mj_model.nu)405+        if self.config.get("FREE_BASE", False):406+            self.torque_limit = np.concatenate((np.zeros(6), self.torque_limit))407+        if self.torque_limit.shape != self.torques.shape:408+            raise ValueError("motor_effort_limit_list must match the scene's actuator count")409+        for name in self.camera_configs:410+            if mujoco.mj_name2id(self.mj_model, mujoco.mjtObj.mjOBJ_CAMERA, name) < 0:411+                raise ValueError(f"Camera {name!r} does not exist in {self.config['ROBOT_SCENE']}")412+        self.reset()413 414     def init_renderers(self):415         # Initialize camera renderers416@@ -212,12 +231,12 @@417                         + self.unitree_bridge.low_cmd.motor_cmd[i].kp418                         * (419                             self.unitree_bridge.low_cmd.motor_cmd[i].q420-                            - self.mj_data.qpos[self.body_joint_index[i] + 7 - 1]421+                            - self.mj_data.qpos[self.body_qpos_index[i]]422                         )423                         + self.unitree_bridge.low_cmd.motor_cmd[i].kd424                         * (425                             self.unitree_bridge.low_cmd.motor_cmd[i].dq426-                            - self.mj_data.qvel[self.body_joint_index[i] + 6 - 1]427+                            - self.mj_data.qvel[self.body_dof_index[i]]428                         )429                     )430         return body_torques431@@ -233,12 +252,12 @@432                     + self.unitree_bridge.left_hand_cmd.motor_cmd[i].kp433                     * (434                         self.unitree_bridge.left_hand_cmd.motor_cmd[i].q435-                        - self.mj_data.qpos[self.left_hand_index[i] + 7 - 1]436+                        - self.mj_data.qpos[self.left_hand_qpos_index[i]]437                     )438                     + self.unitree_bridge.left_hand_cmd.motor_cmd[i].kd439                     * (440                         self.unitree_bridge.left_hand_cmd.motor_cmd[i].dq441-                        - self.mj_data.qvel[self.left_hand_index[i] + 6 - 1]442+                        - self.mj_data.qvel[self.left_hand_dof_index[i]]443                     )444                 )445                 right_hand_torques[i] = (446@@ -246,12 +265,12 @@447                     + self.unitree_bridge.right_hand_cmd.motor_cmd[i].kp448                     * (449                         self.unitree_bridge.right_hand_cmd.motor_cmd[i].q450-                        - self.mj_data.qpos[self.right_hand_index[i] + 7 - 1]451+                        - self.mj_data.qpos[self.right_hand_qpos_index[i]]452                     )453                     + self.unitree_bridge.right_hand_cmd.motor_cmd[i].kd454                     * (455                         self.unitree_bridge.right_hand_cmd.motor_cmd[i].dq456-                        - self.mj_data.qvel[self.right_hand_index[i] + 6 - 1]457+                        - self.mj_data.qvel[self.right_hand_dof_index[i]]458                     )459                 )460         return np.concatenate((left_hand_torques, right_hand_torques))461@@ -281,19 +300,19 @@462         obs["floating_base_acc"] = self.mj_data.qacc[:6]463         obs["secondary_imu_quat"] = self.mj_data.xquat[self.torso_index]464         obs["secondary_imu_vel"] = self.mj_data.cvel[self.torso_index]465-        obs["body_q"] = self.mj_data.qpos[self.body_joint_index + 7 - 1]466-        obs["body_dq"] = self.mj_data.qvel[self.body_joint_index + 6 - 1]467-        obs["body_ddq"] = self.mj_data.qacc[self.body_joint_index + 6 - 1]468-        obs["body_tau_est"] = self.mj_data.actuator_force[self.body_joint_index - 1]469+        obs["body_q"] = self.mj_data.qpos[self.body_qpos_index]470+        obs["body_dq"] = self.mj_data.qvel[self.body_dof_index]471+        obs["body_ddq"] = self.mj_data.qacc[self.body_dof_index]472+        obs["body_tau_est"] = self.mj_data.actuator_force[self.body_actuator_index]473         if self.num_hand_dof > 0:474-            obs["left_hand_q"] = self.mj_data.qpos[self.left_hand_index + 7 - 1]475-            obs["left_hand_dq"] = self.mj_data.qvel[self.left_hand_index + 6 - 1]476-            obs["left_hand_ddq"] = self.mj_data.qacc[self.left_hand_index + 6 - 1]477-            obs["left_hand_tau_est"] = self.mj_data.actuator_force[self.left_hand_index - 1]478-            obs["right_hand_q"] = self.mj_data.qpos[self.right_hand_index + 7 - 1]479-            obs["right_hand_dq"] = self.mj_data.qvel[self.right_hand_index + 6 - 1]480-            obs["right_hand_ddq"] = self.mj_data.qacc[self.right_hand_index + 6 - 1]481-            obs["right_hand_tau_est"] = self.mj_data.actuator_force[self.right_hand_index - 1]482+            obs["left_hand_q"] = self.mj_data.qpos[self.left_hand_qpos_index]483+            obs["left_hand_dq"] = self.mj_data.qvel[self.left_hand_dof_index]484+            obs["left_hand_ddq"] = self.mj_data.qacc[self.left_hand_dof_index]485+            obs["left_hand_tau_est"] = self.mj_data.actuator_force[self.left_hand_actuator_index]486+            obs["right_hand_q"] = self.mj_data.qpos[self.right_hand_qpos_index]487+            obs["right_hand_dq"] = self.mj_data.qvel[self.right_hand_dof_index]488+            obs["right_hand_ddq"] = self.mj_data.qacc[self.right_hand_dof_index]489+            obs["right_hand_tau_est"] = self.mj_data.actuator_force[self.right_hand_actuator_index]490         obs["time"] = self.mj_data.time491         return obs492 493@@ -333,17 +352,14 @@494                 self.mj_data.xfrc_applied[self.band_attached_link] = np.zeros(6)495         body_torques = self.compute_body_torques()496         hand_torques = self.compute_hand_torques()497-        self.torques[self.body_joint_index - 1] = body_torques498+        self.torques[self.body_actuator_index] = body_torques499         if self.num_hand_dof > 0:500-            self.torques[self.left_hand_index - 1] = hand_torques[: self.num_hand_dof]501-            self.torques[self.right_hand_index - 1] = hand_torques[self.num_hand_dof :]502+            self.torques[self.left_hand_actuator_index] = hand_torques[: self.num_hand_dof]503+            self.torques[self.right_hand_actuator_index] = hand_torques[self.num_hand_dof :]504 505         self.torques = np.clip(self.torques, -self.torque_limit, self.torque_limit)506 507-        if self.config["FREE_BASE"]:508-            self.mj_data.ctrl = np.concatenate((np.zeros(6), self.torques))509-        else:510-            self.mj_data.ctrl = self.torques511+        self.mj_data.ctrl[:] = self.torques512         mujoco.mj_step(self.mj_model, self.mj_data)513         # self.check_self_collision()514 515@@ -391,9 +407,9 @@516         body_qpos = self.compute_body_qpos()  # (num_body_dof,)517         hand_qpos = self.compute_hand_qpos()  # (num_hand_dof * 2,)518 519-        self.mj_data.qpos[self.body_joint_index + 7 - 1] = body_qpos520-        self.mj_data.qpos[self.left_hand_index + 7 - 1] = hand_qpos[: self.num_hand_dof]521-        self.mj_data.qpos[self.right_hand_index + 7 - 1] = hand_qpos[self.num_hand_dof :]522+        self.mj_data.qpos[self.body_qpos_index] = body_qpos523+        self.mj_data.qpos[self.left_hand_qpos_index] = hand_qpos[: self.num_hand_dof]524+        self.mj_data.qpos[self.right_hand_qpos_index] = hand_qpos[self.num_hand_dof :]525 526         mujoco.mj_kinematics(self.mj_model, self.mj_data)527         mujoco.mj_comPos(self.mj_model, self.mj_data)528@@ -507,6 +523,11 @@529         # Set valid floating base quaternion (identity: w=1, x=y=z=0)530         # mj_resetData sets qpos to zeros, which gives invalid [0,0,0,0] quaternion531         self.mj_data.qpos[3:7] = [1.0, 0.0, 0.0, 0.0]532+        if self.config.get("END_EFFECTOR") == "grippers":533+            for side in ("left", "right"):534+                joints = getattr(self, side + "_hand_index")535+                addresses = getattr(self, side + "_hand_qpos_index")536+                self.mj_data.qpos[addresses] = self.mj_model.jnt_range[joints, 1]537         # Propagate qpos to derived quantities (xquat, xpos, etc.)538         mujoco.mj_forward(self.mj_model, self.mj_data)539 540@@ -515,6 +536,7 @@541     """Base simulator class that handles initialization and running of simulations"""542 543     def __init__(self, config: Dict[str, any], env_name: str = "default", **kwargs):544+        config = select_end_effector(config)545         self.config = config546         self.env_name = env_name547 548@@ -636,6 +658,7 @@549 550     def reset(self):551         """Reset the simulation. Can be overridden by subclasses."""552+        self.unitree_bridge.reset()553         self.sim_env.reset()554 555     def close(self):556@@ -650,6 +673,11 @@557             if hasattr(self.sim_env, "viewer") and self.sim_env.viewer is not None:558                 self.sim_env.viewer.close()559 560+            for renderer in self.sim_env.renderers.values():561+                renderer.close()562+            self.sim_env.renderers.clear()563+            self.sim_env._renderers_initialized = False564+565             # Shutdown ROS (if available)566             if HAS_RCLPY and rclpy.ok():567                 rclpy.shutdown()568--- a/sim/unitree_sdk2py_bridge.py569+++ b/sim/unitree_sdk2py_bridge.py570@@ -127,9 +127,22 @@571         with self.left_hand_cmd_lock:572             self.left_hand_cmd_received = False573             self.new_left_hand_cmd = False574+            if self.config.get("END_EFFECTOR") == "grippers":575+                self._open_gripper(self.left_hand_cmd)576         with self.right_hand_cmd_lock:577             self.right_hand_cmd_received = False578             self.new_right_hand_cmd = False579+            if self.config.get("END_EFFECTOR") == "grippers":580+                self._open_gripper(self.right_hand_cmd)581+582+    def _open_gripper(self, command):583+        """Hold Dex1 fingers open until a hand command arrives (positions in m)."""584+        for motor in command.motor_cmd[:self.num_hand_motor]:585+            motor.q = 0.0245586+            motor.dq = 0.0587+            motor.kp = 1000.0588+            motor.kd = 25.0589+            motor.tau = 0.0590 591     def LowCmdHandler(self, msg):592         with self.low_cmd_lock:593