SimpleWaypointFollower에 로봇 상태 → RGB LED 색상 변환 기능 추가

1. 개요

로봇을 실제 현장에서 운용하다 보면 터미널 로그만으로는 현재 상태를 바로 파악하기 어렵습니다.

예를 들어 로봇이 다음 중 어떤 상태인지 즉시 알아야 합니다.

  • 부팅 중인지
  • Nav2가 준비 중인지
  • 주행 중인지
  • 경유점에 도착했는지
  • 미션이 성공했는지
  • 실패했는지
  • 사용자가 중지했는지

이런 상태를 RGB LED 색상으로 표시하면 현장에서 로봇 상태를 훨씬 직관적으로 확인할 수 있습니다.

이번 글에서는 기존의 두 가지 기능을 결합합니다.

첫 번째는 /rgb_led/cmd 토픽으로 들어온 RGB LED 메시지를 시리얼 명령으로 변환해 아두이노 또는 LED 컨트롤러로 보내는 노드입니다.

두 번째는 Nav2 BasicNavigator를 사용해 경유점 주행을 수행하고, 주행 상황에 따라 음성 안내를 출력하는 노드입니다.

여기에 로봇 상태 → RGB LED 색상 변환 기능을 추가해 다음 구조를 만듭니다.

핵심은 주행 노드가 LED 하드웨어를 직접 제어하지 않는다는 점입니다.

주행 노드는 단지 /rgb_led/cmd 토픽으로 RGB 메시지만 발행합니다. 실제 시리얼 통신은 기존 RGB LED 시리얼 노드가 담당합니다.

이 구조가 깔끔합니다. 유지보수도 쉽고, LED 제어 장치가 바뀌어도 주행 로직은 거의 그대로 사용할 수 있습니다.

2. 상태별 RGB LED 색상 정책

이번 예제에서는 로봇 상태에 따라 다음과 같이 LED 색상을 지정했습니다.

로봇 상태의미RGB 색상
BOOTING노드 시작흰색
NAV2_WAITINGNav2 활성화 대기주황색
READY주행 준비 완료파란색
MISSION_START미션 시작청록색
MOVING경유점 이동 중초록색
WAYPOINT_REACHED경유점 도착 표시보라색
SUCCEEDED미션 성공초록색
CANCELED미션 취소노란색
FAILED미션 실패빨간색
EMERGENCY_STOP사용자 중지 / 긴급 정지빨간색
IDLE종료 / 대기LED 꺼짐

실제 로봇에서는 색상 정책이 중요합니다.

일반적으로 다음과 같이 잡는 것이 직관적입니다.

초록색: 정상
파란색: 준비
노란색: 주의
빨간색: 실패 또는 긴급 상황
흰색: 부팅
꺼짐: 종료 또는 대기

현장 작업자는 색상만 보고도 로봇의 상태를 즉시 판단할 수 있습니다.

3. 전체 Python 소스

파일명 예시:

robot_state_led_waypoint_follower.py

기존 simple_waypoint_follower.py에 기능을 추가한 것입니다.

전체 소스는 다음과 같습니다.

#!/usr/bin/env python3

import math
import time
from enum import Enum

import rclpy
from rclpy.node import Node
from pathlib import Path
import yaml

from geometry_msgs.msg import PoseStamped
from nav2_simple_commander.robot_navigator import BasicNavigator, TaskResult

from robot_audio_interfaces.msg import AudioCommand
from rgb_led_interfaces.msg import RgbLed
from sensor_msgs.msg import BatteryState


class RobotState(Enum):
    """Robot state definitions mapped to RGB LED colors."""

    BOOTING = 'booting'
    NAV2_WAITING = 'nav2_waiting'
    READY = 'ready'
    MISSION_START = 'mission_start'
    MOVING = 'moving'
    WAYPOINT_REACHED = 'waypoint_reached'
    SUCCEEDED = 'succeeded'
    CANCELED = 'canceled'
    FAILED = 'failed'
    EMERGENCY_STOP = 'emergency_stop'
    IDLE = 'idle'


class RobotStateLedWaypointFollower(Node):
    
    def __init__(self):
        super().__init__('robot_state_led_waypoint_follower')

        self.declare_parameter('led_topic', '/rgb_led/cmd')
        self.declare_parameter('audio_topic', '/audio/command')
        self.declare_parameter('feedback_period_sec', 0.5)
        self.declare_parameter('waypoint_reached_flash_sec', 0.3)
        self.declare_parameter('wp_file', 'waypoints.yaml')

        self.led_topic = self.get_parameter('led_topic').value
        self.audio_topic = self.get_parameter('audio_topic').value
        self.wp_file = self.get_parameter('wp_file').value
        self.feedback_period_sec = float(self.get_parameter('feedback_period_sec').value)
        self.waypoint_reached_flash_sec = float(
            self.get_parameter('waypoint_reached_flash_sec').value
        )

        self.led_pub = self.create_publisher(RgbLed, self.led_topic, 10)
        self.audio_pub = self.create_publisher(AudioCommand, self.audio_topic, 10)

        self.create_subscription(
            BatteryState,
            "/battery_state",
            self.battery_state_callback,
            10
        )

        self.bat_soc = 0.0
        self.voltage = 0.0
        self.current_time = 0
        self.prev_time = 0

        self.navigator = BasicNavigator()

        self.yaml_data = self.load_waypoint_yaml(self.wp_file)

        self.frame_id = self.yaml_data.get("frame_id", "map")

        self.state_color_map = {
            RobotState.BOOTING: (255, 255, 255),         # white
            RobotState.NAV2_WAITING: (255, 180, 0),     # orange
            RobotState.READY: (0, 0, 255),              # blue
            RobotState.MISSION_START: (0, 255, 255),    # cyan
            RobotState.MOVING: (0, 255, 0),             # green
            RobotState.WAYPOINT_REACHED: (180, 0, 255), # purple
            RobotState.SUCCEEDED: (0, 255, 0),          # green
            RobotState.CANCELED: (255, 255, 0),         # yellow
            RobotState.FAILED: (255, 0, 0),             # red
            RobotState.EMERGENCY_STOP: (255, 0, 0),     # red
            RobotState.IDLE: (0, 0, 0),                 # off
        }

        self.current_state = None
        self.set_robot_state(RobotState.BOOTING)

        self.get_logger().info('Robot State LED Waypoint Follower initialized')
        self.get_logger().info(f'LED topic: {self.led_topic}')
        self.get_logger().info(f'Audio topic: {self.audio_topic}')

    def battery_state_callback(self, msg):
        self.bat_soc = msg.percentage
        self.voltage = msg.voltage

    def yaw_to_quaternion(self, yaw: float):        
        qz = math.sin(yaw * 0.5)
        qw = math.cos(yaw * 0.5)
        return qz, qw
    
    def load_waypoint_yaml(self, yaml_path: str) -> dict:
        path = Path(yaml_path).expanduser()

        if not path.exists():
            raise FileNotFoundError(f"YAML file not found: {path}")

        with open(path, "r", encoding="utf-8") as file:
            data = yaml.safe_load(file)

        if data is None:
            raise ValueError("YAML file is empty.")

        if "waypoints" not in data:
            raise ValueError("YAML file must contain 'waypoints' field.")

        if not isinstance(data["waypoints"], list):
            raise ValueError("'waypoints' must be a list.")

        return data    


    def create_pose(self, x, y, yaw):
        pose = PoseStamped()
        pose.header.frame_id = 'map'
        pose.header.stamp = self.get_clock().now().to_msg()

        pose.pose.position.x = float(x)
        pose.pose.position.y = float(y)
        pose.pose.position.z = 0.0

        pose.pose.orientation.z = math.sin(yaw / 2.0)
        pose.pose.orientation.w = math.cos(yaw / 2.0)

        return pose

    def clamp_u8(self, value):
        return max(0, min(255, int(value)))

    def publish_led_rgb(self, r, g, b, enable=True):
        msg = RgbLed()
        msg.enable = bool(enable)
        msg.r = self.clamp_u8(r)
        msg.g = self.clamp_u8(g)
        msg.b = self.clamp_u8(b)

        self.led_pub.publish(msg)

    def set_robot_state(self, state):
        if state not in self.state_color_map:
            self.get_logger().warn(f'Unknown robot state: {state}')
            return

        self.current_state = state

        r, g, b = self.state_color_map[state]
        self.publish_led_rgb(r, g, b, enable=True)

        self.get_logger().info(
            f'Robot state changed: {state.value} -> RGB({r}, {g}, {b})'
        )

    def flash_state(self, state, return_state=None, duration_sec=0.3):
        if return_state is None:
            return_state = self.current_state

        self.set_robot_state(state)
        time.sleep(duration_sec)

        if return_state is not None:
            self.set_robot_state(return_state)

    def publish_audio(self, text='', sound_id='', audio_type=AudioCommand.TYPE_TTS):
        msg = AudioCommand()
        msg.type = audio_type
        msg.text = text
        msg.sound_id = sound_id
        msg.volume = 1.0
        msg.repeat = 1

        self.audio_pub.publish(msg)

    def speak(self, text):
        self.publish_audio(
            text=text,
            sound_id='',
            audio_type=AudioCommand.TYPE_TTS
        )

    def play_effect(self, sound_id):
        self.publish_audio(
            text='',
            sound_id=sound_id,
            audio_type=AudioCommand.TYPE_EFFECT
        )

    def speak_with_effect(self, text, sound_id):
        self.publish_audio(
            text=text,
            sound_id=sound_id,
            audio_type=AudioCommand.TYPE_TTS_AND_EFFECT
        )

    def run(self):
        self.get_logger().info('Waiting for Nav2 to become active...')

        self.set_robot_state(RobotState.NAV2_WAITING)
        self.speak('내비게이션 시스템을 준비합니다')

        self.navigator.waitUntilNav2Active()

        self.get_logger().info('Nav2 is active')

        self.set_robot_state(RobotState.READY)
        self.speak_with_effect('경유점 주행을 시작합니다', 'start')

        time.sleep(1.0)

        self.set_robot_state(RobotState.MISSION_START)
        time.sleep(0.5)

        goal_poses = []

        for wp in self.yaml_data["waypoints"]:
            name = wp.get("name", "noname")
            x = wp["x"]
            y = wp["y"]
            yaw = wp.get("yaw", 0.0)

            pose = self.create_pose(
                x=x,
                y=y,
                yaw=yaw,
            )

            goal_poses.append(pose)
            self.get_logger().info(f"Loaded waypoint: {name}, x={x}, y={y}, yaw={yaw}")


        self.navigator.followWaypoints(goal_poses)

        self.set_robot_state(RobotState.MOVING)

        last_feedback_index = -1

        while not self.navigator.isTaskComplete():
            rclpy.spin_once(self, timeout_sec=0.0)

            self.current_time = time.time()
            if self.current_time - self.prev_time > 30.0:
                batinfo_msg = f'배터리 전압은 {int(self.voltage)}볼트이고, 충전량은 {int(self.bat_soc)} 퍼센트 입니다'
                # batinfo_msg = f'배터리 충전량은 {int(self.bat_soc)} 퍼센트 입니다'
                self.get_logger().info(batinfo_msg)
                self.speak(batinfo_msg)
                self.prev_time = self.current_time

            feedback = self.navigator.getFeedback()

            if feedback is not None:
                current_index = feedback.current_waypoint

                if current_index != last_feedback_index:
                    if last_feedback_index != -1:
                        self.flash_state(
                            RobotState.WAYPOINT_REACHED,
                            return_state=RobotState.MOVING,
                            duration_sec=self.waypoint_reached_flash_sec,
                        )

                    last_feedback_index = current_index

                    msg = f'{current_index + 1}번 경유점으로 이동 중입니다'
                    self.get_logger().info(msg)
                    self.speak(msg)

            time.sleep(self.feedback_period_sec)

        result = self.navigator.getResult()

        if result == TaskResult.SUCCEEDED:
            self.set_robot_state(RobotState.SUCCEEDED)
            self.get_logger().info('Waypoint mission succeeded')
            self.speak_with_effect('모든 경유점 주행을 완료했습니다', 'goal')

        elif result == TaskResult.CANCELED:
            self.set_robot_state(RobotState.CANCELED)
            self.get_logger().warn('Waypoint mission was canceled')
            self.speak_with_effect('경유점 주행이 취소되었습니다', 'warning')

        elif result == TaskResult.FAILED:
            self.set_robot_state(RobotState.FAILED)
            self.get_logger().error('Waypoint mission failed')
            self.speak_with_effect('경유점 주행에 실패했습니다', 'error')

        else:
            self.set_robot_state(RobotState.CANCELED)
            self.get_logger().warn('Unknown waypoint result')
            self.speak_with_effect('알 수 없는 주행 결과입니다', 'warning')

    def destroy_node(self):
        try:
            self.set_robot_state(RobotState.IDLE)
        except Exception:
            pass

        super().destroy_node()


def main(args=None):
    rclpy.init(args=args)

    node = RobotStateLedWaypointFollower()

    try:
        node.run()

    except KeyboardInterrupt:
        node.get_logger().warn('Keyboard interrupt received')
        node.set_robot_state(RobotState.EMERGENCY_STOP)
        node.speak_with_effect('사용자에 의해 주행이 중지되었습니다', 'warning')
        time.sleep(0.5)

    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()
    

4. 소스 설명

1) 로봇 상태 Enum 추가

새 소스에서 가장 먼저 눈에 띄는 추가 내용은 RobotState Enum입니다.

class RobotState(Enum):
BOOTING = 'booting'
NAV2_WAITING = 'nav2_waiting'
READY = 'ready'
MISSION_START = 'mission_start'
MOVING = 'moving'
WAYPOINT_REACHED = 'waypoint_reached'
SUCCEEDED = 'succeeded'
CANCELED = 'canceled'
FAILED = 'failed'
EMERGENCY_STOP = 'emergency_stop'
IDLE = 'idle'

기존 소스에서는 로봇의 상태를 별도 상태값으로 관리하지 않았습니다. 로그와 음성 안내만으로 현재 상황을 표현했습니다. 예를 들어 Nav2 준비, 주행 시작, 성공, 실패 등을 각각 로그와 TTS로 출력하는 방식이었습니다.

새 소스는 상태를 명확한 Enum으로 정의했습니다. 이 덕분에 로봇의 동작 흐름을 다음처럼 체계적으로 관리할 수 있습니다.

BOOTING
NAV2_WAITING
READY
MISSION_START
MOVING
WAYPOINT_REACHED
SUCCEEDED
CANCELED
FAILED
EMERGENCY_STOP
IDLE

이 방식은 LED 색상, 음성 안내, 로그 출력을 상태 기준으로 묶기 좋습니다. 즉, 새 소스는 단순히 “주행한다”에서 끝나는 코드가 아니라, “현재 로봇이 어떤 상태인지 표현하는 코드”로 확장되었습니다.

2) RGB LED 제어 기능 추가

기존 소스에는 오디오 publisher는 있었지만 RGB LED publisher는 없었습니다.

기존 소스:

self.audio_pub = self.create_publisher(
AudioCommand,
'/audio/command',
10
)

기존 소스는 음성 안내 중심이었습니다.

새 소스에는 RGB LED 메시지 타입과 publisher가 추가되었습니다.

from rgb_led_interfaces.msg import RgbLed
self.led_pub = self.create_publisher(RgbLed, self.led_topic, 10)

이제 로봇은 음성뿐만 아니라 LED 색상으로도 상태를 표현할 수 있습니다.

LED 명령을 실제로 보내는 함수도 새로 추가되었습니다.

def publish_led_rgb(self, r, g, b, enable=True):
msg = RgbLed()
msg.enable = bool(enable)
msg.r = self.clamp_u8(r)
msg.g = self.clamp_u8(g)
msg.b = self.clamp_u8(b)

self.led_pub.publish(msg)

이 함수는 RGB 값을 받아 /rgb_led/cmd 같은 LED 제어 토픽으로 메시지를 발행합니다.

3) LED 값 보호용 clamp_u8() 추가

새 소스에는 RGB 값이 0~255 범위를 벗어나지 않도록 제한하는 함수가 추가되었습니다.

def clamp_u8(self, value):
return max(0, min(255, int(value)))

RGB LED 값은 일반적으로 0부터 255 사이의 정수값을 사용합니다. 이 함수는 잘못된 값이 들어오더라도 안전한 범위로 보정합니다.

예를 들면 다음과 같습니다.

-200
100100
300255

이런 보호 처리는 하드웨어 제어 코드에서 중요합니다. LED 제어 메시지에 이상한 값이 들어가는 것을 막아주기 때문입니다.

4) 상태별 LED 색상 매핑 추가

새 소스에는 로봇 상태와 LED 색상을 연결하는 state_color_map이 추가되었습니다.

self.state_color_map = {
RobotState.BOOTING: (255, 255, 255),
RobotState.NAV2_WAITING: (255, 180, 0),
RobotState.READY: (0, 0, 255),
RobotState.MISSION_START: (0, 255, 255),
RobotState.MOVING: (0, 255, 0),
RobotState.WAYPOINT_REACHED: (180, 0, 255),
RobotState.SUCCEEDED: (0, 255, 0),
RobotState.CANCELED: (255, 255, 0),
RobotState.FAILED: (255, 0, 0),
RobotState.EMERGENCY_STOP: (255, 0, 0),
RobotState.IDLE: (0, 0, 0),
}

기존 소스에서는 상태별 시각 표시가 없었습니다. 새 소스에서는 상태가 바뀔 때마다 LED 색상이 함께 바뀝니다.

상태별 의미는 앞에 정의한 표를 참조해 주시기 바랍니다.

이 추가 기능의 핵심은 터미널을 보지 않아도 로봇 상태를 LED로 바로 확인할 수 있다는 점입니다.

5) set_robot_state() 함수 추가

상태 변경과 LED 출력을 한 번에 처리하는 함수가 추가되었습니다.

def set_robot_state(self, state):
if state not in self.state_color_map:
self.get_logger().warn(f'Unknown robot state: {state}')
return

self.current_state = state

r, g, b = self.state_color_map[state]
self.publish_led_rgb(r, g, b, enable=True)

self.get_logger().info(
f'Robot state changed: {state.value} -> RGB({r}, {g}, {b})'
)

이 함수는 다음 작업을 수행합니다.

  1. 입력된 상태가 유효한 상태인지 검사
  2. 현재 상태값 갱신
  3. 상태에 맞는 RGB 색상 선택
  4. LED 토픽으로 색상 메시지 발행
  5. 상태 변경 로그 출력

기존 소스에서는 상태 변경이라는 개념이 없었기 때문에, 각 상황에서 로그와 음성만 직접 출력했습니다. 새 소스에서는 상태를 중심으로 LED까지 함께 제어합니다.

예를 들어 아래 한 줄이면:

self.set_robot_state(RobotState.MOVING)

내부적으로 초록색 LED가 켜지고, 현재 상태가 MOVING으로 저장됩니다.

6) 경유점 도달 LED flash 기능 추가

새 소스에는 flash_state() 함수가 추가되었습니다.

def flash_state(self, state, return_state=None, duration_sec=0.3):
if return_state is None:
return_state = self.current_state

self.set_robot_state(state)
time.sleep(duration_sec)

if return_state is not None:
self.set_robot_state(return_state)

이 함수는 특정 상태의 LED를 잠깐 표시한 뒤 원래 상태로 되돌리는 기능입니다.

새 소스에서는 경유점에 도달했다고 판단될 때 이 함수를 사용합니다.

self.flash_state(
RobotState.WAYPOINT_REACHED,
return_state=RobotState.MOVING,
duration_sec=self.waypoint_reached_flash_sec,
)

동작은 다음과 같습니다.

  1. 로봇이 이동 중이면 초록색 LED 표시
  2. 경유점 도달 감지
  3. 보라색 LED를 짧게 표시
  4. 다시 이동 중 상태인 초록색 LED로 복귀

기존 소스에서는 경유점이 바뀌면 음성으로만 안내했습니다.

msg = f'{current_index + 1}번 경유점으로 이동 중입니다'
self.get_logger().info(msg)
self.speak(msg)

새 소스는 여기에 LED flash를 추가해서 경유점 통과 이벤트를 시각적으로도 표시합니다.

7) ROS 2 파라미터 추가

기존 소스는 주요 설정값이 코드에 직접 고정되어 있었습니다.

예를 들어 오디오 토픽은 /audio/command로 고정되어 있고, 경유점도 코드 내부 리스트로 고정되어 있습니다.

새 소스는 다음 파라미터들을 선언합니다.

self.declare_parameter('led_topic', '/rgb_led/cmd')
self.declare_parameter('audio_topic', '/audio/command')
self.declare_parameter('feedback_period_sec', 0.5)
self.declare_parameter('waypoint_reached_flash_sec', 0.3)
self.declare_parameter('wp_file', 'waypoints.yaml')

각 파라미터의 의미는 다음과 같습니다.

파라미터의미
led_topicRGB LED 명령 토픽
audio_topic오디오 명령 토픽
feedback_period_secNav2 feedback 확인 주기
waypoint_reached_flash_sec경유점 도달 LED flash 시간
wp_filewaypoint YAML 파일 경로

이 변경 덕분에 launch 파일이나 ros2 run --ros-args -p 명령으로 설정을 바꿀 수 있습니다.

예:

ros2 run your_package robot_state_led_waypoint_follower \
--ros-args \
-p wp_file:=/home/robot/waypoints.yaml \
-p led_topic:=/rgb_led/cmd \
-p audio_topic:=/audio/command

8) YAML 기반 waypoint 로딩 기능 추가

기존 소스는 waypoint가 코드 내부에 고정되어 있습니다.

self.waypoints = [
self.create_pose(-0.543, 7.726, 0.25),
self.create_pose(1.014, 6.529, -1.873),
self.create_pose(0.997, 3.637, 1.792),
self.create_pose(1.014, 6.529, -1.873),
]

새 소스는 YAML 파일에서 waypoint를 읽습니다.

self.yaml_data = self.load_waypoint_yaml(self.wp_file)
self.frame_id = self.yaml_data.get("frame_id", "map")

YAML 파일을 읽는 함수도 새로 추가되었습니다.

def load_waypoint_yaml(self, yaml_path: str) -> dict:
path = Path(yaml_path).expanduser()

if not path.exists():
raise FileNotFoundError(f"YAML file not found: {path}")

with open(path, "r", encoding="utf-8") as file:
data = yaml.safe_load(file)

if data is None:
raise ValueError("YAML file is empty.")

if "waypoints" not in data:
raise ValueError("YAML file must contain 'waypoints' field.")

if not isinstance(data["waypoints"], list):
raise ValueError("'waypoints' must be a list.")

return data

이 함수는 파일 존재 여부, YAML이 비어 있는지 여부, waypoints 필드가 있는지 여부, waypoints가 리스트인지 여부를 검사합니다.

예상 YAML 구조는 다음과 같습니다.

frame_id: map

waypoints:
- name: wp1
x: -0.543
y: 7.726
yaw: 0.25

- name: wp2
x: 1.014
y: 6.529
yaw: -1.873

9) waypoint 생성 방식 변경

기존 소스는 __init__()에서 바로 self.waypoints 리스트를 만듭니다.

self.waypoints = [
self.create_pose(-0.543, 7.726, 0.25),
...
]

그리고 run()에서 그대로 사용합니다.

self.navigator.followWaypoints(self.waypoints)

새 소스는 run() 안에서 YAML 데이터를 읽어 goal_poses 리스트를 동적으로 생성합니다.

goal_poses = []

for wp in self.yaml_data["waypoints"]:
name = wp.get("name", "noname")
x = wp["x"]
y = wp["y"]
yaw = wp.get("yaw", 0.0)

pose = self.create_pose(
x=x,
y=y,
yaw=yaw,
)

goal_poses.append(pose)
self.get_logger().info(f"Loaded waypoint: {name}, x={x}, y={y}, yaw={yaw}")

이 방식은 waypoint 개수가 몇 개든 YAML 파일만 맞으면 자동으로 처리할 수 있습니다.

기존 방식은 waypoint 개수를 늘리려면 코드를 직접 수정해야 했습니다. 새 방식은 파일만 수정하면 됩니다.

10) Nav2 단계별 LED 상태 표시 추가

새 소스는 Nav2 주행 흐름에 맞춰 LED 상태를 계속 바꿉니다.

Nav2 활성화 대기

self.set_robot_state(RobotState.NAV2_WAITING)
self.speak('내비게이션 시스템을 준비합니다')

Nav2가 준비될 때까지 주황색 LED가 켜집니다.

Nav2 준비 완료

self.set_robot_state(RobotState.READY)
self.speak_with_effect('경유점 주행을 시작합니다', 'start')

Nav2가 active 상태가 되면 파란색 LED와 시작 음성이 출력됩니다.

미션 시작

self.set_robot_state(RobotState.MISSION_START)
time.sleep(0.5)

미션 시작 단계에서는 청록색 LED가 잠시 표시됩니다.

이동 중

self.navigator.followWaypoints(goal_poses)
self.set_robot_state(RobotState.MOVING)

waypoint 주행이 시작되면 초록색 LED로 이동 중임을 표시합니다.

성공, 취소, 실패

주행 결과에 따라 상태 LED와 음성 안내가 다르게 출력됩니다.

self.set_robot_state(RobotState.SUCCEEDED)
self.speak_with_effect('모든 경유점 주행을 완료했습니다', 'goal')
self.set_robot_state(RobotState.CANCELED)
self.speak_with_effect('경유점 주행이 취소되었습니다', 'warning')
self.set_robot_state(RobotState.FAILED)
self.speak_with_effect('경유점 주행에 실패했습니다', 'error')

기존 소스도 결과별 음성 안내는 있었지만, LED 상태 표시는 새 소스에서 추가된 부분입니다.

11) 경유점 feedback 처리 방식 변경

기존 소스는 waypoint feedback이 바뀔 때 음성 안내를 출력합니다.

if current_index != last_feedback_index:
last_feedback_index = current_index

msg = f'{current_index + 1}번 경유점으로 이동 중입니다'
self.get_logger().info(msg)
self.speak(msg)

새 소스는 여기에 경유점 도달 LED flash 기능을 추가했습니다.

if current_index != last_feedback_index:
if last_feedback_index != -1:
self.flash_state(
RobotState.WAYPOINT_REACHED,
return_state=RobotState.MOVING,
duration_sec=self.waypoint_reached_flash_sec,
)

last_feedback_index = current_index

msg = f'{current_index + 1}번 경유점으로 이동 중입니다'
self.get_logger().info(msg)
self.speak(msg)

차이는 last_feedback_index != -1 조건입니다.

이 조건은 첫 번째 waypoint로 출발할 때는 도달 flash를 하지 않도록 하기 위한 처리입니다. 처음 current_waypoint가 0으로 잡히는 것은 “1번 경유점으로 이동 시작”이지, “경유점 도달”이 아니기 때문입니다.

따라서 새 소스의 동작은 다음처럼 해석할 수 있습니다.

1번 경유점으로 이동 시작 → 음성 안내
2번 경유점으로 index 변경 → 1번 경유점 도달로 보고 보라색 flash
3번 경유점으로 index 변경 → 2번 경유점 도달로 보고 보라색 flash

12) feedback 주기 파라미터화

기존 소스는 feedback loop 마지막에 time.sleep(0.5)를 직접 사용합니다.

time.sleep(0.5)

새 소스는 이 값을 파라미터로 변경했습니다.

self.declare_parameter('feedback_period_sec', 0.5)
self.feedback_period_sec = float(self.get_parameter('feedback_period_sec').value)

그리고 loop에서는 다음처럼 사용합니다.

time.sleep(self.feedback_period_sec)

이제 feedback 확인 주기를 코드 수정 없이 바꿀 수 있습니다.

예를 들어 더 빠른 상태 반응이 필요하면:

-p feedback_period_sec:=0.2

음성 안내나 로그가 너무 자주 나오는 것이 싫으면:

-p feedback_period_sec:=1.0

처럼 설정할 수 있습니다.

13) waypoint 도달 flash 시간 파라미터화

새 소스에는 waypoint_reached_flash_sec 파라미터가 추가되었습니다.

self.declare_parameter('waypoint_reached_flash_sec', 0.3)

이 값은 경유점 도달 시 보라색 LED를 얼마나 오래 표시할지 결정합니다.

duration_sec=self.waypoint_reached_flash_sec

즉, 기본값은 0.3초입니다.

현장에서 LED flash가 너무 짧으면 다음처럼 늘릴 수 있습니다.

-p waypoint_reached_flash_sec:=0.7

14) 종료 시 LED 끄기 기능 추가

새 소스에는 destroy_node()가 재정의되어 있습니다.

def destroy_node(self):
try:
self.set_robot_state(RobotState.IDLE)
except Exception:
pass

super().destroy_node()

이 기능은 노드가 종료될 때 LED를 꺼주는 역할을 합니다. IDLE 상태는 RGB 값이 (0, 0, 0)으로 설정되어 있으므로 LED OFF 상태입니다.

기존 소스는 종료 시 노드만 destroy하고 ROS를 shutdown합니다.

finally:
node.destroy_node()
rclpy.shutdown()

새 소스도 최종적으로는 destroy_node()를 호출하지만, 그 안에서 LED를 OFF하는 처리가 추가되었습니다.

실제 로봇에서는 프로그램이 종료됐는데 LED가 계속 켜져 있으면 상태를 오해할 수 있습니다. 따라서 종료 시 LED를 끄는 처리는 좋은 추가 기능입니다.

15) 긴급 정지 상태 표시 추가

기존 소스에서도 KeyboardInterrupt는 처리합니다.

except KeyboardInterrupt:
node.get_logger().warn("Keyboard interrupt received")
node.speak_with_effect('사용자에 의해 주행이 중지되었습니다', 'warning')

새 소스는 여기에 긴급정지 상태 LED 표시가 추가되었습니다.

except KeyboardInterrupt:
node.get_logger().warn('Keyboard interrupt received')
node.set_robot_state(RobotState.EMERGENCY_STOP)
node.speak_with_effect('사용자에 의해 주행이 중지되었습니다', 'warning')
time.sleep(0.5)

즉, 사용자가 Ctrl+C로 중단하면 빨간색 LED와 음성 안내가 함께 출력됩니다.

단, 실전 운용 기준으로는 아래 코드도 같이 추가하는 것이 더 안전합니다.

node.navigator.cancelTask()

현재 새 소스는 긴급정지 상태 표시는 하지만, Nav2 task를 명시적으로 cancel하지는 않습니다. 실제 로봇에서는 사용자 중단 시 주행 task도 확실히 취소하는 것이 맞습니다.

5. setup.py 등록 예시

ROS 2 Python 패키지에서 ros2 run으로 실행하려면 setup.pyentry_points에 등록하는 것이 좋습니다.

예시는 다음과 같습니다.

entry_points={
    'console_scripts': [
            'amcl_waypoint_recorder = tb3_waypoint_nav.amcl_waypoint_recorder:main',
            'waypoint_follower_yaml = tb3_waypoint_nav.waypoint_follower_yaml:main',
            'simple_audio_command_publisher = tb3_waypoint_nav.simple_audio_command_publisher:main',
            'simple_waypoint_follower = tb3_waypoint_nav.simple_waypoint_follower:main',
            'robot_state_led_waypoint_follower = tb3_waypoint_nav.robot_state_led_waypoint_follower:main',
    ],
},

수정 후 빌드합니다.

colcon build --packages-select tb3_waypoint_nav
source install/setup.bash

6. 의존 패키지

이 예제에서 사용하는 주요 의존성은 다음과 같습니다.

rclpy
geometry_msgs
nav2_simple_commander
robot_audio_interfaces
rgb_led_interfaces

package.xml에는 상황에 맞게 다음 의존성을 추가합니다.

<depend>rclpy</depend>
<depend>geometry_msgs</depend>
<depend>nav2_simple_commander</depend>
<depend>robot_audio_interfaces</depend>
<depend>rgb_led_interfaces</depend>

<exec_depend>python3-yaml</exec_depend>

rgb_led_interfaces에는 RgbLed.msg가 있어야 합니다.

예상 메시지 구조는 다음과 같습니다.

bool enable
uint8 r
uint8 g
uint8 b

robot_audio_interfaces에는 AudioCommand.msg가 있어야 합니다.

기존 오디오 시스템을 사용하지 않는다면 오디오 관련 코드를 제거하고 LED 상태 표시만 사용해도 됩니다.

7. LED 출력 + Audio 출력 노드 실행

TurtleBot3의 turtlebot3_ws/src/turtlebot3/turtlebot3_bringup/launch에 audio_led_nodes.launch.py이름으로 아래의 파일을 생성합니다.

from launch import LaunchDescription
from launch_ros.actions import Node
from launch.substitutions import LaunchConfiguration
from launch.actions import DeclareLaunchArgument


def generate_launch_description():

    port = LaunchConfiguration('port', default='/dev/tb3_sensor')

    return LaunchDescription([
        DeclareLaunchArgument(
            'port',
            default_value=port,
            description='Connected USB port with Arduino Uno Board'),

        Node(
            package='rgb_led_serial',
            executable='serial_led_node',
            name='serial_led_node',
            parameters=[
                {'port': port},
            ],
            output='screen',
        ),

        Node(
            package='robot_audio_output',
            executable='audio_output_node',
            name='audio_output_node',           
            output='screen',
       )
    ])

.bashrc에 rgb_ed_ws의 활성화 명령어도 추가합니다.

echo 'source ~/rgb_led_ws/install/setup.bash' >> ~/.bashrc
source ~/.bashrc

9. 노드 실행

1) 로봇 터미널 1 : turtlebot3_bringup 실행

ros2 launch turtlebot3_bringup robot.launch.py 

2) 로봇 터미널 2 : audio_led_nodes 실행

ros2 launch turtlebot3_bringup audio_led_nodes.launch.py 

3) Remote PC 터미널 3: Nav2 실행

경유점 주행 노드는 Nav2가 실행된 상태에서 동작합니다. 아래의 명령 중 개인환경에 적합하게 수정한 후 실행하시기 바랍니다.

ros2 launch turtlebot3_navigation2 navigation2.launch.py map:=/home/sjyong/turtlebot3_ws/src/turtlebot3/turtlebot3_navigation2/map/map.yaml

4) Remote PC 터미널 4: 로봇 상태 LED 경유점 주행 노드 실행

Nav2가 준비된 뒤 이번에 만든 노드를 실행합니다.

ros2 run tb3_waypoint_nav robot_state_led_waypoint_follower --ros-args -p wp_file:=/home/sjyong/turtlebot3_ws/src/tb3_waypoint_nav/waypoints/recorded_waypoints.yaml

파라미터를 지정해서 실행할 수도 있습니다.

ros2 run robot_status_led robot_state_led_waypoint_follower \
  --ros-args \
  -p led_topic:=/rgb_led/cmd \
  -p audio_topic:=/audio/command \
  -p feedback_period_sec:=0.5 \
  -p waypoint_reached_flash_sec:=0.3

실행하면 로봇 상태에 따라 LED 색상이 바뀝니다.

BOOTING        → 흰색
NAV2_WAITING   → 주황색
READY          → 파란색
MISSION_START  → 청록색
MOVING         → 초록색
SUCCEEDED      → 초록색
FAILED         → 빨간색
CANCELED       → 노란색

5) Remote PC 터미널 5: RGB LED 토픽 확인

LED 메시지가 정상적으로 발행되는지 확인합니다.

ros2 topic echo /rgb_led/cmd

예상 출력은 다음과 같습니다.

enable: true
r: 0
g: 255
b: 0

이 값은 초록색을 의미합니다.

R = 0
G = 255
B = 0

즉, 로봇이 주행 중이거나 미션 성공 상태일 가능성이 높습니다.

6) 수동으로 LED 테스트하기

주행 노드를 실행하기 전에 LED만 먼저 테스트할 수도 있습니다.

ros2 topic pub /rgb_led/cmd rgb_led_interfaces/msg/RgbLed \
"{enable: true, r: 255, g: 0, b: 0}"

위 명령은 LED를 빨간색으로 켭니다.

초록색 테스트:

ros2 topic pub /rgb_led/cmd rgb_led_interfaces/msg/RgbLed \
"{enable: true, r: 0, g: 255, b: 0}"

파란색 테스트:

ros2 topic pub /rgb_led/cmd rgb_led_interfaces/msg/RgbLed \
"{enable: true, r: 0, g: 0, b: 255}"

LED 끄기:

ros2 topic pub /rgb_led/cmd rgb_led_interfaces/msg/RgbLed \
"{enable: false, r: 0, g: 0, b: 0}"

이 테스트가 정상적으로 동작하면 RGB LED 시리얼 노드는 제대로 동작하는 것입니다.
배터리 전압이 낮아지면 LED를 주황색 또는 빨간색으로 표시할 수 있습니다.

LOW_BATTERY = 'low_battery'
RobotState.LOW_BATTERY: (255, 80, 0)

2) 장애물 감지 상태

장애물이 감지되면 노란색 점멸로 표시할 수 있습니다.

OBSTACLE_DETECTED = 'obstacle_detected'
RobotState.OBSTACLE_DETECTED: (255, 255, 0)

3) 수동 조작 모드

자율주행이 아니라 조이스틱이나 RC로 조작 중이라면 별도 색상을 줄 수 있습니다.

MANUAL_CONTROL = 'manual_control'
RobotState.MANUAL_CONTROL: (0, 120, 255)

4) 도킹 상태

충전 도킹 중이라면 파란색 점멸, 충전 완료라면 초록색으로 표시할 수 있습니다.

DOCKING = 'docking'
CHARGING = 'charging'
CHARGE_COMPLETE = 'charge_complete'

10. device rules 파일

99-turtlebot3-cdc.rules 파일을 수정합니다. 아래와 같이 첫번째 줄을 주석처리합니다.


#ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", ENV{ID_MM_DEVICE_IGNORE}="1", MODE:="0666"
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="df11", MODE:="0666"
ATTRS{idVendor}=="fff1", ATTRS{idProduct}=="ff48", ENV{ID_MM_DEVICE_IGNORE}="1", MODE:="0666"
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", ATTRS{serial}=="0001", ENV{ID_MM_DEVICE_IGNORE}="1", MODE:="0666", SYMLINK+="tb3_lidar"

파일 이름 : 99-arduino-opencr.rules

ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", MODE:="0666", SYMLINK+="tb3_opencr"
ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="2341", ATTRS{idProduct}=="0043", MODE:="0666", SYMLINK+="tb3_sensor"

위의 파일을 /etc/udev/rules.d/에 복사합니다.

sudo cp 99-arduino-opencr.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger

cd /dev
ls -al

turtlebot3 bringup 실행

 ros2 launch turtlebot3_bringup robot.launch.py usb_port:=/dev/tb3_opencr

audio_led_nodes.launch.py 실행

ros2 launch turtlebot3_bringup audio_led_nodes.launch.py port:=/dev/tb3_sensor

Leave a Comment