라즈베리파이 + 아두이노로 스마트팜 HMI 만들기 — 의존성 0 Node.js, Modbus 함정, 안 꺼지던 펌프

라즈베리파이 + 아두이노로 스마트팜 HMI 만들기
Arduino Mega가 센서·액추에이터를 다루고, Senvas Touch(라즈베리파이 기반)가 Modbus RTU 마스터 겸 Node.js HMI로 전체를 조율하는 스마트팜 시스템. 의존성 0 원칙, 1년치 CSV 트렌드, Tapo RTSP 카메라까지 한 패널에.
무엇을 만들었나
실내 소형 스마트팜이다. 잎채소부터 토마토·딸기까지 작물 6종 프리셋을 터치 한 번으로 전환하고, 팬·LED·펌프가 그에 맞춰 자동 제어된다. 화면은 1024×600 터치 패널 하나. 외부 서버나 클라우드 연결 없이 독립 동작한다.
기능 목록을 나열하면 이렇다.
- 온도·습도·토양수분 실시간 모니터링
- 팬·LED(NeoPixel)·펌프 수동/자동 제어
- 작물 6종 프리셋 (상추·토마토·딸기·오이·고추·바질)
- 광주기 스케줄 — 일출·일몰 30분 페이드 인/아웃 포함
- 자동 급수 스케줄 — 일일 횟수 또는 주간 요일 선택
- Tapo RTSP 카메라 라이브 영상
- 1년치 시계열 데이터 (분당 CSV, 365일 자동 삭제)
기능 자체는 평범해 보일 수 있다. 하지만 개발 과정에서 Modbus 코일 비트 패킹의 함정, MJPEG 스트림 검은 화면, 펌프가 끝내 안 꺼지던 이유 등 세 가지 트러블슈팅 회고가 이 글의 핵심이다.
시스템 아키텍처
하드웨어 구성은 단순하다.
| 구분 | 부품 |
|---|---|
| MCU | Arduino Mega 2560 (Serial1, GPIO 다수) |
| HMI 패널 | Senvas Touch (라즈베리파이 기반, 1024×600) |
| 통신 | USB↔RS-485 어댑터, 9600 8N1 |
| 센서 | DHT11 (온·습도), 아날로그 토양수분 |
| 조명 | WS2812 NeoPixel 12px |
| 환기 | DC 팬 + H-Bridge |
| 급수 | 12V 펌프 + 릴레이 |
| 영상 | Tapo C200/C210 (RTSP 지원) |
소프트웨어 스택도 군더더기 없다. Arduino 펌웨어가 Modbus RTU Slave로 동작하고, Senvas Touch 위의 Node.js app.js가 Modbus RTU Master 역할을 맡는다. 브라우저는 SSE(/events)로 실시간 상태를 수신하고, Tapo RTSP 카메라 영상은 ffmpeg가 JPEG 프레임으로 변환해 HTTP 폴링으로 내려준다.
설계 원칙 세 가지를 먼저 이야기해야 이후 트러블슈팅 맥락이 이해된다.
- 펌웨어는 “단순 액추에이터” — 자동 로직, 타이머, 엣지 감지 없음. 코일 받아서 출력만.
- HMI는 의존성 0 —
package.json의dependencies가 비어있다.npm install없이 ZIP 올리고node app.js만 하면 끝. - 모든 자동 제어는 HMI 측 — 펌웨어 재플래시 없이 설정만 바꾸면 동작 변경 가능.
펌웨어: 단순할수록 좋다
Arduino 펌웨어의 핵심 원칙은 “시키는 대로만 한다”이다. Modbus HR(Holding Register)로 센서값을 올리고, 코일(Coil)로 받은 명령을 그대로 GPIO에 출력한다.
Modbus 메모리 맵
| 태그 | 주소 | 타입 | R/W |
|---|---|---|---|
| Temp | HR 0x7000 | Int16 | R |
| Hum | HR 0x7001 | Int16 | R |
| Sol (토양수분) | HR 0x7002 | Int16 | R |
| Sol_Set (임계값) | HR 0x7003 | Int16 | R/W |
| Bright (LED 밝기) | HR 0x7005 | Int16 | R/W |
| Fan | Coil 0x1000 | Bool | R/W |
| LED | Coil 0x1001 | Bool | R/W |
| Auto (자동 모드) | Coil 0x1002 | Bool | R/W |
| Pump | Coil 0x1003 | Bool | R/W |
메인 루프에서 호출되는 ControlTask의 전체 코드가 이렇다.
void ControlTask() {
// 밝기 변화 시에만 NeoPixel 갱신 (불필요한 show() 방지)
static unsigned short last_bright = 0xFFFF;
if (D_BRIGHT != last_bright) {
RGB_LED.setBrightness(map(D_BRIGHT, 0, 100, 0, 255));
RGB_LED.show();
last_bright = D_BRIGHT;
}
// 코일 그대로 출력 — 그게 전부
if (M_FAN) FAN_ON(); else FAN_OFF();
RGB_Color(M_LED ? RGB_LED.Color(250,250,250) : 0);
digitalWrite(PIN_PUMP, M_PUMP ? HIGH : LOW);
}
열 줄짜리 함수다. 펌웨어가 이렇게 단순하면 자동 급수 간격을 바꾸든, 광주기를 바꾸든, 펌웨어를 재플래시할 필요가 없다. HMI에서 설정값만 바꾸면 된다.
트러블슈팅 1: “LED 누르면 팬이 돌고, LED는 안 켜진다”
증상
UI에서 LED 버튼을 누르면 팬이 켜지고, LED는 반응하지 않는다. Fan 버튼을 누르면 반응이 뒤죽박죽이다. 통신 자체는 정상(CRC 에러 없음), 레지스터 값도 제대로 쓰이는 것처럼 보인다.
가설들
처음엔 핀 번호 혼동을 의심했다. PIN_FAN과 PIN_LED가 바뀐 건 아닐까. 확인해도 맞다. 그다음엔 Modbus 주소 오프셋을 의심했다. 0x1000 대신 0x0000부터 시작해야 하는 건 아닐까. 변경해도 증상이 달라질 뿐 해결되지 않는다.
진짜 원인
라이브러리 소스(ModbusRTUSlave)를 직접 뜯어봤다. 코일은 8개 단위로 1바이트에 비트팩 저장되고 있었다.
addBitArea(0x1000, _M, 50)
→ 코일 0x1000 = _M[0]의 비트 0
→ 코일 0x1001 = _M[0]의 비트 1
→ 코일 0x1002 = _M[0]의 비트 2
→ 코일 0x1003 = _M[0]의 비트 3
그런데 매크로를 이렇게 정의해 놓고 있었다.
// 잘못된 정의 — _M[0]은 Fan, _M[1]은 LED 전체 바이트
#define M_FAN _M[0]
#define M_LED _M[1]
HMI에서 LED(0x1001)를 쓰면, _M[0]의 비트 1만 바뀐다. 하지만 _M[0] 전체를 Fan으로 쓰고 있으니, 비트 1이 1이 되는 순간 M_FAN은 0x02 (non-zero, 즉 true)가 되어 팬이 켜진다. LED는 _M[1]을 보는데 거기는 아무것도 없다.
해결
// 올바른 정의 — bitRead로 해당 비트만 읽음
#define M_FAN bitRead(_M[0], 0)
#define M_LED bitRead(_M[0], 1)
#define M_AUTO bitRead(_M[0], 2)
#define M_PUMP bitRead(_M[0], 3)
한 줄 수정으로 모든 버튼이 정상 동작했다.
교훈
bit-packed Modbus 라이브러리에서 코일 N번은 array[N/8]의 N%8 비트다. _M[1] 같은 바이트 단위 매크로는 코일 8~15번을 가리킨다. 라이브러리를 처음 쓸 때 비트 vs 바이트 저장 방식부터 확인해야 한다.
HMI: 의존성 0 Node.js
Senvas Touch는 라즈베리파이 기반 산업용 터치 패널이다. Node.js와 Chromium이 사전 설치되어 있어서, ZIP 파일 올리고 node app.js 하면 끝이다. 그런데 일반적인 Node.js 프로젝트라면 npm install이 필요하다. 이걸 없애려면 외부 패키지를 전혀 쓰지 않으면 된다.
package.json의 dependencies는 비어있다. Node 내장 모듈(http, fs, child_process, net, os)만 사용한다. 시스템 의존성은 ffmpeg 하나(CCTV용, 선택).
Modbus RTU 마스터 직접 구현
modbus-serial 같은 npm 패키지 대신 modbus_rtu.js를 직접 작성했다. 핵심은 두 가지다.
stty명령으로 시리얼 포트를 raw 모드로 설정fs.openSync("/dev/ttyUSB0", "r+")로 포트를 파일처럼 열고readSync/writeSync로 raw byte 송수신
CRC16 계산도 표준 Modbus 공식 그대로:
function crc16(buf, len) {
let crc = 0xFFFF;
for (let i = 0; i < len; i++) {
crc ^= buf[i];
for (let j = 0; j < 8; j++)
crc = (crc & 1) ? ((crc >> 1) ^ 0xA001) : (crc >> 1);
}
return crc & 0xFFFF;
}
지원 함수코드는 01(Read Coils), 03(Read HR), 05(Write Coil), 06(Write Reg). CRC/통신 에러 시 최대 3회 자동 재시도한다.
조명 자동화: 일출·일몰 페이드
AUTO 모드에서 LED는 단순 ON/OFF가 아니라 일출·일몰을 흉내낸다. 광주기 시작 30분간 0→최대 페이드 인, 종료 30분 전부터 최대→0 페이드 아웃이다. 라즈베리파이 시스템 시계에 의존하지 않고 항상 KST(UTC+9)로 직접 계산한다.
function lightLevel() {
const L = cfg.light, h = L.hours, max = cfg.brightness;
const durMin = h * 60;
const rel = ((kstMinutes() - L.startHour * 60) % 1440 + 1440) % 1440;
if (rel >= durMin) return 0;
const ramp = Math.min(30, durMin / 4);
let factor = 1;
if (rel < ramp) factor = rel / ramp; // fade in
else if (durMin - rel < ramp) factor = (durMin - rel) / ramp; // fade out
return Math.round(max * factor);
}
kstMinutes()는 new Date()에서 UTC 분 + 540(9시간)을 계산해 1440으로 mod한 값이다. 이 값을 100ms마다 D_BRIGHT HR에 쓰면 NeoPixel이 서서히 밝아지거나 어두워진다.
자동 급수 스케줄
급수 자동화에는 두 모드가 있다.
일일 모드: 시작 시각 + 일일 횟수 N을 지정하면 24/N 시간 간격으로 실행된다. 예를 들어 06:00 + 3회라면 06:00 / 14:00 / 22:00에 펌프가 1초 동작한다.
주간 모드: 요일 체크박스(월~일)와 시작 시각을 지정하면, 선택한 요일에만 해당 시각에 1회 실행된다.
초기 설계는 “토양수분 < 임계값”이면 펌프를 켜는 조건 기반이었다. 그런데 토양 센서가 공기 중에 있거나 배선이 빠지면 값이 0이 되고, 0은 항상 임계값보다 작으니 펌프가 무한 동작할 위험이 있었다. 그래서 시간 기반 스케줄로 전환했다.
트러블슈팅 2: MJPEG 검은 화면
증상
ffmpeg가 Tapo RTSP 스트림에서 JPEG을 추출해 multipart/x-mixed-replace; boundary=ffmpeg로 내려주는 방식으로 구현했다. 일부 브라우저에서 검은 화면이 나온다. ffmpeg 프로세스는 살아있고 JPEG 데이터도 정상이다.
가설
boundary 문자열 대소문자 문제? Content-Length 헤더 누락? ffmpeg 출력 버퍼 타이밍 문제?
진짜 원인
ffmpeg가 출력하는 multipart 바운더리 포맷이 RFC 2046 표준과 미묘하게 달랐고, 브라우저마다 파싱 허용 범위가 달랐다. 특정 브라우저에서 첫 프레임 이후 파싱을 포기하고 검은 화면을 유지했다.
해결
MJPEG 파이프 방식을 버리고 순수 JPEG 폴링으로 변경했다.
- Node.js가 ffmpeg 출력을 메모리의 최신 JPEG 1장으로 유지
- 브라우저는
/camera/snapshot을 약 120ms 간격으로<img>src 교체로 폴링 - 영상 탭을 떠나면 8초 idle 후 ffmpeg 자동 종료 → 평소 CPU 0%
MJPEG보다 단순하고, 호환성이 100%다. CPU 부하도 필요할 때만 발생한다.
트러블슈팅 3: “비상정지는 되는데 자동정지는 안 되는” 펌프
이것이 세 트러블슈팅 중 가장 오래 걸렸다.
증상
펌프는 1초 동작 후 꺼져야 한다. UI에서 수동으로 “비상 정지”를 누르면 꺼진다. 그런데 자동 스케줄로 켜진 펌프는 1초가 지나도 계속 동작한다.
시도들과 가설들
1라운드 — setTimeout으로 1초 후 OFF
setPump(true);
setTimeout(() => setPump(false), 1000);
타임아웃이 실행되는데도 펌프가 꺼지지 않는 경우가 생긴다. 이상하다.
2라운드 — 응답 확인 후 OFF
Modbus Write 후 Read로 코일 상태를 확인하고, 값이 1이면 OFF 재시도. 그래도 가끔 꺼지지 않는다.
3라운드 — pumpStartTs 기반 상태 머신
pumpStartTs에 켠 시각을 기록하고, 폴링 루프에서 경과시간 확인 후 OFF. 제일 안정적이어야 할 것 같았는데 여전히 실패 사례가 있다.
4라운드 — 로그 추가해서 디버깅
로그를 보니 두 가지 문제가 동시에 있었다.
진짜 원인
원인 A: Modbus 쓰기 충돌
Node.js의 Modbus 폴링은 100ms 간격으로 HR 읽기를 보낸다. OFF 명령을 보내는 타이밍과 폴링 읽기가 겹치면 시리얼 버스에서 충돌이 생기고, OFF 명령이 손실된다. 9600bps에서 100ms는 촉박하다.
원인 B: pumpStartTs 조기 리셋
// 버그: OFF 송신 전에 이미 0으로 리셋
pumpStartTs = 0;
await setPump(false); // 이게 실패해도 재시도 로직이 없음
pumpStartTs를 OFF 명령 송신 전에 0으로 리셋하면, 송신이 실패해도 “이미 꺼진 것”으로 처리되어 재시도가 일어나지 않는다.
해결
두 원인을 동시에 해결하는 방법은 “끄는 동안 계속 OFF를 보낸다” 였다.
// 핵심 아이디어: 1초 동안은 ON, 그 후에도 꺼질 때까지 200ms마다 OFF 계속 송신
// pumpStartTs는 OFF가 확인될 때까지 유지
if (pumpRunning && elapsed < PUMP_DURATION) {
sendCoil(COIL_PUMP, 1); // ON 유지
} else if (pumpRunning) {
sendCoil(COIL_PUMP, 0); // OFF 계속 시도 (200ms마다)
if (confirmedOff) pumpStartTs = 0; // 확인 후에만 리셋
}
한 번이라도 통신이 성공하면 꺼진다. 설령 몇 번 손실되더라도 200ms 후 재시도가 보장된다.
교훈
시리얼 통신 폴링 주기는 처음부터 보수적으로(500ms 이상) 잡아야 한다. 100ms는 9600bps 환경에 부담이다. 그리고 “상태 플래그는 작업이 완전히 완료된 후에 리셋한다”는 원칙을 지켜야 한다. 이 두 실수가 겹쳐서 디버깅에 오랜 시간이 걸렸다.
그 외 소소한 트러블들
토글 버튼 깜빡임: SSE가 옛 상태값을 보내면 버튼이 잠시 원래 상태로 돌아갔다 다시 새 값으로 바뀐다. 해결은 pendingTags 테이블을 두고, 클릭 후 2초간 사용자 값을 우선 표시하는 것이었다. SSE는 2초 후 동기화.
설정창 스크롤 중 닫힘: 모달 바깥 클릭으로 닫히도록 했더니, 긴 모달을 스크롤하다 바깥 영역을 클릭해 닫혀버리는 문제가 생겼다. 설정창은 X 버튼과 “저장&적용” 버튼으로만 닫히도록 변경했다.
배포: Senvas Touch에 올리기
배포 구조는 단순하다.
Smart_Farm_NodeJS/
├── app.js # HTTP + SSE + Modbus + 카메라 + 자동 제어
├── modbus_rtu.js # 의존성 0 Modbus RTU 마스터
├── index.html # 1024×600 전용 UI
├── package.json # dependencies 없음
├── config.json # 영속 설정 (포트, 작물, 스케줄, 카메라)
└── README.md
Senvas Touch MCP의 install_program으로 ZIP을 올리고, projectType: NodeJs, executableFileName: app.js를 지정하면 설치 완료. CCTV 기능을 쓰려면 sudo apt install -y ffmpeg 한 줄 추가. 자동시작을 활성화하면 부팅 시 Chromium 키오스크 모드로 자동 실행된다.
의존성 0 원칙 덕분에 인터넷 연결이 없어도, npm 없이도 배포 자체는 수초 안에 끝난다.
회고
잘 된 것들
의존성 0 원칙이 현장에서 빛났다. 임베디드 기기는 인터넷 환경이 불안정하거나 아예 없는 경우가 많다. npm install 없이 ZIP만 올리면 된다는 것이 배포를 극단적으로 단순하게 만들었다.
펌웨어를 단순 액추에이터로 유지한 것도 좋았다. 광주기 시간을 바꾸거나 급수 횟수를 조정할 때 Arduino를 건드릴 필요가 없다. 설정창에서 값 바꾸면 끝이다.
인라인 SVG 아이콘도 예상보다 중요했다. 이모지 렌더링이 플랫폼마다 달라지는 문제 없이 모든 기기에서 동일하게 보인다.
다시 한다면
- 시리얼 폴링 주기를 처음부터 500ms 이상으로 잡는다. 100ms는 9600bps에서 무리였다.
- Modbus 라이브러리를 선택할 때 비트 패킹 방식부터 문서/소스로 확인한다. 이게 가장 큰 시간 낭비였다.
- 펌프처럼 모멘터리 동작(1초 ON 후 OFF)이 필요한 경우, 펌웨어 측이 직접 타이머를 관리하는 것도 대안이다. 이번엔 “펌웨어 최대한 단순”이라는 방침이 있어서 HMI 측에서 처리했지만, 트레이드오프를 이해하고 선택해야 한다.
향후 확장 아이디어
- 작물 프리셋 사용자 정의 (JSON 직접 추가·편집)
- 자동 급수에 토양수분 가드 추가 (스케줄 시각이어도 수분이 충분하면 스킵)
- ffmpeg 영상 녹화 (시간별 파일)
- 다중 카메라 지원
- 온도/습도 임계 초과 시 외부 알림 (텔레그램 등)
Senvas Touch 패널 위에서 돌아가는 이 시스템은 현재 실제 스마트팜에서 운영 중이다. 코드는 별도로 공개 예정이다.
임베디드와 웹 사이 어딘가에 있는 이런 류의 프로젝트에서 비슷한 트러블을 겪고 있다면, 특히 Modbus 코일 비트 패킹 문제라면, 이 글이 조금이라도 도움이 됐으면 한다.