Arduino + 라즈베리파이로 만든 태양광 추적 충전 HMI 개발기
Arduino + 라즈베리파이로 만든 태양광 추적 충전 HMI 개발기
LDR 4분면 광센서로 태양을 추적하는 2축 솔라 트래커를, Arduino Mega 펌웨어 + 라즈베리파이 Python 백엔드 + 7” 터치 HMI로 엮고, 실전에서 만난 통신·떨림·배포 문제를 하나씩 잡아낸 기록.
![]()
1. 무엇을 만들었나
“태양광 패널이 스스로 태양을 따라가면 발전량이 올라간다”는 건 이론적으로 누구나 안다. 문제는 실제로 구현하면 생각보다 골치 아픈 일들이 줄줄이 따라온다는 것이다.
이 프로젝트의 목표는 단순했다: LDR(광의존 저항, Light Dependent Resistor) 4개를 분면별로 배치해 태양 방향을 감지하고, 서보 모터 2축으로 패널 각도를 추종하면서, 충전 전압과 전류를 7인치 터치 패널에 실시간으로 표시한다. 그리고 사용자가 직접 각도를 조작할 수 있는 수동 모드도 갖춘다.
결과물은 세 계층으로 나뉜다. Arduino Mega가 센서를 읽고 서보를 움직이는 엣지 컨트롤러 역할을 하고, 라즈베리파이가 Modbus RTU로 데이터를 수집해 웹 서버를 통해 HMI에 밀어준다. HMI는 Senvas Touch 런처 위에서 Chromium 키오스크로 뜨는 단일 HTML 파일이다.
전체 데이터 흐름은 이렇다.
태양 위치 → LDR 4분면 → Arduino가 서보 2축 제어 → 충전 전압/전류 측정
→ Modbus RTU(115200bps)로 라즈베리파이에 보고
→ WebSocket으로 브라우저 HMI에 실시간 표시(150ms 폴링)
→ 사용자가 HMI 슬라이더로 수동 제어
→ 다시 Modbus로 Arduino에 명령
2. 하드웨어 구성 — LDR 4분면과 서보 2축
광 추적 방식으로 LDR 4분면을 쓴 데는 이유가 있다. 흔히 쓰는 단일 LDR + 비교기 방식은 “현재 위치가 최대 밝기인지”를 알려주지 못한다. 4분면 구성은 서로 인접한 두 쌍을 비교해 방향 오차를 벡터로 얻는다.
- 좌우 오차(azimuth error):
(LDR_왼쪽합 - LDR_오른쪽합) / 전체합 - 상하 오차(elevation error):
(LDR_위쪽합 - LDR_아래쪽합) / 전체합
비율 정규화 덕분에 흐린 날에도 상대적 방향 판단이 가능하다. ADC 10비트 값이라 0~1023 범위, 서보 범위는 좌우 0°~180°, 상하 60°~160°로 기구 구조에 맞춰 제한한다.
한 가지 미묘한 함정: LDR이 가리키는 방향이 항상 최대 발전 위치는 아니다. 확산광이 강한 구름낀 날이나 반사광이 있을 때는 LDR이 엉뚱한 방향을 “밝다”고 판단할 수 있다. 이 프로젝트는 그런 정밀도보다는 일반 추적 시연을 목표로 했으므로 순수 LDR 기반으로 진행했다.
3. 세 계층 설계
펌웨어 — 협조적 멀티태스킹
Arduino 코드(424줄)에서 가장 중요한 설계 결정은 delay()를 쓰지 않는 것이다. Modbus RTU Slave는 언제 마스터 요청이 들어올지 모른다. delay()가 루프를 막고 있으면 응답이 늦어지고, 마스터는 타임아웃으로 재시도를 반복한다.
대신 millis() 기반 타임슬라이스 방식을 썼다. 각 태스크는 자기 주기가 됐을 때만 실행하고 즉시 반환한다.
void loop() {
rtu.loop(); // Modbus 우선 처리
unsigned long now = millis();
if ((unsigned long)(now - sensor_ts) >= 30UL) { sensor_ts = now; SensorTask(); }
if ((unsigned long)(now - control_ts) >= 10UL) { control_ts = now; ControlTask(); }
if ((unsigned long)(now - display_ts) >= 1000UL) { display_ts = now; DisplayTask(); }
// 부호 없는 차분 → millis() 49.7일 wrap-around 안전
}
rtu.loop()가 매 회 최우선 호출되므로, 센서/제어/표시 태스크가 무엇을 하든 Modbus 응답은 즉시 처리된다. 타임스탬프를 unsigned long으로 처리해 49.7일 wrap-around도 안전하다.
하트비트로는 M2 코일을 1초마다 토글한다. HMI는 이 비트가 1초 안에 변화하지 않으면 “통신 단절”로 판단해 상태 표시를 빨간색으로 바꾼다.
백엔드 — 단일 프로세스 서버
라즈베리파이 백엔드(server.py, 534줄)는 세 가지 역할을 한 파일에서 처리한다. pymodbus 마스터로 폴링하고, Flask/WebSocket으로 HMI에 데이터를 밀어주고, Chromium 키오스크를 직접 스폰한다.
def modbus_loop():
while True:
# 1) UI 명령 먼저 반영
while not command_queue.empty():
_apply_command(client, command_queue.get_nowait())
# 2) 텔레메트리 32워드 한 번에 읽기
rr = _mb_call(client.read_holding_registers, address=0, count=32, slave_id=1)
# 3) WebSocket으로 브라우저에 push
_broadcast()
time.sleep(0.15) # 150ms 폴링
명령 큐 패턴이 핵심이다. HMI 슬라이더 조작이나 버튼 입력은 큐에 쌓이고, 폴링 사이클 시작마다 한꺼번에 flush된다. 이렇게 하면 Modbus 읽기와 쓰기가 섞이지 않아 타이밍 충돌이 없다.
HMI — 단일 HTML 파일
HMI(SolarCharge.html, 696줄)는 프레임워크를 쓰지 않는다. 1024×600 해상도 7인치 패널에서 React나 Vue를 돌리는 것은 부담이 크고, 재배포도 번거롭다. 순수 HTML/CSS/JS 단일 파일로 구성하니 ZIP으로 묶어 올리면 그것으로 배포가 끝난다.
WebSocket이 끊어지면 1초마다 자동 재연결을 시도하고, 연결 상태는 푸터의 Modbus 통신 상태 표시로 시각화된다. 슬라이더 조작 중에는 800ms 유예 기간을 두어 백엔드 echo로 인한 값 튐을 차단한다.
4. 데이터 계약 — Modbus 레지스터 맵
Arduino와 라즈베리파이 사이의 데이터 계약은 Modbus 레지스터 맵으로 정의된다. 이걸 문서화해 두면 양쪽 디버깅이 훨씬 쉬워진다.
핵심 레지스터만 짚으면:
- D0~D3: LDR 4분면 원시값 (0~1023)
- D4: 충전 전압 (×10 스케일, 예: 123 = 12.3V)
- D5: 충전 전류 (×100 스케일)
- D10: 서보 좌우 현재 각도 (0~180)
- D11: 서보 상하 현재 각도 (60~160)
- D20~D21: HMI→Arduino 목표 각도 쓰기 레지스터 (수동 모드)
- M0: 자동/수동 모드 전환 비트
- M2: 하트비트 비트 (1초마다 토글)
레지스터 맵을 정의해 두면 “왜 값이 이상하지?”라는 디버깅이 “D4 레지스터를 직접 읽어보자”로 구체화된다. 추상적인 디버깅보다 훨씬 빠르다.
5. ★ 떨림과의 싸움 — 신호 처리 파이프라인
이 프로젝트에서 가장 오래 붙잡았던 문제다. 서보가 “까딱까딱”거리는 증상, 또는 갑자기 “휙” 튀는 증상은 임베디드 개발자라면 한 번쯤 겪는다.
증상
처음 작동했을 때 서보는 끊임없이 미세하게 떨렸다. LDR 값이 ADC 노이즈로 ±2~3 LSB씩 흔들리는 것만으로도, 오차 계산 결과가 매 제어 주기마다 +1, -1, +1, -1을 오가면서 서보에 반복 명령이 떨어졌다.
가설과 진짜 원인
“PID를 달면 되지 않나?”라는 생각이 먼저 들었다. 하지만 P 게인을 올리면 오히려 oscillation이 심해졌고, D 항은 노이즈를 그대로 증폭했다. 태양 추적은 외란 변화가 매우 느리다(태양이 하늘을 가로지르는 속도). 빠른 PID 응답이 필요한 게 아니라, 느리고 안정적인 추종이 필요하다.
진짜 문제는 신호 자체가 더러웠다는 것이다.
해결 — 3중 방어선
원시 LDR 값이 제어 로직에 도달하기 전에 세 단계를 거친다.
1단계 — 메디안 필터 (9회 샘플): 임펄스 노이즈(순간 스파이크)를 제거한다. 평균 필터는 이상값에 끌려가지만, 메디안은 중간값을 취하므로 스파이크에 둔감하다.
2단계 — EMA (지수이동평균, α=0.05): 남은 고주파 노이즈를 부드럽게 만든다. α가 작을수록 과거 값의 가중치가 높아 더 부드럽지만, 실제 변화에 반응하는 속도도 느려진다.
3단계 — 3중 히스테리시스 제어 로직:
// ① 데드밴드 — 미세 차이 무시 (좌우 40 / 상하 15)
if (abs(azError) > azDeadband) {
int dir = (azError > 0) ? +1 : -1;
// ② 연속 방향 카운터 — 같은 방향 4회 연속일 때만 step
if (sameDirection) azDirStreak += dir; else azDirStreak = dir;
if (abs(azDirStreak) >= DIR_STREAK_TRIGGER &&
// ③ cooldown — step 후 600ms 대기
(now - lastAzMove) >= MOVE_COOLDOWN_MS) {
azPos += dir; azDirStreak = 0; lastAzMove = now;
}
}
// + writeMicroseconds 보간으로 1°를 50ms에 걸쳐 부드럽게
- 데드밴드: 오차가 일정 임계값(좌우 40, 상하 15) 이하면 아예 무시한다. 미세한 떨림을 LDR 부정확도의 한계로 받아들이는 것이다.
- 연속 방향 카운터(streak): 같은 방향 신호가 연속 4회 이상 와야만 실제로 서보를 움직인다. “반짝 방향이 바뀌었다”는 일시적 노이즈를 거른다.
- cooldown: 한 번 움직인 후 600ms 동안은 추가 움직임을 막는다. 서보가 목적지에 도달할 시간을 주고, 연쇄 반응을 방지한다.
서보 이동 자체도 writeMicroseconds() 보간으로 1도를 50ms에 걸쳐 나눠서 움직여, 기계적 충격도 줄였다.
튜닝 — 반응속도 vs 안정성 트레이드오프
| 상수 | 안정 우선 | 균형(최종) | 반응 우선 |
|---|---|---|---|
| DIR_STREAK_TRIGGER | 6 | 4 | 2 |
| MOVE_COOLDOWN_MS | 700 | 600 | 250 |
| 데드밴드(좌/상) | 60/25 | 40/15 | 30/10 |
| EMA α | 0.05 | 0.05 | 0.15 |
한 상수만 극단으로 밀면 반드시 다른 문제가 생긴다. cooldown을 250ms까지 줄였더니 확확 급가속하는 새 증상이 나타났다. streak을 2로 낮추면 흐린 날 산란광에 너무 민감하게 반응했다. 결국 네 상수를 함께 조율하는 것이 답이었다.
임베디드 떨림은 PID로 덮지 말고, 신호 단계에서 갈아내라.
6. 배포 트러블슈팅 7라운드
이 섹션이 아마 가장 공감이 갈 부분일 것이다. “동작하는 코드를 만드는 것”과 “실제 기기에서 돌아가게 하는 것” 사이의 간극은 항상 예상보다 크다.
라운드 1 — “프로그램은 도는데 화면이 안 나와”
증상: 백엔드 로그는 정상, 7인치 패널은 검은 화면.
원인: Senvas Touch는 Python 프로세스를 실행해주지만, 그 프로세스가 띄울 GUI를 자동으로 알지 못한다. Chromium 키오스크를 누군가 직접 실행해줘야 했다.
해결: server.py가 HTTP 서버 준비 완료 후, 백그라운드 스레드에서 Chromium을 키오스크 모드로 직접 스폰하도록 변경.
교훈: “프레임워크가 다 해준다”는 가정을 하지 말 것. 실행 환경의 기대치를 먼저 확인하라.
라운드 2 — “ModuleNotFoundError: flask_sock”
증상: 첫 실행 시 즉시 크래시.
원인: 시스템 Python으로 실행되어 pip install로 설치한 패키지가 없었다.
해결: server.py 최상단에 self-bootstrap 코드를 추가. venv가 없으면 자동 생성하고, 의존성을 설치한 뒤 os.execve()로 자기 자신을 재실행한다. 이후 실행은 항상 venv 안에서 이루어진다.
교훈: 배포 대상 환경의 Python 상태를 가정하지 말 것. 자기 환경을 직접 만드는 코드가 가장 안전하다.
라운드 3 — “Modbus가 응답을 안 함 (No response after 3 retries)”
증상: 백엔드 로그에 타임아웃 오류가 반복.
원인 후보 2개를 순차 제거했다.
첫 번째 후보: pymodbus 3.7+ 버전에서 API가 변경되었다. slave= 인자가 device_id=로 바뀌었는데, 이전 인자명을 그냥 무시해 기본값(Slave ID 0)으로 요청했다. Arduino는 ID 1로 설정되어 있어 응답이 없었다. 버전별 인자를 자동 감지하는 래퍼 함수로 해결.
두 번째 후보: 베이스 주소가 0x7000이 아닌 0임을 로그로 확인. 이건 문제가 아니었다.
교훈: 라이브러리 메이저 버전 비호환은 “조용히 잘못된 값”으로 실패한다. slave=1을 넣었는데 무시된다는 경고는 없었다. 로그를 정독해야 보인다.
라운드 4 — “WebSocket: Cannot obtain socket from WSGI environment”
증상: WebSocket 연결 시도 즉시 서버 오류.
원인: waitress는 훌륭한 WSGI 프로덕션 서버지만 WebSocket을 지원하지 않는다.
해결: Flask 내장 threaded dev 서버로 교체. 단일 패널, 소수 연결 환경에서는 내장 서버로 충분하다.
교훈: 도구의 한계를 알아야 한다. waitress가 “더 안정적”이라는 이유로 선택했지만, 쓰려는 기능을 지원하지 않으면 의미가 없다.
라운드 5 — “데이터가 안 움직임”
증상: HMI에 값이 표시되지만 변화가 없음.
원인: USB-시리얼 어댑터 연결 포트와 Arduino 직렬 포트 번호 불일치. baudrate 115200 정합 재확인 필요.
해결: 하드웨어 배선 재확인 + baudrate 양쪽 동일 설정 확인.
교훈: 소프트웨어 디버깅을 시작하기 전에 하드웨어 배선을 먼저 점검하라.
라운드 6 — “서보가 까딱까딱”
앞서 5장에서 상세히 다뤘다. 이 라운드에 가장 많은 시간이 걸렸고, 3중 방어선 아키텍처로 해결했다.
라운드 7 — “실행이 너무 오래 걸림 / 슬라이더 값이 튐 / thumb 정렬”
세 개의 작은 문제가 한 라운드에 몰렸다.
시작 시간 60초 → 5초: venv를 매번 생성하지 않고 홈 폴더에 영구 캐시했다. 재설치해도 venv는 유지된다.
슬라이더 값 튐: 슬라이더를 움직이면 백엔드가 명령을 받아 레지스터를 쓰고, 다음 폴링에서 그 값을 다시 HMI로 echo한다. 이 echo가 슬라이더를 조작 중인 값 위에 덮어써서 튀는 것처럼 보였다. pointerup 이후 800ms 유예 기간을 두어 조작 중에는 echo를 무시하도록 변경.
CSS thumb 정렬: WebKit 비표준 동작으로 슬라이더 thumb가 트랙과 어긋났다. 표준 패턴(track 6px + margin-top -8px)으로 교체해 해결.
7. 마무리 — 배운 것과 다음 과제
이 프로젝트를 통해 확인한 것들을 정리하면:
아키텍처 측면: 역할 분리가 확실할수록 디버깅이 쉽다. “Arduino가 이상한지, Modbus가 이상한지, 백엔드가 이상한지, HMI가 이상한지”를 독립적으로 검증할 수 있었다. 레지스터 맵을 계약서로 두니 어느 계층에서 문제가 생겼는지 좁히기가 빨랐다.
신호 처리 측면: ADC 노이즈는 예상보다 크고 다양하다. 메디안으로 스파이크를, EMA로 가우시안 노이즈를, 히스테리시스로 경계 진동을 각각 다른 도구로 잡아야 했다. “필터 하나로 다 해결”은 없었다.
배포 측면: 실행 환경을 코드가 직접 보장하는 self-bootstrap 패턴은 임베디드/IoT 배포에 강력하다. “설치하고 실행”이 아니라 “실행하면 알아서 준비”가 되면 운영 부담이 크게 줄어든다.
이 프로젝트는 Senvas Touch 산업용 터치 런처 위에서 돌아간다. ZIP 하나를 업로드하고 실행 파일을 지정하면 배포가 끝나는 구조 덕분에, 펌웨어 업데이트 없이 HMI 화면만 교체하거나 백엔드 로직만 수정하는 것이 매우 빠르다.
다음 과제가 몇 가지 남아 있다. 지금은 LDR이 가장 밝은 방향을 따라가지만, 진짜 최대 발전 위치는 전압 hill-climbing MPPT(Maximum Power Point Tracking)로 찾아야 한다. 또 구름이나 실내 산란광 환경에서 LDR 추적이 불안정해지는 문제도 있다. 야간에는 일몰 전 마지막 각도를 유지하다가 일출 위치로 돌아가는 복귀 로직도 필요하다.
기술적 완성도보다 “실전에서 만난 문제를 어떻게 해결했는가”에 초점을 맞춘 개발기였다. 임베디드 프로젝트를 시작하는 누군가에게 “이런 함정이 있었다”는 선례가 되면 좋겠다.
코드 전체는 Sun_Charge.ino(펌웨어), server.py(백엔드), SolarCharge.html(HMI)로 구성되며, Senvas Touch 배포 패키지(SolarCharge.zip)로 묶어 터치 패널에 바로 올릴 수 있습니다.