4. 실기기 연동
4.5시뮬 → 실기기 전환
2.7장과 2.8장에서 에뮬레이터로 짠 노드는 /cmd_vel로 Twist를 내보내고 /odom과 /scan을 받습니다. 실기기의 토픽 이름과 타입도 같습니다. 그래서 코드는 거의 그대로 돌아갑니다. 이 장은 "거의"에 해당하는 부분, 즉 바꿔야 하는 몇 줄과 그 이유를 다룹니다.
그대로인 것과 달라지는 것
| 항목 | 에뮬레이터 | 실기기 | 코드 수정 |
|---|---|---|---|
| 명령 토픽 | /cmd_vel (Twist) | 같음. 컨트롤러가 리매핑으로 /cmd_vel을 받음 | 없음 |
| 거리 센서 | /scan (LaserScan) | 같음. 프레임은 laser | 구독 QoS 명시, 각도 기준 확인 |
| 주행 기록 | /odom (Odometry) | 같음. 프레임은 odom → base_footprint | 시작 자세를 기준으로 계산 |
| 시간 | 가상 시간, 빠르게 흐름 | 실제 시간 | 타이머 주기 재점검 |
| 속도 | 0.2 m/s, 0.85 rad/s로 잘림 | 같은 값으로 컨트롤러가 자름 | 코드에서도 먼저 자름 |
| 입력이 끊길 때 | 센서는 끊기지 않음 | 센서 노드가 죽을 수 있고, 그러면 마지막 값만 남음 | 스캔 신선도 검사 |
| 종료 | 실행이 끝나면 시뮬레이션도 끝남 | 노드가 죽어도 최대 0.5초는 움직임 | 종료 핸들러 추가 |
| 실행 위치 | 브라우저 | VM 또는 로봇 | ROS_DOMAIN_ID 확인 |
속도 한계는 tiki_control의 tiki_controllers.yaml에서 읽습니다. linear.x는 ±0.2 m/s, angular.z는 ±0.85 rad/s이고 가속도 제한은 꺼져 있습니다. 컨트롤러가 이 값으로 자르기 때문에 0.5 m/s를 보내도 에러 없이 0.2 m/s로 달립니다. 이대로면 노드가 의도한 궤적과 실제 궤적이 어긋나므로 코드에서 먼저 같은 값으로 자릅니다.
어디에서 실행할까
노드는 VM과 로봇 어느 쪽에서도 돌릴 수 있습니다.
- VM에서 실행(권장). 코드를 고치고 다시 돌리기 쉽고, 2.7장과 2.8장에서 쓰던 편집 환경 그대로입니다. 토픽이 네트워크를 지나므로 4.2장의 연결과 같은
ROS_DOMAIN_ID가 필요합니다. - 로봇에서 실행. 네트워크 지연이 사라집니다. 대신 로봇에 코드를 옮기고 터미널을 열어야 하고, 로봇의 Python 환경은 4.3장 기준입니다.
VM에서 실행하는 경우 로봇의 /scan 데이터가 무선망을 건너옵니다. 메시지 하나에 한 바퀴분 값이 360개 이상 들어 있고 이것이 초당 수 회 오가므로(소스의 scan_frequency 기본값은 10, 실제 주기는 ros2 topic hz /scan으로 잽니다), 지연이나 손실이 생기면 반응이 늦어집니다. 아래 QoS 이야기도 이 상황과 이어집니다.
/scan의 QoS를 확인한다
1.8장에서 센서 토픽은 보통 Best effort로 발행하고 기본 설정(Reliable)으로 구독하면 연결되지 않는다고 배웠습니다. 그래서 TIKI-MINI의 /scan도 Best effort일 거라고 짐작하기 쉽습니다. 소스를 확인해 보면 다릅니다.
rplidar_node는 Slamtec의 rplidar_ros 패키지(ros2 브랜치)에 들어 있고, tiki_bringup의 rplidar.launch.py가 이 노드를 실행합니다. 스캔 퍼블리셔는 rplidar_node.cpp에서 이렇게 만들어집니다.
scan_pub = this->create_publisher<sensor_msgs::msg::LaserScan>(
topic_name, rclcpp::QoS(rclcpp::KeepLast(10)));
SensorDataQoS가 아니라 깊이 10의 기본 QoS입니다. 신뢰성 정책을 따로 정하지 않았으므로 Reliable입니다. 2026년 10월에 ros2 브랜치 최신 소스와 2.0.3, 2.1.0, 2.1.4 태그를 열어 본 결과 모두 같은 방식이었습니다(2.0.x와 2.1.0은 create_publisher<LaserScan>(topic_name_, 10)). 실제 값은 로봇에서 ros2 topic info /scan --verbose로 볼 수 있습니다.
ros2 topic info /scan --verbose
출력의 Publisher 쪽 Reliability가 RELIABLE이면 위 소스와 일치합니다. 이 값이 BEST_EFFORT로 나온다면 로봇의 드라이버가 다른 버전이라는 뜻이므로 아래 표를 보고 구독 QoS를 판단하세요.
| 퍼블리셔 | 구독 (코드) | 결과 |
|---|---|---|
| Reliable (소스로 확인한 값) | 기본(Reliable) | 연결됨 |
| Reliable | qos_profile_sensor_data (Best effort) | 연결됨, Best effort로 수신 |
| Best effort | 기본(Reliable) | 연결되지 않음. 에러 없이 데이터가 안 옴 |
| Best effort | qos_profile_sensor_data | 연결됨 |
그래도 코드에는 qos_profile_sensor_data를 씁니다. 어느 쪽 퍼블리셔와도 연결되기 때문입니다. Reliable 구독은 무선망에서 손실된 메시지를 재전송하느라 최신 값이 늦어질 수 있습니다. 장애물 회피에는 오래됐지만 완전한 스캔보다 조금 빠졌더라도 최신 스캔이 낫습니다. 이 부분은 설계 선택이고 실기기에서 측정한 결과는 아닙니다.
반대로 /cmd_vel은 기본 QoS로 발행합니다. diff_drive_controller가 시스템 기본 QoS로 구독하는 것을 diff_drive_controller.cpp(humble 브랜치)에서 확인했습니다. 여기에 qos_profile_sensor_data를 발행 쪽에 쓰면 Best effort 퍼블리셔가 되어 Reliable 구독자와 연결되지 않습니다. 구독에는 센서 프로파일을 쓰고 발행에는 쓰지 않는다고 기억해 두세요.
cmd_vel이 모터까지 가는 길
노드가 /cmd_vel에 발행한 값은 diff_controller가 받아 바퀴 속도로 바꿉니다. 이 과정은 2.6장에서 계산했습니다. 이식할 때는 컨트롤러가 두 가지를 덮어쓴다는 데 주의합니다.
- 속도 한계: ±0.2 m/s와 ±0.85 rad/s로 잘립니다.
- 타임아웃: humble의
diff_drive_controller는 마지막 명령 후cmd_vel_timeout(기본 0.5초, 파라미터 정의 파일에서 확인)이 지나면 속도를 0으로 덮어씁니다.tiki_controllers.yaml에는 이 값을 따로 지정하지 않았으므로 기본값이 적용된다고 읽힙니다. 로봇에서ros2 param get /diff_controller cmd_vel_timeout으로 확인하세요.
타임아웃 덕분에 노드가 죽어도 로봇이 영원히 달리지는 않습니다. 하지만 0.5초는 짧지 않습니다. 0.2 m/s로 달리던 로봇은 그 사이 10 cm를 더 갑니다. 그래서 노드가 종료될 때 스스로 제로 Twist를 보내게 합니다.
타임아웃 때문에 명령을 한 번만 보내서도 안 됩니다. 한 번 발행하고 끝내는 코드는 실기기에서 0.5초 움직이고 섭니다. 타이머로 10~20 Hz에 계속 발행하세요.
코드 1: 정사각형 주행
2.7장의 정사각형 주행을 실기기용으로 옮긴 노드입니다. 한 변은 0.5 m, 직진은 0.1 m/s, 회전은 0.4 rad/s입니다. 거리와 각도는 /odom으로 판단합니다. 정적 코드이며 이 페이지에서 실행되지 않습니다. 파일을 square_driver.py로 저장하고 VM의 ~/ros2_ws 바깥 아무 폴더에서 python3 square_driver.py로 실행합니다.
import math
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from rclpy.signals import SignalHandlerOptions
from geometry_msgs.msg import Twist
from nav_msgs.msg import Odometry
MAX_LIN = 0.2 # tiki_controllers.yaml linear.x 한계 (m/s)
MAX_ANG = 0.85 # tiki_controllers.yaml angular.z 한계 (rad/s)
SIDE = 0.5 # 한 변 길이 (m)
LIN = 0.10 # 직진 속도 (m/s), 한계의 절반
ANG = 0.40 # 회전 속도 (rad/s)
TURN = math.pi / 2
def clamp(v, limit):
return max(-limit, min(limit, v))
def wrap(a):
return math.atan2(math.sin(a), math.cos(a))
def yaw_of(q):
return math.atan2(2.0 * (q.w * q.z + q.x * q.y),
1.0 - 2.0 * (q.y * q.y + q.z * q.z))
class SquareDriver(Node):
def __init__(self):
super().__init__('square_driver')
self.pub = self.create_publisher(Twist, 'cmd_vel', 10)
# 구독 QoS를 센서 데이터 프로파일로 명시한다 (Best effort, depth 5)
self.create_subscription(Odometry, 'odom', self.on_odom,
qos_profile_sensor_data)
self.create_timer(0.05, self.tick) # 20 Hz: cmd_vel 타임아웃보다 훨씬 짧게
self.pose = None # (x, y, yaw)
self.start = None
self.state = 'FORWARD'
self.sides = 0
def on_odom(self, msg):
p = msg.pose.pose
self.pose = (p.position.x, p.position.y, yaw_of(p.orientation))
if self.start is None:
self.start = self.pose
def send(self, lin, ang):
cmd = Twist()
cmd.linear.x = clamp(lin, MAX_LIN)
cmd.angular.z = clamp(ang, MAX_ANG)
self.pub.publish(cmd)
def stop(self):
self.send(0.0, 0.0)
def tick(self):
if self.pose is None:
self.stop() # odom이 올 때까지 움직이지 않는다
return
x, y, yaw = self.pose
sx, sy, syaw = self.start
if self.state == 'FORWARD':
if math.hypot(x - sx, y - sy) >= SIDE:
self.state = 'TURN'
self.start = self.pose # 회전 각도는 회전을 시작한 방향 기준
self.stop()
else:
self.send(LIN, 0.0)
elif self.state == 'TURN':
turned = wrap(yaw - syaw)
if turned >= TURN:
self.sides += 1
self.start = self.pose
self.state = 'DONE' if self.sides >= 4 else 'FORWARD'
self.stop()
else:
self.send(0.0, ANG)
else:
self.stop()
self.get_logger().info('정사각형 완료', once=True)
def main():
# 종료 시 제로 Twist를 보내려면 rclpy가 먼저 컨텍스트를 닫지 않아야 한다
rclpy.init(signal_handler_options=SignalHandlerOptions.NO)
node = SquareDriver()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
for _ in range(5): # 한 번만 보내면 유실될 수 있어 여러 번 보낸다
node.stop()
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
에뮬레이터 버전과 달라지는 부분을 정리합니다.
qos_profile_sensor_data를/odom구독에 명시했습니다./odom은 Reliable로 발행되므로 어느 설정이든 연결되지만 습관을 하나로 맞췄습니다.clamp로 속도를 코드에서 먼저 자릅니다.self.start에 시작 자세를 저장하고 그 차이로 계산합니다./odom은 컨트롤러를 켠 지점이 원점이라서 노드를 다시 실행해도 이전 위치가 이어집니다.- odom이 오기 전에는 제로 속도를 발행합니다.
SignalHandlerOptions.NO로rclpy.init을 부르는 이유는 종료 처리 때문입니다. 기본 설정에서는 Ctrl+C를 받으면rclpy가 컨텍스트를 먼저 닫아서,finally에서 발행하려 하면 실패할 수 있습니다. 이 옵션이 humble의rclpy에 있는 것은 소스에서 확인했습니다. 실제로 Ctrl+C 후 정지 명령이 나가는지는 로봇에서ros2 topic echo /cmd_vel로 확인하세요.- 종료 때 제로 속도를 다섯 번 보냅니다. 한 번은 유실될 수 있어서입니다.
코드 2: 라이다로 멈추고 피하기
2.8장의 장애물 회피를 옮긴 노드입니다. 정면 거리가 0.35 m보다 가까우면 서서 더 트인 쪽으로 돌고, 0.6 m 안이면 속도를 반으로 줄입니다.
import math
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from rclpy.signals import SignalHandlerOptions
from geometry_msgs.msg import Twist
from sensor_msgs.msg import LaserScan
LASER_YAW = math.pi # URDF: laser 링크가 base_link 기준 yaw 3.14159로 장착
STOP_DIST = 0.35 # laser 기준 거리 (m). 라이다는 base_footprint 바로 위라 로봇 중심에서 잰 값과 같다
SLOW_DIST = 0.60
LIN = 0.10
ANG = 0.40 # 한계 0.85 rad/s 이내
SCAN_TIMEOUT = 0.5 # 이 시간 동안 /scan이 없으면 정지 (s)
def wrap(a):
return math.atan2(math.sin(a), math.cos(a))
class ScanGuard(Node):
def __init__(self):
super().__init__('scan_guard')
self.pub = self.create_publisher(Twist, 'cmd_vel', 10)
self.create_subscription(LaserScan, 'scan', self.on_scan,
qos_profile_sensor_data)
self.create_timer(0.1, self.tick) # 10 Hz
self.front = None
self.left = None
self.right = None
self.last_scan = None
def sector(self, msg, lo, hi):
"""로봇 기준 각도 lo..hi (rad) 구간에서 유효한 가장 가까운 거리"""
best = None
for i, r in enumerate(msg.ranges):
if not math.isfinite(r) or r < msg.range_min or r > msg.range_max:
continue
a = wrap(msg.angle_min + i * msg.angle_increment + LASER_YAW)
if lo <= a <= hi and (best is None or r < best):
best = r
return best
def on_scan(self, msg):
d30, d90 = math.radians(30), math.radians(90)
self.front = self.sector(msg, -d30, d30)
self.left = self.sector(msg, d30, d90)
self.right = self.sector(msg, -d90, -d30)
self.last_scan = self.get_clock().now()
def send(self, lin, ang):
cmd = Twist()
cmd.linear.x = lin
cmd.angular.z = ang
self.pub.publish(cmd)
def stop(self):
self.send(0.0, 0.0)
def tick(self):
if self.last_scan is None:
self.stop()
return
age = (self.get_clock().now() - self.last_scan).nanoseconds / 1e9
if age > SCAN_TIMEOUT:
self.stop() # 센서가 끊기면 달리지 않는다
self.get_logger().warn('scan 없음, 정지', throttle_duration_sec=2.0)
return
if self.front is None:
self.stop() # 정면에 유효한 측정이 하나도 없으면 모른다고 보고 멈춘다
return
if self.front < STOP_DIST:
# 더 트인 쪽으로 제자리 회전 (측정이 없는 쪽은 트인 것으로 본다)
left = self.left if self.left is not None else 99.0
right = self.right if self.right is not None else 99.0
self.send(0.0, ANG if left >= right else -ANG)
elif self.front < SLOW_DIST:
self.send(LIN / 2.0, 0.0)
else:
self.send(LIN, 0.0)
def main():
rclpy.init(signal_handler_options=SignalHandlerOptions.NO)
node = ScanGuard()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
for _ in range(5):
node.stop()
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
에뮬레이터 버전과 달라지는 부분은 이렇습니다.
- 각도 기준.
tiki.urdf.xacro에서laser링크는lidar_top_joint의rpy="0 0 3.14159"로 달려 있습니다. 라이다의 0도 방향이 로봇 뒤쪽이라는 뜻입니다. 코드의LASER_YAW = math.pi가 이를 보정합니다. 에뮬레이터의/scan은 각도 0이 로봇 정면이라 정면이ranges인덱스 180 근처입니다(2.8장에서 쓴 값입니다). 그 인덱스를 그대로 쓴 코드는 실기기에서 뒤쪽을 보게 됩니다. 실제 빔 수와angle_min은 드라이버 설정과 모델에 따르므로 인덱스를 고정하지 말고, 코드처럼 각 빔의 각도를angle_min + i * angle_increment로 계산한 뒤LASER_YAW를 더해 로봇 기준 각도로 바꿔서 고르세요. 다른 방법은 TF로laser에서base_footprint로 변환하는 것입니다. 실기기에서는 정면이 인덱스 0 또는 끝 쪽(±π 부근)에서 보입니다. 첫 실행에서ros2 topic echo /scan --once --field angle_min으로 값을 확인하세요. - 거리의 기준점.
laser는lidar_mount_joint의 x, y가 0이라base_footprint바로 위에 있습니다. 정면 장애물까지의 거리는 로봇 앞면이 아니라 로봇 중심에서 잰 값이어서, 같은 0.35 m도 앞면(앞쪽 본체 길이 0.1 m의 절반, 중심에서 0.05 m 앞)에서는 약 0.30 m입니다. 임계값은 실험하며 키우세요. - 유효하지 않은 값.
rplidar_node는 측정 실패를inf로 채우고range_min을 0.15 m로 고정합니다(소스에서 확인).math.isfinite와 범위 검사로 걸러냅니다. - 정면에 유효한 값이 하나도 없으면 멈춥니다. "아무것도 안 보임"과 "측정이 안 됨"은 라이다 입장에서 구분되지 않기 때문입니다.
- 스캔이 0.5초 넘게 끊기면 멈춥니다. 에뮬레이터에는 없던 처리이고 센서 드라이버가 죽었을 때를 대비한 것입니다.
두 노드의 속도 상수는 일부러 한계의 절반 이하로 둡니다. 처음에는 느려야 합니다.
이식 점검 목록
위 내용과 4.1장의 안전수칙을 합친 점검 목록입니다. 항목을 체크하면 브라우저에 저장됩니다.
이식 점검: 0 / 18
1) 연결 확인 (VM과 로봇)
echo $ROS_DOMAIN_ID (양쪽에서)값이 다르면 서로의 토픽이 보이지 않습니다. 에러 없이 조용히 안 보입니다.
ros2 topic list코드를 돌리기 전에 연결부터 확인합니다. 안 보이면 4.2장과 4.3장으로 돌아갑니다.
ros2 topic info /scan (sensor_msgs/msg/LaserScan)/cmd_vel은 Twist, /odom은 Odometry입니다. 타입이 다르면 연결되지 않습니다.
ros2 topic info /scan --verbose퍼블리셔의 Reliability가 Reliable인지 Best effort인지 눈으로 봅니다. 구독 QoS는 코드에 명시합니다.
ros2 param get /<노드이름> use_sim_time실기기에는 /clock이 없습니다. true이면 타이머가 멈춘 것처럼 보입니다.
2) 코드 점검
코드의 clamp 또는 상수 확인컨트롤러가 한계를 넘는 값을 잘라 내므로 에러는 없지만, 의도한 궤적과 달라집니다.
Ctrl+C 후 로봇이 즉시 서는지 확인노드가 죽은 뒤에는 cmd_vel 타임아웃(기본 0.5초)이 지나야 멈춥니다. 그 사이 로봇은 움직입니다.
ros2 topic hz /cmd_vel명령이 0.5초 넘게 끊기면 컨트롤러가 정지로 덮어씁니다. 한 번만 발행하면 잠깐 움직이고 섭니다.
create_subscription(LaserScan, 'scan', cb, qos_profile_sensor_data)Reliable과 Best effort 어느 쪽 퍼블리셔와도 연결되는 설정입니다.
인덱스 180 대신 angle_min + i * angle_increment 로 각도를 계산하고 LASER_YAW = math.pi 를 더함에뮬레이터는 정면이 각도 0(인덱스 180 근처)이지만, 실기기는 laser가 yaw π로 달려 정면이 ±π 부근입니다. 빔 수와 angle_min은 직접 확인합니다.
math.isfinite(r) and msg.range_min <= r <= msg.range_max실제 /scan에는 측정 실패 값이 섞여 있습니다. 그대로 min()에 넣으면 엉뚱한 값이 나옵니다.
마지막 수신 시각과 현재 시각 비교센서 노드가 죽어도 마지막 값이 남아 있으면 로봇은 계속 달립니다.
ros2 run tf2_tools view_frames코드에서 프레임 이름을 직접 쓴다면 URDF와 컨트롤러 설정의 이름과 같아야 합니다.
시작 시점의 pose를 저장하고 차이로 계산컨트롤러를 켠 이후 쌓인 값이라, 다시 시작한 노드는 이전 위치를 이어받습니다.
3) 첫 실행
바퀴가 도는 방향과 속도만 확인부호 실수(회전 방향 반대)나 폭주를 바닥에서 겪지 않기 위해서입니다.
LIN = 0.05 같은 값으로 시작코드와 로봇이 처음 만나는 실행에서는 느려야 멈추기 쉽습니다.
4.1장의 안전수칙 확인Ctrl+C가 안 먹을 때 물리적으로 잡거나 전원을 끌 수 있어야 합니다.
같은 코드, 같은 파라미터로 한 번씩 실행차이의 크기가 4.6장에서 설명하는 오차 요인의 크기를 알려 줍니다.
첫 실행 순서
세 단계로 나눠 실행하세요. 한 단계라도 이상하면 멈추고 점검 목록으로 돌아갑니다.
- 공중. 로봇을 상자 위에 올려 바퀴가 바닥에 닿지 않게 합니다. 기본 구동을 켠 뒤(4.4장) 노드를 실행해서 바퀴가 도는 방향이 맞는지, 종료하면 바로 서는지 봅니다. 정사각형 주행은 직진 때 두 바퀴가 같은 방향, 회전 때 서로 반대 방향이어야 합니다.
- 저속. 바닥에 내려놓고
LIN을 0.05로 낮춰 실행합니다. 주변 1 m 이상을 비우고, 손은 로봇 전원이나 터미널의 Ctrl+C에 둡니다. - 본 주행. 속도를 원래 값으로 올리고 에뮬레이터 결과와 궤적을 비교합니다. 이 차이가 4.6장의 주제입니다.
# 터미널 1 (로봇): 기본 구동
ros2 launch tiki_bringup tiki.launch.py
# 터미널 2 (VM): 명령 확인용
ros2 topic echo /cmd_vel
# 터미널 3 (VM): 노드 실행
source /opt/ros/humble/setup.bash
python3 square_driver.py
/cmd_vel 출력이 20 Hz로 계속 흐르고 Ctrl+C를 눌렀을 때 마지막 값이 모두 0.0으로 나오면 종료 처리가 제대로 동작합니다.
한눈에 정리
- 실기기의
/cmd_vel(Twist),/scan(LaserScan),/odom(Odometry)은 에뮬레이터와 이름, 타입이 같다. rplidar_ros의/scan은 깊이 10의 기본 QoS(Reliable)로 발행된다. 설치 버전은ros2 topic info /scan --verbose로 확인한다.- 구독에는
qos_profile_sensor_data,/cmd_vel발행에는 기본 QoS를 쓴다. - 컨트롤러가 ±0.2 m/s, ±0.85 rad/s로 자르고 0.5초 넘게 명령이 없으면 멈춘다. 코드는 이 값 안에서 10~20 Hz로 발행한다.
- 라이다는 URDF에서 yaw π로 달려 있어 로봇 정면이 scan 각도 ±π다. 거리는
base_footprint바로 위(로봇 중심)에서 잰다. - 종료 때 제로
Twist를 보내려면SignalHandlerOptions.NO로 초기화하고finally에서 발행한다. - 첫 실행은 공중, 저속, 본 주행 순서로 한다.
확인 문제
Q1. 정사각형 주행 노드가 명령을 한 번만 발행하도록 짰더니 실기기에서 잠깐 움직이다 섰습니다. 원인과 해결은?
컨트롤러의 cmd_vel_timeout(기본 0.5초) 때문입니다. 마지막 명령 후 0.5초가 지나면 속도가 0으로 덮어씌워집니다. 타이머로 10~20 Hz에 계속 발행하도록 고칩니다.
Q2. 장애물 회피 노드를 실행했는데 로봇이 앞의 벽을 못 보고 뒤쪽 벽만 신경 쓰는 것처럼 움직입니다. 먼저 의심할 것은?
라이다 각도 기준입니다. URDF에서 laser가 yaw π로 달려 있어 로봇 정면이 scan 각도 0이 아니라 ±π입니다. LASER_YAW 보정 유무를 확인합니다.
Q3. /scan 구독 콜백이 한 번도 불리지 않습니다. 토픽 목록에는 /scan이 있습니다. 확인할 두 가지는?
ros2 topic info /scan --verbose로 퍼블리셔 QoS와 내 구독의 QoS가 호환되는지 (Best effort 퍼블리셔에 Reliable 구독이면 연결되지 않음)- VM과 로봇의
ROS_DOMAIN_ID가 같은지
다음 장
같은 코드를 같은 속도로 돌려도 실제 궤적은 에뮬레이터와 다릅니다. 다음 장에서는 그 차이가 어디서 나오는지 숫자로 따져 보고, 로봇에서 직접 재는 실험을 설계합니다.