File size: 26,902 Bytes
257dded
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
--- a/README.md
+++ b/README.md
@@ -1,5 +1,129 @@
-# MuJoCo Sim for Unitree G1
+---
+tags:
+  - lerobot
+  - mujoco
+  - unitree-g1
+---
 
-Standalone MuJoCo physics simulator for the Unitree G1 robot, adapted from gr00t_wbc. Currently supports G1_29dof.
+# Unitree G1 MuJoCo model for LeRobot
 
-set use joystick to 1 to control the robot +The default model has two **Dex1-1 parallel grippers** and three published cameras.
+The original articulated hands, URDF, MJCF and meshes remain included.
+This is the model repository loaded dynamically by LeRobot through `env.py`.
+
+The existing `make_env()` entry point, 29 body motor commands, body state DDS
+topics, simulation stepping and ZMQ image message format are preserved.
+MuJoCo loads `assets/scene_33dof.xml`, which includes the gripper MJCF derived
+from the supplied URDF. The portable URDF is also included at
+`assets/g1_29dof_with_dex1_1.urdf`; runtime cameras and actuators are defined in MJCF.
+
+## Choose the end effectors
+
+`config.yaml` defaults to `END_EFFECTOR: grippers`. Set it to `hands` to use the
+original seven-joint hands. Scene, finger counts and effort limits follow that
+selection automatically. Both modes have all three camera streams.
+
+| Selection | Runtime scene | Actuated joints |
+| --- | --- | --- |
+| `grippers` (default) | `assets/scene_33dof.xml` | 29 body + 2 fingers per gripper |
+| `hands` | `assets/scene_hands_cameras.xml` | 29 body + 7 joints per hand |
+
+Direct users of this repository can also call:
+
+```python
+from env import make_env
+
+if __name__ == "__main__":
+    env = make_env(end_effector="hands")  # Omit the argument for grippers.
+    try:
+        env.reset()
+        while True:
+            env.step()  # Body commands continue to arrive over DDS.
+    finally:
+        env.close()
+```
+
+The original `assets/scene_43dof.xml`, `assets/g1_29dof_with_hand.xml`,
+`assets/g1_body29_hand14.urdf` and no-hand model are retained unchanged.
+
+## Cameras
+
+All three streams are enabled by default on **`tcp://127.0.0.1:5555`**, with
+640 × 480 images and the existing approximately 30 Hz publishing setting.
+
+| Stream name | Mount |
+| --- | --- |
+| `head_camera` | Existing head camera |
+| `left_wrist_cam` | Left gripper base / wrist |
+| `right_wrist_cam` | Right gripper base / wrist |
+
+Each stream is advertised through the same top-level JPEG key and nested
+`images` / `timestamps` entries as `head_camera`. The existing
+`view_cameras_live.py` discovers the names from those messages. They are also
+listed in `env.camera_configs`, `env.camera_names` and `env.metadata["cameras"]`.
+The wrist cameras move with their respective wrists, with 95° vertical field of
+view and the approximate extrinsics from the HIW-500 model builder.
+
+LeRobot's ZMQ cameras require explicit client configuration, just as the head
+camera does. Pass this dictionary as `UnitreeG1Config(..., cameras=cameras)`:
+
+```python
+from lerobot.cameras.zmq.configuration_zmq import ZMQCameraConfig
+
+cameras = {
+    name: ZMQCameraConfig(
+        server_address="127.0.0.1", port=5555, camera_name=name,
+        width=640, height=480, fps=30,
+    )
+    for name in ("head_camera", "left_wrist_cam", "right_wrist_cam")
+}
+```
+
+An equivalent camera configuration is in `lerobot_cameras.json`.
+`make_env(cameras=["head_camera"])` selects a subset; `publish_images=False`
+disables publishing. `onscreen=False` disables the viewer for headless use.
+
+## Gripper control
+
+Dex1 fingers use force actuators, driven by the existing bridge's external PD
+controller. They start open at 0.0245 m. To preserve the simulator's hand
+transport, the first two motor entries on `rt/dex3/left/cmd` and
+`rt/dex3/right/cmd` command fingers 1 and 2, with matching state topics.
+Gripper `q` is in metres and `tau` is in newtons; each finger is limited to 20 N.
+Command both fingers to the same position for symmetric opening or closing.
+Original hand mode retains all seven rotational command entries per side.
+
+The simulation lower limit is -0.023 m, following HIW-500's mesh closure trim.
+The source and portable URDF retain the official -0.020 m lower limit.
+Run `python build_gripper_model.py --official-limits` to use that limit in MJCF
+too; it leaves approximately 5.88 mm between the supplied finger pads.
+This model update does not add finger actions to LeRobot's 29-body-motor
+`UnitreeG1` action schema.
+
+## Development and checks
+
+Install the existing Unitree SDK2 / CycloneDDS prerequisites, then
+`python -m pip install -r requirements.txt`. Regenerate the variants with
+`python build_gripper_model.py`.
+
+```bash
+MUJOCO_GL=egl python -m unittest discover -s tests -v
+MUJOCO_GL=egl python tests/smoke_live.py
+MUJOCO_GL=egl python tests/smoke_live.py --end-effector hands
+```
+
+The regression suite uses real MuJoCo for both variants, motor/observation
+mapping, force-controlled closure, wrist camera motion, rendering and camera
+publisher shared-memory buffers. It isolates DDS with a test double.
+`smoke_live.py` additionally requires the real Gymnasium, Unitree SDK2 and
+ZMQ/OpenCV dependencies and checks the live environment and transports.
+See `VALIDATION.md` for what was executed for this update.
+
+## Sources
+
+- Base model/runtime: [lerobot/unitree-g1-mujoco](https://huggingface.co/lerobot/unitree-g1-mujoco/tree/a38dc8617f0fca51b38e9354dc58ee35ad850fb5).
+- Dex1-1 URDF, meshes and wrist-camera geometry: [Hxxxz0/HIW-500-controoler](https://github.com/Hxxxz0/HIW-500-controoler/tree/c69d89d88bb51774fe9a3684b90d9ff1abf801da).
+- LeRobot loading and camera configuration checked against [main at b6ec006](https://github.com/huggingface/lerobot/tree/b6ec0060779550c0a157ae34feb89e0cf86012a8).
+
+The original Dex1 source URDF, Apache 2.0 license and attribution notice are in
+`reference/hiw500/`. Unitree mesh assets retain their upstream terms.
--- a/config.yaml
+++ b/config.yaml
@@ -1,6 +1,7 @@
 # Robot Configuration
 ROBOT_TYPE: 'g1_29dof'
-ROBOT_SCENE: "assets/scene_43dof.xml"
+END_EFFECTOR: "grippers"  # "grippers" (Dex1-1) or "hands" (original Dex3)
+ROBOT_SCENE: "assets/scene_33dof.xml"  # Selected automatically from END_EFFECTOR
 
 # DDS Communication
 DOMAIN_ID: 0
@@ -25,6 +26,7 @@
 ENABLE_ONSCREEN: true
 ENABLE_OFFSCREEN: false
 MP_START_METHOD: "spawn"
+CAMERAS: ["head_camera", "left_wrist_cam", "right_wrist_cam"]
 
 # Sensors
 USE_SENSOR: False
@@ -33,16 +35,17 @@
 # Robot Dimensions
 NUM_MOTORS: 29
 NUM_JOINTS: 29
-NUM_HAND_MOTORS: 7
-NUM_HAND_JOINTS: 7
+NUM_HAND_MOTORS: 2
+NUM_HAND_JOINTS: 2
 
-# Torque Limits (Nm) - 29 body + 14 hand = 43 total
+# Selected automatically per mode: 29 body + 4 gripper = 33 total.
+# Revolute joint torque limits in Nm; prismatic finger force limits in N.
 motor_effort_limit_list: [
     88.0, 88.0, 88.0, 139.0, 50.0, 50.0,   # left leg
     88.0, 88.0, 88.0, 139.0, 50.0, 50.0,   # right leg
     88.0, 50.0, 50.0,                       # waist
     25.0, 25.0, 25.0, 25.0, 25.0, 5.0, 5.0, # left arm
-    2.45, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7,    # left hand
+    20.0, 20.0,                            # left gripper
     25.0, 25.0, 25.0, 25.0, 25.0, 5.0, 5.0, # right arm
-    2.45, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7     # right hand
+    20.0, 20.0                             # right gripper
 ]
--- a/env.py
+++ b/env.py
@@ -5,12 +5,18 @@
 import numpy as np
 from huggingface_hub import snapshot_download
 import yaml
-snapshot_download("lerobot/unitree-g1-mujoco")
+# LeRobot initially fetches only env.py. Hydrate its matching Hub snapshot;
+# a complete local checkout can also be used without another network request.
+_repo_dir = Path(__file__).parent
+if not (_repo_dir / "assets/scene_33dof.xml").is_file():
+    _revision = _repo_dir.name if _repo_dir.parent.name == "snapshots" else None
+    snapshot_download("lerobot/unitree-g1-mujoco", revision=_revision)
 
 # Ensure sim module is importable
 sys.path.insert(0, str(Path(__file__).parent))
 
 from sim.simulator_factory import SimulatorFactory, init_channel
+from sim.model_config import select_end_effector
 
 
 def make_env(n_envs=1, use_async_envs=False, **kwargs):
@@ -23,6 +29,8 @@
             - publish_images: bool, whether to publish camera images via ZMQ
             - camera_port: int, ZMQ port for camera images
             - cameras: list of camera names
+            - end_effector: "grippers" (default) or "hands"
+            - onscreen: bool, override the interactive viewer setting
     """
     repo_dir = Path(__file__).parent
     
@@ -30,11 +38,14 @@
     config_path = repo_dir / "config.yaml"
     with open(config_path) as f:
         config = yaml.safe_load(f)
+    config = select_end_effector(config, kwargs.get("end_effector"))
     
     # Configure cameras if requested
     publish_images = kwargs.get("publish_images", True)
     camera_port = kwargs.get("camera_port", 5555)
-    cameras = kwargs.get("cameras", ["head_camera"])
+    cameras = kwargs.get("cameras")
+    if cameras is None:
+        cameras = config["CAMERAS"]
     
     enable_offscreen = publish_images or config.get("ENABLE_OFFSCREEN", False)
     camera_configs = {}
@@ -49,7 +60,7 @@
     simulator = SimulatorFactory.create_simulator(
         config=config,
         env_name="default",
-        onscreen=config.get("ENABLE_ONSCREEN", True),
+        onscreen=kwargs.get("onscreen", config.get("ENABLE_ONSCREEN", True)),
         offscreen=enable_offscreen,
         camera_configs=camera_configs,
     )
@@ -65,6 +76,10 @@
             self.sim_env = sim.sim_env
             self.step_count = 0
             self.camera_configs = cam_configs
+            self.camera_names = tuple(cam_configs)
+            self.camera_port = cam_port
+            self.end_effector = config["END_EFFECTOR"]
+            self.metadata = {"render_modes": ["human"], "cameras": self.camera_names}
             
             # Get timing from config
             self.sim_dt = config["SIMULATE_DT"]
@@ -77,7 +92,7 @@
                     start_method=config.get("MP_START_METHOD", "spawn"),
                     camera_port=cam_port
                 )
-                print(f"Camera images publishing on tcp://localhost:{cam_port}")
+                print(f"Camera images publishing on tcp://localhost:{cam_port}: {', '.join(self.camera_names)}")
             
             # Define spaces
             num_joints = config.get("NUM_MOTORS", 29)
@@ -123,7 +138,7 @@
                 obs_dict.get("body_tau_est", np.zeros(29)),
                 obs_dict.get("floating_base_pose", np.zeros(7))[:4],
                 obs_dict.get("floating_base_vel", np.zeros(6))[:3],
-                obs_dict.get("floating_base_acc", np.zeros(3)),
+                obs_dict.get("floating_base_acc", np.zeros(3))[:3],
             ]).astype(np.float32)
             return obs
 
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,8 +1,13 @@
-mujoco>=3.0.0
+mujoco>=3.3.0
 numpy>=1.24.0
 pyyaml>=6.0
 unitree-sdk2py>=1.0.0
 loguru>=0.7.0
+gymnasium>=1.0.0
+huggingface_hub>=0.25.0
+pygame>=2.5.0
+termcolor>=2.0.0
+scipy>=1.10.0
 
 # Camera publishing dependencies
 opencv-python>=4.8.0
@@ -10,4 +15,3 @@
 msgpack>=1.0.0
 msgpack-numpy>=0.4.8
 matplotlib>=3.5.0  # For live camera viewer
-
--- a/run_sim.py
+++ b/run_sim.py
@@ -7,6 +7,7 @@
 
 import yaml
 from sim.simulator_factory import SimulatorFactory, init_channel
+from sim.model_config import select_end_effector
 
 def main(n_envs=1, use_async_envs: bool = False, 
              publish_images=True, camera_port=5555, cameras=None, **kwargs):
@@ -15,6 +16,7 @@
     config_path = Path(__file__).parent / "config.yaml"
     with open(config_path) as f:
         config = yaml.safe_load(f)
+    config = select_end_effector(config, kwargs.get("end_effector"))
     
     # Override config with default values
     enable_offscreen = publish_images or config.get("ENABLE_OFFSCREEN", False)
@@ -22,7 +24,7 @@
     # Configure cameras if requested
     camera_configs = {}
     if enable_offscreen:
-        camera_list = cameras or ["head_camera"]
+        camera_list = config["CAMERAS"] if cameras is None else cameras
         for cam_name in camera_list:
             camera_configs[cam_name] = {"height": 480, "width": 640}
         print(f"📷 Cameras: {', '.join(camera_list)} → ZMQ port {camera_port}")
@@ -36,7 +38,7 @@
     sim = SimulatorFactory.create_simulator(
         config=config,
         env_name="default",
-        onscreen=config.get("ENABLE_ONSCREEN", True),
+        onscreen=kwargs.get("onscreen", config.get("ENABLE_ONSCREEN", True)),
         offscreen=enable_offscreen,
         camera_configs=camera_configs,
     )
@@ -63,4 +65,3 @@
 
 if __name__ == "__main__":
     main()
-
--- a/sim/base_sim.py
+++ b/sim/base_sim.py
@@ -13,7 +13,7 @@
     HAS_RCLPY = True
 except ImportError:
     HAS_RCLPY = False
-    print("Warning: rclpy not found. Camera image publishing will be disabled.")
+    print("ROS 2 integration unavailable; camera images use the ZMQ publisher.")
 from unitree_sdk2py.core.channel import ChannelFactoryInitialize
 import yaml
 import os
@@ -21,6 +21,7 @@
 from .metric_utils import check_contact
 from .sim_utils import get_subtree_body_names
 from .unitree_sdk2py_bridge import ElasticBand, UnitreeSdk2Bridge
+from .model_config import select_end_effector
 
 GR00T_WBC_ROOT = Path(__file__).resolve().parent.parent  # Points to mujoco_sim_g1/
 
@@ -52,7 +53,6 @@
         self.num_hand_dof = self.config["NUM_HAND_JOINTS"]
         self.sim_dt = self.config["SIMULATE_DT"]
         self.obs = None
-        self.torques = np.zeros(self.num_body_dof + self.num_hand_dof * 2)
         self.torque_limit = np.array(self.config["motor_effort_limit_list"])
         self.camera_configs = camera_configs
         
@@ -131,7 +131,8 @@
             self.viewer.cam.distance = 2.0  # Distance from camera to target
             self.viewer.cam.lookat = np.array([0, 0, 0.5])  # Point the camera is looking at
 
-        # Note that the actuator order is the same as the joint order in the mujoco model.
+        # Body DDS indices exclude the end effectors. Map each scalar joint
+        # explicitly: actuator order need not match the model's joint order.
         self.body_joint_index = []
         self.left_hand_index = []
         self.right_hand_index = []
@@ -144,23 +145,41 @@
                 ]
             ):
                 self.body_joint_index.append(i)
-            elif "left_hand" in name:
+            elif "left_hand" in name or name.startswith("left_dex1_finger_joint_"):
                 self.left_hand_index.append(i)
-            elif "right_hand" in name:
+            elif "right_hand" in name or name.startswith("right_dex1_finger_joint_"):
                 self.right_hand_index.append(i)
 
         assert len(self.body_joint_index) == self.config["NUM_JOINTS"], \
             f"Expected {self.config['NUM_JOINTS']} body joints, got {len(self.body_joint_index)}"
-        # Hand joints are optional (some models don't have hands)
-        if self.config.get("NUM_HAND_JOINTS", 0) > 0:
-            expected_hands = self.config["NUM_HAND_JOINTS"]
-            if len(self.left_hand_index) != expected_hands or len(self.right_hand_index) != expected_hands:
-                print(f"Warning: Expected {expected_hands} hand joints, got left={len(self.left_hand_index)}, right={len(self.right_hand_index)}")
-                print("Continuing without hands...")
-
-        self.body_joint_index = np.array(self.body_joint_index)
-        self.left_hand_index = np.array(self.left_hand_index)
-        self.right_hand_index = np.array(self.right_hand_index)
+        expected_hands = self.config.get("NUM_HAND_JOINTS", 0)
+        if len(self.left_hand_index) != expected_hands or len(self.right_hand_index) != expected_hands:
+            raise ValueError(f"Expected {expected_hands} joints per end effector, got left={len(self.left_hand_index)}, right={len(self.right_hand_index)}")
+
+        for prefix, attribute in (("body", "body_joint_index"), ("left_hand", "left_hand_index"), ("right_hand", "right_hand_index")):
+            joint_ids = np.asarray(getattr(self, attribute), dtype=int)
+            setattr(self, attribute, joint_ids)
+            setattr(self, prefix + "_qpos_index", self.mj_model.jnt_qposadr[joint_ids])
+            setattr(self, prefix + "_dof_index", self.mj_model.jnt_dofadr[joint_ids])
+            actuator_ids = []
+            for joint_id in joint_ids:
+                matches = np.flatnonzero(
+                    (self.mj_model.actuator_trntype == mujoco.mjtTrn.mjTRN_JOINT)
+                    & (self.mj_model.actuator_trnid[:, 0] == joint_id)
+                )
+                if len(matches) != 1:
+                    raise ValueError(f"Expected one actuator for {self.mj_model.joint(joint_id).name}, got {len(matches)}")
+                actuator_ids.append(matches[0])
+            setattr(self, prefix + "_actuator_index", np.asarray(actuator_ids, dtype=int))
+        self.torques = np.zeros(self.mj_model.nu)
+        if self.config.get("FREE_BASE", False):
+            self.torque_limit = np.concatenate((np.zeros(6), self.torque_limit))
+        if self.torque_limit.shape != self.torques.shape:
+            raise ValueError("motor_effort_limit_list must match the scene's actuator count")
+        for name in self.camera_configs:
+            if mujoco.mj_name2id(self.mj_model, mujoco.mjtObj.mjOBJ_CAMERA, name) < 0:
+                raise ValueError(f"Camera {name!r} does not exist in {self.config['ROBOT_SCENE']}")
+        self.reset()
 
     def init_renderers(self):
         # Initialize camera renderers
@@ -212,12 +231,12 @@
                         + self.unitree_bridge.low_cmd.motor_cmd[i].kp
                         * (
                             self.unitree_bridge.low_cmd.motor_cmd[i].q
-                            - self.mj_data.qpos[self.body_joint_index[i] + 7 - 1]
+                            - self.mj_data.qpos[self.body_qpos_index[i]]
                         )
                         + self.unitree_bridge.low_cmd.motor_cmd[i].kd
                         * (
                             self.unitree_bridge.low_cmd.motor_cmd[i].dq
-                            - self.mj_data.qvel[self.body_joint_index[i] + 6 - 1]
+                            - self.mj_data.qvel[self.body_dof_index[i]]
                         )
                     )
         return body_torques
@@ -233,12 +252,12 @@
                     + self.unitree_bridge.left_hand_cmd.motor_cmd[i].kp
                     * (
                         self.unitree_bridge.left_hand_cmd.motor_cmd[i].q
-                        - self.mj_data.qpos[self.left_hand_index[i] + 7 - 1]
+                        - self.mj_data.qpos[self.left_hand_qpos_index[i]]
                     )
                     + self.unitree_bridge.left_hand_cmd.motor_cmd[i].kd
                     * (
                         self.unitree_bridge.left_hand_cmd.motor_cmd[i].dq
-                        - self.mj_data.qvel[self.left_hand_index[i] + 6 - 1]
+                        - self.mj_data.qvel[self.left_hand_dof_index[i]]
                     )
                 )
                 right_hand_torques[i] = (
@@ -246,12 +265,12 @@
                     + self.unitree_bridge.right_hand_cmd.motor_cmd[i].kp
                     * (
                         self.unitree_bridge.right_hand_cmd.motor_cmd[i].q
-                        - self.mj_data.qpos[self.right_hand_index[i] + 7 - 1]
+                        - self.mj_data.qpos[self.right_hand_qpos_index[i]]
                     )
                     + self.unitree_bridge.right_hand_cmd.motor_cmd[i].kd
                     * (
                         self.unitree_bridge.right_hand_cmd.motor_cmd[i].dq
-                        - self.mj_data.qvel[self.right_hand_index[i] + 6 - 1]
+                        - self.mj_data.qvel[self.right_hand_dof_index[i]]
                     )
                 )
         return np.concatenate((left_hand_torques, right_hand_torques))
@@ -281,19 +300,19 @@
         obs["floating_base_acc"] = self.mj_data.qacc[:6]
         obs["secondary_imu_quat"] = self.mj_data.xquat[self.torso_index]
         obs["secondary_imu_vel"] = self.mj_data.cvel[self.torso_index]
-        obs["body_q"] = self.mj_data.qpos[self.body_joint_index + 7 - 1]
-        obs["body_dq"] = self.mj_data.qvel[self.body_joint_index + 6 - 1]
-        obs["body_ddq"] = self.mj_data.qacc[self.body_joint_index + 6 - 1]
-        obs["body_tau_est"] = self.mj_data.actuator_force[self.body_joint_index - 1]
+        obs["body_q"] = self.mj_data.qpos[self.body_qpos_index]
+        obs["body_dq"] = self.mj_data.qvel[self.body_dof_index]
+        obs["body_ddq"] = self.mj_data.qacc[self.body_dof_index]
+        obs["body_tau_est"] = self.mj_data.actuator_force[self.body_actuator_index]
         if self.num_hand_dof > 0:
-            obs["left_hand_q"] = self.mj_data.qpos[self.left_hand_index + 7 - 1]
-            obs["left_hand_dq"] = self.mj_data.qvel[self.left_hand_index + 6 - 1]
-            obs["left_hand_ddq"] = self.mj_data.qacc[self.left_hand_index + 6 - 1]
-            obs["left_hand_tau_est"] = self.mj_data.actuator_force[self.left_hand_index - 1]
-            obs["right_hand_q"] = self.mj_data.qpos[self.right_hand_index + 7 - 1]
-            obs["right_hand_dq"] = self.mj_data.qvel[self.right_hand_index + 6 - 1]
-            obs["right_hand_ddq"] = self.mj_data.qacc[self.right_hand_index + 6 - 1]
-            obs["right_hand_tau_est"] = self.mj_data.actuator_force[self.right_hand_index - 1]
+            obs["left_hand_q"] = self.mj_data.qpos[self.left_hand_qpos_index]
+            obs["left_hand_dq"] = self.mj_data.qvel[self.left_hand_dof_index]
+            obs["left_hand_ddq"] = self.mj_data.qacc[self.left_hand_dof_index]
+            obs["left_hand_tau_est"] = self.mj_data.actuator_force[self.left_hand_actuator_index]
+            obs["right_hand_q"] = self.mj_data.qpos[self.right_hand_qpos_index]
+            obs["right_hand_dq"] = self.mj_data.qvel[self.right_hand_dof_index]
+            obs["right_hand_ddq"] = self.mj_data.qacc[self.right_hand_dof_index]
+            obs["right_hand_tau_est"] = self.mj_data.actuator_force[self.right_hand_actuator_index]
         obs["time"] = self.mj_data.time
         return obs
 
@@ -333,17 +352,14 @@
                 self.mj_data.xfrc_applied[self.band_attached_link] = np.zeros(6)
         body_torques = self.compute_body_torques()
         hand_torques = self.compute_hand_torques()
-        self.torques[self.body_joint_index - 1] = body_torques
+        self.torques[self.body_actuator_index] = body_torques
         if self.num_hand_dof > 0:
-            self.torques[self.left_hand_index - 1] = hand_torques[: self.num_hand_dof]
-            self.torques[self.right_hand_index - 1] = hand_torques[self.num_hand_dof :]
+            self.torques[self.left_hand_actuator_index] = hand_torques[: self.num_hand_dof]
+            self.torques[self.right_hand_actuator_index] = hand_torques[self.num_hand_dof :]
 
         self.torques = np.clip(self.torques, -self.torque_limit, self.torque_limit)
 
-        if self.config["FREE_BASE"]:
-            self.mj_data.ctrl = np.concatenate((np.zeros(6), self.torques))
-        else:
-            self.mj_data.ctrl = self.torques
+        self.mj_data.ctrl[:] = self.torques
         mujoco.mj_step(self.mj_model, self.mj_data)
         # self.check_self_collision()
 
@@ -391,9 +407,9 @@
         body_qpos = self.compute_body_qpos()  # (num_body_dof,)
         hand_qpos = self.compute_hand_qpos()  # (num_hand_dof * 2,)
 
-        self.mj_data.qpos[self.body_joint_index + 7 - 1] = body_qpos
-        self.mj_data.qpos[self.left_hand_index + 7 - 1] = hand_qpos[: self.num_hand_dof]
-        self.mj_data.qpos[self.right_hand_index + 7 - 1] = hand_qpos[self.num_hand_dof :]
+        self.mj_data.qpos[self.body_qpos_index] = body_qpos
+        self.mj_data.qpos[self.left_hand_qpos_index] = hand_qpos[: self.num_hand_dof]
+        self.mj_data.qpos[self.right_hand_qpos_index] = hand_qpos[self.num_hand_dof :]
 
         mujoco.mj_kinematics(self.mj_model, self.mj_data)
         mujoco.mj_comPos(self.mj_model, self.mj_data)
@@ -507,6 +523,11 @@
         # Set valid floating base quaternion (identity: w=1, x=y=z=0)
         # mj_resetData sets qpos to zeros, which gives invalid [0,0,0,0] quaternion
         self.mj_data.qpos[3:7] = [1.0, 0.0, 0.0, 0.0]
+        if self.config.get("END_EFFECTOR") == "grippers":
+            for side in ("left", "right"):
+                joints = getattr(self, side + "_hand_index")
+                addresses = getattr(self, side + "_hand_qpos_index")
+                self.mj_data.qpos[addresses] = self.mj_model.jnt_range[joints, 1]
         # Propagate qpos to derived quantities (xquat, xpos, etc.)
         mujoco.mj_forward(self.mj_model, self.mj_data)
 
@@ -515,6 +536,7 @@
     """Base simulator class that handles initialization and running of simulations"""
 
     def __init__(self, config: Dict[str, any], env_name: str = "default", **kwargs):
+        config = select_end_effector(config)
         self.config = config
         self.env_name = env_name
 
@@ -636,6 +658,7 @@
 
     def reset(self):
         """Reset the simulation. Can be overridden by subclasses."""
+        self.unitree_bridge.reset()
         self.sim_env.reset()
 
     def close(self):
@@ -650,6 +673,11 @@
             if hasattr(self.sim_env, "viewer") and self.sim_env.viewer is not None:
                 self.sim_env.viewer.close()
 
+            for renderer in self.sim_env.renderers.values():
+                renderer.close()
+            self.sim_env.renderers.clear()
+            self.sim_env._renderers_initialized = False
+
             # Shutdown ROS (if available)
             if HAS_RCLPY and rclpy.ok():
                 rclpy.shutdown()
--- a/sim/unitree_sdk2py_bridge.py
+++ b/sim/unitree_sdk2py_bridge.py
@@ -127,9 +127,22 @@
         with self.left_hand_cmd_lock:
             self.left_hand_cmd_received = False
             self.new_left_hand_cmd = False
+            if self.config.get("END_EFFECTOR") == "grippers":
+                self._open_gripper(self.left_hand_cmd)
         with self.right_hand_cmd_lock:
             self.right_hand_cmd_received = False
             self.new_right_hand_cmd = False
+            if self.config.get("END_EFFECTOR") == "grippers":
+                self._open_gripper(self.right_hand_cmd)
+
+    def _open_gripper(self, command):
+        """Hold Dex1 fingers open until a hand command arrives (positions in m)."""
+        for motor in command.motor_cmd[:self.num_hand_motor]:
+            motor.q = 0.0245
+            motor.dq = 0.0
+            motor.kp = 1000.0
+            motor.kd = 25.0
+            motor.tau = 0.0
 
     def LowCmdHandler(self, msg):
         with self.low_cmd_lock: