02. 디지털 입력 - PySide6 제어

1. 목표
(1) 전체 시스템 구조와 학습 목표

디지털 입력 센서(PIR)를 Arduino에 연결하고,
PySide6 GUI에서 시리얼 통신을 통해 센서 상태를 실시간으로 수신하는 구조를 구현합니다.
- PySide6 GUI에서 Arduino와 시리얼 통신을 연결할 수 있다.
- QThread를 사용해 센서 데이터 수신을 비동기로 처리할 수 있다.
- TX / RX 로그를 통해 통신 흐름을 이해할 수 있다.
- PIR 센서의 디지털 입력(HIGH / LOW)을 GUI에서 확인할 수 있다.
- 센서 입력 → Arduino 처리 → 시리얼 송신 → PySide6 수신 구조를 구현한다.
(2) 최종 결과물

- PySide6 GUI 프로그램
- PIR 동작 감지 센서 상태 표시
- Arduino에서 읽은 디지털 입력값(0 / 1) 실시간 수신
- TX / RX 로그가 출력되는 시리얼 모니터 형태의 프로그램
2. 아두이노 스케치 구현 (디지털 입력 – PIR 센서)
: 교재 P.172~ 176
(1) 아두이노 동작 구조

PIR
PIR 센서는 움직임이 감지되면 HIGH,
움직임이 없으면 LOW 값을 출력하는 디지털 입력 센서입니다.
- PIR 센서 상태를 디지털 핀에서 읽음
- 읽은 값을 시리얼로 전송
- 센서 상태에 따라 LED 출력 제어
(2) 아두이노 측 회로
PIR 센서 기본 연결 (Arduino Uno 기준)
| PIR 센서 핀 | Arduino 연결 |
| VCC | 5V |
| GND | GND |
| OUT | 디지털 7번 핀 |
LED 연결
| 항목 | 연결 |
| LED | 디지털 8번 핀 |
| 저항 | 220Ω |
| GND | 공통 GND |
PIR 센서의 OUT 핀은 디지털 입력으로 사용합니다.
(3) 아두이노 스케치 코드
void setup()
{
Serial.begin(9600); // 시리얼 통신 초기화
pinMode(8, OUTPUT); // LED 출력 핀
pinMode(7, INPUT); // PIR 센서 입력 핀
}
void loop()
{
int readValue = digitalRead(7); // PIR 센서 값 읽기
Serial.println(readValue); // 시리얼로 값 전송
if(readValue == HIGH) { // 움직임 감지
digitalWrite(8, HIGH); // LED ON
}
else {
digitalWrite(8, LOW); // LED OFF
}
}
(4) 시리얼 모니터 테스트
Arduino IDE의 시리얼 모니터에서 다음을 확인합니다.
- 사람이 움직일 때 → 1 출력
- 움직임이 없을 때 → 0 출력
- LED가 센서 상태에 따라 자동 ON / OFF
이 단계는 PySide6 연동 전 반드시 확인해야 합니다.




3. PySide6 GUI 구현
(1) GUI 구현 목표

Arduino IDE의 시리얼 모니터가 아닌,
내가 만든 PySide6 프로그램에서 센서 상태를 확인하는 것이 목표입니다.
- 센서 값(0 / 1)을 GUI에서 실시간 확인
- 수신 데이터가 있어도 GUI가 멈추지 않도록 QThread 사용
(2) 준비사항
pip install pyside6 pyserial
(3) PySide6 GUI 전체 코드 (pir_serial_gui.py)
| 클래스 | 역할 |
| SerialConfig | 포트/보드레이트 설정 |
| SerialReaderThread | 시리얼 수신 전용 스레드 |
| MainWindow | GUI 및 전체 제어 |
import sys
import time
from dataclasses import dataclass
import serial
from serial.tools import list_ports
from PySide6.QtCore import QThread, Signal
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QFormLayout,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QPushButton,
QTextEdit,
QVBoxLayout,
QWidget,
)
@dataclass
class SerialConfig:
port: str
baudrate: int
class SerialReaderThread(QThread):
received = Signal(str) # 수신된 한 줄 텍스트
status = Signal(str) # 시스템 상태 로그
def __init__(self, ser: serial.Serial):
super().__init__()
self._ser = ser
self._running = True
def stop(self):
self._running = False
def run(self):
self.status.emit("[SYS] RX thread started")
try:
while self._running:
if self._ser is None or not self._ser.is_open:
time.sleep(0.05)
continue
try:
line = self._ser.readline() # Arduino println 기준 한 줄 수신
if line:
text = line.decode(errors="replace").rstrip("\r\n")
self.received.emit(text)
else:
time.sleep(0.01)
except serial.SerialException as e:
self.status.emit(f"[SYS] SerialException: {e}")
time.sleep(0.2)
except Exception as e:
self.status.emit(f"[SYS] RX error: {e}")
time.sleep(0.2)
finally:
self.status.emit("[SYS] RX thread stopped")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("PySide6 Arduino Serial Controller (PIR)")
self.resize(760, 520)
self.ser: serial.Serial | None = None
self.rx_thread: SerialReaderThread | None = None
# UI
self.combo_port = QComboBox()
self.btn_refresh = QPushButton("Refresh")
self.combo_baud = QComboBox()
self.combo_baud.addItems(["9600", "115200"])
self.btn_open = QPushButton("Open")
self.btn_close = QPushButton("Close")
self.btn_close.setEnabled(False)
self.lbl_pir = QLabel("PIR 상태: (연결 안됨)")
self.lbl_pir.setStyleSheet("font-weight: 700; padding: 6px;")
self.log = QTextEdit()
self.log.setReadOnly(True)
# Layout
top_row = QHBoxLayout()
top_row.addWidget(QLabel("Port"))
top_row.addWidget(self.combo_port, 1)
top_row.addWidget(self.btn_refresh)
form = QFormLayout()
form.addRow("Baudrate", self.combo_baud)
btn_row = QHBoxLayout()
btn_row.addWidget(self.btn_open)
btn_row.addWidget(self.btn_close)
btn_row.addStretch(1)
btn_row.addWidget(self.lbl_pir, 0)
root = QVBoxLayout()
root.addLayout(top_row)
root.addLayout(form)
root.addLayout(btn_row)
root.addWidget(self.log, 1)
central = QWidget()
central.setLayout(root)
self.setCentralWidget(central)
# Signals
self.btn_refresh.clicked.connect(self.refresh_ports)
self.btn_open.clicked.connect(self.open_serial)
self.btn_close.clicked.connect(self.close_serial)
# Init
self.refresh_ports()
self.append_log("[INFO] Ready. Select port and baudrate, then Open.")
def append_log(self, text: str):
self.log.append(text)
def set_pir_state(self, detected: bool):
if detected:
self.lbl_pir.setText("PIR 상태: 감지됨")
self.lbl_pir.setStyleSheet(
"font-weight: 800; padding: 6px; border-radius: 6px;"
"background: #ffe7e7; color: #8a0000;"
)
else:
self.lbl_pir.setText("PIR 상태: 대기 중")
self.lbl_pir.setStyleSheet(
"font-weight: 800; padding: 6px; border-radius: 6px;"
"background: #eef3ff; color: #0b2a6a;"
)
def refresh_ports(self):
self.combo_port.clear()
ports = list_ports.comports()
for p in ports:
self.combo_port.addItem(f"{p.device} - {p.description}", p.device)
if self.combo_port.count() == 0:
self.combo_port.addItem("(no ports)", "")
self.append_log("[INFO] Ports refreshed")
def get_config(self) -> SerialConfig:
port = self.combo_port.currentData()
baud = int(self.combo_baud.currentText())
return SerialConfig(port=port, baudrate=baud)
def open_serial(self):
cfg = self.get_config()
if not cfg.port:
QMessageBox.warning(self, "Warning", "No serial port selected.")
return
try:
self.ser = serial.Serial(
port=cfg.port,
baudrate=cfg.baudrate,
timeout=0.1,
write_timeout=0.5,
)
except Exception as e:
QMessageBox.critical(self, "Open failed", str(e))
self.ser = None
return
self.append_log(f"[INFO] Opened: {cfg.port} @ {cfg.baudrate}")
self.set_pir_state(False) # 기본 표시
# RX thread
self.rx_thread = SerialReaderThread(self.ser)
self.rx_thread.received.connect(self.on_rx_line)
self.rx_thread.status.connect(self.append_log)
self.rx_thread.start()
# UI state
self.btn_open.setEnabled(False)
self.btn_close.setEnabled(True)
self.combo_port.setEnabled(False)
self.combo_baud.setEnabled(False)
self.btn_refresh.setEnabled(False)
def on_rx_line(self, line: str):
# Arduino가 0/1을 println으로 계속 보낸다는 전제
self.append_log(f"RX << {line}")
if line.strip() == "1":
self.set_pir_state(True)
elif line.strip() == "0":
self.set_pir_state(False)
def close_serial(self):
# stop thread
if self.rx_thread is not None:
self.rx_thread.stop()
self.rx_thread.wait(800)
self.rx_thread = None
# close serial
if self.ser is not None:
try:
if self.ser.is_open:
self.ser.close()
except Exception:
pass
self.ser = None
self.append_log("[INFO] Closed")
self.lbl_pir.setText("PIR 상태: (연결 안됨)")
self.lbl_pir.setStyleSheet("font-weight: 700; padding: 6px;")
# UI state
self.btn_open.setEnabled(True)
self.btn_close.setEnabled(False)
self.combo_port.setEnabled(True)
self.combo_baud.setEnabled(True)
self.btn_refresh.setEnabled(True)
def closeEvent(self, event):
self.close_serial()
super().closeEvent(event)
if __name__ == "__main__":
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec())
ㄴ 3.1 RX (수신) 구현 - QThread 사용

왜 QThread가 필요한가?
- 시리얼 수신은 블로킹 작업
- GUI 응답성 유지 필요
학습 포인트
- 스레드 → UI 직접 접근 금지
- Signal / Slot 구조 필수
ㄴ 3.2 TX & RX
(1) TR & TX 기본 의미

TX / RX는 시리얼 통신, 네트워크, 임베디드 전반에서 사용되는 데이터 송수신 방향 표기입니다.
(2) 용어 개념
| 용어 | 약어 | 의미 | 설명 |
| TX | Transmit | 송신 | 장치에서 밖으로 내보내는 데이터 |
| RX | Receive | 수신 | 장치로 들어오는 데이터 |
- TX: 내가 보낸다
- RX: 내가 받는다
- TX/RX는 절대적 개념이 아니다
- 항상 "누구 기준인지" 먼저 생각한다
- 기준 장치가 바뀌면 의미가 바뀜
- 핀 이름과 데이터 흐름을 혼동
- TX 핀은 "여기서 나간다"
- TX >> / RX << 처럼 방향을 명확히 표기하는 것이 좋음
4. 테스트

테스트 절차
- Arduino 연결
- PySide6 프로그램 실행
- 포트 / 보드레이트 선택
- Open 클릭
- PIR 센서 앞에서 움직임 발생
- GUI 로그에 RX << 1 또는 RX << 0 확인
실습 과제 (확장)
기본 과제
- PIR 센서 값 수신 확인
- RX 로그 구분 출력
확장 과제
- PIR 센서 감지 시 QLabel 색상 변경
- 감지 횟수 카운트 표시
- 감지 시간 기록 로그 추가
- CSV 파일로 센서 로그 저장
다음 단계 제안
- 센서 데이터 실시간 시각화
- 멀티 센서 입력 구조
- 프로토콜 기반 메시지 설계 (SENSOR:PIR:1)
- IoT 교육과정용 프로젝트 확장