1. 통신 프로토콜 설계 및 구조 설명
1) 전체 패킷 구조
Arduino와 ROS2 시리얼 브리지 노드는 같은 바이너리 패킷 구조를 사용합니다.

각 필드 의미는 다음과 같습니다.
| 필드 | 크기 | 설명 |
|---|---|---|
| Start | 3 bytes | 패킷 시작 표시. 항상 0xAA 0xAA 0xAA |
| Packet ID | 1 byte | 데이터 또는 명령 종류 |
| Length | 1 byte | Payload 길이 |
| Sequence | 1 byte | 패킷 번호. 0~255 반복 |
| Payload | N bytes | 실제 데이터 |
| Checksum | 1 byte | 오류 검출용 합산 체크섬 |
| End | 2 bytes | 패킷 종료 표시. 항상 0xAA 0xEE |
전체 패킷 길이는 다음과 같습니다.
Total Length = 3 + 1 + 1 + 1 + Payload Length + 1 + 2
Total Length = 9 + Payload Length
예를 들어 Payload가 5바이트이면 전체 패킷은 14바이트입니다.
9 + 5 = 14 bytes
2) Packet ID 설계
이번 예제에서는 3개 모듈만 사용하므로 Packet ID를 단순하게 잡습니다.
#define PKT_SENSOR_STATE 0x31
#define PKT_SET_RELAY 0x41
#define PKT_SET_RGB 0x42
#define PKT_SET_ALL 0x43
#define PKT_PING 0x7F
각 Packet ID의 역할은 다음과 같습니다.
| Packet ID | 방향 | 설명 |
|---|---|---|
0x31 | Arduino → ROS2 | 릴레이 상태, 버튼 상태, RGB LED 상태 보고 |
0x41 | ROS2 → Arduino | 릴레이 ON/OFF 명령 |
0x42 | ROS2 → Arduino | RGB LED 색상 명령 |
0x43 | ROS2 → Arduino | 릴레이와 RGB를 한 번에 제어 |
0x7F | 양방향 | 통신 확인용 Ping |
3) 센서 상태 패킷 Payload 구조
Arduino가 ROS2로 보내는 상태 패킷입니다.
Packet ID: 0x31
Length : 5
Payload : relay_state, button_state, red, green, blue
Payload 구조는 다음과 같습니다.
| Payload index | 이름 | 크기 | 값 |
|---|---|---|---|
| 0 | relay_state | 1 byte | 0: OFF, 1: ON |
| 1 | button_state | 1 byte | 0: 안 눌림, 1: 눌림 |
| 2 | red | 1 byte | 0~255 |
| 3 | green | 1 byte | 0~255 |
| 4 | blue | 1 byte | 0~255 |
예를 들어 릴레이 ON, 버튼 눌림, RGB가 빨간색이면 Payload는 다음과 같습니다.
01 01 FF 00 00
전체 패킷 예시는 다음과 같습니다.
AA AA AA 31 05 10 01 01 FF 00 00 47 AA EE
여기서 0x10은 Sequence입니다.
4) 릴레이 제어 명령 패킷
ROS2에서 Arduino로 릴레이를 제어할 때 사용합니다.
Packet ID: 0x41
Length : 1
Payload : relay_state
Payload:
0x00 = relay OFF
0x01 = relay ON
예시:
AA AA AA 41 01 01 01 44 AA EE
5) RGB LED 제어 명령 패킷
ROS2에서 Arduino로 RGB LED 색상을 보냅니다.
Packet ID: 0x42
Length : 3
Payload : red, green, blue
예를 들어 파란색이면 다음과 같습니다.
red = 0
green = 0
blue = 255
Payload:
00 00 FF
6) 릴레이와 RGB 동시 제어 패킷
릴레이와 RGB를 한 번에 제어하려면 PKT_SET_ALL을 사용합니다.
Packet ID: 0x43
Length : 4
Payload : relay_state, red, green, blue
예를 들어 릴레이 ON, RGB 녹색이면 다음과 같습니다.
01 00 FF 00
7) Checksum 계산 방식
Checksum은 다음 범위를 모두 더한 뒤 하위 1바이트만 사용합니다.
Checksum = PacketID + Length + Sequence + Payload 전체 합
Checksum = sum & 0xFF
예시 코드입니다.
uint8_t calcChecksum(uint8_t packet_id,
uint8_t length,
uint8_t sequence,
const uint8_t *payload)
{
uint16_t sum = 0;
sum += packet_id;
sum += length;
sum += sequence;
for (uint8_t i = 0; i < length; i++)
{
sum += payload[i];
}
return (uint8_t)(sum & 0xFF);
}
수신 측에서는 같은 방식으로 다시 계산합니다.
수신 checksum == 계산 checksum → 정상 패킷
수신 checksum != 계산 checksum → 오류 패킷, 폐기
이 방식은 CRC보다는 약하지만, Arduino–ROS2 간 짧은 센서 패킷에서는 충분히 실용적입니다. 실무에서 노이즈가 많은 환경이면 CRC-8 또는 CRC-16으로 바꾸는 것이 맞습니다.
2. 회로 연결 구조 설명
1) Arduino와 Raspberry Pi 또는 TurtleBot3 SBC 연결
가장 단순한 연결은 USB 케이블입니다.
Arduino USB ↔ Raspberry Pi USB
ROS2 노드에서는 보통 다음 장치로 인식됩니다.
/dev/ttyACM0
또는 CH340, CP2102 계열 USB-Serial 보드라면 다음처럼 잡힐 수 있습니다.
/dev/ttyUSB0
USB 연결을 쓰면 TX/RX를 직접 연결하지 않아도 됩니다. 전원과 GND도 USB를 통해 연결됩니다.
UART 핀을 직접 연결하는 경우에는 다음처럼 연결합니다.
| Raspberry Pi | Arduino |
|---|---|
| TX | RX |
| RX | TX |
| GND | GND |
주의할 점은 전압 레벨입니다. Raspberry Pi UART는 3.3V이고, Arduino UNO는 5V TTL입니다. 직접 연결하면 Raspberry Pi RX가 손상될 수 있습니다. 실전에서는 USB 연결이 제일 안전합니다.
2) 릴레이 모듈 연결
Relay Module 기준입니다.
| 릴레이 모듈 | Arduino |
|---|---|
| S / IN | D10 |
| + | 5V |
| – | GND |
코드에서는 다음 핀을 사용합니다.
const uint8_t PIN_RELAY = 10;
릴레이는 active-high 기준입니다.
D10 HIGH → 릴레이 ON
D10 LOW → 릴레이 OFF
사용자가 제시한 KY-019 예제도 relay = 10, digitalWrite(relay, HIGH)로 릴레이를 켜는 구조입니다.


3) RGB LED 모듈 연결
일반적인 3색 RGB LED 모듈 기준입니다.
| RGB LED 모듈 | Arduino |
|---|---|
| R | D5 |
| G | D6 |
| B | D9 |
| – 또는 GND | GND |
코드에서는 PWM 가능한 핀을 사용합니다.
const uint8_t PIN_RGB_R = 5;
const uint8_t PIN_RGB_G = 6;
const uint8_t PIN_RGB_B = 9;
이 코드는 common cathode RGB LED 기준입니다.
analogWrite(pin, 0) → 꺼짐
analogWrite(pin, 255) → 최대 밝기
만약 common anode RGB LED라면 출력이 반대입니다. 그 경우에는 아래 코드의 COMMON_ANODE_RGB 값을 true로 바꾸면 됩니다.
const bool COMMON_ANODE_RGB = false;

4) 버튼 모듈 연결
버튼 모듈은 내부 풀업 방식을 사용합니다.
| 버튼 모듈 | Arduino |
|---|---|
| S / OUT | D2 |
| + | 5V |
| – | GND |
코드에서는 다음 핀을 사용합니다.
const uint8_t PIN_BUTTON = 2;
내부 풀업을 사용하므로 버튼 입력 논리는 다음과 같습니다.
버튼 안 눌림 → HIGH
버튼 눌림 → LOW
따라서 코드에서는 LOW일 때 버튼이 눌린 것으로 처리합니다.


3. 아두이노 펌웨어 소스 및 소스에 대한 자세한 설명
파일명 예시:
turtlebot3_relay_rgb_button.ino
1) Arduino 펌웨어 전체 소스
#include <Arduino.h>
const uint32_t SERIAL_BAUD = 115200;
// Pin map
const uint8_t PIN_RELAY = 10;
const uint8_t PIN_RGB_R = 5;
const uint8_t PIN_RGB_G = 6;
const uint8_t PIN_RGB_B = 9;
const uint8_t PIN_BUTTON = 2;
// Set true when using common-anode RGB LED.
const bool COMMON_ANODE_RGB = false;
// Protocol constants
const uint8_t START_BYTE = 0xAA;
const uint8_t END_BYTE_1 = 0xAA;
const uint8_t END_BYTE_2 = 0xEE;
const uint8_t PKT_SENSOR_STATE = 0x31;
const uint8_t PKT_SET_RELAY = 0x41;
const uint8_t PKT_SET_RGB = 0x42;
const uint8_t PKT_SET_ALL = 0x43;
const uint8_t PKT_PING = 0x7F;
const uint8_t MAX_PAYLOAD_LEN = 32;
// Device states
uint8_t relay_state = 0;
uint8_t button_state = 0;
uint8_t rgb_r = 0;
uint8_t rgb_g = 0;
uint8_t rgb_b = 0;
uint8_t tx_sequence = 0;
// Button debounce
uint8_t last_button_raw = HIGH;
uint8_t stable_button_raw = HIGH;
unsigned long last_button_change_ms = 0;
const unsigned long BUTTON_DEBOUNCE_MS = 30;
// Periodic publish
unsigned long last_publish_ms = 0;
const unsigned long PUBLISH_PERIOD_MS = 100;
// RX parser state
enum ParserState
{
WAIT_START_1,
WAIT_START_2,
WAIT_START_3,
READ_PACKET_ID,
READ_LENGTH,
READ_SEQUENCE,
READ_PAYLOAD,
READ_CHECKSUM,
READ_END_1,
READ_END_2
};
ParserState parser_state = WAIT_START_1;
uint8_t rx_packet_id = 0;
uint8_t rx_length = 0;
uint8_t rx_sequence = 0;
uint8_t rx_payload[MAX_PAYLOAD_LEN];
uint8_t rx_payload_index = 0;
uint8_t rx_checksum = 0;
uint8_t calcChecksum(uint8_t packet_id,
uint8_t length,
uint8_t sequence,
const uint8_t *payload)
{
uint16_t sum = 0;
sum += packet_id;
sum += length;
sum += sequence;
for (uint8_t i = 0; i < length; i++)
{
sum += payload[i];
}
return (uint8_t)(sum & 0xFF);
}
void sendPacket(uint8_t packet_id,
const uint8_t *payload,
uint8_t length)
{
uint8_t checksum = calcChecksum(packet_id, length, tx_sequence, payload);
Serial.write(START_BYTE);
Serial.write(START_BYTE);
Serial.write(START_BYTE);
Serial.write(packet_id);
Serial.write(length);
Serial.write(tx_sequence);
for (uint8_t i = 0; i < length; i++)
{
Serial.write(payload[i]);
}
Serial.write(checksum);
Serial.write(END_BYTE_1);
Serial.write(END_BYTE_2);
tx_sequence++;
}
void applyRelay(uint8_t state)
{
relay_state = state ? 1 : 0;
digitalWrite(PIN_RELAY, relay_state ? HIGH : LOW);
}
void writeRgbPin(uint8_t pin, uint8_t value)
{
if (COMMON_ANODE_RGB)
{
analogWrite(pin, 255 - value);
}
else
{
analogWrite(pin, value);
}
}
void applyRgb(uint8_t r, uint8_t g, uint8_t b)
{
rgb_r = r;
rgb_g = g;
rgb_b = b;
writeRgbPin(PIN_RGB_R, rgb_r);
writeRgbPin(PIN_RGB_G, rgb_g);
writeRgbPin(PIN_RGB_B, rgb_b);
}
void readButtonDebounced()
{
uint8_t raw = digitalRead(PIN_BUTTON);
unsigned long now = millis();
if (raw != last_button_raw)
{
last_button_change_ms = now;
last_button_raw = raw;
}
if ((now - last_button_change_ms) > BUTTON_DEBOUNCE_MS)
{
stable_button_raw = raw;
}
// INPUT_PULLUP mode:
// LOW means pressed.
button_state = (stable_button_raw == LOW) ? 1 : 0;
}
void sendSensorState()
{
uint8_t payload[5];
payload[0] = relay_state;
payload[1] = button_state;
payload[2] = rgb_r;
payload[3] = rgb_g;
payload[4] = rgb_b;
sendPacket(PKT_SENSOR_STATE, payload, 5);
}
void handlePacket(uint8_t packet_id,
uint8_t length,
uint8_t sequence,
const uint8_t *payload)
{
(void)sequence;
if (packet_id == PKT_SET_RELAY)
{
if (length != 1)
{
return;
}
applyRelay(payload[0]);
sendSensorState();
}
else if (packet_id == PKT_SET_RGB)
{
if (length != 3)
{
return;
}
applyRgb(payload[0], payload[1], payload[2]);
sendSensorState();
}
else if (packet_id == PKT_SET_ALL)
{
if (length != 4)
{
return;
}
applyRelay(payload[0]);
applyRgb(payload[1], payload[2], payload[3]);
sendSensorState();
}
else if (packet_id == PKT_PING)
{
// Echo current state as a simple response.
sendSensorState();
}
}
void resetParser()
{
parser_state = WAIT_START_1;
rx_packet_id = 0;
rx_length = 0;
rx_sequence = 0;
rx_payload_index = 0;
rx_checksum = 0;
}
void parseByte(uint8_t byte_in)
{
switch (parser_state)
{
case WAIT_START_1:
if (byte_in == START_BYTE)
{
parser_state = WAIT_START_2;
}
break;
case WAIT_START_2:
if (byte_in == START_BYTE)
{
parser_state = WAIT_START_3;
}
else
{
parser_state = WAIT_START_1;
}
break;
case WAIT_START_3:
if (byte_in == START_BYTE)
{
parser_state = READ_PACKET_ID;
}
else
{
parser_state = WAIT_START_1;
}
break;
case READ_PACKET_ID:
rx_packet_id = byte_in;
parser_state = READ_LENGTH;
break;
case READ_LENGTH:
rx_length = byte_in;
if (rx_length > MAX_PAYLOAD_LEN)
{
resetParser();
return;
}
rx_payload_index = 0;
parser_state = READ_SEQUENCE;
break;
case READ_SEQUENCE:
rx_sequence = byte_in;
if (rx_length == 0)
{
parser_state = READ_CHECKSUM;
}
else
{
parser_state = READ_PAYLOAD;
}
break;
case READ_PAYLOAD:
rx_payload[rx_payload_index] = byte_in;
rx_payload_index++;
if (rx_payload_index >= rx_length)
{
parser_state = READ_CHECKSUM;
}
break;
case READ_CHECKSUM:
rx_checksum = byte_in;
parser_state = READ_END_1;
break;
case READ_END_1:
if (byte_in == END_BYTE_1)
{
parser_state = READ_END_2;
}
else
{
resetParser();
}
break;
case READ_END_2:
if (byte_in == END_BYTE_2)
{
uint8_t calculated = calcChecksum(rx_packet_id,
rx_length,
rx_sequence,
rx_payload);
if (calculated == rx_checksum)
{
handlePacket(rx_packet_id,
rx_length,
rx_sequence,
rx_payload);
}
}
resetParser();
break;
default:
resetParser();
break;
}
}
void readSerial()
{
while (Serial.available() > 0)
{
uint8_t b = (uint8_t)Serial.read();
parseByte(b);
}
}
void setup()
{
pinMode(PIN_RELAY, OUTPUT);
pinMode(PIN_RGB_R, OUTPUT);
pinMode(PIN_RGB_G, OUTPUT);
pinMode(PIN_RGB_B, OUTPUT);
pinMode(PIN_BUTTON, INPUT_PULLUP);
applyRelay(0);
applyRgb(0, 0, 0);
Serial.begin(SERIAL_BAUD);
delay(500);
sendSensorState();
}
void loop()
{
readSerial();
readButtonDebounced();
unsigned long now = millis();
if ((now - last_publish_ms) >= PUBLISH_PERIOD_MS)
{
last_publish_ms = now;
sendSensorState();
}
}

2) Arduino 펌웨어 설명
이 펌웨어는 Arduino를 하위 I/O 제어 보드로 사용합니다. Arduino는 릴레이, RGB LED, 버튼을 직접 제어하고 읽습니다. Raspberry Pi 또는 TurtleBot3 SBC에서 실행되는 ROS2 노드는 시리얼로 명령을 보내고, Arduino는 현재 상태를 다시 ROS2 쪽으로 보고합니다.
핵심 구조는 다음입니다.
const uint8_t PIN_RELAY = 10;
const uint8_t PIN_RGB_R = 5;
const uint8_t PIN_RGB_G = 6;
const uint8_t PIN_RGB_B = 9;
const uint8_t PIN_BUTTON = 2;
릴레이는 D10에 연결합니다. RGB LED는 PWM 제어를 위해 D5, D6, D9에 연결합니다. 버튼은 D2에 연결하고 INPUT_PULLUP으로 설정합니다.
pinMode(PIN_BUTTON, INPUT_PULLUP);
이 구조에서는 버튼이 눌리지 않았을 때 HIGH, 눌렸을 때 LOW가 됩니다. 그래서 코드에서는 다음처럼 눌림 상태를 계산합니다.
button_state = (stable_button_raw == LOW) ? 1 : 0;
릴레이 제어는 applyRelay() 함수에서 처리합니다.
void applyRelay(uint8_t state)
{
relay_state = state ? 1 : 0;
digitalWrite(PIN_RELAY, relay_state ? HIGH : LOW);
}
state가 1이면 릴레이가 켜지고, 0이면 꺼집니다. 릴레이 모듈은 보통 active-high로 동작하므로 HIGH 출력이 ON입니다.
RGB LED 제어는 applyRgb()에서 처리합니다.
void applyRgb(uint8_t r, uint8_t g, uint8_t b)
{
rgb_r = r;
rgb_g = g;
rgb_b = b;
writeRgbPin(PIN_RGB_R, rgb_r);
writeRgbPin(PIN_RGB_G, rgb_g);
writeRgbPin(PIN_RGB_B, rgb_b);
}
RGB 값은 0~255 범위입니다.
0 = 꺼짐
255 = 최대 밝기
common cathode RGB LED는 이 값 그대로 쓰면 됩니다. common anode RGB LED는 밝기 논리가 반대라서 다음 설정을 바꿔야 합니다.
const bool COMMON_ANODE_RGB = true;
패킷 송신은 sendPacket() 함수가 담당합니다.
void sendPacket(uint8_t packet_id,
const uint8_t *payload,
uint8_t length)
이 함수는 Start, Packet ID, Length, Sequence, Payload, Checksum, End를 순서대로 Serial.write()로 보냅니다. 문자열이 아니라 바이너리 바이트를 직접 보내므로 빠르고 안정적입니다.
상태 보고는 sendSensorState()에서 처리합니다.
void sendSensorState()
{
uint8_t payload[5];
payload[0] = relay_state;
payload[1] = button_state;
payload[2] = rgb_r;
payload[3] = rgb_g;
payload[4] = rgb_b;
sendPacket(PKT_SENSOR_STATE, payload, 5);
}
즉 Arduino는 다음 정보를 ROS2에 계속 보냅니다.
릴레이 상태
버튼 상태
RGB Red 값
RGB Green 값
RGB Blue 값
기본 주기는 100ms입니다.
const unsigned long PUBLISH_PERIOD_MS = 100;
즉 약 10Hz로 상태를 전송합니다.
수신 파서는 상태 머신 방식입니다.
WAIT_START_1
WAIT_START_2
WAIT_START_3
READ_PACKET_ID
READ_LENGTH
READ_SEQUENCE
READ_PAYLOAD
READ_CHECKSUM
READ_END_1
READ_END_2
이 방식이 중요한 이유는 시리얼 데이터가 항상 한 번에 완성된 패킷으로 들어오지 않기 때문입니다. USB 시리얼에서는 패킷이 중간에 끊겨 들어올 수 있습니다. 상태 머신은 바이트를 하나씩 읽으면서 현재 위치를 기억하기 때문에, 패킷이 나누어 들어와도 정상적으로 조립할 수 있습니다.
명령 처리 함수는 handlePacket()입니다.
void handlePacket(uint8_t packet_id,
uint8_t length,
uint8_t sequence,
const uint8_t *payload)
PKT_SET_RELAY를 받으면 릴레이만 제어합니다.
applyRelay(payload[0]);
PKT_SET_RGB를 받으면 RGB LED만 제어합니다.
applyRgb(payload[0], payload[1], payload[2]);
PKT_SET_ALL을 받으면 릴레이와 RGB를 동시에 제어합니다.
applyRelay(payload[0]);
applyRgb(payload[1], payload[2], payload[3]);
명령 처리 후에는 바로 sendSensorState()를 호출합니다. 그래서 ROS2 노드에서 명령을 보낸 직후 실제 Arduino 상태를 다시 확인할 수 있습니다.
4. 윈도우에서 통신 프로그램으로 Arduino 펌웨어 테스트
ROS 2 브리지 노드를 연결하기 전에, 먼저 Windows PC에서 Arduino 펌웨어가 정상적으로 패킷을 송수신하는지 확인하는 것이 좋습니다. 이 과정을 먼저 하면 문제 원인을 분리하기 쉽습니다.
전체 테스트 구조는 다음과 같습니다.
Windows PC
│
│ USB Serial
│
Arduino UNO / Nano / Mega
│
├── KY-019 Relay Module
├── RGB LED Module
└── Button Module
이 단계에서는 ROS 2를 사용하지 않습니다. Windows의 시리얼 통신 프로그램으로 Arduino가 보내는 바이너리 패킷을 확인하고, 직접 명령 패킷을 보내서 릴레이와 RGB LED를 제어합니다.
1) Arduino COM 포트 확인
Arduino를 Windows PC에 USB로 연결합니다.
Arduino IDE에서 포트를 확인할 수 있습니다.
Arduino IDE 실행
→ Tools
→ Port
→ COM 번호 확인
예를 들어 다음처럼 보일 수 있습니다.
COM3
COM4
COM7
장치 관리자에서도 확인할 수 있습니다.
장치 관리자
→ 포트(COM & LPT)
→ Arduino Uno (COM3)
CH340 USB-Serial 보드라면 다음처럼 보일 수 있습니다.
USB-SERIAL CH340 (COM5)
이 COM 번호를 나중에 통신 프로그램에서 사용합니다.
2) 시리얼 통신 프로그램 선택
Windows에서 바이너리 패킷 테스트를 하려면 일반 문자열 터미널보다 HEX 송수신이 가능한 프로그램을 쓰는 것이 좋습니다.
이번 프로토콜은 바이너리 패킷입니다.
AA AA AA 41 01 01 01 44 AA EE
이런 데이터를 보내야 하므로 HEX 송신 기능이 있는 ComPortMaster가 편합니다. PuTTY는 단순 문자열 터미널에 가까워서 이 테스트에는 덜 적합합니다.
3) 수신 패킷을 HEX로 보기
통신속도를 115200으로 설정하고, 출력을 ASCII -> Hex로 선택합니다.

정상이라면 다음과 비슷한 패킷이 반복해서 보입니다.

이 패킷의 의미는 다음입니다.
AA AA AA : Start
31 : Packet ID = 센서 상태
05 : Payload 길이 5바이트
00 : Sequence
00 : relay_state = OFF
00 : button_state = 안 눌림
00 : red
00 : green
00 : blue
36 : Checksum
AA EE : End
버튼을 누르면 Payload 두 번째 값이 바뀌어야 합니다.
버튼 안 눌림:
AA AA AA 31 05 xx 00 00 00 00 00 checksum AA EE
버튼 눌림:
AA AA AA 31 05 xx 00 01 00 00 00 checksum AA EE
여기서 xx는 Sequence라 계속 증가합니다.

5) 릴레이 ON 명령 보내기
릴레이 ON 명령 패킷 구조는 다음입니다.
Packet ID : 0x41
Length : 0x01
Sequence : 사용자가 임의 지정 가능
Payload : 0x01
예를 들어 Sequence를 0x01로 하면 체크섬은 다음처럼 계산합니다.
Checksum = Packet ID + Length + Sequence + Payload
Checksum = 0x41 + 0x01 + 0x01 + 0x01
Checksum = 0x44
따라서 릴레이 ON 전체 패킷은 다음입니다.
AA AA AA 41 01 01 01 44 AA EE
첫 번째 Send 에디터에 위의 패킷을 복사하고 에디터 오른쪽의 ASCII 버튼을 클릭하여 Hex로 선택합니다.

Sens 버튼을 클릭하면 패킷이 전송되고 릴레이가 동작합니다.

정상이라면 릴레이가 켜지고, Arduino가 다음과 비슷한 상태 패킷을 다시 보냅니다.
AA AA AA 31 05 xx 01 00 00 00 00 checksum AA EE
Payload 첫 번째 값이 01이면 릴레이 ON 상태입니다.
relay_state = 01

6) 릴레이 OFF 명령 보내기
릴레이 OFF 명령은 Payload가 0x00입니다.
Packet ID : 0x41
Length : 0x01
Sequence : 0x02
Payload : 0x00
Checksum 계산은 다음입니다.
Checksum = 0x41 + 0x01 + 0x02 + 0x00
Checksum = 0x44
전체 패킷은 다음입니다.
AA AA AA 41 01 02 00 44 AA EE
전송 후 릴레이가 꺼져야 합니다.
수신 상태 패킷에서 Payload 첫 번째 값이 00이면 정상입니다.
relay_state = 00

7) RGB LED 빨간색 명령 보내기
RGB LED 제어 패킷 구조는 다음입니다.
Packet ID : 0x42
Length : 0x03
Sequence : 사용자가 임의 지정
Payload : red, green, blue
빨간색은 다음입니다.
red = FF
green = 00
blue = 00
Sequence를 0x03으로 하면 Checksum은 다음입니다.
Checksum = 0x42 + 0x03 + 0x03 + 0xFF + 0x00 + 0x00
Checksum = 0x147
하위 1바이트 = 0x47
전체 패킷은 다음입니다.
AA AA AA 42 03 03 FF 00 00 47 AA EE
RealTerm에서 위 HEX 값을 전송하면 RGB LED가 빨간색으로 켜져야 합니다.

Arduino 상태 패킷에서는 RGB 값이 다음처럼 나와야 합니다.
relay_state, button_state, red, green, blue
xx xx FF 00 00

8) RGB LED 초록색 명령 보내기
초록색 Payload는 다음입니다.
00 FF 00
Sequence를 0x04로 하면 Checksum은 다음입니다.
Checksum = 0x42 + 0x03 + 0x04 + 0x00 + 0xFF + 0x00
Checksum = 0x148
하위 1바이트 = 0x48
전체 패킷은 다음입니다.
AA AA AA 42 03 04 00 FF 00 48 AA EE


9_ RGB LED 파란색 명령 보내기
파란색 Payload는 다음입니다.
00 00 FF
Sequence를 0x05로 하면 Checksum은 다음입니다.
Checksum = 0x42 + 0x03 + 0x05 + 0x00 + 0x00 + 0xFF
Checksum = 0x149
하위 1바이트 = 0x49
전체 패킷은 다음입니다.
AA AA AA 42 03 05 00 00 FF 49 AA EE


10) RGB LED 끄기 명령 보내기
RGB LED를 끄려면 RGB 값을 모두 0으로 보냅니다.
red = 00
green = 00
blue = 00
Sequence를 0x06으로 하면 Checksum은 다음입니다.
Checksum = 0x42 + 0x03 + 0x06 + 0x00 + 0x00 + 0x00
Checksum = 0x4B
전체 패킷은 다음입니다.
AA AA AA 42 03 06 00 00 00 4B AA EE

4.11 릴레이 ON + RGB 노란색 동시 제어
릴레이와 RGB LED를 동시에 제어하려면 PKT_SET_ALL, 즉 0x43 패킷을 사용합니다.
패킷 구조는 다음입니다.
Packet ID : 0x43
Length : 0x04
Sequence : 사용자가 임의 지정
Payload : relay_state, red, green, blue
릴레이 ON, RGB 노란색은 다음입니다.
relay_state = 01
red = FF
green = FF
blue = 00
Sequence를 0x07로 하면 Checksum은 다음입니다.
Checksum = 0x43 + 0x04 + 0x07 + 0x01 + 0xFF + 0xFF + 0x00
Checksum = 0x24D
하위 1바이트 = 0x4D
전체 패킷은 다음입니다.
AA AA AA 43 04 07 01 FF FF 00 4D AA EE
전송 후 릴레이가 켜지고 RGB LED가 노란색으로 켜져야 합니다.


12) Ping 패킷 보내기
Ping 패킷은 Arduino에게 현재 상태를 다시 보내라고 요청하는 용도입니다.
Packet ID : 0x7F
Length : 0x00
Sequence : 0x08
Payload : 없음
Checksum은 다음입니다.
Checksum = 0x7F + 0x00 + 0x08
Checksum = 0x87
전체 패킷은 다음입니다.
AA AA AA 7F 00 08 87 AA EE
이 패킷을 보내면 Arduino는 현재 센서 상태 패킷 0x31을 다시 보냅니다.

13) Windows 테스트용 패킷 정리표
| 기능 | HEX 송신 패킷 |
|---|---|
| 릴레이 ON | AA AA AA 41 01 01 01 44 AA EE |
| 릴레이 OFF | AA AA AA 41 01 02 00 44 AA EE |
| RGB 빨간색 | AA AA AA 42 03 03 FF 00 00 47 AA EE |
| RGB 초록색 | AA AA AA 42 03 04 00 FF 00 48 AA EE |
| RGB 파란색 | AA AA AA 42 03 05 00 00 FF 49 AA EE |
| RGB OFF | AA AA AA 42 03 06 00 00 00 4B AA EE |
| 릴레이 ON + 노란색 | AA AA AA 43 04 07 01 FF FF 00 4D AA EE |
| Ping | AA AA AA 7F 00 08 87 AA EE |
주의할 점은 Sequence를 바꾸면 Checksum도 바뀐다는 것입니다. 위 표는 표에 적힌 Sequence 기준으로 계산된 패킷입니다.
5. ROS2 Humble용 시리얼 통신 브리지
이번 브리지는 turtleBot3 Burger에서 Python으로 작성합니다. 패키지 이름은 다음과 같습니다.
tb3_arduino_sensor_bridge
패키지 구조는 다음과 같습니다.
tb3_arduino_sensor_bridge/
├── package.xml
├── setup.py
├── setup.cfg
├── resource/
│ └── tb3_arduino_sensor_bridge
└── tb3_arduino_sensor_bridge/
├── __init__.py
└── arduino_sensor_bridge.py
패키지 생성 명령은 다음입니다.
cd ~/turtlebot3_ws/src
ros2 pkg create tb3_arduino_sensor_bridge \
--build-type ament_python \
--dependencies rclpy std_msgs

package.xml입니다.
<?xml version="1.0"?>
<package format="3">
<name>tb3_arduino_sensor_bridge</name>
<version>0.0.1</version>
<description>ROS 2 Humble Python serial bridge for Arduino relay, RGB LED, and button modules.</description>
<maintainer email="user@example.com">user</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_python</buildtool_depend>
<exec_depend>rclpy</exec_depend>
<exec_depend>std_msgs</exec_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
setup.py입니다.
from setuptools import setup
package_name = 'tb3_arduino_sensor_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'],
zip_safe=True,
maintainer='user',
maintainer_email='user@example.com',
description='ROS 2 Humble Python serial bridge for Arduino relay, RGB LED, and button modules.',
license='Apache-2.0',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'arduino_sensor_bridge = tb3_arduino_sensor_bridge.arduino_sensor_bridge:main',
],
},
)

핵심 노드 소스입니다.
파일명:
tb3_arduino_sensor_bridge/arduino_sensor_bridge.py
#!/usr/bin/env python3
import threading
from typing import List, Optional
import rclpy
from rclpy.node import Node
from std_msgs.msg import Bool
from std_msgs.msg import ColorRGBA
from std_msgs.msg import UInt8
import serial
START_BYTE = 0xAA
END_BYTE_1 = 0xAA
END_BYTE_2 = 0xEE
PKT_SENSOR_STATE = 0x31
PKT_SET_RELAY = 0x41
PKT_SET_RGB = 0x42
PKT_SET_ALL = 0x43
PKT_PING = 0x7F
MAX_PAYLOAD_LEN = 32
class ParserState:
WAIT_START_1 = 0
WAIT_START_2 = 1
WAIT_START_3 = 2
READ_PACKET_ID = 3
READ_LENGTH = 4
READ_SEQUENCE = 5
READ_PAYLOAD = 6
READ_CHECKSUM = 7
READ_END_1 = 8
READ_END_2 = 9
class ArduinoSensorBridge(Node):
def __init__(self):
super().__init__('arduino_sensor_bridge')
self.declare_parameter('port', '/dev/ttyACM0')
self.declare_parameter('baudrate', 115200)
self.declare_parameter('read_period_sec', 0.005)
self.declare_parameter('ping_period_sec', 1.0)
self.port = self.get_parameter('port').value
self.baudrate = int(self.get_parameter('baudrate').value)
self.read_period_sec = float(self.get_parameter('read_period_sec').value)
self.ping_period_sec = float(self.get_parameter('ping_period_sec').value)
self.serial_port: Optional[serial.Serial] = None
self.serial_lock = threading.Lock()
self.tx_sequence = 0
self.parser_state = ParserState.WAIT_START_1
self.rx_packet_id = 0
self.rx_length = 0
self.rx_sequence = 0
self.rx_payload: List[int] = []
self.rx_checksum = 0
self.relay_state_pub = self.create_publisher(
Bool,
'sensor_bridge/relay_state',
10
)
self.button_state_pub = self.create_publisher(
Bool,
'sensor_bridge/button_state',
10
)
self.rgb_state_pub = self.create_publisher(
ColorRGBA,
'sensor_bridge/rgb_state',
10
)
self.rx_sequence_pub = self.create_publisher(
UInt8,
'sensor_bridge/rx_sequence',
10
)
self.relay_cmd_sub = self.create_subscription(
Bool,
'sensor_bridge/relay_cmd',
self.relay_cmd_callback,
10
)
self.rgb_cmd_sub = self.create_subscription(
ColorRGBA,
'sensor_bridge/rgb_cmd',
self.rgb_cmd_callback,
10
)
self.all_cmd_sub = self.create_subscription(
ColorRGBA,
'sensor_bridge/rgb_cmd_with_relay_on',
self.rgb_cmd_with_relay_on_callback,
10
)
self.open_serial()
self.read_timer = self.create_timer(
self.read_period_sec,
self.read_serial_timer_callback
)
self.ping_timer = self.create_timer(
self.ping_period_sec,
self.ping_timer_callback
)
def open_serial(self):
try:
self.serial_port = serial.Serial(
port=self.port,
baudrate=self.baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.0,
write_timeout=0.1
)
self.serial_port.reset_input_buffer()
self.serial_port.reset_output_buffer()
self.get_logger().info(
f'Opened serial port {self.port} at {self.baudrate} baud.'
)
except serial.SerialException as e:
self.serial_port = None
self.get_logger().error(
f'Failed to open serial port {self.port}: {e}'
)
def calc_checksum(self,
packet_id: int,
length: int,
sequence: int,
payload: List[int]) -> int:
checksum_sum = 0
checksum_sum += packet_id
checksum_sum += length
checksum_sum += sequence
for byte_value in payload:
checksum_sum += byte_value
return checksum_sum & 0xFF
def send_packet(self, packet_id: int, payload: List[int]):
if self.serial_port is None:
return
if not self.serial_port.is_open:
return
if len(payload) > 255:
self.get_logger().error('Payload too large.')
return
length = len(payload)
sequence = self.tx_sequence & 0xFF
checksum = self.calc_checksum(packet_id, length, sequence, payload)
packet = bytearray()
packet.append(START_BYTE)
packet.append(START_BYTE)
packet.append(START_BYTE)
packet.append(packet_id & 0xFF)
packet.append(length & 0xFF)
packet.append(sequence)
for byte_value in payload:
packet.append(byte_value & 0xFF)
packet.append(checksum)
packet.append(END_BYTE_1)
packet.append(END_BYTE_2)
try:
with self.serial_lock:
self.serial_port.write(packet)
self.tx_sequence = (self.tx_sequence + 1) & 0xFF
except serial.SerialException as e:
self.get_logger().warn(f'Serial write failed: {e}')
def clamp_color_to_u8(self, value: float) -> int:
if value <= 0.0:
return 0
if value >= 1.0:
return 255
return int(value * 255.0)
def relay_cmd_callback(self, msg: Bool):
relay_state = 1 if msg.data else 0
self.send_packet(PKT_SET_RELAY, [relay_state])
def rgb_cmd_callback(self, msg: ColorRGBA):
red = self.clamp_color_to_u8(msg.r)
green = self.clamp_color_to_u8(msg.g)
blue = self.clamp_color_to_u8(msg.b)
self.send_packet(PKT_SET_RGB, [red, green, blue])
def rgb_cmd_with_relay_on_callback(self, msg: ColorRGBA):
red = self.clamp_color_to_u8(msg.r)
green = self.clamp_color_to_u8(msg.g)
blue = self.clamp_color_to_u8(msg.b)
self.send_packet(PKT_SET_ALL, [1, red, green, blue])
def ping_timer_callback(self):
self.send_packet(PKT_PING, [])
def read_serial_timer_callback(self):
if self.serial_port is None:
return
if not self.serial_port.is_open:
return
try:
waiting = self.serial_port.in_waiting
if waiting <= 0:
return
with self.serial_lock:
data = self.serial_port.read(waiting)
for byte_value in data:
self.parse_byte(byte_value)
except serial.SerialException as e:
self.get_logger().warn(f'Serial read failed: {e}')
def reset_parser(self):
self.parser_state = ParserState.WAIT_START_1
self.rx_packet_id = 0
self.rx_length = 0
self.rx_sequence = 0
self.rx_payload = []
self.rx_checksum = 0
def parse_byte(self, byte_in: int):
byte_in = byte_in & 0xFF
if self.parser_state == ParserState.WAIT_START_1:
if byte_in == START_BYTE:
self.parser_state = ParserState.WAIT_START_2
elif self.parser_state == ParserState.WAIT_START_2:
if byte_in == START_BYTE:
self.parser_state = ParserState.WAIT_START_3
else:
self.parser_state = ParserState.WAIT_START_1
elif self.parser_state == ParserState.WAIT_START_3:
if byte_in == START_BYTE:
self.parser_state = ParserState.READ_PACKET_ID
else:
self.parser_state = ParserState.WAIT_START_1
elif self.parser_state == ParserState.READ_PACKET_ID:
self.rx_packet_id = byte_in
self.parser_state = ParserState.READ_LENGTH
elif self.parser_state == ParserState.READ_LENGTH:
self.rx_length = byte_in
if self.rx_length > MAX_PAYLOAD_LEN:
self.get_logger().warn(
f'Invalid payload length: {self.rx_length}'
)
self.reset_parser()
return
self.rx_payload = []
self.parser_state = ParserState.READ_SEQUENCE
elif self.parser_state == ParserState.READ_SEQUENCE:
self.rx_sequence = byte_in
if self.rx_length == 0:
self.parser_state = ParserState.READ_CHECKSUM
else:
self.parser_state = ParserState.READ_PAYLOAD
elif self.parser_state == ParserState.READ_PAYLOAD:
self.rx_payload.append(byte_in)
if len(self.rx_payload) >= self.rx_length:
self.parser_state = ParserState.READ_CHECKSUM
elif self.parser_state == ParserState.READ_CHECKSUM:
self.rx_checksum = byte_in
self.parser_state = ParserState.READ_END_1
elif self.parser_state == ParserState.READ_END_1:
if byte_in == END_BYTE_1:
self.parser_state = ParserState.READ_END_2
else:
self.reset_parser()
elif self.parser_state == ParserState.READ_END_2:
if byte_in == END_BYTE_2:
calculated = self.calc_checksum(
self.rx_packet_id,
self.rx_length,
self.rx_sequence,
self.rx_payload
)
if calculated == self.rx_checksum:
self.handle_packet(
self.rx_packet_id,
self.rx_length,
self.rx_sequence,
self.rx_payload
)
else:
self.get_logger().warn(
'Checksum mismatch. '
f'packet_id=0x{self.rx_packet_id:02X}, '
f'rx=0x{self.rx_checksum:02X}, '
f'calc=0x{calculated:02X}'
)
self.reset_parser()
else:
self.reset_parser()
def handle_packet(self,
packet_id: int,
length: int,
sequence: int,
payload: List[int]):
if packet_id == PKT_SENSOR_STATE:
self.parse_sensor_state(length, sequence, payload)
else:
self.get_logger().debug(
f'Unknown packet id: 0x{packet_id:02X}'
)
def parse_sensor_state(self,
length: int,
sequence: int,
payload: List[int]):
if length != 5:
self.get_logger().warn(
f'Invalid sensor state payload length: {length}'
)
return
relay_state = payload[0] != 0
button_state = payload[1] != 0
red = payload[2]
green = payload[3]
blue = payload[4]
relay_msg = Bool()
relay_msg.data = relay_state
self.relay_state_pub.publish(relay_msg)
button_msg = Bool()
button_msg.data = button_state
self.button_state_pub.publish(button_msg)
rgb_msg = ColorRGBA()
rgb_msg.r = float(red) / 255.0
rgb_msg.g = float(green) / 255.0
rgb_msg.b = float(blue) / 255.0
rgb_msg.a = 1.0
self.rgb_state_pub.publish(rgb_msg)
seq_msg = UInt8()
seq_msg.data = sequence & 0xFF
self.rx_sequence_pub.publish(seq_msg)
def main(args=None):
rclpy.init(args=args)
node = ArduinoSensorBridge()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
if node.serial_port is not None and node.serial_port.is_open:
node.serial_port.close()
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()

이 Python 노드는 ROS 2와 Arduino 사이의 시리얼 프로토콜 브리지입니다.
전체 흐름은 다음입니다.
ROS 2 Topic
↓
Python Serial Bridge Node
↓
Binary Serial Packet
↓
Arduino
↓
Relay / RGB LED / Button
노드 이름은 다음입니다.
arduino_sensor_bridge
기본 포트는 /dev/ttyACM0입니다.
self.declare_parameter('port', '/dev/ttyACM1')
기본 Baudrate는 Arduino 펌웨어와 같은 115200입니다.
self.declare_parameter('baudrate', 115200)
시리얼 포트는 pyserial로 엽니다.
self.serial_port = serial.Serial(
port=self.port,
baudrate=self.baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.0,
write_timeout=0.1
)
timeout=0.0은 non-blocking read입니다. ROS 2 타이머 콜백이 시리얼 입력 대기 때문에 멈추지 않게 하기 위한 설정입니다.
이 노드가 퍼블리시하는 토픽은 다음입니다.
| 토픽 | 메시지 타입 | 의미 |
|---|---|---|
/sensor_bridge/relay_state | std_msgs/msg/Bool | 현재 릴레이 상태 |
/sensor_bridge/button_state | std_msgs/msg/Bool | 현재 버튼 상태 |
/sensor_bridge/rgb_state | std_msgs/msg/ColorRGBA | 현재 RGB LED 상태 |
/sensor_bridge/rx_sequence | std_msgs/msg/UInt8 | Arduino에서 받은 패킷 Sequence |
이 노드가 구독하는 명령 토픽은 다음입니다.
| 토픽 | 메시지 타입 | 의미 |
|---|---|---|
/sensor_bridge/relay_cmd | std_msgs/msg/Bool | 릴레이 ON/OFF 명령 |
/sensor_bridge/rgb_cmd | std_msgs/msg/ColorRGBA | RGB LED 색상 명령 |
/sensor_bridge/rgb_cmd_with_relay_on | std_msgs/msg/ColorRGBA | 릴레이 ON + RGB 색상 동시 명령 |
릴레이 명령 콜백은 다음입니다.
def relay_cmd_callback(self, msg: Bool):
relay_state = 1 if msg.data else 0
self.send_packet(PKT_SET_RELAY, [relay_state])
ROS 2에서 true를 보내면 Payload [1]이 들어간 0x41 패킷을 Arduino로 보냅니다. false를 보내면 Payload [0]을 보냅니다.
RGB 명령 콜백은 다음입니다.
def rgb_cmd_callback(self, msg: ColorRGBA):
red = self.clamp_color_to_u8(msg.r)
green = self.clamp_color_to_u8(msg.g)
blue = self.clamp_color_to_u8(msg.b)
self.send_packet(PKT_SET_RGB, [red, green, blue])
ROS 2의 ColorRGBA는 보통 0.0~1.0 범위의 float 값을 사용합니다. Arduino는 0~255 값을 쓰므로 다음 함수에서 변환합니다.
def clamp_color_to_u8(self, value: float) -> int:
if value <= 0.0:
return 0
if value >= 1.0:
return 255
return int(value * 255.0)
예를 들어 ROS 2에서 다음 명령을 보내면,
r = 1.0
g = 0.0
b = 0.0
Arduino로 가는 Payload는 다음입니다.
FF 00 00
즉 RGB LED가 빨간색으로 켜집니다.
수신 파서도 Arduino와 같은 상태 머신 구조입니다.
class ParserState:
WAIT_START_1 = 0
WAIT_START_2 = 1
WAIT_START_3 = 2
READ_PACKET_ID = 3
READ_LENGTH = 4
READ_SEQUENCE = 5
READ_PAYLOAD = 6
READ_CHECKSUM = 7
READ_END_1 = 8
READ_END_2 = 9
정상 패킷이 들어오면 Checksum을 검증합니다.
calculated = self.calc_checksum(
self.rx_packet_id,
self.rx_length,
self.rx_sequence,
self.rx_payload
)
if calculated == self.rx_checksum:
self.handle_packet(
self.rx_packet_id,
self.rx_length,
self.rx_sequence,
self.rx_payload
)
Checksum이 다르면 패킷은 버립니다. 이 부분이 없으면 시리얼 노이즈나 중간 데이터 깨짐 때문에 잘못된 릴레이 상태, RGB 상태가 ROS 2로 퍼블리시될 수 있습니다.
Arduino에서 상태 패킷 0x31이 들어오면 다음 함수가 실행됩니다.
def parse_sensor_state(self,
length: int,
sequence: int,
payload: List[int]):
Payload는 반드시 5바이트입니다.
if length != 5:
return
Payload 해석은 다음입니다.
relay_state = payload[0] != 0
button_state = payload[1] != 0
red = payload[2]
green = payload[3]
blue = payload[4]
그다음 각각 ROS 2 토픽으로 퍼블리시합니다.
relay_msg = Bool()
relay_msg.data = relay_state
self.relay_state_pub.publish(relay_msg)
button_msg = Bool()
button_msg.data = button_state
self.button_state_pub.publish(button_msg)
rgb_msg = ColorRGBA()
rgb_msg.r = float(red) / 255.0
rgb_msg.g = float(green) / 255.0
rgb_msg.b = float(blue) / 255.0
rgb_msg.a = 1.0
self.rgb_state_pub.publish(rgb_msg)
즉 Arduino에서 받은 0~255 RGB 값을 ROS 2에서는 0.0~1.0 float 값으로 변환해서 퍼블리시합니다.
빌드 명령은 다음입니다.
cd ~/turtlebot3_ws
colcon build --packages-select tb3_arduino_sensor_bridge
source install/setup.bash

실행 권한도 부여합니다.
chmod +x ~/turtlebot3_ws/src/tb3_arduino_sensor_bridge/tb3_arduino_sensor_bridge/arduino_sensor_bridge.

5. 토픽 확인 및 테스트
1) ROS2 브리지 노드 실행
기본 포트가 /dev/ttyACM1이면 다음처럼 실행합니다.
ros2 run tb3_arduino_sensor_bridge arduino_sensor_bridge
포트가 /dev/ttyUSB0이면 파라미터로 지정합니다.
ros2 run tb3_arduino_sensor_bridge arduino_sensor_bridge_node \
--ros-args -p port:=/dev/ttyUSB0 -p baudrate:=115200
정상적으로 열리면 다음과 비슷한 로그가 나옵니다.
Opened serial port /dev/ttyACM1 at 115200 baud.

ros2 run tb3_arduino_sensor_bridge arduino_sensor_bridge \
--ros-args -p port:=/dev/tb3_sensor -p baudrate:=115200
2) 토픽 목록 확인
ros2 topic list
다음 토픽이 보여야 합니다.
/sensor_bridge/relay_state
/sensor_bridge/button_state
/sensor_bridge/rgb_state
/sensor_bridge/rx_sequence
/sensor_bridge/relay_cmd
/sensor_bridge/rgb_cmd

3) 버튼 상태 확인
ros2 topic echo /sensor_bridge/button_state
버튼을 누르지 않으면 보통 다음처럼 나옵니다.
data: false

버튼을 누르면 다음처럼 바뀝니다.
data: true

7) 릴레이 상태 확인
ros2 topic echo /sensor_bridge/relay_state
릴레이가 꺼져 있으면 다음입니다.
data: false

릴레이가 켜져 있으면 다음입니다.
data: true

8) 릴레이 켜기
ros2 topic pub --once /sensor_bridge/relay_cmd std_msgs/msg/Bool "{data: true}"
릴레이가 켜져야 합니다.

9) 릴레이 끄기
ros2 topic pub --once /sensor_bridge/relay_cmd std_msgs/msg/Bool "{data: false}"
릴레이가 꺼져야 합니다.

10) RGB LED 빨간색 켜기
ros2 topic pub --once /sensor_bridge/rgb_cmd std_msgs/msg/ColorRGBA \
"{r: 1.0, g: 0.0, b: 0.0, a: 1.0}"

11) RGB LED 초록색 켜기
ros2 topic pub --once /sensor_bridge/rgb_cmd std_msgs/msg/ColorRGBA \
"{r: 0.0, g: 1.0, b: 0.0, a: 1.0}"

12) RGB LED 파란색 켜기
ros2 topic pub --once /sensor_bridge/rgb_cmd std_msgs/msg/ColorRGBA \
"{r: 0.0, g: 0.0, b: 1.0, a: 1.0}"

13) RGB LED 끄기
ros2 topic pub --once /sensor_bridge/rgb_cmd std_msgs/msg/ColorRGBA \
"{r: 0.0, g: 0.0, b: 0.0, a: 1.0}"

14) RGB 상태 확인
ros2 topic echo /sensor_bridge/rgb_state
빨간색이면 다음과 비슷하게 나옵니다.
r: 1.0
g: 0.0
b: 0.0
a: 1.0

15) Sequence 확인
ros2 topic echo /sensor_bridge/rx_sequence
값이 계속 증가하면 Arduino에서 ROS2로 패킷이 정상적으로 들어오고 있다는 뜻입니다.
data: 10
---
data: 11
---
data: 12

rx_sequence가 중간에 자주 튀거나 멈추면 다음을 의심해야 합니다.
시리얼 포트 불안정
Baudrate 불일치
USB 케이블 문제
Arduino 리셋 반복
전원 부족
Checksum 오류
16) 실제 테스트 순서
가장 현실적인 테스트 순서는 다음입니다.
- Arduino에 펌웨어 업로드
- USB로 Raspberry Pi 또는 PC에 연결
/dev/ttyACM0또는/dev/ttyUSB0확인- ROS2 브리지 노드 실행
/sensor_bridge/button_state확인- 버튼 눌러서
true/false변화 확인 /sensor_bridge/relay_cmd로 릴레이 ON/OFF 확인/sensor_bridge/rgb_cmd로 RGB 색상 변경 확인/sensor_bridge/relay_state,/sensor_bridge/rgb_state로 실제 상태 피드백 확인
이 구조로 만들면 TurtleBot3 Burger의 Raspberry Pi 4에서 Arduino를 보조 I/O 보드처럼 사용할 수 있습니다. 버튼은 입력 센서로 쓰고, 릴레이와 RGB LED는 ROS2 명령으로 제어하면서 동시에 현재 상태를 토픽으로 확인할 수 있습니다.