TIKI-MINI로 시작하는 ROS2 실무
목차

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)연결됨
Reliableqos_profile_sensor_data (Best effort)연결됨, Best effort로 수신
Best effort기본(Reliable)연결되지 않음. 에러 없이 데이터가 안 옴
Best effortqos_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초 넘게 끊기면 멈춥니다. 에뮬레이터에는 없던 처리이고 센서 드라이버가 죽었을 때를 대비한 것입니다.
scan 각도 0의 방향 비교에뮬레이터 /scan실기기 /scan로봇 정면각도 0 = 정면로봇정면 빔은 ranges 180 근처(2.8장 코드가 쓴 값)로봇 정면정면 = ±π로봇laser (yaw π)laser 0도 = 뒤쪽빔 각도 + LASER_YAW(π)= 로봇 기준 각도
같은 로봇 정면이 에뮬레이터에서는 scan 각도 0, 실기기에서는 ±π입니다. 실기기 코드는 빔 각도에 LASER_YAW를 더해 로봇 기준으로 바꿉니다.
정지 거리의 기준점로봇중심 (laser)장애물앞면에서 약 0.30 mlaser에서 0.35 m (STOP_DIST)앞면: 중심에서 0.05 m 앞로봇 정면 방향
STOP_DIST 0.35 m는 로봇 중심 바로 위의 laser에서 잰 거리입니다. 로봇 앞면에서 재면 약 0.30 m이고, 몸체 모양은 개략으로 그렸습니다.

두 노드의 속도 상수는 일부러 한계의 절반 이하로 둡니다. 처음에는 느려야 합니다.

이식 점검 목록

위 내용과 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장에서 설명하는 오차 요인의 크기를 알려 줍니다.

첫 실행 순서

세 단계로 나눠 실행하세요. 한 단계라도 이상하면 멈추고 점검 목록으로 돌아갑니다.

  1. 공중. 로봇을 상자 위에 올려 바퀴가 바닥에 닿지 않게 합니다. 기본 구동을 켠 뒤(4.4장) 노드를 실행해서 바퀴가 도는 방향이 맞는지, 종료하면 바로 서는지 봅니다. 정사각형 주행은 직진 때 두 바퀴가 같은 방향, 회전 때 서로 반대 방향이어야 합니다.
  2. 저속. 바닥에 내려놓고 LIN을 0.05로 낮춰 실행합니다. 주변 1 m 이상을 비우고, 손은 로봇 전원이나 터미널의 Ctrl+C에 둡니다.
  3. 본 주행. 속도를 원래 값으로 올리고 에뮬레이터 결과와 궤적을 비교합니다. 이 차이가 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으로 나오면 종료 처리가 제대로 동작합니다.

한눈에 정리

  1. 실기기의 /cmd_vel(Twist), /scan(LaserScan), /odom(Odometry)은 에뮬레이터와 이름, 타입이 같다.
  2. rplidar_ros의 /scan은 깊이 10의 기본 QoS(Reliable)로 발행된다. 설치 버전은 ros2 topic info /scan --verbose로 확인한다.
  3. 구독에는 qos_profile_sensor_data, /cmd_vel 발행에는 기본 QoS를 쓴다.
  4. 컨트롤러가 ±0.2 m/s, ±0.85 rad/s로 자르고 0.5초 넘게 명령이 없으면 멈춘다. 코드는 이 값 안에서 10~20 Hz로 발행한다.
  5. 라이다는 URDF에서 yaw π로 달려 있어 로봇 정면이 scan 각도 ±π다. 거리는 base_footprint 바로 위(로봇 중심)에서 잰다.
  6. 종료 때 제로 Twist를 보내려면 SignalHandlerOptions.NO로 초기화하고 finally에서 발행한다.
  7. 첫 실행은 공중, 저속, 본 주행 순서로 한다.

확인 문제

Q1. 정사각형 주행 노드가 명령을 한 번만 발행하도록 짰더니 실기기에서 잠깐 움직이다 섰습니다. 원인과 해결은?

컨트롤러의 cmd_vel_timeout(기본 0.5초) 때문입니다. 마지막 명령 후 0.5초가 지나면 속도가 0으로 덮어씌워집니다. 타이머로 10~20 Hz에 계속 발행하도록 고칩니다.

Q2. 장애물 회피 노드를 실행했는데 로봇이 앞의 벽을 못 보고 뒤쪽 벽만 신경 쓰는 것처럼 움직입니다. 먼저 의심할 것은?

라이다 각도 기준입니다. URDF에서 laser가 yaw π로 달려 있어 로봇 정면이 scan 각도 0이 아니라 ±π입니다. LASER_YAW 보정 유무를 확인합니다.

Q3. /scan 구독 콜백이 한 번도 불리지 않습니다. 토픽 목록에는 /scan이 있습니다. 확인할 두 가지는?
  1. ros2 topic info /scan --verbose로 퍼블리셔 QoS와 내 구독의 QoS가 호환되는지 (Best effort 퍼블리셔에 Reliable 구독이면 연결되지 않음)
  2. VM과 로봇의 ROS_DOMAIN_ID가 같은지

다음 장

같은 코드를 같은 속도로 돌려도 실제 궤적은 에뮬레이터와 다릅니다. 다음 장에서는 그 차이가 어디서 나오는지 숫자로 따져 보고, 로봇에서 직접 재는 실험을 설계합니다.