교육용 Bluetooth 키트를 산업 HMI로 — Arduino Mega + Modbus RTU 마이그레이션 작업기

TL;DR
교육용 Arduino 자동분류 키트에서 블루투스와 스마트폰 앱을 떼어내고, 산업용 터치 HMI(Senvas Touch)와 Modbus RTU 유선 통신으로 새로 연결했다. 보드는 UNO에서 Mega 2560으로 바꿨고, 원본의 delay() 직렬 시퀀스를 millis() 기반 8단계 상태머신으로 전면 재설계했다. 통신 계약은 코드보다 먼저 Holding Register 표(40001~40101) 로 확정했다. 결과적으로 HMI 한 화면에서 시작/정지/초기화, 공정 단계 애니메이션, R/G/B 분류 누적 수량을 실시간으로 볼 수 있게 됐다.
원본 키트와 한계
원본은 15_conveyer_belt_bluetooth.ino 한 파일에 모든 로직이 들어 있는 전형적인 교육용 스케치다. 컨베이어 벨트, IR 센서, TCS34725 컬러 센서, 서보 분류기, NeoPixel LED, HC-05 블루투스가 한데 묶여 있고, 앱인벤터로 만든 스마트폰 앱(smart_factory_advancedUI.aia)과 문자 명령(s, 0, 1, r/g/b)을 주고받는 구조다.
시연하기엔 충분하다. 뭐가 돌아가는지 눈에 보이고, 폰으로 제어하는 경험이 직관적이다. 그런데 “라인 옆에 붙어있는 진짜 HMI처럼 쓰고 싶다”는 생각이 들 때부터 한계가 눈에 띄기 시작한다.
- 폰을 들고 있어야 한다. 패널에 고정된 HMI가 아니다.
- 누적 분류 수, 공정 단계 진척 같은 정보가 없다. 지금 몇 단계인지, R/G/B 각각 얼마나 쌓였는지 알 방법이 없다.
- 통신 끊김이나 장치 오류 같은 상태 구분이 없다. 블루투스가 끊기면 그냥 아무 반응이 없어진다.
- 1:1 페어링이라 두 사람이 동시에 같은 화면을 볼 수 없다.
이 네 가지가 모여서 “산업 HMI 같지 않은” 느낌의 실체다.
Senvas Touch + Modbus RTU로 바꾸기로
Senvas Touch는 산업용 터치 HMI로, Modbus RTU 마스터 역할을 한다. HMI 측에서 주기적으로 레지스터를 읽고 쓰는 방식이다(구체적인 내부 구현은 여기서 다루지 않는다 — “표대로 읽고 쓴다”는 것이 전부다).
Modbus RTU를 고른 이유는 단순하다. 산업 현장의 공용어이고, 유선이라 신뢰성과 결정성이 무선보다 위다. Slave ID와 레지스터 주소로 다대다 확장도 가능하다(이번엔 1:1이지만). 블루투스는 페어링 관리, 재연결 지연, 음영 지역 문제가 있다. 유선으로 바꾸면 그 고민이 통째로 사라진다.
변경 후 구성은 위 블록도처럼 Senvas Touch → USB-to-TTL 컨버터 → Mega Serial1(RX1=핀19, TX1=핀18) → 센서/액추에이터다. USB-to-TTL 컨버터가 HMI의 RS-485 신호를 UART 레벨로 바꿔 Mega에 연결한다.
설계 문서부터: Holding Register 계약
코드를 열기 전에 design.md와 arduino-modbus-rtu-protocol.md 두 문서를 먼저 확정했다. 통신이란 결국 “이 주소에 이 값이 들어 있다”는 계약이다. 양쪽이 같은 표를 보고 각자 구현하면 된다.
| 주소 | 0-base | 방향 | 의미 | 값 |
|---|---|---|---|---|
| 40001 | 0 | Arduino → HMI | 운전 상태 | 0=정지, 1=운전, 99=장치 오류 |
| 40002 | 1 | Arduino → HMI | 공정 단계 | 0=대기 … 7=정지, 99=오류 |
| 40003 | 2 | Arduino → HMI | 최근 색상 | 0=없음, 1=빨강, 2=초록, 3=파랑 |
| 40021~40023 | 20~22 | Arduino → HMI | R/G/B 누적 카운트 | 정수 |
| 40101 | 100 | HMI → Arduino | 순간 명령 | 0=없음, 1=시작, 2=정지, 3=초기화 |
펌웨어에서 이 주소들을 상수로 박아뒀다. 이렇게 하면 나중에 표를 수정해도 숫자 하나만 바꾸면 전파된다.
const uint16_t REG_RUN_STATE = 0; // 40001 운전 상태
const uint16_t REG_STAGE = 1; // 40002 공정 단계
const uint16_t REG_COLOR = 2; // 40003 최근 색상
const uint16_t REG_RED_COUNT = 20; // 40021
const uint16_t REG_GREEN_COUNT = 21; // 40022
const uint16_t REG_BLUE_COUNT = 22; // 40023
const uint16_t REG_COMMAND = 100; // 40101 HMI→Arduino 순간 명령
40101 명령 레지스터에는 규칙이 하나 있다. Arduino가 명령을 실행한 직후 반드시 0으로 되돌려야 한다. HMI는 레지스터를 주기적으로 쓰는 구조가 아니라 버튼을 누를 때만 값을 넣는다. 만약 Arduino가 0으로 되돌리지 않으면 다음 폴링 사이클에서 같은 명령이 다시 처리될 수 있다. “한 번만 처리” 보장이 이 한 줄에 달려 있다.
void handleCommand() {
uint16_t cmd = holdingRegisters[REG_COMMAND];
if (cmd == CMD_NONE) return;
switch (cmd) {
case CMD_START: if (runState != RUN_ERROR) startRunning(); break;
case CMD_STOP: if (runState != RUN_ERROR) stopRunning(); break;
case CMD_RESET: redCount = greenCount = blueCount = 0; break;
}
// 순간 명령은 반드시 즉시 0 으로 (한 번만 처리 보장)
holdingRegisters[REG_COMMAND] = CMD_NONE;
}
통신이 끊기면 HMI는 모든 명령 버튼을 비활성화한다. 아래 화면이 그 상태다. 마지막으로 수신한 카운트 값은 carry-over로 표시되지만 조작 자체가 막힌다.

연결 설정은 별도 화면에서 한다. COM 포트, Baud rate, Slave ID, 패리티/스톱 비트를 잡고 저장하면 된다.

하드웨어 결정: UNO → Mega 2560
처음엔 UNO에서 그냥 SoftwareSerial로 Modbus를 돌리면 되지 않을까 생각했다. 해보니까 안 된다. 이유는 세 가지가 동시에 필요했기 때문이다.
- Modbus RTU 시리얼 — 115200 baud, 안정적인 하드웨어 UART 필수. SoftwareSerial은 높은 baud rate에서 오류율이 높고, 다른 인터럽트와 충돌한다.
- USB 시리얼 — 디버그 출력용. Modbus와 같은 포트를 쓰면 통신이 섞인다.
- I2C — TCS34725 컬러 센서 연결용.
UNO는 하드웨어 UART가 하나뿐이라 Modbus와 USB 디버그를 동시에 살릴 수 없다. Mega 2560은 Serial(USB), Serial1(핀18/19), Serial2(핀16/17), Serial3(핀14/15) 네 개를 분리해서 쓸 수 있고, I2C도 디지털 핀 20/21에 따로 배치돼 있다.
핀 배치를 정리하면:
- Modbus(Serial1): TX=핀18, RX=핀19
- I2C(TCS34725): SDA=핀20, SCL=핀21
- DC 방향=13, DC 속도(PWM)=11, 서보=9, NeoPixel=5, 부저=4, IR=A0
Mega로 바꾸고 나서 핀 충돌 걱정이 사라졌다.
펌웨어 핵심: delay() 다 들어내고 상태머신으로
원본 코드의 흐름은 delay(2000) → delay(1500) → delay(1500) → delay(1000) 이었다. 컨베이어가 이동하고, IR이 감지하고, 컬러 센서가 읽고, 서보가 분류하는 각 단계를 블로킹으로 기다리는 구조다.
교육용으로는 읽기 쉽다. Modbus 슬레이브에서는 치명적이다. delay(2000) 동안 loop()가 멈추니까 Modbus 폴링도 멈춘다. HMI가 연속 몇 번 읽기에 실패하면 “통신 끊김”으로 판단한다. 결과적으로 동작은 하는데 HMI는 계속 연결 실패를 표시하는 상황이 된다.
해결책은 delay()를 전부 들어내고 millis() 기반 상태머신으로 바꾸는 것이다. loop() 한 사이클이 수 ms 이내에 끝나야 한다. 시간이 필요한 작업은 “이 단계에 진입한 시각”을 기록해 두고, 다음 사이클에서 경과 시간을 비교해 다음 단계로 전이한다.
단계를 8개로 쪼갰다.
START 명령(40101=1)
│
▼
[0 대기] ──시작──▶ [1 컨베이어 이동] ──IR감지──▶ [2 IR정지(2s)]
│
▼
[3 컬러센서 이동(저속)] ◀──┘
│ sum ≥ 20
▼
[4 색상 측정(1.5s)]
│
▼
[5 서보 분류(1.5s)]
│
▼
[6 배출 재가동(1s)] ──▶ 다시 [1]
STOP 명령(40101=2) → [7 정지]
RESET 명령(40101=3) → 카운터만 0으로 (단계 유지)
상태머신의 한 단계를 들여다보면 이렇게 생겼다. 센서 값을 읽어서 임계값을 넘으면 다음 단계로 전이한다. 이 코드가 실행되는 시간은 마이크로초 단위다.
case STAGE_TO_COLOR: {
uint16_t rawR, rawG, rawB, rawC;
tcs.getRawData(&rawR, &rawG, &rawB, &rawC);
int r = map(rawR, 0, 21504, 0, 1000);
int g = map(rawG, 0, 21504, 0, 1000);
int b = map(rawB, 0, 21504, 0, 1000);
if (r + g + b >= COLOR_SUM_THRESHOLD) {
railStop();
if (r > g && r > b) lastColor = COLOR_RED;
else if (g > r && g > b) lastColor = COLOR_GREEN;
else lastColor = COLOR_BLUE;
enterStage(STAGE_COLOR_MEASURE);
}
break;
}
상태머신으로 바꾸고 나서 loop() 안에서 Modbus 폴링과 공정 로직이 경쟁하지 않는다. 폴링이 밀리는 일도 없다.
막힘과 디버깅 3컷
이 작업에서 가장 오래 걸린 부분이 여기다. 기록해 두는 게 나중에 가장 값어치가 있을 것 같다.
컷 ①: 라이브러리는 설치했는데 IDE가 못 찾는다
증상은 깔끔했다. Adafruit_TCS34725.h: No such file or directory. 분명히 라이브러리 매니저에서 설치했는데 빌드가 안 된다.
원인은 경로 불일치였다. arduino-cli가 기본 경로(OS가 sketchbook 경로를 자동 동기화 폴더에 넣어둔 곳)에 라이브러리를 설치했는데, Arduino IDE의 sketchbook은 다른 드라이브의 별도 폴더로 잡혀 있었다. IDE는 자기 sketchbook 폴더만 뒤진다.
arduino-cli config dump를 실행해보면(또는 ~/.arduinoIDE/arduino-cli.yaml의 directories.user 항목을 확인하면) 실제 설치 경로를 볼 수 있다. 두 경로가 다르면 라이브러리가 “설치는 됐는데 안 보이는” 상태가 된다. 해결책은 IDE의 sketchbook 경로를 바꾸거나, 해당 폴더에 직접 라이브러리를 복사해 넣는 것이다.
컷 ②: 통신이 완전 침묵이다 — 라이브러리 v3.x 의 함정
이게 제일 오래 걸렸다. 빌드도 됐고 업로드도 됐다. Senvas는 “통신 끊김”만 표시했다. 배선을 세 번 확인했다. Baud rate도 맞췄다. Slave ID도 1로 맞췄다. 여전히 침묵.
결국 라이브러리 소스를 직접 열어봤다. CMB27의 ModbusRTUSlave v3.x는 modbus.begin()이 시리얼 포트를 자동 시작하지 않는다. 라이브러리 생성자가 Stream& 참조만 받아서 다형적으로 serial.begin()을 호출할 방법이 없다. 사용자가 반드시 먼저 Serial1.begin()을 직접 불러줘야 한다.
라이브러리 공식 예제(ModbusRTUSlaveExample.ino)를 보면 83~84번째 줄이 명확하게 두 줄로 분리돼 있다.
// ⚠️ v3.x 의 함정: 이 한 줄을 빼면 라인이 완전 침묵이다.
Serial1.begin(MODBUS_BAUD, SERIAL_8N1);
modbus.configureHoldingRegisters(holdingRegisters, NUM_HOLDING_REGISTERS);
modbus.begin(MODBUS_SLAVE_ID, MODBUS_BAUD, SERIAL_8N1);
v2에서 v3로 넘어오면서 API가 살짝 바뀐 것이다. 기억으로 쓴 코드가 이런 변화를 잡아내지 못한다. 라이브러리 메이저 버전이 바뀌면 공식 예제부터 다시 보는 게 맞다.
컷 ③: 통신은 되는데 동작 안 함 → 진단 로그를 깔다
컷 ②를 해결하고 나니 이번엔 다른 증상이 나왔다. Senvas가 운전 상태와 카운트를 읽기는 하는데 START 버튼을 눌러도 모터가 안 돈다.
추측을 시작하기 전에 USB Serial에 진단 로그를 깔았다. Mega는 Serial(USB)과 Serial1(Modbus)이 완전히 분리돼 있어서 로그 출력이 Modbus 통신에 영향을 주지 않는다.
#define DEBUG_USB_SERIAL 1 // 0 으로 두면 컴파일에서 다 사라짐
void serviceHeartbeat() {
DBG(F("[HB] run=")); DBG(runState);
DBG(F(" stage=")); DBG(stage);
DBG(F(" cmd=")); DBG(holdingRegisters[REG_COMMAND]);
DBG(F(" R/G/B=")); DBG(redCount);
DBG(F("/")); DBG(greenCount);
DBG(F("/")); DBG(blueCount);
DBG(F(" IR=")); DBGLN(digitalRead(PIN_IR));
}
Serial Monitor에 이런 출력이 흘렀다.
[BOOT] 16_conveyer_belt_modbus 시작
[BOOT] TCS34725 OK (I2C SDA=20 SCL=21)
[BOOT] Modbus RTU Slave id=1 baud=115200 8N1 on Serial1 (Mega: 핀19=RX1, 핀18=TX1)
[HB] run=0 stage=0 color=0 cmd=0 R/G/B=0/0/0 IR=1
[CMD] received=1 runState=0
[STAGE] 0 -> 1
[CMD] received=1이 찍혔다. 명령은 들어오고 있었다. 그런데 [STAGE] 전이 로그가 안 찍혔다. 문제는 명령 처리 로직이 아니라 startRunning() 안의 컬러 센서 초기화에서 I2C가 응답하지 않는 것이었다.
여기서 추가로 확인한 것이 있다. I2C 핀이 보드마다 다르다. UNO는 A4/A5, Leonardo는 2/3, Mega는 20/21이다. 보드를 Mega로 바꿨는데 배선은 그대로 A4/A5에 꽂혀 있었다. 핀을 20/21로 옮기자 컬러 센서가 정상 응답했다.
“디버깅의 80%는 어디서 끊겼는지 알아내는 것”이라는 말이 있다. 헤드비트 로그 한 줄이 30분의 추측을 줄였다.
마무리·다음 단계
지금은 시작/정지/초기화, 공정 단계 8개, R/G/B 분류 누적 표시가 모두 정상 동작한다. 모터 PWM을 50/40으로 천천히 설정해서 시연에 맞췄다(PWM 데드존 아래로 내려가면 모터가 안 도는 구간이 있으니 주의).
이 작업에서 얻은 것을 한 줄로 요약하면, 교육용 키트를 산업용 HMI에 붙이는 건 통신 방식을 바꾸는 것만이 아니었다. 시간을 다루는 방식(delay → 상태머신)과 디버깅 도구(USB 로그 + 라이브러리 소스 읽기)까지 같이 바꿔야 했다.
다음에 해보고 싶은 것들:
- 카운트 EEPROM 저장 — 전원이 꺼졌다 켜져도 R/G/B 누적값을 유지
- 컬러 임계값 캘리브레이션을 HMI에서 — 지금은 코드에 하드코딩된 값을 HMI 설정 화면에서 조정 가능하게
- 한 HMI에 여러 라인 — Slave ID를 분리해서 2번, 3번 라인을 같은 HMI에서 모니터링
- 알람 이력 별도 화면 — 장치 오류가 몇 번 발생했는지 기록
세 가지 디버깅 일화 중 v3.x 라이브러리 함정이 제일 허탈했다. 코드가 아무 문제없어 보이는데 완전 침묵인 상황. 라이브러리 소스를 직접 열어보기 전까지는 원인을 찾을 수가 없었다. 다음에도 비슷한 증상이 나오면 배선 확인보다 라이브러리 예제부터 열어볼 것 같다.