이번에 사용할 센서는 IR 적외선 장애물 감지 센서 모듈입니다. 이 센서는 정확한 거리 측정 센서가 아닙니다.
초음파 센서 HC-SR04나 ToF 센서 VL53L0X처럼 cm, mm 단위 거리를 안정적으로 주는 센서가 아니라, 적외선을 쏘고 반사광을 받아서 장애물이 감지되었는지 여부를 판단하는 센서입니다.
그래서 이 강의에서는 ROS 2 토픽에 다음처럼 발행하는 구조가 현실적입니다.
detected = true / false
distance_mm = 0 또는 -1
raw = Arduino에서 읽은 디지털 값
distance_mm = 0은 장애물이 감지되었다는 의미로 쓰고,distance_mm = -1은 거리 측정 불가 또는 장애물 없음으로 처리합니다.
1. 전체 시스템 구조

2. 센서 모듈 설명
사진의 센서 모듈은 다음 부품으로 구성되어 있습니다.


동작 방식은 단순합니다.
센서의 적외선 LED가 전방으로 빛을 쏩니다.
장애물이 가까이 있으면 적외선이 반사되어 수신부로 들어옵니다.
비교기 IC가 반사량을 기준값과 비교합니다.
기준값은 모듈 위의 가변저항으로 조절합니다.
결과는 OUT 핀으로 출력됩니다.
대부분의 이 계열 모듈은 다음처럼 동작합니다.
장애물 감지됨 → OUT = LOW
장애물 없음 → OUT = HIGH
단, 모듈 제조사에 따라 반대로 동작할 수도 있으므로 반드시 Windows Serial Monitor에서 먼저 확인해야 합니다.
3. Arduino UNO 배선
기본 배선은 다음처럼 합니다.
센서 EN → Arduino D3(점퍼 핀이 연결되어 있는 경우에는 D3에 연결하지 않음)
센서 VCC → Arduino 5V
센서 OUT → Arduino D2
센서 GND → Arduino GND
Arduino 내장 LED → D13
모듈에 EN 핀이 있는 경우는 보통 Enable 핀입니다. 보드에 있는 점퍼 연결 헤더에 점퍼 핀이 장착되어 있으면 EN핀을 연결하지 않습니다.
4. Arduino 라이브러리 설치 내용
이 예제는 별도 센서 라이브러리가 필요 없습니다.
Arduino에서 사용하는 기능은 기본 내장 기능입니다.
Serial.begin()
Serial.println()
Serial.available()
Serial.readStringUntil()
pinMode()
digitalRead()
digitalWrite()
Windows에서 준비할 것은 다음 정도입니다.
Arduino IDE 설치
Arduino AVR Boards 설치 확인
보드: Arduino Uno 선택
포트: COM 포트 선택
클론 보드라면 CH340 USB 드라이버 설치 필요 가능
Arduino IDE에서 확인할 메뉴는 다음입니다.
Tools → Board → Arduino AVR Boards → Arduino Uno
Tools → Port → COMx
Tools → Serial Monitor
Baudrate → 115200
Line ending → Newline
5. Arduino 펌웨어
이 펌웨어는 두 가지 일을 합니다.
첫째, 장애물 감지 센서 값을 읽어서 시리얼로 전송합니다.
OBS,detected,raw,distance_mm
예시는 다음과 같습니다.
OBS,1,0,0
OBS,0,1,-1
의미는 다음과 같습니다.
OBS : 장애물 센서 데이터
detected : 1이면 장애물 감지, 0이면 감지 안 됨
raw : Arduino digitalRead() 원본 값
distance_mm: 실제 거리값이 아니라 상태 표현용 값
0 = 장애물 감지
-1 = 장애물 없음 또는 거리 측정 불가
둘째, Raspberry Pi 또는 Windows Serial Monitor에서 LED 제어 명령을 받습니다.
LED,1
LED,0
의미는 다음과 같습니다.
LED,1 → Arduino 내장 LED ON
LED,0 → Arduino 내장 LED OFF
obstacle_led_firmware.ino
/*
Arduino UNO + IR Obstacle Sensor + Serial LED Control
Sensor:
VCC -> 5V
GND -> GND
OUT -> D2
LED:
Built-in LED -> D13
Serial output:
OBS,detected,raw,distance_mm
Serial input:
LED,1
LED,0
*/
#define SENSOR_PIN 2
#define EN_PIN 3
#define LED_PIN 13
// 대부분의 IR 장애물 감지 센서는 장애물 감지 시 OUT이 LOW가 됩니다.
// 만약 테스트 결과 반대로 동작하면 true를 false로 바꾸면 됩니다.
const bool SENSOR_ACTIVE_LOW = true;
const unsigned long SENSOR_SEND_INTERVAL_MS = 100;
unsigned long last_sensor_send_time = 0;
void setup()
{
pinMode(SENSOR_PIN, INPUT);
pinMode(LED_PIN, OUTPUT);
#pinMode(EN_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
#digitalWrite(EN_PIN, HIGH);
Serial.begin(115200);
delay(1000);
Serial.println("READY");
}
void loop()
{
handleSerialCommand();
unsigned long now = millis();
if (now - last_sensor_send_time >= SENSOR_SEND_INTERVAL_MS)
{
last_sensor_send_time = now;
sendObstacleData();
}
}
void sendObstacleData()
{
int raw_value = digitalRead(SENSOR_PIN);
bool detected;
if (SENSOR_ACTIVE_LOW)
{
detected = (raw_value == LOW);
}
else
{
detected = (raw_value == HIGH);
}
int distance_mm;
if (detected)
{
// 이 센서는 실제 거리 센서가 아닙니다.
// 장애물이 감지되었다는 상태 표현으로 0을 보냅니다.
distance_mm = 0;
}
else
{
// 거리 측정 불가 또는 장애물 없음
distance_mm = -1;
}
Serial.print("OBS,");
Serial.print(detected ? 1 : 0);
Serial.print(",");
Serial.print(raw_value);
Serial.print(",");
Serial.println(distance_mm);
}
void handleSerialCommand()
{
if (Serial.available() <= 0)
{
return;
}
String line = Serial.readStringUntil('\n');
line.trim();
if (line.length() == 0)
{
return;
}
if (line == "LED,ON")
{
digitalWrite(LED_PIN, HIGH);
Serial.println("ACK,LED,ON");
}
else if (line == "LED,OFF")
{
digitalWrite(LED_PIN, LOW);
Serial.println("ACK,LED,OFF");
}
else
{
Serial.print("ERR,UNKNOWN_CMD,");
Serial.println(line);
}
}

6. Windows에서 Arduino 펌웨어 먼저 테스트
Arduino IDE에서 위 코드를 업로드합니다.

업로드 후 Serial Monitor를 엽니다.
설정은 다음처럼 합니다.
Baudrate: 115200
Line ending: Newline
정상이라면 다음과 같은 데이터가 반복 출력됩니다.
READY
OBS,0,1,-1
OBS,0,1,-1
OBS,1,0,0
OBS,1,0,0
손이나 물체를 센서 앞에 가까이 가져가면 detected 값이 바뀌어야 합니다.

OBS,1,0,0
손을 치우면 다시 다음처럼 바뀝니다.
OBS,0,1,-1
LED 제어 테스트는 Serial Monitor 입력창에 다음을 입력합니다.
LED,1
Arduino UNO의 내장 LED가 켜져야 합니다.
ACK,LED,1

끄려면 다음을 입력합니다.
LED,0
응답은 다음처럼 나옵니다.
ACK,LED,0

이 단계가 성공해야 ROS 2 작업으로 넘어가는 것이 맞습니다.
시리얼 테스트가 실패한 상태에서 ROS 2로 넘어가면 문제 원인을 찾기 어렵습니다.
7. ROS 2 토픽 인터페이스 정의
작업 공간을 만듭니다.
mkdir -p ~/sensor_ws/src
cd ~/sensor_ws/src
인터페이스 패키지를 생성합니다.
ros2 pkg create sensor_interfaces --build-type ament_cmake
메시지 폴더를 만듭니다.
cd ~/sensor_ws/src/sensor_interfaces
mkdir msg
만약에 sensor_interfaces 폴더가 존재하면 위의 과정을 생략합니다.
인터페이스를 정의합니다.
nano msg/ObstacleStatus.msg
1) msg/ObstacleStatus.msg
std_msgs/Header header
bool detected
int32 raw
int32 distance_mm
string sensor_model

필드 의미는 다음과 같습니다.
header.stamp : ROS 2 노드가 데이터를 받은 시간
header.frame_id: 센서가 장착된 기준 프레임 이름
detected : 장애물 감지 여부
raw : Arduino digitalRead() 원본 값
distance_mm : 상태 표현용 거리값
0 = 장애물 감지
-1 = 거리 측정 불가 또는 장애물 없음
sensor_model : 센서 이름
2) sensor_interfaces/package.xml
기존 내용을 아래처럼 수정합니다.
<?xml version="1.0"?>
<package format="3">
<name>sensor_interfaces</name>
<version>0.0.1</version>
<description>Custom interfaces for TurtleBot3 obstacle sensor and Arduino LED control</description>
<maintainer email="user@example.com">user</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<depend>std_msgs</depend>
<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>

<build_type>ament_cmake</build_type>가 빠지면 ROS 2 패키지 인식 문제로 삽질하는 경우가 많습니다. 인터페이스 패키지에서는 꼭 넣어두는 것이 좋습니다.
3) sensor_interfaces/CMakeLists.txt
cmake_minimum_required(VERSION 3.8)
project(sensor_interfaces)
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic)
endif()
find_package(ament_cmake REQUIRED)
find_package(std_msgs REQUIRED)
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/ObstacleStatus.msg"
DEPENDENCIES std_msgs
)
ament_export_dependencies(rosidl_default_runtime)
ament_package()

8. ROS 2 Arduino Serial Bridge 패키지 생성
cd ~/sensor_ws/src
ros2 pkg create tb3_obstacle_bridge --build-type ament_python --dependencies rclpy sensor_interfaces

Python 시리얼 통신을 위해 pyserial을 설치합니다.
sudo apt update
sudo apt install -y python3-pip
pip3 install pyserial
사용자가 /dev/ttyACM0 또는 /dev/ttyUSB0에 접근할 수 있도록 권한을 추가합니다.
sudo usermod -aG dialout $USER
이 명령 후에는 로그아웃 후 다시 로그인하거나 재부팅해야 합니다. 이미 관련 파일들을 설치한 경우 위의 과정을 생략합니다.
Arduino가 잡힌 포트를 확인합니다.
ls /dev/ttyACM*
ls /dev/ttyUSB*
일반 Arduino UNO 정품 계열은 보통 다음입니다.
/dev/ttyACM1
CH340 클론 보드는 보통 다음입니다.
/dev/ttyUSB0
9. ROS 2 Serial Bridge 노드
파일을 만듭니다.
cd ~/sensor_ws/src/tb3_obstacle_bridge/tb3_obstacle_bridge
nano arduino_obstacle_bridge.py
arduino_obstacle_bridge.py
#!/usr/bin/env python3
import serial
import rclpy
from rclpy.node import Node
from sensor_interfaces.msg import ObstacleStatus
class ArduinoObstacleBridge(Node):
def __init__(self):
super().__init__('arduino_obstacle_bridge')
self.declare_parameter('port', '/dev/ttyACM1')
self.declare_parameter('baudrate', 115200)
self.declare_parameter('frame_id', 'ir_obstacle_sensor')
self.declare_parameter('sensor_model', 'IR obstacle avoidance sensor')
self.port = self.get_parameter('port').value
self.baudrate = int(self.get_parameter('baudrate').value)
self.frame_id = self.get_parameter('frame_id').value
self.sensor_model = self.get_parameter('sensor_model').value
self.publisher = self.create_publisher(
ObstacleStatus,
'/obstacle/status',
10
)
self.serial_port = None
self.connect_serial()
self.timer = self.create_timer(0.01, self.read_serial_timer_callback)
self.get_logger().info('Arduino obstacle bridge node started')
self.get_logger().info(f'Serial port: {self.port}')
self.get_logger().info(f'Baudrate: {self.baudrate}')
def connect_serial(self):
try:
self.serial_port = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=0.01
)
self.get_logger().info('Serial port opened successfully')
except serial.SerialException as e:
self.serial_port = None
self.get_logger().error(f'Failed to open serial port: {e}')
def read_serial_timer_callback(self):
if self.serial_port is None:
return
try:
while self.serial_port.in_waiting > 0:
line = self.serial_port.readline()
line = line.decode('utf-8', errors='ignore').strip()
if len(line) == 0:
continue
self.handle_serial_line(line)
except serial.SerialException as e:
self.get_logger().error(f'Serial read error: {e}')
self.serial_port = None
def handle_serial_line(self, line):
if line == 'READY':
self.get_logger().info('Arduino is ready')
return
if line.startswith('OBS,'):
self.parse_obstacle_line(line)
return
self.get_logger().debug(f'Ignored serial line: {line}')
def parse_obstacle_line(self, line):
# Expected format:
# OBS,detected,raw,distance_mm
#
# Example:
# OBS,1,0,0
# OBS,0,1,-1
tokens = line.split(',')
if len(tokens) != 4:
self.get_logger().warn(f'Invalid OBS format: {line}')
return
try:
detected = bool(int(tokens[1]))
raw = int(tokens[2])
distance_mm = int(tokens[3])
except ValueError:
self.get_logger().warn(f'Invalid OBS value: {line}')
return
msg = ObstacleStatus()
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = self.frame_id
msg.detected = detected
msg.raw = raw
msg.distance_mm = distance_mm
msg.sensor_model = self.sensor_model
self.publisher.publish(msg)
def main(args=None):
rclpy.init(args=args)
node = ArduinoObstacleBridge()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
if node.serial_port is not None:
node.serial_port.close()
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()

10. ROS 2 Python 노드 소스 설명
필요한 모듈을 가져옵니다.
import serial
import rclpy
from rclpy.node import Node
from tb3_obstacle_interfaces.msg import ObstacleStatus
serial은 Arduino와 USB Serial 통신을 하기 위한 Python 모듈입니다.rclpy는 Python에서 ROS 2 노드를 만들기 위한 라이브러리입니다.ObstacleStatus는 우리가 직접 만든 메시지 타입입니다.
노드 이름은 다음에서 정합니다.
super().__init__('arduino_obstacle_bridge')
ROS 2에서 이 노드는 다음 이름으로 보입니다.
/arduino_obstacle_bridge
파라미터는 다음처럼 선언합니다.
self.declare_parameter('port', '/dev/ttyACM0')
self.declare_parameter('baudrate', 115200)
self.declare_parameter('frame_id', 'ir_obstacle_sensor')
self.declare_parameter('sensor_model', 'IR obstacle avoidance sensor')
이렇게 해두면 실행할 때 포트를 바꿀 수 있습니다.
예를 들어 Arduino가 /dev/ttyUSB0에 잡혔다면 다음처럼 실행할 수 있습니다.
ros2 run tb3_obstacle_bridge arduino_obstacle_bridge --ros-args -p port:=/dev/ttyUSB0
토픽 발행자는 다음 코드에서 생성합니다.
self.publisher = self.create_publisher(
ObstacleStatus,
'/obstacle/status',
10
)
이 노드는 /obstacle/status 토픽으로 ObstacleStatus 메시지를 발행합니다.
시리얼 포트 연결은 다음 함수에서 처리합니다.
def connect_serial(self):
실제 연결 코드는 다음입니다.
self.serial_port = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=0.01
)
Arduino 펌웨어에서 Serial.begin(115200)을 사용했기 때문에 Raspberry Pi 쪽도 반드시 115200으로 맞춰야 합니다.
주기적으로 시리얼 데이터를 읽기 위해 타이머를 사용합니다.
self.timer = self.create_timer(0.01, self.read_serial_timer_callback)
0.01초마다 한 번씩 시리얼 버퍼를 확인합니다.
즉, 100Hz 주기로 읽기 루프가 실행됩니다.
시리얼 버퍼에 데이터가 있으면 한 줄씩 읽습니다.
while self.serial_port.in_waiting > 0:
line = self.serial_port.readline()
line = line.decode('utf-8', errors='ignore').strip()
Arduino가 보내는 데이터는 다음 형식입니다.
OBS,1,0,0
이 문자열은 다음 함수에서 처리됩니다.
self.handle_serial_line(line)
READY 메시지는 Arduino가 부팅되었다는 의미입니다.
if line == 'READY':
self.get_logger().info('Arduino is ready')
return
OBS,로 시작하는 줄만 센서 데이터로 처리합니다.
if line.startswith('OBS,'):
self.parse_obstacle_line(line)
return
센서 데이터 파싱은 다음에서 수행합니다.
tokens = line.split(',')
예를 들어 다음 문자열이 들어오면,
OBS,1,0,0
분리 결과는 다음과 같습니다.
tokens[0] = OBS
tokens[1] = 1
tokens[2] = 0
tokens[3] = 0
문자열을 정수로 변환합니다.
detected = bool(int(tokens[1]))
raw = int(tokens[2])
distance_mm = int(tokens[3])
그 다음 ROS 2 메시지를 생성합니다.
msg = ObstacleStatus()
시간과 프레임 이름을 넣습니다.
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = self.frame_id
센서 값을 메시지에 넣습니다.
msg.detected = detected
msg.raw = raw
msg.distance_mm = distance_mm
msg.sensor_model = self.sensor_model
마지막으로 토픽으로 발행합니다.
self.publisher.publish(msg)
11. setup.py 수정
cd ~/sensor_ws/src/tb3_obstacle_bridge
nano setup.py
아래처럼 수정합니다.
from setuptools import setup
package_name = 'tb3_obstacle_bridge'
setup(
name=package_name,
version='0.0.1',
packages=[package_name],
data_files=[
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
],
install_requires=[
'setuptools',
'pyserial',
],
zip_safe=True,
maintainer='user',
maintainer_email='user@example.com',
description='Arduino serial bridge for IR obstacle sensor',
license='Apache-2.0',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'arduino_obstacle_bridge = tb3_obstacle_bridge.arduino_obstacle_bridge:main',
],
},
)
중요한 부분은 다음입니다.
'arduino_obstacle_bridge = tb3_obstacle_bridge.arduino_obstacle_bridge:main',
이 설정 때문에 다음 명령으로 노드를 실행할 수 있습니다.
ros2 run tb3_obstacle_bridge arduino_obstacle_bridge

12. package.xml 수정
nano package.xml
<?xml version="1.0"?>
<package format="3">
<name>tb3_obstacle_bridge</name>
<version>0.0.1</version>
<description>Arduino serial bridge for IR obstacle sensor</description>
<maintainer email="user@example.com">user</maintainer>
<license>Apache-2.0</license>
<exec_depend>rclpy</exec_depend>
<exec_depend>sensor_interfaces</exec_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>

13. 실행 권한 부여
chmod +x ~/sensor_ws/src/tb3_obstacle_bridge/tb3_obstacle_bridge/arduino_obstacle_bridge.py

14. ROS 2 빌드
cd ~/sensor_ws
colcon build
source install/setup.bash
매번 source 명령을 입력하기 싫다면 .bashrc에 추가합니다.
echo "source ~/sensorws/install/setup.bash" >> ~/.bashrc

15. ROS 2 실행
Arduino를 Raspberry Pi 4에 USB로 연결합니다.
포트를 확인합니다.
ls /dev/ttyACM*
ls /dev/ttyUSB*
Arduino가 /dev/ttyACM1이면 다음처럼 실행합니다.
ros2 run tb3_obstacle_bridge arduino_obstacle_bridge --ros-args -p port:=/dev/ttyACM1
Arduino가 /dev/ttyUSB0이면 다음처럼 실행합니다.
ros2 run tb3_obstacle_bridge arduino_obstacle_bridge --ros-args -p port:=/dev/ttyUSB0
심볼릭 링크가 정의되어 있으면 아래와 같이 실행 명령어를 입력합니다.
ros2 run tb3_obstacle_bridge arduino_obstacle_bridge --ros-args -p port:=/dev/tb3_sensor
다른 터미널에서 토픽 목록을 확인합니다.
source ~/tb3_obstacle_ws/install/setup.bash
ros2 topic list
다음 토픽이 보여야 합니다.
/obstacle/status

토픽 데이터를 확인합니다.
ros2 topic echo /obstacle/status
장애물이 감지되면 다음과 비슷하게 나옵니다.
header:
stamp:
sec: 123
nanosec: 456000000
frame_id: ir_obstacle_sensor
detected: true
raw: 0
distance_mm: 0
sensor_model: IR obstacle avoidance sensor
장애물이 없으면 다음처럼 나옵니다.
header:
stamp:
sec: 124
nanosec: 120000000
frame_id: ir_obstacle_sensor
detected: false
raw: 1
distance_mm: -1
sensor_model: IR obstacle avoidance sensor

16. 토픽 주파수 확인
Arduino에서 100ms마다 데이터를 보내므로 약 10Hz로 토픽이 발행됩니다.
확인은 다음 명령으로 합니다.
ros2 topic hz /obstacle/status
정상이라면 대략 다음과 비슷하게 나옵니다.
average rate: 10.0
정확히 10.0이 아니어도 됩니다.
시리얼 처리, Raspberry Pi 부하, ROS 2 실행 상태에 따라 약간 달라질 수 있습니다.

17. 정리
이 강의의 핵심은 다음입니다.
Arduino는 센서 데이터를 읽는다.
Arduino는 센서 데이터를 시리얼로 보낸다.
Raspberry Pi는 시리얼 데이터를 받는다.
ROS 2 노드는 데이터를 파싱한다.
ROS 2 노드는 /obstacle/status 토픽으로 발행한다.
현재 센서는 실제 거리 센서가 아니기 때문에 distance_mm은 실제 거리값이 아닙니다.
현재 정의는 다음과 같습니다.
detected = true → 장애물 감지
distance_mm = 0
detected = false → 장애물 없음
distance_mm = -1
이 구조로 강의하면 나중에 HC-SR04, VL53L0X, TF-Luna 같은 실제 거리 센서로 확장할 때도 ROS 2 구조를 그대로 이어갈 수 있습니다.
18. 확장
장애물 감지 토픽을 구독하여 주행 중 장애물 감지시 “지금 주행중입니다. 길을 비켜 주세요!” TTS를 출력하도록 robot_state_led_waypoint_follower.py 소스를 수정합니다.