03. 아날로그 입력 - PySide6 제어

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

- 조도 센서(LDR)의 아날로그 값(0~1023)을 Arduino에서 읽을 수 있다.
- Arduino가 읽은 센서 값을 시리얼로 주기적으로 송신할 수 있다.
- PySide6 GUI에서 Arduino와 시리얼 통신을 연결할 수 있다.
- QThread를 사용해 센서 데이터 수신을 비동기로 처리할 수 있다.
- RX 로그를 통해 데이터 흐름(Arduino -> PySide6)을 이해할 수 있다.
- 조도 값에 따라 가로등 LED의 자동 ON/OFF(또는 PWM 밝기 제어) 구조를 구현한다.
- "센서 입력 -> Arduino 판단/제어 -> 시리얼 송신 -> PySide6 수신/시각화" 흐름을 구현한다.
(2) 최종 결과물

- Arduino:
- 조도 센서 값(A0)을 읽고
- 어두우면 LED ON(또는 밝기 증가), 밝으면 LED OFF(또는 밝기 감소)
- 조도 값을 시리얼로 주기적으로 출력
- PySide6 GUI 프로그램:
- 포트/보드레이트 선택 + 열기/닫기
- 조도 값 실시간 표시(예: "조도 값: 732")
- 상태 표시(예: "가로등 상태: ON/OFF")
- RX 로그가 출력되는 "시리얼 모니터 형태" UI
2. 아두이노 스케치 구현 (아날로그 입력 - 조도 센서)
: 교재 P.193~ 201
(1) 아두이노 동작 구조

- 조도 센서는 빛의 세기에 따라 저항이 변하는 센서입니다.
- 일반적으로 분압 회로로 구성하여 A0에서 0~1023 값을 읽습니다.
동작 흐름:
- analogRead(A0)로 조도 값 읽기
- 기준값(임계값)과 비교
- 어두우면 LED ON(또는 PWM 밝기 증가)
- 밝으면 LED OFF(또는 PWM 밝기 감소)
- 조도 값을 시리얼로 출력(주기적으로)
(2) 아두이노 측 회로
조도 센서(LDR) 분압 기본 연결 (Arduino Uno 기준)
- LDR 1쪽: 5V
- LDR 다른 쪽: A0
- A0에서 GND로 10k 저항 연결(분압)
| 항목 | Arduino 연결 |
| LDR 한쪽 | 5V |
| LDR 다른쪽 | A0 |
| 저항(10k) | A0 - GND |
가로등 LED 연결(디지털/아날로그 출력)
- 간단 ON/OFF: D8 (디지털 출력)
- 밝기 조절(PWM): D9 (PWM 출력) 권장
| 항목 | Arduino 연결 |
| LED(+) | D9(PWM) 또는 D8 |
| 저항(220Ω) | LED와 직렬 |
| LED(-) | GND |
(3) 아두이노 스케치 코드
아래 코드는 "조도 값 송신" + "가로등 자동 밝기" 형태입니다.
(밝기 제어를 원치 않으면 analogWrite 대신 digitalWrite로 바꿔도 됩니다.)
// 03. 아날로그 입력 - 조도 센서 스마트 가로등
// - A0: 조도 센서(LDR) 분압 입력
// - D9: 가로등 LED(PWM 출력)
// - 어두울수록 LED 밝게, 밝을수록 LED 어둡게
// - 시리얼로 조도값(0~1023) 주기 출력
const int LDR_PIN = A0;
const int LED_PIN = 9; // PWM 핀
const int THRESHOLD = 600; // 임계값(환경에 맞게 조정)
void setup() {
Serial.begin(9600);
pinMode(LED_PIN, OUTPUT);
}
void loop() {
int lightValue = analogRead(LDR_PIN); // 0~1023
Serial.println(lightValue); // PySide6로 송신(RX 관점)
// 예시 1) ON/OFF 형태(임계값 기준)
// if (lightValue > THRESHOLD) { // 밝음
// digitalWrite(LED_PIN, LOW);
// } else { // 어두움
// digitalWrite(LED_PIN, HIGH);
// }
// 예시 2) PWM 밝기 형태(스마트 가로등 느낌)
// 밝을수록 LED 어둡게: lightValue가 커지면 brightness 작아지게
int brightness = map(lightValue, 0, 1023, 255, 0);
brightness = constrain(brightness, 0, 255);
analogWrite(LED_PIN, brightness);
delay(100);
}
(4) 시리얼 모니터 테스트
Arduino IDE 시리얼 모니터(9600 baud)에서 확인:
- 밝기를 바꾸면 조도 값이 변화:
- 밝을 때: 값이 커지거나(회로 방향에 따라 반대 가능)
- 어두울 때: 값이 작아짐(또는 반대)
- LED 밝기도 함께 변화(PWM 사용 시)
이 단계는 PySide6 연동 전 반드시 확인합니다.




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

- Arduino IDE 시리얼 모니터 대신
내가 만든 PySide6 프로그램에서 조도 값을 실시간 확인한다. - 센서 값이 계속 들어와도 GUI가 멈추지 않도록 QThread로 수신 처리한다.
- UI에 표시:
- "조도 값: NNN"
- "가로등 상태: ON/OFF" (임계값 기준)
- RX 로그
(3) PySide6 GUI 전체 코드 (smart_streetlight_gui.py)
아래 코드는 사용자가 1~2단계에서 사용한 구조(SerialConfig / SerialReaderThread / MainWindow)를 그대로 유지하면서, 조도값 표시용 QLabel과 가로등 상태 표시를 추가한 버전입니다.
import sys
import time
from dataclasses import dataclass
import serial
from serial.tools import list_ports
from PySide6.QtCore import QThread, Signal, Qt
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QPushButton,
QSpinBox,
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("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()
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"SerialException: {e}")
time.sleep(0.2)
except Exception as e:
self.status.emit(f"RX error: {e}")
time.sleep(0.2)
finally:
self.status.emit("RX thread stopped")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("PySide6 Arduino Smart Streetlight Controller")
self.resize(860, 520)
self.ser: serial.Serial | None = None
self.rx_thread: SerialReaderThread | None = None
# 상태값
self.last_light_value: int | None = None
self.streetlight_on: bool = False
self.last_sent_cmd: str | None = None
# -------------------------
# UI widgets
# -------------------------
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.chk_auto = QCheckBox("자동 제어 사용")
self.chk_auto.setChecked(True)
self.chk_auto.setEnabled(False)
self.spin_threshold = QSpinBox()
self.spin_threshold.setRange(0, 1023)
self.spin_threshold.setValue(100) # Arduino 예시와 동일 기본값
self.spin_threshold.setEnabled(False)
# 표시용
self.lbl_light = QLabel("조도 값: -")
self.lbl_light.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.lbl_state = QLabel("가로등 상태: OFF")
self.lbl_state.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.btn_state = QPushButton("STREETLIGHT: OFF")
self.btn_state.setEnabled(False)
self.btn_state.setMinimumHeight(56)
self.log = QTextEdit()
self.log.setReadOnly(True)
# -------------------------
# Layout
# -------------------------
row_top = QHBoxLayout()
row_top.addWidget(QLabel("Port"))
row_top.addWidget(self.combo_port, 1)
row_top.addWidget(self.btn_refresh)
row_top.addSpacing(12)
row_top.addWidget(QLabel("Baudrate"))
row_top.addWidget(self.combo_baud)
row_top.addSpacing(12)
row_top.addWidget(self.btn_open)
row_top.addWidget(self.btn_close)
group_ctrl = QGroupBox("조도 기반 자동 제어 설정")
form = QFormLayout()
form.addRow("자동 제어", self.chk_auto)
form.addRow("임계값(threshold)", self.spin_threshold)
group_ctrl.setLayout(form)
row_status = QHBoxLayout()
row_status.addWidget(self.lbl_light, 1)
row_status.addWidget(self.lbl_state, 1)
root = QVBoxLayout()
root.addLayout(row_top)
root.addWidget(group_ctrl)
root.addWidget(self.btn_state)
root.addLayout(row_status)
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)
self.chk_auto.stateChanged.connect(self.on_auto_changed)
self.spin_threshold.valueChanged.connect(self.on_threshold_changed)
# Init
self.refresh_ports()
# -------------------------
# Helpers
# -------------------------
def append_log(self, text: str):
self.log.append(text)
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("[SYS] 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 set_ui_opened(self, opened: bool):
self.btn_open.setEnabled(not opened)
self.btn_close.setEnabled(opened)
self.combo_port.setEnabled(not opened)
self.combo_baud.setEnabled(not opened)
self.btn_refresh.setEnabled(not opened)
self.chk_auto.setEnabled(opened)
self.spin_threshold.setEnabled(opened and self.chk_auto.isChecked())
self.btn_state.setEnabled(opened)
def write_line(self, text: str):
if self.ser is None or not self.ser.is_open:
self.append_log("[ERR] Serial not open")
return False
try:
data = (text + "\n").encode("utf-8")
self.ser.write(data)
self.ser.flush()
self.append_log(f"TX >> {text}")
self.last_sent_cmd = text
return True
except Exception as e:
self.append_log(f"[ERR] TX failed: {e}")
return False
# -------------------------
# Serial open/close
# -------------------------
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"Opened: {cfg.port} @ {cfg.baudrate}")
# RX thread start
self.rx_thread = SerialReaderThread(self.ser)
self.rx_thread.received.connect(self.on_rx_line)
self.rx_thread.status.connect(lambda s: self.append_log(f"[SYS] {s}"))
self.rx_thread.start()
self.set_ui_opened(True)
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("Closed")
self.set_ui_opened(False)
# 화면 상태 초기화(선택)
self.lbl_light.setText("조도 값: -")
self.lbl_state.setText("가로등 상태: OFF")
self.btn_state.setText("STREETLIGHT: OFF")
self.streetlight_on = False
self.last_light_value = None
self.last_sent_cmd = None
# -------------------------
# UI events
# -------------------------
def on_auto_changed(self, _state: int):
opened = self.ser is not None and self.ser.is_open
self.spin_threshold.setEnabled(opened and self.chk_auto.isChecked())
if self.chk_auto.isChecked():
self.append_log("[SYS] Auto control enabled")
# 최근 값이 있으면 즉시 한번 판정
if self.last_light_value is not None:
self.apply_streetlight_logic(self.last_light_value)
else:
self.append_log("[SYS] Auto control disabled (monitoring only)")
def on_threshold_changed(self, value: int):
self.append_log(f"[SYS] Threshold set to {value}")
if self.chk_auto.isChecked() and self.last_light_value is not None:
self.apply_streetlight_logic(self.last_light_value)
# -------------------------
# RX handling
# -------------------------
def on_rx_line(self, line: str):
# 로그 표시
self.append_log(f"RX << {line}")
# 조도값 파싱 시도 (예: "123", 혹은 "Light: 123" 같은 변형도 일부 허용)
value = self.try_parse_light_value(line)
if value is None:
return
self.last_light_value = value
self.lbl_light.setText(f"조도 값: {value}")
# 자동제어면 조도값으로 LED 제어(가로등)
if self.chk_auto.isChecked():
self.apply_streetlight_logic(value)
@staticmethod
def try_parse_light_value(text: str) -> int | None:
t = text.strip()
# 1) 순수 숫자
if t.isdigit():
return int(t)
# 2) "Light: 123" 같은 포맷
for token in ["Light:", "LIGHT:", "ldr:", "LDR:"]:
if token in t:
tail = t.split(token, 1)[1].strip()
if tail.isdigit():
return int(tail)
return None
def apply_streetlight_logic(self, light_value: int):
threshold = self.spin_threshold.value()
# Arduino 예시 기준:
# if(photoresistor > 100) digitalWrite(13, HIGH); (어두울 때 켬) 라고 적혀있지만
# 코드 자체는 "photoresistor > 100 이면 HIGH"입니다.
# 실제 LDR 회로에 따라 값 방향이 달라질 수 있으므로, 아래는 "요구사항에 맞게" 설정합니다:
#
# - 밝으면(값이 큼) OFF
# - 어두우면(값이 작음) ON
desired_on = (light_value <= threshold)
# 상태가 바뀔 때만 TX 송신
if desired_on != self.streetlight_on:
cmd = "1" if desired_on else "0" # Arduino가 1/0으로 LED 제어하도록 구성한 경우
ok = self.write_line(cmd)
if ok:
self.streetlight_on = desired_on
self.update_streetlight_ui()
def update_streetlight_ui(self):
if self.streetlight_on:
self.lbl_state.setText("가로등 상태: ON")
self.btn_state.setText("STREETLIGHT: ON")
else:
self.lbl_state.setText("가로등 상태: OFF")
self.btn_state.setText("STREETLIGHT: OFF")
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가 필요한가?
- 조도 센서 값은 loop에서 계속 출력되므로, PC에서는 계속 수신해야 합니다.
- readline 같은 수신은 대표적인 블로킹 작업입니다.
- 메인(UI) 스레드가 블로킹되면 버튼 클릭/창 이동이 멈춰서 "응답 없음"이 됩니다.
학습 포인트
- 스레드 → UI 직접 접근 금지
- Signal / Slot 구조 필수
ㄴ 3.2 TX & RX
(1) TR & TX 기본 의미

Beginner-friendly educational illustration explaining serial communication using a delivery analogy.
Left: a computer labeled "PySide6 App".
Right: an Arduino board labeled "Arduino".
Between them: a USB cable visualized as a road.
Data packets shown as envelopes traveling both directions:
- Envelope labeled "1" going to Arduino
- Envelope labeled "LED:ON" returning to PC
Simple captions:
- "Send data (TX)"
- "Receive data (RX)"
Style:
- Friendly flat illustration
- Soft colors
- Cartoon-like but professional
- Ideal for beginners
Aspect ratio: 16:9
Clean background, no realistic textures
TX / RX는 시리얼 통신, 네트워크, 임베디드 전반에서 사용되는 데이터 송수신 방향 표기입니다.
(2) 용어 개념
| 용어 | 약어 | 의미 | 설명 |
| TX | Transmit | 송신 | 장치에서 밖으로 내보내는 데이터 |
| RX | Receive | 수신 | 장치로 들어오는 데이터 |
- TX: 내가 보낸다
- RX: 내가 받는다
- TX/RX는 절대적 개념이 아니다
- 항상 "누구 기준인지" 먼저 생각한다
- 기준 장치가 바뀌면 의미가 바뀜
- 핀 이름과 데이터 흐름을 혼동
- TX 핀은 "여기서 나간다"
- TX >> / RX << 처럼 방향을 명확히 표기하는 것이 좋음
4. 테스트

테스트 절차
- Arduino 연결 + 조도 센서(LDR) 회로 구성
- Arduino 스케치 업로드
- PySide6 프로그램 실행
- 포트/보드레이트 선택 -> Open
- 센서에 빛을 비추거나 가리면서 값 변화 확인
- GUI에서
- "RX << 값" 로그 확인
- "조도 값: NNN" 갱신 확인
- "가로등 상태: ON/OFF" 변화 확인
실습 과제 (확장)
기본 과제
- 조도 값 실시간 수신 확인
- RX 로그 구분 출력
- 임계값 기준 상태 표시(ON/OFF)
확장 과제
- 조도 값에 따라 QLabel 배경색을 그라데이션처럼 변화
- 조도 값 최소/최대 기록
- CSV 로그 저장
- 임계값을 GUI에서 변경하고(SpinBox) Arduino에 전송해 동기화(고급)