2. 시뮬레이션
2.2커스텀 msg, srv 만들기
1.3장에서 토픽으로 std_msgs/String을 주고받았고, 1.4장에서 AddTwoInts 서비스를 썼습니다. 모두 ROS2가 미리 만들어 둔 타입입니다. 로봇을 만들다 보면 이런 질문이 생깁니다. 모터 이름과 온도와 상태를 한 번에 보내고 싶은데, 맞는 타입이 없습니다.
먼저 있는 타입을 찾는다
타입을 새로 만들기 전에 이미 있는 것을 확인합니다. 속도는 geometry_msgs/Twist, 거리 센서는 sensor_msgs/Range, 레이저 스캔은 sensor_msgs/LaserScan처럼 로봇에 흔한 데이터는 대부분 표준 타입이 있습니다. 표준 타입을 쓰면 RViz와 rqt, Nav2 같은 기존 도구가 그 메시지를 바로 이해합니다.
없을 때 std_msgs/String에 "motor_left,31.5" 같은 문자열을 넣는 방법이 있지만 권하지 않습니다. 받는 쪽이 문자열을 직접 쪼개야 하고, 형식이 바뀌어도 컴파일러나 도구가 알려 주지 않습니다. 필드마다 이름과 타입을 가진 메시지를 정의하면 이 문제가 사라집니다.
.msg 파일
메시지 타입은 .msg 텍스트 파일 하나로 정의합니다. 한 줄에 필드 하나를 타입 이름 순서로 적습니다.
# my_interfaces/msg/SensorReading.msg
# 모터 온도 센서의 측정값 한 번
string name
float32 temperature
uint8 OK=0
uint8 HOT=1
uint8 status 0
#뒤는 주석입니다.uint8 OK=0처럼 이름이 대문자이고=가 있는 줄은 상수입니다. 필드가 아니라 타입에 붙는 고정값입니다.uint8 status 0처럼 이름 뒤에 값을 적으면 그 필드의 기본값입니다.- 파일 이름은 대문자로 시작하는 CamelCase(
SensorReading)입니다. 필드 이름은 소문자와 숫자, 밑줄만 씁니다(temperature,left_speed).
쓸 수 있는 타입
| 종류 | 타입 | Python에서 |
|---|---|---|
| 참거짓 | bool | bool |
| 정수 | int8, int16, int32, int64, uint8, uint16, uint32, uint64 | int (범위를 넘으면 오류) |
| 실수 | float32, float64 | float |
| 문자열 | string | str |
| 배열 | float32[](길이 제한 없음), float32[3](고정), float32[<=10](최대 10) | 리스트 |
| 다른 메시지 | geometry_msgs/Point position, std_msgs/Header header | 그 메시지의 객체 |
다른 메시지를 필드로 넣을 때는 패키지/타입 형식으로 씁니다. 같은 패키지 안의 메시지는 패키지 이름을 생략할 수 있습니다. 시간표(스탬프)가 필요하면 std_msgs/Header header를 첫 필드로 둡니다. 센서 메시지들이 이 관례를 따릅니다.
.srv 파일
서비스는 요청과 응답을 --- 줄로 나눠 한 파일에 적습니다. 1.4장에서 본 AddTwoInts.srv와 같은 구조입니다.
# my_interfaces/srv/SetThreshold.srv
# 과열로 볼 온도를 바꿔 달라는 요청
float32 limit
---
bool accepted
string message
요청에는 바꿀 값을, 응답에는 성공 여부와 이유를 담았습니다. 응답에 bool success와 string message 한 쌍을 두는 방식은 서비스에서 흔한 관례입니다. 호출한 쪽이 실패했을 때 사유를 읽을 수 있습니다.
직접 써 보기
아래 실행기에는 위의 두 파일이 정의 파일로 들어 있습니다(편집기 위쪽 읽기 전용 상자). 코드에서는 from my_interfaces.msg import SensorReading처럼 불러 씁니다. 에뮬레이터는 .msg의 정의를 읽어 같은 모양의 Python 클래스를 만듭니다. 실제 ROS2에서는 빌드할 때 rosidl이 이 클래스를 생성합니다.
먼저 클래스가 어떻게 생겼는지 만져 봅니다.
정의한 메시지의 기본값과 타입 검사
인터페이스 정의 파일 (2개, 읽기 전용)
msg/SensorReading.msg
# 모터 온도 센서의 측정값 한 번 string name float32 temperature uint8 OK=0 uint8 HOT=1 uint8 status 0
srv/SetThreshold.srv
# 과열로 볼 온도를 바꿔 달라는 요청 float32 limit --- bool accepted string message
직접 바꿔 보기
- 처음 출력에서
status가 0입니다..msg의uint8 status 0이 기본값이었기 때문입니다. 정의 파일을 에뮬레이터에서 고칠 수는 없지만, 실제로는 이 숫자를 바꾸고 다시 빌드하면 기본값이 바뀝니다. temperature에 정수31을 넣으면AssertionError가 납니다.float32필드는 Pythonfloat만 받습니다.31.0으로 쓰세요. 실제 ROS2의 Python 메시지도 같은 검사를 합니다.status에 300을 넣어도 오류입니다.uint8의 범위는 0~255입니다.SensorReading.HOT처럼 상수는 객체가 아니라 클래스에서 읽습니다.
이제 센서, 모니터, 관리자 세 노드가 이 타입으로 통신합니다. 센서가 SensorReading을 토픽으로 보내고, 모니터가 기준 온도(처음 30도)와 비교해 status를 정합니다. 관리자는 4.5초에 서비스 SetThreshold로 기준을 40도로 올려 달라고 요청합니다.
커스텀 메시지와 서비스로 통신하기
인터페이스 정의 파일 (2개, 읽기 전용)
msg/SensorReading.msg
# 모터 온도 센서의 측정값 한 번 string name float32 temperature uint8 OK=0 uint8 HOT=1 uint8 status 0
srv/SetThreshold.srv
# 과열로 볼 온도를 바꿔 달라는 요청 float32 limit --- bool accepted string message
실행하면 온도가 1초에 2.5도씩 오르다가 4초에 30.0도가 되어 과열 (status=1)이 찍힙니다. 4.5초에 관리자의 요청이 받아들여져 기준이 40도로 바뀌고, 5초부터는 다시 정상입니다. 8초에 40.0도가 되면 새 기준에서 과열이 됩니다. 한 번 바뀐 기준이 이후 메시지 판정에 반영되는 것을 보세요.
직접 바꿔 보기
req.limit = 40.0을 10.0으로 바꾸면 서비스 서버가accepted=False와 사유를 돌려줍니다. 응답에 사유를 넣어 두면 이럴 때 원인을 바로 읽을 수 있습니다.msg.temperature = self.temp를msg.temperature = int(self.temp)로 바꿔 보세요. 노드가 멈추고AssertionError가 나옵니다. 타입이 틀린 값은 발행하기 전에 걸러집니다.SensorReading()줄 아래에msg.speed = 1.0을 넣으면 없는 필드라 오류가 납니다. 이름을 잘못 쓴 실수도 일찍 드러납니다.
실제로는 어떻게 만드나
브라우저에서는 정의 파일을 실행기에 넘기면 끝이지만, 실제 ROS2에서는 인터페이스를 패키지로 만들고 빌드해야 합니다. 3.6장과 3.7장에서 패키지와 워크스페이스를 다룹니다. 이 절은 그때 따라 할 수 있도록 순서를 미리 보여 줍니다.
인터페이스는 별도 패키지에 둔다
.msg와 .srv는 Python만으로 이루어진 패키지(ament_python)에 넣을 수 없습니다. 인터페이스 코드를 생성하려면 ament_cmake 패키지가 필요합니다. 그래서 인터페이스만 모은 패키지를 따로 만듭니다.
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_cmake my_interfaces
mkdir -p my_interfaces/msg my_interfaces/srv
그다음 msg/SensorReading.msg와 srv/SetThreshold.srv를 위 내용으로 만듭니다. 폴더 구조는 이렇습니다.
my_interfaces/
├─ CMakeLists.txt
├─ package.xml
├─ msg/
│ └─ SensorReading.msg
└─ srv/
└─ SetThreshold.srv
CMakeLists.txt와 package.xml
CMakeLists.txt에는 두 가지를 추가합니다. 인터페이스 생성 도구를 찾고, 어떤 파일에서 코드를 만들지 알려 줍니다.
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/SensorReading.msg"
"srv/SetThreshold.srv"
)
ament_package() 줄 앞에 find_package와 rosidl_generate_interfaces를 둡니다. 새 파일을 만들 때마다 목록에 줄을 더해야 합니다. 빠뜨리면 그 파일은 빌드되지 않습니다.
package.xml에는 생성 도구와 실행 시 라이브러리, 그리고 이 패키지가 인터페이스 패키지라는 표시를 넣습니다.
<buildtool_depend>rosidl_default_generators</buildtool_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
빌드하고 확인한다
cd ~/ros2_ws
colcon build --packages-select my_interfaces
source install/setup.bash
ros2 interface show my_interfaces/msg/SensorReading
ros2 interface show가 파일 내용을 그대로 보여 주면 성공입니다. 목록에서 찾으려면 ros2 interface list | grep my_interfaces를 씁니다. 터미널을 새로 열었다면 source install/setup.bash를 다시 해야 보입니다.
노드 패키지에서 쓰기
이 타입을 쓰는 노드 패키지의 package.xml에 의존성을 적습니다.
<depend>my_interfaces</depend>
Python 코드는 에뮬레이터와 같습니다. from my_interfaces.msg import SensorReading. 인터페이스 패키지를 고치고 다시 빌드한 뒤에는 이 타입을 쓰는 패키지도 다시 빌드하는 편이 안전합니다.
막혔을 때
| 증상 | 확인할 것 |
|---|---|
ModuleNotFoundError: No module named 'my_interfaces' | 인터페이스 패키지를 빌드했는지, 이 터미널에서 source install/setup.bash를 했는지 |
ros2 interface show에 타입이 안 나온다 | rosidl_generate_interfaces의 파일 목록에 줄을 넣었는지, 오타가 없는지 |
| 빌드 오류: 파일 이름이 올바르지 않다 | 파일 이름이 대문자로 시작하는 CamelCase인지, 필드 이름이 소문자인지 |
AssertionError: ... must be of type 'float' | 정수 3이 아니라 3.0을 넣었는지 |
| 다른 패키지에서 타입을 못 찾는다 | 그 패키지의 package.xml에 <depend>my_interfaces</depend>를 적었는지 |
한눈에 정리
- 타입을 만들기 전에 표준 타입이 있는지 먼저 찾는다.
.msg는 한 줄에타입 이름, 기본값은 이름 뒤에, 상수는 대문자 이름에=로 적는다..srv는---로 요청과 응답을 나눈다. 응답에는 성공 여부와 사유를 넣어 두는 것이 관례다.- 인터페이스는
ament_cmake패키지에 두고rosidl_generate_interfaces에 파일을 등록해 빌드한다. - 쓰는 쪽은
package.xml에 의존성을 적고from 패키지.msg import 타입으로 불러 쓴다.
확인 문제
Q1. 새 `.msg` 파일을 만들고 빌드했는데 `ros2 interface show`에 나오지 않습니다. 가장 먼저 확인할 곳은 어디인가요?
CMakeLists.txt의 rosidl_generate_interfaces에 그 파일을 적었는지 봅니다. 목록에 없는 파일은 코드로 생성되지 않습니다. 그다음으로 colcon build를 다시 했는지, 이 터미널에서 source install/setup.bash를 했는지 확인합니다.
Q2. `float32 temperature` 필드에 `msg.temperature = 25`를 넣었더니 오류가 납니다. 이유와 고치는 방법은?
float32 필드는 Python float만 받는데 정수 25를 넣었기 때문입니다. 25.0으로 쓰거나 float(25)로 바꿉니다.
Q3. 서비스 응답에 `bool accepted`만 두지 않고 `string message`도 함께 두는 이유는 무엇인가요?
거절됐을 때 이유를 호출한 쪽에 알릴 수 있기 때문입니다. False만 받으면 값이 범위를 벗어났는지, 서버가 바쁜지 알 수 없어 원인을 찾으려고 서버 로그를 따로 봐야 합니다.
다음 장
2.3장에서는 같은 방식으로 .action 파일을 정의하고, 오래 걸리는 일을 맡는 액션 서버와 클라이언트를 Python으로 작성합니다.