
Arduino Mega가 센서·액추에이터를 다루고, Senvas Touch(라즈베리파이 기반)가 Modbus RTU 마스터 겸 Node.js HMI로 전체를 조율하는 스마트팜 시스템. 의존성 0 원칙, 1년치 CSV 트렌드, Tapo RTSP 카메라까지 한 패널에.
스마트팜 HMI로 무엇을 만들었나
실내 소형 스마트팜입니다. 잎채소부터 토마토·딸기까지 작물 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를 눌렀는데 팬이 돈다
![코일 비트 패킹 함정 — _M[0] 한 바이트의 8비트가 0x1000~0x1007 코일에 매핑](/blog/images/posts/smartfarm-hmi/coil-bit-packing.webp)
증상
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
+ 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 간격으로 “ src 교체로 폴링 - 영상 탭을 떠나면 8초 idle 후 ffmpeg 자동 종료 → 평소 CPU 0%
MJPEG보다 단순하고, 호환성이 100%입니다. CPU 부하도 필요할 때만 발생합니다.
트러블슈팅 3: 자동정지가 안 되던 펌프와 Modbus 쓰기 충돌

펌프 자동정지 문제가 세 트러블슈팅 중 가장 오래 걸렸습니다.
증상
펌프는 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 환경에 부담입니다. 그리고 “상태 플래그는 작업이 완전히 완료된 후에 리셋한다”는 원칙을 지켜야 합니다. 이 두 실수가 겹쳐서 디버깅에 오랜 시간이 걸렸습니다.
그 외 소소한 UI 트러블들
토글 버튼 깜빡임: SSE가 옛 상태값을 보내면 버튼이 잠시 원래 상태로 돌아갔다 다시 새 값으로 바뀝니다. 해결은 pendingTags 테이블을 두고, 클릭 후 2초간 사용자 값을 우선 표시하는 것이었습니다. SSE는 2초 후 동기화.
설정창 스크롤 중 닫힘: 모달 바깥 클릭으로 닫히도록 했더니, 긴 모달을 스크롤하다 바깥 영역을 클릭해 닫혀버리는 문제가 생겼습니다. 설정창은 X 버튼과 “저장&적용” 버튼으로만 닫히도록 변경했습니다.
배포: Senvas Touch에 올리기
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 없이도 배포 자체는 수초 안에 끝납니다.
스마트팜 HMI 개발 회고
잘 된 것들
의존성 0 원칙이 현장에서 빛났습니다. 임베디드 기기는 인터넷 환경이 불안정하거나 아예 없는 경우가 많습니다. npm install 없이 ZIP만 올리면 된다는 것이 배포를 극단적으로 단순하게 만들었습니다.
펌웨어를 단순 액추에이터로 유지한 것도 좋았습니다. 광주기 시간을 바꾸거나 급수 횟수를 조정할 때 Arduino를 건드릴 필요가 없습니다. 설정창에서 값 바꾸면 끝입니다.
인라인 SVG 아이콘도 예상보다 중요했습니다. 이모지 렌더링이 플랫폼마다 달라지는 문제 없이 모든 기기에서 동일하게 보입니다.
다시 한다면
- 시리얼 폴링 주기를 처음부터 500ms 이상으로 잡습니다. 100ms는 9600bps에서 무리였습니다.
- Modbus 라이브러리를 선택할 때 비트 패킹 방식부터 문서/소스로 확인합니다. 이게 가장 큰 시간 낭비였습니다.
- 펌프처럼 모멘터리 동작(1초 ON 후 OFF)이 필요한 경우, 펌웨어 측이 직접 타이머를 관리하는 것도 대안입니다. 이번엔 “펌웨어 최대한 단순”이라는 방침이 있어서 HMI 측에서 처리했지만, 트레이드오프를 이해하고 선택해야 합니다.
향후 확장 아이디어
- 작물 프리셋 사용자 정의 (JSON 직접 추가·편집)
- 자동 급수에 토양수분 가드 추가 (스케줄 시각이어도 수분이 충분하면 스킵)
- ffmpeg 영상 녹화 (시간별 파일)
- 다중 카메라 지원
- 온도/습도 임계 초과 시 외부 알림 (텔레그램 등)
Senvas Touch 패널 위에서 돌아가는 이 시스템은 현재 실제 스마트팜에서 운영 중입니다. 코드는 별도로 공개 예정입니다.
임베디드와 웹 사이 어딘가에 있는 이런 류의 프로젝트에서 비슷한 트러블을 겪고 있다면, 특히 Modbus 코일 비트 패킹 문제라면, 이 글이 조금이라도 도움이 됐으면 합니다.
스마트팜 HMI의 핵심은 펌웨어를 단순 액추에이터로 두고, 자동 제어와 의존성 0 배포를 HMI 쪽에 모은 것입니다.
그 뒤 — 토마토가 케이스를 넘었다
작물 프리셋을 토마토로 맞춰두고 한동안 그대로 뒀습니다. 결과는 사진이 대신합니다.
케이스는 모종 크기를 기준으로 만들어진 물건이라, 작물이 제대로 자라면 금방 한계가 옵니다. 다음에 만든다면 케이스 높이부터 다시 잡아야 합니다.
사용 키트 — 이 프로젝트는 eduino 스마트팜 키트로 제작했습니다.
문의
- Email : [email protected]
- Insta : https://www.instagram.com/going.sen/
- Website : https://intosen.com/kr/consult/
댓글
닉네임만 입력하면 바로 댓글을 남길 수 있어요. Google/GitHub 로그인도 가능합니다.