DHT11 온습도 센서 모듈

1. DHT11 센서 모듈

이번 실습에서 사용할 센서는 DHT11 온습도 센서 모듈입니다.

DHT11은 디지털 방식의 온도/습도 센서입니다. 아날로그 센서가 아니라 내부에서 온도와 습도를 측정한 뒤 디지털 신호로 Arduino에 전달합니다.

일반적인 DHT11 모듈 핀 구성은 다음과 같습니다.

S 또는 DATA : 데이터 신호 핀
+ 또는 VCC : 전원 입력
- 또는 GND : 접지

모듈마다 핀 순서가 다를 수 있으므로 보드에 인쇄된 글자를 반드시 확인해야 합니다. 보통 3핀 DHT11 모듈이며, 대부분 37종 키트에서는 S, +, - 순서로 되어 있습니다.

DHT11의 특징은 다음과 같습니다.

측정 온도 범위 : 약 0 ~ 50 ℃
측정 습도 범위 : 약 20 ~ 90 %RH
전원 전압 : 보통 3.3V 또는 5V
통신 방식 : 단일 디지털 데이터 라인
측정 주기 : 약 1초 이상 간격 권장
정밀도 : 교육용, 실습용 수준

정밀한 산업용 센서는 아닙니다. 하지만 ROS 2 토픽 발행, 센서 데이터 수집, 시리얼 통신 실습용으로는 충분합니다.

2. 전체 시스템 구조

전체 데이터 흐름은 다음과 같습니다.

LED 제어 흐름은 반대 방향입니다.

[ROS 2 Topic]

/arduino/led_cmd

[Raspberry Pi 4 ROS 2 Node]
↓ Serial USB
[Arduino UNO]

LED ON/OFF

즉, Arduino는 센서 값을 읽고 시리얼로 보내며, Raspberry Pi는 그 데이터를 ROS 2 토픽으로 바꿔서 발행합니다.
또한 Raspberry Pi는 ROS 2 토픽으로 들어온 LED 명령을 Arduino에 시리얼로 전달합니다.

3. Arduino 회로 연결

1) DHT11 연결

Arduino UNO 기준 연결 예시는 다음과 같습니다.

DHT11 VCC  → Arduino 5V
DHT11 GND → Arduino GND
DHT11 DATA → Arduino D2

2) LED 연결

가장 간단하게는 Arduino UNO의 내장 LED를 사용합니다.

LED_BUILTIN → Arduino D13

외부 LED를 사용할 경우 다음처럼 연결합니다.

Arduino D13 → 220Ω 저항 → LED 애노드(+)
LED 캐소드(-) → GND

4. Arduino 라이브러리 설치

Arduino IDE에서 DHT11을 사용하려면 라이브러리를 설치해야 합니다.

Arduino IDE에서 다음 순서로 설치합니다.

스케치 → 라이브러리 포함하기 → 라이브러리 관리

검색창에 다음을 입력합니다.

DHT sensor library

설치할 라이브러리는 다음입니다.

DHT sensor library by Adafruit

라이브러리를 설치하면 예제가 자동으로 설치됩니다. 예제를 분석하면 라이브러리 사용법을 대략은 확인할 수 있습니다.

설치 후 Arduino 코드에서 다음 헤더를 사용할 수 있습니다.

#include <DHT.h>

5. Arduino 펌웨어

이 펌웨어는 두 가지 일을 합니다.

첫째, DHT11 센서에서 온도와 습도를 읽어서 시리얼로 전송합니다.
둘째, 시리얼로 LED 명령을 받아 내장 LED를 켜거나 끕니다.

파일 이름 예시는 다음과 같습니다.

arduino_dht11_led_serial.ino
#include <DHT.h>

#define DHT_PIN 2
#define DHT_TYPE DHT11

#define LED_PIN LED_BUILTIN

DHT dht(DHT_PIN, DHT_TYPE);

unsigned long previousMillis = 0;
const unsigned long sensorInterval = 2000;

String rxBuffer = "";

void setup()
{
Serial.begin(115200);

dht.begin();

pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);

Serial.println("BOOT,ARDUINO_DHT11_LED_READY");
}

void loop()
{
receiveSerialCommand();

unsigned long currentMillis = millis();

if (currentMillis - previousMillis >= sensorInterval)
{
previousMillis = currentMillis;
publishDht11Data();
}
}

void publishDht11Data()
{
float humidity = dht.readHumidity();
float temperature = dht.readTemperature();

if (isnan(humidity) || isnan(temperature))
{
Serial.println("DHT,ERROR,0.0,0.0");
return;
}

Serial.print("DHT,OK,");
Serial.print(temperature, 1);
Serial.print(",");
Serial.println(humidity, 1);
}

void receiveSerialCommand()
{
while (Serial.available() > 0)
{
char c = Serial.read();

if (c == '\n')
{
rxBuffer.trim();
processCommand(rxBuffer);
rxBuffer = "";
}
else
{
rxBuffer += c;
}
}
}

void processCommand(String command)
{
command.toUpperCase();

if (command == "LED,ON")
{
digitalWrite(LED_PIN, HIGH);
Serial.println("ACK,LED,ON");
}
else if (command == "LED,OFF")
{
digitalWrite(LED_PIN, LOW);
Serial.println("ACK,LED,OFF");
}
else
{
Serial.print("ERR,UNKNOWN_CMD,");
Serial.println(command);
}
}

6. Arduino 펌웨어 설명

1) 라이브러리 포함

#include <DHT.h>

DHT11 센서를 쉽게 사용하기 위한 라이브러리입니다. 직접 타이밍 신호를 구현하지 않고 라이브러리 함수로 온도와 습도를 읽을 수 있습니다.

2) 센서 핀 설정

#define DHT_PIN 2
#define DHT_TYPE DHT11

DHT11의 DATA 핀을 Arduino D2에 연결한다는 뜻입니다.

센서 종류는 DHT11로 지정합니다.

3) LED 핀 설정

#define LED_PIN LED_BUILTIN

Arduino UNO의 내장 LED를 사용합니다. UNO에서는 보통 D13 핀과 연결되어 있습니다.

외부 LED를 D8에 연결하고 싶다면 다음처럼 바꾸면 됩니다.

#define LED_PIN 8

4) DHT 객체 생성

DHT dht(DHT_PIN, DHT_TYPE);

DHT 라이브러리에서 센서를 제어하기 위한 객체입니다.
센서 핀 번호와 센서 종류를 넘겨서 생성합니다.

5) 시리얼 통신 시작

Serial.begin(115200);

Arduino와 Raspberry Pi 또는 Windows PC가 통신할 속도입니다.

Raspberry Pi의 Python 코드에서도 반드시 같은 속도를 사용해야 합니다.

115200 bps

6) 센서 측정 주기

const unsigned long sensorInterval = 2000;

DHT11은 너무 빠르게 읽으면 오류가 날 수 있습니다.
그래서 2초마다 한 번씩 읽도록 했습니다.

7) 센서 데이터 전송 형식

정상 데이터는 다음 형식으로 전송합니다.

DHT,OK,온도,습도

예시는 다음과 같습니다.

DHT,OK,25.3,48.0

센서 읽기에 실패하면 다음처럼 보냅니다.

DHT,ERROR,0.0,0.0

이렇게 CSV 형태로 정하면 Raspberry Pi에서 파싱하기 쉽습니다.

8) LED 명령 수신

Arduino는 다음 명령을 받습니다.

LED,ON
LED,OFF

Windows 시리얼 모니터나 Raspberry Pi에서 LED,ON을 보내면 LED가 켜집니다.

if (command == "LED,ON")
{
digitalWrite(LED_PIN, HIGH);
Serial.println("ACK,LED,ON");
}

명령을 처리한 뒤 Arduino는 응답을 보냅니다.

ACK,LED,ON
ACK,LED,OFF

이 응답은 나중에 디버깅할 때 매우 유용합니다.

7. Windows에서 Arduino 펌웨어 먼저 테스트하기

ROS 2 작업 전에 반드시 Windows에서 Arduino 단독 테스트를 먼저 하는 것이 좋습니다.

순서는 다음과 같습니다.

  1. Arduino UNO에 DHT11 연결
  2. Arduino IDE 실행
  3. 보드 선택
도구 → 보드 → Arduino Uno
  1. 포트 선택
도구 → 포트 → COMx
  1. 위 펌웨어 업로드
  2. 시리얼 모니터 실행
  3. 통신 속도 설정
115200 baud
  1. 줄 끝 설정
Newline

정상이라면 시리얼 모니터에 다음과 같이 출력됩니다.

BOOT,ARDUINO_DHT11_LED_READY
DHT,OK,25.0,45.0
DHT,OK,25.1,45.0
DHT,OK,25.1,46.0

시리얼 모니터 입력창에 다음을 입력합니다.

LED,ON

내장 LED가 켜지고 다음 응답이 나와야 합니다.

ACK,LED,ON

다음 명령을 입력합니다.

LED,OFF

내장 LED가 꺼지고 다음 응답이 나와야 합니다.

ACK,LED,OFF

센서 오류가 계속 나오는 경우 확인할 항목은 다음과 같습니다.

DHT11 VCC가 5V에 연결되었는지 확인
DHT11 GND가 GND에 연결되었는지 확인
DHT11 DATA가 D2에 연결되었는지 확인
DHT_PIN 번호가 실제 배선과 같은지 확인
라이브러리가 정상 설치되었는지 확인
시리얼 모니터 속도가 115200인지 확인

8. ROS 2 패키지 구성

Raspberry Pi 4의 Ubuntu 22.04 Server에서 ROS 2 Humble을 사용합니다.

Raspberry Pi 4에서 ROS 2 워크스페이스를 생성합니다.

mkdir -p ~/sensor_ws/src
cd ~/sensor_ws/src

다음은 메시지 인터페이스 패키지를 만듭니다.

cd ~/sensor_ws/src
ros2 pkg create sensor_interfaces --build-type ament_cmake

메시지 폴더를 생성합니다.

cd ~/sensor_ws/src/sensor_interfaces 
mkdir msg

시리얼 브리지 노드 패키지를 만듭니다.

cd ~/sensor_ws/src
ros2 pkg create arduino_dht11_bridge --build-type ament_python --dependencies rclpy std_msgs sensor_interfaces

Python에서 시리얼 통신을 하기 위해 pyserial을 설치합니다. 이미 설치되어 있으면 생략 가능합니다.

sudo apt update
sudo apt install python3-pip
pip3 install pyserial

Ubuntu에서 Arduino USB 포트 접근 권한을 위해 사용자를 dialout 그룹에 추가합니다.

sudo usermod -a -G dialout $USER

이 명령 후에는 로그아웃 후 다시 로그인하거나 재부팅해야 합니다.

Arduino를 Raspberry Pi에 USB로 연결한 뒤 포트를 확인합니다.

ls /dev/ttyACM*
ls /dev/ttyUSB*

Arduino UNO는 보통 다음 중 하나로 잡힙니다.

/dev/ttyACM0
/dev/ttyUSB0

9. dht11_interfaces 메시지 정의

메시지 이름은 다음과 같이 정의합니다.

nano Dht11.msg

메시지 내용은 다음과 같습니다.

float32 temperature
float32 humidity
string status

각 필드 의미는 다음과 같습니다.

temperature : 섭씨 온도 값
humidity : 상대 습도 값
status : 센서 상태 문자열

예를 들면 토픽 데이터는 다음처럼 나갑니다.

temperature: 25.0
humidity: 48.0
status: "OK"

LED 제어 명령은 ROS 2 기본 메시지인 std_msgs/msg/Bool을 사용하겠습니다.

true  → LED ON
false → LED OFF

토픽 이름은 다음과 같이 사용합니다.

/dht11/data
/arduino/led_cmd

1) package.xml 수정

nano package.xml

아래 내용을 확인하거나 추가합니다.

<buildtool_depend>ament_cmake</buildtool_depend>

<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>

<member_of_group>rosidl_interface_packages</member_of_group>

전체 예시는 다음과 같습니다.

<?xml version="1.0"?>
<package format="3">
<name>dht11_interfaces</name>
<version>0.0.0</version>
<description>DHT11 custom interface package</description>
<maintainer email="user@example.com">user</maintainer>
<license>Apache-2.0</license>

<buildtool_depend>ament_cmake</buildtool_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>

여기서 중요한 부분은 export 안의 build_type입니다.

<export>
<build_type>ament_cmake</build_type>
</export>

이 부분이 빠지면 ROS 2 패키지 빌드나 인식 과정에서 문제가 날 수 있습니다.

2) CMakeLists.txt 수정

nano CMakeLists.txt

내용을 다음처럼 수정합니다.

10. ROS 2 Serial Bridge 노드 작성

패키지 폴더로 이동합니다.

cd ~/sensor_ws/src/arduino_dht11_bridge/arduino_dht11_bridge

파일을 만듭니다.

nano serial_bridge_node.py

내용은 다음과 같습니다.

import rclpy
from rclpy.node import Node

from std_msgs.msg import Bool
from sensor_interfaces.msg import Dht11

import serial
import threading
import time


class ArduinoDht11Bridge(Node):
    def __init__(self):
        super().__init__('arduino_dht11_bridge')

        self.declare_parameter('port', '/dev/ttyACM1')
        self.declare_parameter('baudrate', 115200)

        self.port = self.get_parameter('port').value
        self.baudrate = self.get_parameter('baudrate').value

        self.publisher_ = self.create_publisher(
            Dht11,
            '/dht11/data',
            10
        )

        self.led_subscriber_ = self.create_subscription(
            Bool,
            '/arduino/led_cmd',
            self.led_command_callback,
            10
        )

        self.serial_lock = threading.Lock()
        self.running = True

        self.serial_port = serial.Serial(
            port=self.port,
            baudrate=self.baudrate,
            timeout=1.0
        )

        time.sleep(2.0)

        self.get_logger().info(
            f'Connected to Arduino: {self.port}, baudrate={self.baudrate}'
        )

        self.read_thread = threading.Thread(target=self.serial_read_loop)
        self.read_thread.daemon = True
        self.read_thread.start()

    def led_command_callback(self, msg):
        if msg.data:
            command = 'LED,ON\n'
        else:
            command = 'LED,OFF\n'

        with self.serial_lock:
            self.serial_port.write(command.encode('utf-8'))

        self.get_logger().info(f'Sent command to Arduino: {command.strip()}')

    def serial_read_loop(self):
        while self.running and rclpy.ok():
            try:
                line = self.serial_port.readline().decode('utf-8').strip()

                if not line:
                    continue

                self.get_logger().info(f'Received: {line}')

                self.parse_serial_line(line)

            except UnicodeDecodeError:
                self.get_logger().warn('Serial decode error')

            except serial.SerialException as e:
                self.get_logger().error(f'Serial error: {e}')
                break

            except Exception as e:
                self.get_logger().error(f'Unexpected error: {e}')

    def parse_serial_line(self, line):
        tokens = line.split(',')

        if len(tokens) != 4:
            return

        if tokens[0] != 'DHT':
            return

        status = tokens[1]

        try:
            temperature = float(tokens[2])
            humidity = float(tokens[3])
        except ValueError:
            self.get_logger().warn(f'Invalid sensor data: {line}')
            return

        msg = Dht11()
        msg.temperature = temperature
        msg.humidity = humidity
        msg.status = status

        self.publisher_.publish(msg)

        self.get_logger().info(
            f'Published /dht11/data: temperature={temperature}, humidity={humidity}, status={status}'
        )

    def destroy_node(self):
        self.running = False

        if hasattr(self, 'serial_port') and self.serial_port.is_open:
            self.serial_port.close()

        super().destroy_node()


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

    node = ArduinoDht11Bridge()

    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

11. ROS 2 노드 설명

1) 필요한 모듈 import 설명

import rclpy
from rclpy.node import Node

rclpy는 ROS 2의 Python 클라이언트 라이브러리입니다.
Python으로 ROS 2 노드를 만들 때 반드시 사용하는 기본 라이브러리입니다.

Node는 ROS 2 노드를 만들기 위한 기본 클래스입니다.
이 프로그램에서는 ArduinoDht11Bridge 클래스가 Node를 상속받아서 ROS 2 노드로 동작합니다.

from std_msgs.msg import Bool
from dht11_interfaces.msg import Dht11

Bool은 ROS 2에서 기본으로 제공하는 Boolean 메시지 타입입니다.
이 예제에서는 LED를 켜고 끄는 명령에 사용합니다.

true  → LED ON
false → LED OFF

Dht11은 사용자가 직접 정의한 커스텀 메시지입니다.
온도, 습도, 센서 상태를 담기 위해 사용합니다.

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

float32 temperature
float32 humidity
string status
import serial
import threading
import time

serial은 Python에서 시리얼 통신을 하기 위한 모듈입니다.
Arduino와 Raspberry Pi는 USB 케이블로 연결되며, 실제로는 USB Serial 방식으로 데이터를 주고받습니다.

threading은 별도의 실행 흐름을 만들기 위해 사용합니다.
이 프로그램에서는 Arduino에서 들어오는 시리얼 데이터를 계속 읽기 위해 별도 스레드를 사용합니다.

time은 Arduino가 리셋되고 준비될 시간을 주기 위해 사용합니다.

2) 클래스 선언부 설명

class ArduinoDht11Bridge(Node):

ArduinoDht11Bridge 클래스는 ROS 2 노드입니다.
Node를 상속받았기 때문에 ROS 2의 publisher, subscriber, parameter, logger 기능을 사용할 수 있습니다.

이 클래스 안에는 다음 기능들이 포함되어 있습니다.

1. Arduino 시리얼 포트 연결
2. DHT11 데이터 수신
3. 수신한 데이터를 ROS 2 토픽으로 발행
4. ROS 2 LED 명령 토픽 구독
5. LED 명령을 Arduino로 전송
6. 노드 종료 시 시리얼 포트 정리

3) 생성자 __init__() 설명

def __init__(self):
    super().__init__('arduino_dht11_bridge')

__init__() 함수는 클래스가 생성될 때 가장 먼저 실행됩니다.
즉, ROS 2 노드를 실행하면 이 부분부터 동작합니다.

super().__init__('arduino_dht11_bridge')

이 코드는 ROS 2 노드 이름을 설정합니다.

노드 이름은 다음과 같습니다.

arduino_dht11_bridge

실행 후 다음 명령으로 확인할 수 있습니다.

ros2 node list

정상 실행 중이면 다음과 같이 표시됩니다.

/arduino_dht11_bridge

4) ROS 2 파라미터 선언

self.declare_parameter('port', '/dev/ttyACM1')
self.declare_parameter('baudrate', 115200)

이 부분은 ROS 2 파라미터를 선언하는 코드입니다.

파라미터를 사용하면 소스 코드를 수정하지 않고 실행 명령에서 설정값을 바꿀 수 있습니다.

여기서는 두 개의 파라미터를 사용합니다.

port     : Arduino가 연결된 시리얼 포트
baudrate : 시리얼 통신 속도

기본값은 다음과 같습니다.

port     = /dev/ttyACM1
baudrate = 115200

Arduino UNO는 Ubuntu에서 보통 /dev/ttyACM1로 잡힙니다.
CH340 계열 보드나 일부 호환 보드는 /dev/ttyUSB0로 잡힐 수 있습니다.

포트가 다른 경우 실행할 때 다음처럼 바꿀 수 있습니다.

ros2 run arduino_dht11_bridge serial_bridge_node --ros-args -p port:=/dev/ttyUSB0

5) 파라미터 값 읽기

self.port = self.get_parameter('port').value
self.baudrate = self.get_parameter('baudrate').value

위에서 선언한 파라미터 값을 실제 변수에 저장합니다.

self.port에는 Arduino 포트 이름이 저장됩니다.

예시는 다음과 같습니다.

/dev/ttyACM0

self.baudrate에는 시리얼 통신 속도가 저장됩니다.

예시는 다음과 같습니다.

115200

이 값은 나중에 serial.Serial()을 사용해서 시리얼 포트를 열 때 사용됩니다.

6) DHT11 토픽 Publisher 생성

self.publisher_ = self.create_publisher(
    Dht11,
    '/dht11/data',
    10
)

이 코드는 ROS 2 토픽 발행자를 만드는 부분입니다.

발행할 토픽 이름은 다음과 같습니다.

/dht11/data

메시지 타입은 다음입니다.

dht11_interfaces/msg/Dht11

큐 사이즈는 10입니다.

10

큐 사이즈는 ROS 2가 메시지를 임시로 저장할 수 있는 버퍼 크기입니다.
센서 데이터가 계속 발행될 때, 구독자가 잠깐 늦게 처리하더라도 최근 메시지를 어느 정도 보관할 수 있습니다.

이 노드는 Arduino에서 다음과 같은 문자열을 받습니다.

DHT,OK,25.3,48.0

그리고 이것을 ROS 2 메시지로 변환해서 /dht11/data 토픽으로 발행합니다.

ROS 2 토픽으로 발행되는 형태는 다음과 같습니다.

temperature: 25.3
humidity: 48.0
status: "OK"

7) LED 명령 Subscriber 생성

self.led_subscriber_ = self.create_subscription(
    Bool,
    '/arduino/led_cmd',
    self.led_command_callback,
    10
)

이 코드는 ROS 2 토픽 구독자를 만드는 부분입니다.

구독할 토픽 이름은 다음과 같습니다.

/arduino/led_cmd

메시지 타입은 다음입니다.

std_msgs/msg/Bool

이 토픽으로 true가 들어오면 LED를 켜고, false가 들어오면 LED를 끕니다.

토픽 명령 예시는 다음과 같습니다.

ros2 topic pub --once /arduino/led_cmd std_msgs/msg/Bool "{data: true}"

위 명령을 실행하면 led_command_callback() 함수가 자동으로 호출됩니다.

즉, 이 부분의 구조는 다음과 같습니다.

/arduino/led_cmd 토픽 수신
        ↓
led_command_callback() 함수 실행
        ↓
Arduino로 LED,ON 또는 LED,OFF 전송

8) 시리얼 Lock 생성

self.serial_lock = threading.Lock()

이 코드는 시리얼 포트를 안전하게 사용하기 위한 잠금 장치입니다.

이 프로그램에서는 두 곳에서 시리얼 포트를 사용합니다.

1. serial_read_loop() 함수
   - Arduino에서 들어오는 데이터를 읽음

2. led_command_callback() 함수
   - Arduino로 LED 명령을 보냄

읽기와 쓰기가 동시에 일어날 수 있기 때문에, 시리얼 포트를 사용할 때 충돌을 막기 위해 Lock을 사용합니다.

특히 LED 명령을 보낼 때 다음처럼 사용합니다.

with self.serial_lock:
    self.serial_port.write(command.encode('utf-8'))

이렇게 하면 여러 작업이 동시에 시리얼 포트를 건드리는 문제를 줄일 수 있습니다.

9) 실행 상태 변수

self.running = True

self.running은 시리얼 읽기 스레드를 계속 실행할지 결정하는 변수입니다.

노드가 실행 중이면 다음 상태입니다.

self.running = True

노드가 종료될 때는 다음처럼 바뀝니다.

self.running = False

시리얼 읽기 루프에서는 이 값을 계속 확인합니다.

while self.running and rclpy.ok():

즉, 노드가 정상 실행 중이고 ROS 2도 정상 상태일 때만 시리얼 데이터를 계속 읽습니다.

10) 시리얼 포트 연결

self.serial_port = serial.Serial(
    port=self.port,
    baudrate=self.baudrate,
    timeout=1.0
)

이 코드는 Arduino와 연결된 시리얼 포트를 엽니다.

사용되는 값은 앞에서 읽어온 파라미터입니다.

port     = /dev/ttyACM1
baudrate = 115200
timeout  = 1.0

timeout=1.0은 시리얼 데이터를 읽을 때 최대 1초까지 기다린다는 뜻입니다.

Arduino에서 데이터가 바로 오지 않더라도 프로그램이 영원히 멈추지 않도록 하기 위한 설정입니다.

주의할 점은 Arduino 펌웨어의 통신 속도와 이 Python 코드의 통신 속도가 반드시 같아야 한다는 것입니다.

Arduino 코드에서 다음처럼 되어 있다면,

Serial.begin(115200);

Python 코드도 다음처럼 되어 있어야 합니다.

baudrate=115200

둘 중 하나라도 다르면 문자가 깨지거나 통신이 되지 않습니다.

11) Arduino 준비 대기

time.sleep(2.0)

Arduino UNO는 USB 시리얼 포트가 열릴 때 리셋되는 경우가 많습니다.

Raspberry Pi에서 Python 프로그램이 시리얼 포트를 여는 순간 Arduino가 재시작될 수 있습니다.
그래서 Arduino가 다시 부팅되고 setup()을 실행할 시간을 주기 위해 2초 정도 기다립니다.

이 대기 시간이 없으면 처음 몇 줄의 데이터가 누락될 수 있습니다.

12) 연결 로그 출력

self.get_logger().info(
    f'Connected to Arduino: {self.port}, baudrate={self.baudrate}'
)

ROS 2 로그로 Arduino 연결 상태를 출력합니다.

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

Connected to Arduino: /dev/ttyACM0, baudrate=115200

이 로그는 실습 중 매우 중요합니다.

포트 이름이 잘못되었는지, 통신 속도가 맞는지 빠르게 확인할 수 있기 때문입니다.

13) 시리얼 읽기 스레드 생성

self.read_thread = threading.Thread(target=self.serial_read_loop)
self.read_thread.daemon = True
self.read_thread.start()

이 부분은 Arduino에서 들어오는 데이터를 계속 읽기 위한 별도 스레드를 만드는 코드입니다.

ROS 2 노드는 기본적으로 rclpy.spin(node)에서 콜백을 처리합니다.
그런데 시리얼 읽기를 메인 루프에서 직접 처리하면 ROS 2 토픽 콜백 처리와 충돌할 수 있습니다.

그래서 시리얼 읽기는 별도 스레드로 분리합니다.

구조는 다음과 같습니다.

메인 스레드
  └── ROS 2 spin 실행
      └── 토픽 콜백 처리

시리얼 읽기 스레드
  └── Arduino 데이터 계속 읽기
      └── DHT11 데이터 파싱
      └── ROS 2 토픽 발행

daemon = True는 메인 프로그램이 종료될 때 이 스레드도 함께 종료될 수 있게 하는 설정입니다.

14) LED 명령 콜백 함수

def led_command_callback(self, msg):

이 함수는 /arduino/led_cmd 토픽으로 메시지가 들어올 때 자동으로 실행됩니다.

메시지 타입은 std_msgs/msg/Bool입니다.

즉, msg.data에는 True 또는 False가 들어 있습니다.

if msg.data:
    command = 'LED,ON\n'
else:
    command = 'LED,OFF\n'

msg.dataTrue이면 Arduino로 보낼 명령을 LED,ON으로 만듭니다.

LED,ON

msg.dataFalse이면 Arduino로 보낼 명령을 LED,OFF로 만듭니다.

LED,OFF

뒤에 \n을 붙이는 이유는 Arduino 코드에서 한 줄 단위로 명령을 처리하기 때문입니다.

Arduino 펌웨어에서는 보통 다음처럼 줄바꿈 문자를 기준으로 명령이 끝났다고 판단합니다.

if (c == '\n')
{
    processCommand(rxBuffer);
}

따라서 Python에서 반드시 줄바꿈 문자를 붙여야 합니다.

command = 'LED,ON\n'

그 다음 시리얼 포트로 명령을 전송합니다.

with self.serial_lock:
    self.serial_port.write(command.encode('utf-8'))

여기서 encode('utf-8')을 사용하는 이유는 시리얼 포트가 문자열이 아니라 바이트 데이터를 전송하기 때문입니다.

Python 문자열:

LED,ON

시리얼 전송용 바이트:

b'LED,ON\n'

마지막으로 로그를 출력합니다.

self.get_logger().info(f'Sent command to Arduino: {command.strip()}')

strip()은 줄 끝의 \n을 제거해서 로그를 깔끔하게 보이게 합니다.

출력 예시는 다음과 같습니다.

Sent command to Arduino: LED,ON

15) 시리얼 읽기 루프

def serial_read_loop(self):

이 함수는 별도 스레드에서 계속 실행됩니다.
Arduino가 보내는 데이터를 한 줄씩 읽고, 읽은 데이터를 파싱 함수로 넘깁니다.

while self.running and rclpy.ok():

이 조건이 참인 동안 계속 반복합니다.

조건의 의미는 다음과 같습니다.

self.running == True : 노드가 종료 요청을 받지 않음
rclpy.ok() == True   : ROS 2 시스템이 정상 동작 중

시리얼에서 한 줄을 읽는 코드는 다음입니다.

line = self.serial_port.readline().decode('utf-8').strip()

이 한 줄 안에는 세 단계가 들어 있습니다.

첫째, 시리얼 포트에서 한 줄을 읽습니다.

self.serial_port.readline()

Arduino가 다음처럼 보낸다고 가정합니다.

DHT,OK,25.3,48.0

실제로는 바이트 형태로 들어옵니다.

b'DHT,OK,25.3,48.0\r\n'

둘째, 바이트 데이터를 문자열로 변환합니다.

decode('utf-8')

셋째, 앞뒤 공백과 줄바꿈 문자를 제거합니다.

strip()

최종적으로 line에는 다음 문자열이 들어갑니다.

DHT,OK,25.3,48.0

데이터가 없으면 다음 코드에서 건너뜁니다.

if not line:
    continue

정상적으로 한 줄을 받으면 로그를 출력합니다.

self.get_logger().info(f'Received: {line}')

그리고 파싱 함수로 넘깁니다.

self.parse_serial_line(line)

16) 예외 처리 구조

시리얼 통신은 실제 하드웨어와 연결되기 때문에 오류 가능성이 있습니다.
그래서 try-except 구조를 사용합니다.

except UnicodeDecodeError:
    self.get_logger().warn('Serial decode error')

시리얼 데이터가 UTF-8 문자열로 변환되지 않을 때 발생합니다.
통신 속도가 맞지 않거나 데이터가 깨지면 발생할 수 있습니다.

except serial.SerialException as e:
    self.get_logger().error(f'Serial error: {e}')
    break

시리얼 포트 자체에 문제가 생겼을 때 발생합니다.

예를 들면 다음 상황입니다.

Arduino USB 케이블이 빠짐
포트가 강제로 닫힘
시리얼 장치가 사라짐
권한 문제 발생

이 경우 계속 반복해도 해결되지 않으므로 break로 루프를 빠져나옵니다.

except Exception as e:
    self.get_logger().error(f'Unexpected error: {e}')

예상하지 못한 오류를 잡기 위한 마지막 안전장치입니다.

강의에서는 이 부분을 설명할 때 “실제 로봇 시스템에서는 통신 오류가 반드시 발생할 수 있으므로 예외 처리가 필요하다”고 설명하면 좋습니다.

17) 시리얼 데이터 파싱 함수

def parse_serial_line(self, line):

이 함수는 Arduino에서 받은 문자열을 분석해서 ROS 2 메시지로 변환합니다.

Arduino에서 보내는 정상 데이터 형식은 다음과 같습니다.

DHT,OK,25.3,48.0

이 문자열은 쉼표로 구분되어 있습니다.

tokens = line.split(',')

위 코드를 실행하면 다음과 같은 리스트가 만들어집니다.

tokens[0] = 'DHT'
tokens[1] = 'OK'
tokens[2] = '25.3'
tokens[3] = '48.0'

18) 데이터 개수 검사

if len(tokens) != 4:
    return

정상적인 DHT11 데이터는 반드시 4개의 항목을 가져야 합니다.

DHT
상태
온도
습도

즉, 다음처럼 4개여야 합니다.

DHT,OK,25.3,48.0

만약 Arduino가 LED 응답으로 다음 데이터를 보냈다면,

ACK,LED,ON

쉼표로 나누면 3개입니다.

ACK
LED
ON

이 경우 DHT11 센서 데이터가 아니므로 그냥 무시합니다.

이 코드는 센서 데이터와 기타 로그 데이터를 구분하기 위한 필터 역할을 합니다.

19) 데이터 종류 검사

if tokens[0] != 'DHT':
    return

첫 번째 항목이 DHT인지 확인합니다.

이 프로그램은 DHT11 센서 데이터만 ROS 2 토픽으로 발행해야 합니다.
따라서 첫 번째 항목이 DHT가 아니면 처리하지 않습니다.

예를 들어 다음 데이터는 무시됩니다.

BOOT,ARDUINO_DHT11_LED_READY
ACK,LED,ON
ERR,UNKNOWN_CMD,ABC

이렇게 하는 이유는 Arduino가 센서 데이터뿐 아니라 부팅 메시지, ACK 메시지, 에러 메시지도 보낼 수 있기 때문입니다.

20) 센서 상태 값 저장

status = tokens[1]

두 번째 항목은 센서 상태입니다.

정상일 때는 다음과 같습니다.

OK

센서 읽기에 실패하면 Arduino가 다음처럼 보낼 수 있습니다.

ERROR

예시는 다음과 같습니다.

DHT,ERROR,0.0,0.0

이 경우에도 ROS 2 토픽으로 발행할 수 있습니다.
그 이유는 다른 ROS 2 노드가 status 값을 보고 센서 상태를 판단할 수 있기 때문입니다.

21) 온도와 습도 변환

try:
    temperature = float(tokens[2])
    humidity = float(tokens[3])
except ValueError:
    self.get_logger().warn(f'Invalid sensor data: {line}')
    return

Arduino에서 들어온 데이터는 문자열입니다.

예를 들어 tokens[2]는 숫자처럼 보이지만 실제로는 문자열입니다.

tokens[2] = '25.3'
tokens[3] = '48.0'

ROS 2 메시지의 temperaturehumidity 필드는 float32이므로 숫자형으로 변환해야 합니다.

그래서 float()을 사용합니다.

temperature = float(tokens[2])
humidity = float(tokens[3])

만약 Arduino가 잘못된 데이터를 보냈다면 변환에 실패할 수 있습니다.

예를 들어 다음과 같은 데이터가 들어오면,

DHT,OK,TEMP,HUMI

TEMP는 숫자로 변환할 수 없으므로 ValueError가 발생합니다.

이 경우 경고 로그를 출력하고 함수를 종료합니다.

self.get_logger().warn(f'Invalid sensor data: {line}')
return

22) ROS 2 메시지 생성

msg = Dht11()
msg.temperature = temperature
msg.humidity = humidity
msg.status = status

이 부분은 커스텀 메시지 객체를 만드는 코드입니다.

Dht11() 메시지 객체를 만든 뒤, 각 필드에 값을 넣습니다.

예를 들어 Arduino에서 다음 데이터를 받았다면,

DHT,OK,25.3,48.0

ROS 2 메시지는 다음처럼 만들어집니다.

temperature: 25.3
humidity: 48.0
status: "OK"

이렇게 만들어진 메시지는 ROS 2 네트워크 안에서 다른 노드들이 구독할 수 있습니다.

예를 들면 다음과 같은 확장이 가능합니다.

온습도 모니터링 노드
데이터 로깅 노드
LCD 출력 노드
웹 대시보드 노드
팬 제어 노드
경고 알림 노드

23) ROS 2 토픽 발행

self.publisher_.publish(msg)

이 코드는 완성된 DHT11 메시지를 /dht11/data 토픽으로 발행합니다.

토픽 확인 명령은 다음과 같습니다.

ros2 topic echo /dht11/data

출력 예시는 다음과 같습니다.

temperature: 25.3
humidity: 48.0
status: OK

이제 ROS 2 시스템 안에서는 Arduino나 시리얼 통신을 직접 몰라도 됩니다.
다른 노드는 단순히 /dht11/data 토픽만 구독하면 온습도 데이터를 사용할 수 있습니다.

이것이 ROS 2 토픽 구조의 장점입니다.

24) 발행 로그 출력

self.get_logger().info(
    f'Published /dht11/data: temperature={temperature}, humidity={humidity}, status={status}'
)

센서 데이터가 토픽으로 발행될 때마다 로그를 출력합니다.

출력 예시는 다음과 같습니다.

Published /dht11/data: temperature=25.3, humidity=48.0, status=OK

강의나 실습 단계에서는 이 로그가 매우 유용합니다.
데이터가 Arduino에서 들어오는지, ROS 2 토픽으로 발행되는지 바로 확인할 수 있기 때문입니다.

하지만 실제 로봇 운용에서는 센서 데이터가 계속 출력되어 로그가 너무 많아질 수 있습니다.
그 경우에는 이 로그를 debug 수준으로 낮추거나 일정 주기마다 출력하도록 수정할 수 있습니다.

25) 노드 종료 처리

def destroy_node(self):
    self.running = False

    if hasattr(self, 'serial_port') and self.serial_port.is_open:
        self.serial_port.close()

    super().destroy_node()

이 함수는 노드가 종료될 때 호출됩니다.

먼저 시리얼 읽기 루프를 멈추기 위해 self.runningFalse로 바꿉니다.

self.running = False

그러면 시리얼 읽기 스레드의 반복 조건이 거짓이 됩니다.

while self.running and rclpy.ok():

그 다음 시리얼 포트가 열려 있는지 확인합니다.

if hasattr(self, 'serial_port') and self.serial_port.is_open:

hasattr()를 사용하는 이유는 시리얼 포트 객체가 정상적으로 생성되기 전에 오류가 발생할 수도 있기 때문입니다.
이런 경우 무조건 self.serial_port에 접근하면 또 다른 오류가 발생할 수 있습니다.

시리얼 포트가 열려 있으면 닫습니다.

self.serial_port.close()

마지막으로 부모 클래스의 종료 함수를 호출합니다.

super().destroy_node()

이렇게 해야 ROS 2 노드 자원이 정상적으로 정리됩니다.

26) main 함수 설명

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

main() 함수는 프로그램의 시작점입니다.

rclpy.init()은 ROS 2 Python 시스템을 초기화합니다.
ROS 2 노드를 만들기 전에 반드시 실행해야 합니다.

node = ArduinoDht11Bridge()

이 코드에서 실제 노드 객체를 생성합니다.

이 순간 ArduinoDht11Bridge 클래스의 __init__() 함수가 실행됩니다.

즉, 다음 작업들이 이때 한 번에 진행됩니다.

1. ROS 2 노드 생성
2. 파라미터 선언
3. Publisher 생성
4. Subscriber 생성
5. 시리얼 포트 열기
6. 시리얼 읽기 스레드 시작

27) ROS 2 spin 설명

try:
    rclpy.spin(node)
except KeyboardInterrupt:
    pass
finally:
    node.destroy_node()
    rclpy.shutdown()

rclpy.spin(node)는 ROS 2 노드를 계속 실행 상태로 유지합니다.

이 함수가 실행되는 동안 ROS 2는 다음 작업을 처리합니다.

토픽 수신 콜백 실행
서비스 요청 처리
타이머 콜백 처리
노드 상태 유지

이 프로그램에서는 /arduino/led_cmd 토픽이 들어오면 led_command_callback() 함수가 실행됩니다.

사용자가 Ctrl + C를 누르면 KeyboardInterrupt가 발생합니다.

except KeyboardInterrupt:
    pass

그 후 finally 블록에서 노드를 안전하게 종료합니다.

node.destroy_node()
rclpy.shutdown()

11. setup.py 수정

다음 파일을 엽니다.

cd ~/sensor_ws/src/arduino_dht11_bridge
nano setup.py

내용을 다음처럼 수정합니다.

from setuptools import setup

package_name = 'arduino_dht11_bridge'

setup(
name=package_name,
version='0.0.0',
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 DHT11 serial bridge for ROS 2',
license='Apache-2.0',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'serial_bridge_node = arduino_dht11_bridge.serial_bridge_node:main',
],
},
)

중요한 부분은 이것입니다.

entry_points={
'console_scripts': [
'serial_bridge_node = arduino_dht11_bridge.serial_bridge_node:main',
],
},

이 설정이 있어야 다음 명령으로 노드를 실행할 수 있습니다.

ros2 run arduino_dht11_bridge serial_bridge_node

12. package.xml 수정

nano package.xml

아래 의존성이 있는지 확인합니다.

<depend>rclpy</depend>
<depend>std_msgs</depend>
<depend>dht11_interfaces</depend>

전체 예시는 다음과 같습니다.

<?xml version="1.0"?>
<package format="3">
  <name>arduino_dht11_bridge</name>
  <version>0.0.0</version>
  <description>Arduino DHT11 serial bridge package</description>
  <maintainer email="user@example.com">user</maintainer>
  <license>Apache-2.0</license>

  <exec_depend>rclpy</exec_depend>
  <exec_depend>std_msgs</exec_depend>
  <exec_depend>sensor_interfaces</exec_depend>

  <export>
    <build_type>ament_python</build_type>
  </export>
</package>

14. 빌드

작업 공간 루트로 이동합니다.

cd ~/sensor_ws

빌드합니다.

colcon build

환경을 적용합니다.

source install/setup.bash

새 터미널을 열 때마다 다음 명령이 필요합니다.

source ~/ros2_ws/install/setup.bash

편하게 쓰려면 .bashrc에 추가합니다.

echo "source ~/ros2_ws/install/setup.bash" >> ~/.bashrc
source ~/.bashrc

아래의 명령을 실행하여 인터페이스의 정상적인 등록을 확인합니다.

ros2 interface show sensor_interfaces/msg/Dht11

15. ROS 2 실행 테스트

Arduino를 Raspberry Pi에 USB로 연결합니다.

포트를 확인합니다.

ls /dev/ttyACM*

예를 들어 /dev/ttyACM0이면 다음처럼 실행합니다.

ros2 run arduino_dht11_bridge serial_bridge_node --ros-args -p port:=/dev/ttyACM1

정상이라면 로그가 나옵니다.

Connected to Arduino: /dev/ttyACM0, baudrate=115200
Received: DHT,OK,25.0,45.0
Published /dht11/data: temperature=25.0, humidity=45.0, status=OK

심볼릭 링크를 사용하면 아래와 같습니다.

ros2 run arduino_dht11_bridge serial_bridge_node --ros-args -p port:=/dev/tb3_sensor

다른 터미널에서 토픽을 확인합니다.

source ~/ros2_ws/install/setup.bash
ros2 topic list

다음 토픽이 보여야 합니다.

/dht11/data
/arduino/led_cmd

온습도 토픽을 확인합니다.

ros2 topic echo /dht11/data

출력 예시는 다음과 같습니다.

temperature: 25.0
humidity: 45.0
status: OK

16. ROS 2 토픽으로 LED 제어

LED를 켭니다.

ros2 topic pub --once /arduino/led_cmd std_msgs/msg/Bool "{data: true}"

Arduino 내장 LED가 켜져야 합니다.

LED를 끕니다.

ros2 topic pub --once /arduino/led_cmd std_msgs/msg/Bool "{data: false}"

Arduino 내장 LED가 꺼져야 합니다.

반복해서 LED ON 명령을 보내려면 다음처럼 할 수 있습니다.

ros2 topic pub /arduino/led_cmd std_msgs/msg/Bool "{data: true}" -r 1

반복해서 LED OFF 명령을 보내려면 다음처럼 합니다.

ros2 topic pub /arduino/led_cmd std_msgs/msg/Bool "{data: false}" -r 1

17. 작업순서

이 예제에서 반드시 이해해야 하는 포인트는 네 가지입니다.

첫째, Arduino는 ROS 2를 직접 실행하지 않습니다.
Arduino UNO는 성능과 메모리가 부족하기 때문에 ROS 2 노드를 직접 돌리는 구조가 아닙니다. 대신 센서 데이터를 시리얼로 보내고, Raspberry Pi가 ROS 2 토픽으로 변환합니다.

둘째, 시리얼 프로토콜을 먼저 정해야 합니다.
이번 예제에서는 다음 형식을 사용했습니다.

DHT,OK,온도,습도
LED,ON
LED,OFF
ACK,LED,ON
ACK,LED,OFF

이런 식으로 규칙을 정해두면 Arduino와 Raspberry Pi 프로그램을 분리해서 개발할 수 있습니다.

셋째, ROS 2 메시지 인터페이스를 먼저 정의해야 합니다.
센서 데이터 구조가 명확해야 나중에 다른 노드가 쉽게 사용할 수 있습니다.

float32 temperature
float32 humidity
string status

넷째, Windows에서 Arduino 단독 테스트를 먼저 해야 합니다.
처음부터 ROS 2까지 한 번에 연결하면 문제가 생겼을 때 원인을 찾기 어렵습니다.

좋은 테스트 순서는 다음입니다.

1단계: Arduino IDE 시리얼 모니터에서 DHT11 값 확인
2단계: Arduino IDE 시리얼 모니터에서 LED,ON / LED,OFF 테스트
3단계: Raspberry Pi에서 시리얼 포트 확인
4단계: ROS 2 노드 실행
5단계: /dht11/data 토픽 확인
6단계: /arduino/led_cmd 토픽으로 LED 제어

18. 최종 폴더 구조

완성된 ROS 2 작업 공간 구조는 다음과 비슷합니다.

ros2_ws/
├── src/
│ ├── dht11_interfaces/
│ │ ├── msg/
│ │ │ └── Dht11.msg
│ │ ├── CMakeLists.txt
│ │ └── package.xml
│ │
│ └── arduino_dht11_bridge/
│ ├── arduino_dht11_bridge/
│ │ ├── __init__.py
│ │ └── serial_bridge_node.py
│ ├── package.xml
│ ├── setup.py
│ └── resource/
│ └── arduino_dht11_bridge

Arduino 쪽 파일은 별도로 다음처럼 관리하면 좋습니다.

arduino/
└── arduino_dht11_led_serial/
└── arduino_dht11_led_serial.ino

Leave a Comment