diff --git a/robot_assets/workflow/urdf_to_xacro.py b/robot_assets/workflow/urdf_to_xacro.py
index 48411a9..432d471 100644
--- a/robot_assets/workflow/urdf_to_xacro.py
+++ b/robot_assets/workflow/urdf_to_xacro.py
@@ -210,17 +210,23 @@ def _group_macro(robot: str, group: str, joints: list[dict], limits: dict) -> st
def _combined_macro(robot: str, groups: list[dict], backends: dict, imu: dict | None) -> str:
- """Return the single-block macro shared by the mock and MuJoCo backends."""
- calls = "\n".join(f' ' for g in groups)
- # Only MuJoCo backs the IMU state interfaces; mock_components has no source for them.
- sensor = (
- f'\n \n'
- f'{_sensor_element(imu["name"], " ")}\n '
- if imu else ""
- )
- return f"""
-{_macro_open(f"{robot}_ros2_control_combined", ["name", SIM_ARG])}
-
+ """Return the mock and MuJoCo macro: one block per bus, named as the real one."""
+ blocks = []
+ for group in groups:
+ # A gpio only under mock. The real backend exports these two through
+ # export_unlisted_state_interface_descriptions(), and MujocoSystem
+ # overrides the deprecated by-value export_state_interfaces(), which
+ # bypasses the base class's gpio handling -- declaring them there would
+ # promise interfaces that never appear.
+ safety = (
+ f'\n \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' '
+ )
+ blocks.append(f"""
{backends["sim"]}
@@ -229,8 +235,35 @@ def _combined_macro(robot: str, groups: list[dict], backends: dict, imu: dict |
{backends["mock"]}
-{calls}{sensor}
-
+ {safety}
+ """)
+ if imu and imu.get("real_block_name"):
+ # The real backend gives the IMU its own sensor component, so sim matches
+ # the name. Only MuJoCo backs the state interfaces; mock has no source.
+ blocks.append(
+ f' \n'
+ f' \n'
+ f' \n'
+ f' {backends["sim"]}\n'
+ f' \n'
+ f'{_sensor_element(imu["name"], " ")}\n'
+ f' \n'
+ f' ')
+ elif imu:
+ # A sim-only IMU has no real component to match, so it stays nested in the
+ # first bus block rather than inventing a component name of its own.
+ nested = (
+ f'\n \n'
+ f'{_sensor_element(imu["name"], " ")}\n'
+ f' ')
+ blocks[0] = blocks[0].replace("\n ", f"{nested}\n ")
+ body = "\n".join(blocks)
+ return f"""
+{_macro_open(f"{robot}_ros2_control_combined", [SIM_ARG])}
+{body}
"""
@@ -274,9 +307,9 @@ def _top_macro(robot: str, args: list[str], real_params: list[str]) -> str:
return f"""
-{_macro_open(f"{robot}_ros2_control", ["name", *args])}
+{_macro_open(f"{robot}_ros2_control", args)}
-
+
"""
diff --git a/robots/lite_bimanual/cad/ros2_control.json b/robots/lite_bimanual/cad/ros2_control.json
index 822f308..815d75c 100644
--- a/robots/lite_bimanual/cad/ros2_control.json
+++ b/robots/lite_bimanual/cad/ros2_control.json
@@ -22,8 +22,6 @@
"base_link": {"name": "base_link", "child": "chest"},
- "combined_block_name": "LiteHardware",
-
"groups": [
{"name": "left_arm", "block_name": "LiteLeftArm", "can_interface_arg": "can_interface_left"},
{"name": "right_arm", "block_name": "LiteRightArm", "can_interface_arg": "can_interface_right"}
diff --git a/robots/lite_bimanual/xacro/lite_bimanual.ros2_control.xacro b/robots/lite_bimanual/xacro/lite_bimanual.ros2_control.xacro
index b78dda9..209b06a 100644
--- a/robots/lite_bimanual/xacro/lite_bimanual.ros2_control.xacro
+++ b/robots/lite_bimanual/xacro/lite_bimanual.ros2_control.xacro
@@ -75,9 +75,12 @@
current_limit="14"/>
-
-
-
+
+
+
mujoco_ros2_control/MujocoSystem
@@ -87,7 +90,29 @@
+
+
+
+
+
+
+
+
+
+
+ mujoco_ros2_control/MujocoSystem
+
+
+ mock_components/GenericSystem
+
+
+
+
+
+
+
+
@@ -117,10 +142,10 @@
boolean per non-real backend, real hardware as the fallback when both are false.
sim_mujoco wins over use_mock_hardware. -->
-
+
lives in the combined block, backed by MujocoSystem's MJCF sensors. Real: a standalone block (real_block_name) driven by real_plugin (Yahboom WIT USB IMU on imu_port), exposing the SAME interfaces so imu_sensor_broadcaster republishes /imu/data identically -- no separate driver node.",
"imu": {"name": "base_imu", "real_plugin": "humanoid_devices_witmotion/WitImuSensor", "real_block_name": "LiteBipedIMU"},
diff --git a/robots/lite_biped/xacro/lite_biped.ros2_control.xacro b/robots/lite_biped/xacro/lite_biped.ros2_control.xacro
index f2c603e..e693a32 100644
--- a/robots/lite_biped/xacro/lite_biped.ros2_control.xacro
+++ b/robots/lite_biped/xacro/lite_biped.ros2_control.xacro
@@ -121,9 +121,12 @@
current_limit="14"/>
-
-
-
+
+
+
mujoco_ros2_control/MujocoSystem
@@ -133,8 +136,35 @@
+
+
+
+
+
+
+
+
+
+
+ mujoco_ros2_control/MujocoSystem
+
+
+ mock_components/GenericSystem
+
+
-
+
+
+
+
+
+
+
+
+
+
+ mujoco_ros2_control/MujocoSystem
+
@@ -147,8 +177,8 @@
-
-
+
+
-
+
lives in the combined block, backed by MujocoSystem's MJCF sensors. Real: a standalone block (real_block_name) driven by real_plugin (Yahboom WIT USB IMU on imu_port), exposing the SAME interfaces so imu_sensor_broadcaster republishes /imu/data identically -- no separate driver node.",
"imu": {"name": "base_imu", "real_plugin": "humanoid_devices_witmotion/WitImuSensor", "real_block_name": "LiteBipedIMU"},
diff --git a/robots/lite_biped_debug/xacro/lite_biped_debug.ros2_control.xacro b/robots/lite_biped_debug/xacro/lite_biped_debug.ros2_control.xacro
index d172c32..e49ecaf 100644
--- a/robots/lite_biped_debug/xacro/lite_biped_debug.ros2_control.xacro
+++ b/robots/lite_biped_debug/xacro/lite_biped_debug.ros2_control.xacro
@@ -121,9 +121,12 @@
torque_limit="5.5" current_limit="14"/>
-
-
-
+
+
+
mujoco_ros2_control/MujocoSystem
@@ -133,8 +136,35 @@
+
+
+
+
+
+
+
+
+
+
+ mujoco_ros2_control/MujocoSystem
+
+
+ mock_components/GenericSystem
+
+
-
+
+
+
+
+
+
+
+
+
+
+ mujoco_ros2_control/MujocoSystem
+
@@ -147,8 +177,8 @@
-
-
+
+
-
+
-
-
-
+
+
+
mujoco_ros2_control/MujocoSystem
@@ -87,7 +90,29 @@
+
+
+
+
+
+
+
+
+
+
+ mujoco_ros2_control/MujocoSystem
+
+
+ mock_components/GenericSystem
+
+
+
+
+
+
+
+
@@ -117,10 +142,10 @@
boolean per non-real backend, real hardware as the fallback when both are false.
sim_mujoco wins over use_mock_hardware. -->
-
+
.
+
+ The real drivers export them from
+ export_unlisted_state_interface_descriptions(), which mock_components has no
+ equivalent for, so without the gpio a safety monitor cannot be exercised off
+ the robot. MuJoCo is excluded on purpose: our MujocoSystem overrides the
+ deprecated by-value export_state_interfaces(), which bypasses the base
+ class's gpio handling, so declaring them there would promise interfaces that
+ never appear.
+ """
+ robot = robot_dir.name
+ out = subprocess.run(
+ ["xacro", str(robot_dir / "xacro" / f"{robot}.urdf.xacro")],
+ capture_output=True, text=True)
+ assert out.returncode == 0, out.stderr
+ root = ET.fromstring(out.stdout)
+ for block in root.iter("ros2_control"):
+ if block.get("type") != "system":
+ continue
+ gpios = {g.get("name"): {si.get("name") for si in g.iter("state_interface")}
+ for g in block.iter("gpio")}
+ assert block.get("name") in gpios, (
+ f'{robot}: mock component {block.get("name")} declares no safety gpio')
+ assert {"safety_level", "safety_flags"} <= gpios[block.get("name")], (
+ f'{robot}: {block.get("name")} gpio is missing a safety interface')
+