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 값을 읽습니다.

동작 흐름:

  1. analogRead(A0)로 조도 값 읽기
  2. 기준값(임계값)과 비교
  3. 어두우면 LED ON(또는 PWM 밝기 증가)
  4. 밝으면 LED OFF(또는 PWM 밝기 감소)
  5. 조도 값을 시리얼로 출력(주기적으로)

 

 

 

 

(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. 테스트

더보기

 

테스트 절차

  1. Arduino 연결 + 조도 센서(LDR) 회로 구성
  2. Arduino 스케치 업로드
  3. PySide6 프로그램 실행
  4. 포트/보드레이트 선택 -> Open
  5. 센서에 빛을 비추거나 가리면서 값 변화 확인
  6. GUI에서
    • "RX << 값" 로그 확인
    • "조도 값: NNN" 갱신 확인
    • "가로등 상태: ON/OFF" 변화 확인

 

실습 과제 (확장)

더보기

기본 과제

  • 조도 값 실시간 수신 확인
  • RX 로그 구분 출력
  • 임계값 기준 상태 표시(ON/OFF)

확장 과제

  • 조도 값에 따라 QLabel 배경색을 그라데이션처럼 변화
  • 조도 값 최소/최대 기록
  • CSV 로그 저장
  • 임계값을 GUI에서 변경하고(SpinBox) Arduino에 전송해 동기화(고급)