아두이노 웹 서버 포기! ESP-01 클라이언트 푸시와 PyQt 위젯으로 온습도 모니터링 성공기

#Arduino #ESP-01 #PyQt6 #Python #Troubleshooting #IoT

우리 집 펫테일 게코 솜이 의 사육장을 모니터링하기 위한 눈물겨운 여정은 이어진다…

1. 실패: 아두이노는 서버로 쓰기에 너무 연약하다

앞선 포스팅에서 아두이노로 만든 웹서버에 드디어 브라우저에 접속을 성공했다. 하지만 화면에서 ‘새로고침(F5)‘을 누르는 순간 서버가 또다시 죽어버렸다.

원인은 아두이노의 64바이트 버퍼 오버플로우센서의 쿨타임 이었다. 브라우저는 새로고침 시 favicon.ico 를 포함한 300바이트 이상의 잡다한 HTTP 헤더를 쏟아낸다. 이 폭격을 아두이노의 작은 메모리가 감당하지 못했고, 2초에 한 번만 값을 읽어야 하는 센서까지 연달아 찔러버리니 모듈이 완전히 파업을 선언한 것이다.

영혼까지 끌어모아 방어 코드를 짰지만, 느린 소프트웨어 시리얼 통신으로 브라우저의 악성(?) 트래픽을 완벽하게 버텨내는 것은 하드웨어 스펙상 무리였다. 연속 새로고침을 하면 가끔 모듈이 뻗어버리는 일회성 웹 서버 가 되어버린 것이다.

새로고침이 안돼서 걱정을 했는데, 역시나 파이썬 PyQt6 로 데스크톱 위젯을 만들어 API를 1분마다 요청해도 마찬가지의 결과가 나왔다. 파이썬의 requests 라이브러리를 사용해 1분마다 아두이노로 데이터를 요청하는 위젯을 만들었는데, 처음 한 번은 데이터를 잘 받아왔지만 다음 턴이 되자 응답 시간 초과(Timeout)가 뜨며 위젯의 값이 멈춰버렸다.

시리얼 모니터를 확인해 보니 아두이노는 또다시 busy p… 에러를 뱉으며 뻗어 있었다. 파이썬이 보내는 HTTP 요청 역시 User-Agent, Accept-Encoding 등 긴 헤더를 뿜어냈고, 아두이노의 64바이트 버퍼와 구형 펌웨어로는 이 데이터를 온전히 받아내는 것조차 버거웠던 것이다.

순정 AT 명령어로 아두이노를 ‘서버’로 굴리는 것은 태생적 한계가 명확했다. 발상의 전환이 필요했다.

2. 아키텍처 변경: 클라이언트 푸시(Client Push) 도입

만약 아두이노가 브라우저나 위젯의 접속을 기다리는 ‘웹 서버’ 역할을 포기한다면 어떨까? 아두이노가 자기가 원할 때만 PC로 데이터를 던지고 연결을 툭 끊어버리는 클라이언트 푸시(Client Push) 아키텍처로 구조를 완전히 뜯어고치기로 했다.

이 구조에서는 내 PC(위젯)가 서버가 되고, 아두이노가 클라이언트가 된다. 아두이노는 외부의 접속 요청(HTTP 헤더)을 받을 일이 아예 사라지기 때문에, 버퍼 오버플로우가 발생할 원인 자체가 물리적으로 차단된다!

아두이노 전체 코드 (클라이언트 모드)

아두이노는 이제 1분에 한 번씩 잠에서 깨어 PC의 5000번 포트로 JSON 데이터를 POST 요청으로 쏘고 다시 휴식에 들어간다. pc_ip 변수에 자신의 PC IP(IPv4)를 넣어주면 된다.

#include <SoftwareSerial.h>
#include <DHT.h>

SoftwareSerial espSerial(2, 3);
#define DHTPIN 4
#define DHTTYPE DHT22
DHT dht(DHTPIN, DHTTYPE);

String ssid = "와이파이이름";
String pass = "비밀번호";

// ⭐️ 수신받을 PC의 IPv4 주소로 변경
String pc_ip = "192.168.x.x"; 
String pc_port = "5000";

void setup() {
  Serial.begin(9600);
  espSerial.begin(9600);
  dht.begin();

  Serial.println("🚀 클라이언트 모드로 시작합니다...");
  sendCommand("AT+CWMODE=1", 2000);
  
  String connectCmd = "AT+CWJAP=\"" + ssid + "\",\"" + pass + "\"";
  sendCommand(connectCmd, 15000);

  Serial.println("✅ 공유기 연결 완료! 2초 뒤 첫 전송을 시작합니다.");
  delay(2000);
}

void loop() {
  float t = dht.readTemperature();
  float h = dht.readHumidity();

  if (!isnan(t) && !isnan(h)) {
    Serial.println("\n🌡️ 센서 읽기 성공: " + String(t) + "C, " + String(h) + "%");
    
    // 1. PC 서버로 TCP 연결 시도
    String startCmd = "AT+CIPSTART=\"TCP\",\"" + pc_ip + "\"," + pc_port;
    sendCommand(startCmd, 3000);

    // 2. HTTP POST 요청문 만들기
    String payload = "{\"temperature\": " + String(t) + ", \"humidity\": " + String(h) + "}";
    String postRequest = "POST / HTTP/1.1\r\n";
    postRequest += "Host: " + pc_ip + "\r\n";
    postRequest += "Content-Type: application/json\r\n";
    postRequest += "Content-Length: " + String(payload.length()) + "\r\n\r\n";
    postRequest += payload;

    // 3. 데이터 전송 명령
    String sendCmd = "AT+CIPSEND=" + String(postRequest.length());
    espSerial.println(sendCmd);

    // 4. 전송 허락(>)이 떨어지면 데이터 쏘기
    long timeout = millis() + 2000;
    while (millis() < timeout) {
      if (espSerial.find(">")) {
        espSerial.print(postRequest);
        
        // 데이터 전송이 완전히 끝날 때까지 대기
        if (espSerial.find("SEND OK")) {
          Serial.println("✅ PC로 데이터 푸시 완료 (SEND OK)!");
        }
        break;
      }
    }

    // 5. PC 서버가 응답할 시간을 주고 안전하게 닫기
    delay(1000);
    sendCommand("AT+CIPCLOSE", 1000);
  } else {
    Serial.println("⚠️ 센서 에러");
  }

  // 1분(60000ms) 대기
  Serial.println("💤 1분 대기...");
  delay(60000); 
}

void sendCommand(String command, const int timeout) {
  espSerial.print(command + "\r\n");
  long int time = millis();
  while ((time + timeout) > millis()) {
    while (espSerial.available()) {
      Serial.print((char)espSerial.read()); 
    }
  }
}

3. 파이썬 위젯 서버 구축

PC 쪽의 PyQt6 위젯 코드는 단순히 화면만 그리는 것이 아니라, 백그라운드 스레드에서 BaseHTTPRequestHandler 를 돌리며 5000번 포트를 열고 아두이노의 연락을 기다리는 수신 서버 역할을 함께 수행한다.

파이썬 PyQt6 전체 코드 (somi_widget.py)

import sys
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel
from PyQt6.QtCore import Qt, QThread, pyqtSignal

# 전역 변수로 데이터 전달용 시그널 클래스 생성
class SignalEmitter(QThread):
    data_received = pyqtSignal(dict)

emitter = SignalEmitter()

# 아두이노의 POST 요청을 받을 미니 HTTP 서버
class ArduinoHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        try:
            content_length = int(self.headers['Content-Length'])
            post_data = self.rfile.read(content_length) # 데이터 읽기
            data = json.loads(post_data.decode('utf-8'))
            
            # 받은 데이터를 PyQt UI로 전달
            emitter.data_received.emit(data)
            
            # 아두이노에게 "잘 받았어!" 라고 응답
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"OK")
        except Exception as e:
            print("데이터 수신 에러:", e)
            self.send_response(500)
            self.end_headers()

    # 로그 출력 켜기
    def log_message(self, format, *args):
        print(f"👀 [서버 수신 됨!] {self.address_string()} - {format % args}")

# 백그라운드에서 서버를 돌려줄 스레드
class ServerThread(QThread):
    def run(self):
        server_address = ('0.0.0.0', 5000) # 모든 네트워크 인터페이스 허용
        httpd = HTTPServer(server_address, ArduinoHandler)
        print("🖥️ PC 수신 서버가 5000번 포트에서 대기 중입니다...")
        httpd.serve_forever()

# 메인 UI 위젯
class SomiWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()
        
        # 서버 스레드 시작
        self.server_thread = ServerThread()
        self.server_thread.start()
        
        # 데이터 수신 시그널 연결
        emitter.data_received.connect(self.update_ui)

    def initUI(self):
        self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
        self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)

        layout = QVBoxLayout()
        layout.setContentsMargins(20, 20, 20, 20)

        self.title_label = QLabel("🦎 솜이 사육장")
        self.title_label.setObjectName("title")
        self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

        self.temp_label = QLabel("온도: 대기 중..")
        self.temp_label.setObjectName("data")
        self.temp_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

        self.hum_label = QLabel("습도: 대기 중..")
        self.hum_label.setObjectName("data")
        self.hum_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

        self.status_label = QLabel("아두이노 연락 기다리는 중...")
        self.status_label.setObjectName("status")
        self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

        layout.addWidget(self.title_label)
        layout.addWidget(self.temp_label)
        layout.addWidget(self.hum_label)
        layout.addWidget(self.status_label)
        self.setLayout(layout)

        self.setStyleSheet("""
            QWidget { background-color: rgba(44, 62, 80, 230); border-radius: 15px; }
            QLabel#title { color: #f1c40f; font-size: 18px; font-weight: bold; margin-bottom: 10px; }
            QLabel#data { color: #ecf0f1; font-size: 22px; font-weight: bold; }
            QLabel#status { color: #95a5a6; font-size: 12px; margin-top: 10px; }
        """)

    def update_ui(self, data):
        temp = data.get("temperature", "--")
        hum = data.get("humidity", "--")
        self.temp_label.setText(f"온도: {temp} °C")
        self.hum_label.setText(f"습도: {hum} %")
        
        # 온도가 25도 이하일 때 텍스트 색상 빨갛게 경고
        if float(temp) <= 25.0:
            self.temp_label.setStyleSheet("color: #e74c3c; font-size: 22px; font-weight: bold;")
        else:
            self.temp_label.setStyleSheet("color: #ecf0f1; font-size: 22px; font-weight: bold;")
            
        self.status_label.setText("✅ 방금 수신됨!")

    def mousePressEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton: self.oldPos = event.globalPosition().toPoint()
    def mouseMoveEvent(self, event):
        if self.oldPos is not None:
            delta = event.globalPosition().toPoint() - self.oldPos
            self.move(self.pos() + delta)
            self.oldPos = event.globalPosition().toPoint()
    def mouseReleaseEvent(self, event):
        self.oldPos = None

if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = SomiWidget()
    ex.show()
    sys.exit(app.exec())

4. 네트워크 블랙홀: 방화벽의 철벽 방어

양쪽 코드를 완벽하게 세팅하고 실행했다. 아두이노 시리얼 모니터에는 성공 메시지가 떴지만, 파이썬 위젯은 묵묵부답이었다. 클라이언트는 보냈다고 우기는데 서버는 받은 적이 없다고 하는 전형적인 ‘네트워크 블랙홀’ 현상이었다.

원인은 바로 윈도우 디펜더 방화벽 이었다. 아두이노(외부 기기)가 내 PC의 5000번 포트로 낯선 데이터를 밀어 넣으려 하니, 방화벽이 이를 해킹 시도로 간주하고 문전박대해 버린 것이다. 테스트를 위해 방화벽을 잠시 끄고 파이썬 로그의 음소거를 해제해 보니, 그제야 아두이노의 데이터가 위젯으로 콸콸 쏟아져 들어오기 시작했다!

5. 최종 해결: 5000번 포트 인바운드 규칙 허용

보안을 위해 방화벽을 영원히 꺼둘 수는 없다. 윈도우의 방화벽 고급 설정에 들어가 아두이노가 드나들 수 있는 전용 개구멍을 뚫어주었다.

  1. 방화벽 고급 설정 > 인바운드 규칙 > 새 규칙 클릭
  2. 규칙 종류: 포트
  3. 프로토콜 및 포트: TCP, 특정 로컬 포트에 5000 입력
  4. 작업: 연결 허용
  5. 이름: ‘솜이 위젯 5000’ 으로 저장

방화벽을 다시 켜고 설정을 완료하자, 아두이노가 보내는 솜이의 온습도 데이터가 1분마다 위젯에 정확하게 꽂히기 시작했다.

6. 감성 한 스푼: 픽셀 이미지 및 폰트 적용과 동적 색상 변화

제미나이가 만들어 준 테스트 위젯으로 통신을 완료한 뒤, 이전에 만들었던 솜이 데스크톱 위젯을 바탕으로 데이터를 받아 띄우는 코드를 작업했다.

이전에 그렸던 걸어다니는 솜이 픽셀과 더불어 자고 있는 솜이 픽셀도 찍었는데, 숨을 좀 거칠게(ㅋㅋㅋ) 쉬기는 하지만 내 눈에는 귀여워서 마음에 들었다.🥰

폰트도 픽셀 아트 이미지에 어울리는 NeoDunggeunmo 폰트를 적용했다. 단순히 수치를 보여주는 것을 넘어, 펫테일 게코의 적정 생존 범위(온도 2631°C, 습도 5070%)를 판별하는 로직을 추가했다.

온도와 습도를 각각 분리하여, 범위를 벗어나면 경고의 의미로 빨간색(e74c3c)이 점등되고 정상일 때는 초록색(2ecc71)이 되도록 설정했다. 사육장의 컨디션을 한눈에, 그리고 직관적으로 파악할 수 있게 된 것이다.

7. 배포: PyInstaller로 단일 실행 파일(.exe) 만들기

파이썬 환경이 없는 컴퓨터에서도, 혹은 컴퓨터를 켤 때마다 자동으로 실행되게 하려면 .exe 파일로 빌드해야 한다. PyInstaller 를 사용해 패키징을 진행했다.

외부 리소스 경로 문제

단순히 빌드하면 이미지와 폰트 파일을 찾지 못해 에러가 난다. 실행 파일 내부의 임시 폴더(_MEIPASS)를 가리키도록 경로 변환 함수를 추가해 주어야 한다.

import sys
import os

def resource_path(relative_path):
    try:
        base_path = sys._MEIPASS
    except Exception:
        base_path = os.path.abspath(".")
    return os.path.join(base_path, relative_path)

# 사용 예시
font_id = QFontDatabase.addApplicationFont(resource_path("neodgm.ttf"))

[WinError 5] 액세스 거부 에러 트러블슈팅

빌드 명령어를 쳤더니 [WinError 5] 가 발생하며 패키징이 중단되었다. 범인은 바로 백그라운드에 숨어있던 이전 프로세스였다. 위젯이 제대로 종료되지 않고 백그라운드에서 실행 중이거나 윈도우 디펜더가 파일을 물고 있으면, PyInstaller가 기존 파일을 덮어쓰지 못해 에러를 뱉는다.

작업 관리자에서 somi_widget.exe 프로세스를 확실히 종료하고 빌드 폴더를 깨끗하게 지운 뒤 아래 명령어로 다시 빌드했다.

pyinstaller --noconsole --onefile --add-data "*.png;." --add-data "*.ttf;." somi_widget.py

성공적으로 빌드가 완료되었고, dist 폴더 안에 깔끔한 단일 실행 파일이 만들어졌다! 이 파일을 윈도우의 shell:startup (시작프로그램) 폴더에 바로 가기로 넣어, 부팅 시 자동으로 솜이가 출근하도록 설정했다.

마무리

아두이노의 메모리 한계와 네트워크 트러블슈팅부터, PyQt6를 활용한 픽셀 애니메이션 최적화와 PyInstaller 빌드까지 완료했다. 매일 코딩을 시작할 때 우측 하단에서 새근새근 자고 있는 솜이 를 보면 기분이 좋아진다!

이 포스팅은 AI의 도움을 받아 초안을 작성하고, 직접 검수 및 편집한 글입니다.