로보테크AI

융합_로보테크 AI 자율주행 로봇 개발자 과정-26/02/26[조별과제1]

steezer 2026. 2. 26. 18:30

평가요소

TDL

 

기획-주제 선정

현재 가지고 있는 키트로 가능한 프로젝트 조사

아이디어 구현에 필요한 추가 소자 조사

ㄴ학원에서 지원(디바이스마트에서 구매 가능, 재고 있는 물품)

 

발표자 == 조장

 

소스 코드(파일) 및 시연 영상(PPT, 3~7분 사이)

 

보고서(설계, 구현, 테스트 결과)

 

PPT

프로젝트 개요

팀 구성 및 역할

수행절차(경과)(일 단위 구분(프로젝트 기획, 파트별 설계 및 구현, 기능 통합 및 테스트, 피드백))

결과(이미지+ 시연 영상)

자체평가


주제: 자동차 실내 환경 알림

 

온습도

LED

디스플레이

미세먼지

초음파센서

배터리 모듈

케이블

우노 보드

브레드 보드

저항

블루투스 모듈

소리 모듈


주제: 물류센터 자율주행 로봇용 보조 안전 모듈 및 데이터 분석

 

요약: 본 프로젝트는 자율주행 로봇의 실시간 충돌 회피 기능을 보완하는 것이 아니라,
근접 위험 데이터를 수집·분석하여 물류센터의 안전 관리 효율을 향상시키는 보조 분석 모듈을 구현하는 것이다.

 

물류센터의 자율주행 로봇(AMR)은 LiDAR 및 센서를 통해 실시간 충돌 회피 기능을 수행한다.
그러나 이러한 시스템은 ‘즉각적 위험 회피’에 초점을 두고 있으며,
근접 위험 상황(Near-Miss)에 대한 장기적 데이터 축적 및 분석 기능은 제한적이다.

본 프로젝트는 기존 자율주행 로봇의 제어 시스템을 대체하는 것이 아니라,
근접 위험 이벤트를 수집·분석하는 보조 데이터 레이어를 추가하는 것을 목표로 한다.

 

문제 정의

물류센터 환경에서는 다음과 같은 특성이 존재한다.

  1. 작업자와 로봇의 동선이 반복적으로 교차한다.
  2. 실제 충돌 사고는 적지만, 근접 위험(Near-Miss)은 빈번하다.
  3. 위험은 특정 구역 및 시간대에 집중적으로 발생하는 경향이 있다.

기존 시스템은 충돌은 방지하지만,
어디에서 위험이 반복적으로 발생하는지에 대한 구조적 분석은 부족하다.

 

제안 시스템의 역할

본 시스템은 자율주행 로봇에 장착 가능한 저비용 모듈로서,

  • 일정 거리 이하 접근 이벤트 기록
  • 작업자 감지 기반 위험 가중치 부여
  • 급정지 및 급감속 이벤트 기록
  • 시간대별 Near-Miss 데이터 수집

을 수행한다.

이 데이터를 기반으로 다음을 도출한다.

  • 위험 발생 밀집 구역
  • 시간대별 위험도 분포
  • 반복적 근접 이벤트 패턴
  • 운영 환경 개선 필요 지점

핵심 목적

본 시스템의 목적은
실시간 회피 효율을 높이는 것이 아니라,

“위험이 가장 빈번하게 발생하는 구역을 데이터 기반으로 식별하고,
안전 관리 자원을 효율적으로 배치할 수 있도록 지원하는 것”이다.

즉,

  • 충돌을 막는 제어 시스템(Control Layer)이 아니라
  • 위험 패턴을 분석하는 데이터 분석 레이어(Analytics Layer)이다.
품명 구성 가격 수량 구매링크
주행로봇 프레임 세트 5V TT 기어 DC 모터 PH2.0 * 4
바퀴 * 4
프레임 아크릴 보드 상하판 * 2
배터리 홀더(배터리 X)
8300 1 https://www.devicemart.co.kr/goods/view?no=1327455
모터드라이버 모듈 L293D Motor Shield Module 15000 1 https://www.devicemart.co.kr/goods/view?no=14963802
배터리 18650 리튬배터리 3.7V 2200mAh 3200 2 https://www.devicemart.co.kr/goods/view?no=14117576
배터리 홀더 18650x2 리튬이온 배터리 홀더 990 1 https://www.devicemart.co.kr/goods/view?no=1278962
충전기 건전지충전기(18650/AA/AAA용)
(5핀 소켓 사용)
6000 1 https://www.devicemart.co.kr/goods/view?no=1279297
스위치 KCD1-101A 빨강 ON/OFF 300 1 https://www.devicemart.co.kr/goods/view?no=1790
양면테이프
(초음파 고정용)
Coms 강력양면테이프, 21mm 1600 1 https://www.devicemart.co.kr/goods/view?no=1342929
감지센서 HC-SR501 인체감지센서 모듈 0 0 X
아두이노 우노 Arduino Uno R3 0 0 X
서보 모터 SG90 Servo Motor 0 0 X
초음파 거리센서 모듈 HC-SR04 0 0 X
블루투스 모듈 HC-06 블루투스 Slave 모듈 0 0 X
부저 모듈 —------------------------------------------ 0 0 X
LED (red, green, yellow) 0 0 X
LCD LCD 1602 IIC 모듈 0 0 X
저항 220 Ω 0 0 X
브레드보드 —------------------------------------------ 0 0 X
점퍼선 FF, MF, 모터드라이브용 0 0 X
총 가격 38500

자율주행 로봇용 코드

#include "AFMotor.h"
#include <Servo.h>

#define echopin A4 // echo pin
#define trigpin A5 // Trigger pin

Servo myservo;

const int MOTOR_1 = 1; 
const int MOTOR_2 = 2; 
const int MOTOR_3 = 3; 
const int MOTOR_4 = 4; 

AF_DCMotor motor1(MOTOR_1, MOTOR12_64KHZ); // create motor object, 64KHz pwm
AF_DCMotor motor2(MOTOR_2, MOTOR12_64KHZ); // create motor object, 64KHz pwm
AF_DCMotor motor3(MOTOR_3, MOTOR12_64KHZ); // create motor object, 64KHz pwm
AF_DCMotor motor4(MOTOR_4, MOTOR12_64KHZ); // create motor object, 64KHz pwm
//===============================================================================
//  Initialization
//===============================================================================

int distance_L, distance_F, distance_R;
long distance;

int set = 20;
 
void setup() {
  Serial.begin(9600);           // Initialize serial port
  Serial.println("Start");

  myservo.attach(10);
  myservo.write(90);

  pinMode (trigpin, OUTPUT);
  pinMode (echopin, INPUT );
  
  motor1.setSpeed(180);          // set the motor speed to 0-255
  motor2.setSpeed(180);
  motor3.setSpeed(180);
  motor4.setSpeed(180);
}
//===============================================================================
//  Main
//=============================================================================== 
void loop() {
 distance_F = data();
 Serial.print("S=");
 Serial.println(distance_F);
  if (distance_F > set){
   Serial.println("Forward");
  motor1.run(FORWARD);         // turn it on going forward
  motor2.run(FORWARD); 
  motor3.run(FORWARD); 
  motor4.run(FORWARD);
    }
    else{hc_sr4();}
}


long data(){
  digitalWrite(trigpin, LOW);
  delayMicroseconds(2);
  digitalWrite(trigpin, HIGH);
  delayMicroseconds(10);
  distance = pulseIn (echopin, HIGH);
  return distance / 29 / 2;
}


void compareDistance(){
  if (distance_L > distance_R){
  motor1.run(BACKWARD);   // turn it on going left
  motor2.run(BACKWARD);
  motor3.run(FORWARD); 
  motor4.run(FORWARD); 
    delay(350);
  }
  else if (distance_R > distance_L){
  motor1.run(FORWARD);  // the other right
  motor2.run(FORWARD); 
  motor3.run(BACKWARD); 
  motor4.run(BACKWARD);
    delay(350);
  }
  else{
  motor1.run(BACKWARD);  // the other way
  motor2.run(BACKWARD);
  motor3.run(BACKWARD); 
  motor4.run(BACKWARD); 
   delay(300);
  motor1.run(BACKWARD);   // turn it on going left
  motor2.run(BACKWARD);
  motor3.run(FORWARD); 
  motor4.run(FORWARD); 
    delay(500);
  }
}

void hc_sr4(){
    Serial.println("Stop");
    motor1.run(RELEASE);         // stopped
    motor2.run(RELEASE);
    motor3.run(RELEASE);
    motor4.run(RELEASE);
   
    myservo.write(0);
    delay(300);
    distance_R = data();
    delay(100);
    myservo.write(170);
    delay(500);
    distance_L = data();
    delay(100);
    myservo.write(90);
    delay(300);
    compareDistance();
}

 

참고

https://www.youtube.com/watch?v=G0NQQ8GoSJU

https://marobotic.com/2023/11/12/obstacle-avoiding-robot-using-arduino-uno-and-l293d-with-hc-sr04-sensor/

 

 

 

 

GUI 임시 코드

"""
물류센터 자율주행 로봇 안전 분석 시스템
Near-Miss Analytics Dashboard - PyQt5 GUI 전체 틀
"""

import sys
import random
from datetime import datetime, timedelta
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QSplitter,
    QVBoxLayout, QHBoxLayout, QGridLayout,
    QLabel, QPushButton, QTableWidget, QTableWidgetItem,
    QTabWidget, QGroupBox, QStatusBar, QComboBox,
    QDateEdit, QCheckBox, QHeaderView, QFrame,
    QScrollArea, QSizePolicy, QProgressBar, QSpacerItem,
    QDialog, QDialogButtonBox, QLineEdit, QFormLayout,
    QMessageBox, QAction, QMenuBar, QSystemTrayIcon, QMenu
)
from PyQt5.QtCore import (
    Qt, QTimer, QThread, pyqtSignal, QDate, QSize, QPropertyAnimation
)
from PyQt5.QtGui import (
    QColor, QFont, QPalette, QBrush, QIcon, QPainter,
    QLinearGradient, QPixmap
)

# ── matplotlib 임베드를 위한 임포트 (pip install matplotlib 필요) ──────────────
try:
    import matplotlib
    matplotlib.use('Qt5Agg')
    from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
    from matplotlib.figure import Figure
    import matplotlib.pyplot as plt
    import matplotlib.patches as mpatches
    MATPLOTLIB_AVAILABLE = True
except ImportError:
    MATPLOTLIB_AVAILABLE = False

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  색상 & 스타일 상수
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
COLOR = {
    "BG_DARK":      "#0d1117",
    "BG_PANEL":     "#161b22",
    "BG_CARD":      "#1c2128",
    "BORDER":       "#30363d",
    "TEXT_PRIMARY": "#e6edf3",
    "TEXT_MUTED":   "#8b949e",
    "ACCENT":       "#58a6ff",
    "SAFE":         "#3fb950",
    "CAUTION":      "#d29922",
    "NEAR_MISS":    "#f0883e",
    "HUMAN_RISK":   "#da3633",
    "HUMAN_NM":     "#ff4444",
    "EMERGENCY":    "#ff0000",
}

RISK_COLOR = {
    "SAFE":              COLOR["SAFE"],
    "CAUTION":           COLOR["CAUTION"],
    "NEAR_MISS":         COLOR["NEAR_MISS"],
    "HUMAN_RISK":        COLOR["HUMAN_RISK"],
    "HUMAN_NEAR_MISS":   COLOR["HUMAN_NM"],
    "EMERGENCY_STOP":    COLOR["EMERGENCY"],
}

RISK_SCORE = {
    "SAFE": 0, "CAUTION": 1, "NEAR_MISS": 3,
    "HUMAN_RISK": 5, "HUMAN_NEAR_MISS": 8, "EMERGENCY_STOP": 4,
}

GLOBAL_STYLESHEET = f"""
QMainWindow, QWidget {{
    background-color: {COLOR['BG_DARK']};
    color: {COLOR['TEXT_PRIMARY']};
    font-family: 'Consolas', 'D2Coding', 'Courier New', monospace;
    font-size: 13px;
}}
QTabWidget::pane {{
    border: 1px solid {COLOR['BORDER']};
    background-color: {COLOR['BG_PANEL']};
    border-radius: 6px;
}}
QTabBar::tab {{
    background-color: {COLOR['BG_DARK']};
    color: {COLOR['TEXT_MUTED']};
    border: 1px solid {COLOR['BORDER']};
    padding: 8px 20px;
    margin-right: 2px;
    border-top-left-radius: 6px;
    border-top-right-radius: 6px;
}}
QTabBar::tab:selected {{
    background-color: {COLOR['BG_PANEL']};
    color: {COLOR['ACCENT']};
    border-bottom: 2px solid {COLOR['ACCENT']};
}}
QTableWidget {{
    background-color: {COLOR['BG_DARK']};
    gridline-color: {COLOR['BORDER']};
    border: 1px solid {COLOR['BORDER']};
    border-radius: 4px;
    selection-background-color: #264f78;
}}
QTableWidget::item {{
    padding: 6px 10px;
    border-bottom: 1px solid {COLOR['BORDER']};
}}
QHeaderView::section {{
    background-color: {COLOR['BG_CARD']};
    color: {COLOR['TEXT_MUTED']};
    border: none;
    border-bottom: 2px solid {COLOR['BORDER']};
    padding: 8px 10px;
    font-weight: bold;
    letter-spacing: 1px;
    text-transform: uppercase;
}}
QPushButton {{
    background-color: {COLOR['BG_CARD']};
    color: {COLOR['TEXT_PRIMARY']};
    border: 1px solid {COLOR['BORDER']};
    border-radius: 6px;
    padding: 7px 16px;
    font-size: 12px;
}}
QPushButton:hover {{
    background-color: #2d333b;
    border-color: {COLOR['ACCENT']};
    color: {COLOR['ACCENT']};
}}
QPushButton:pressed {{
    background-color: #1f6feb;
    border-color: {COLOR['ACCENT']};
}}
QComboBox {{
    background-color: {COLOR['BG_CARD']};
    border: 1px solid {COLOR['BORDER']};
    border-radius: 4px;
    padding: 5px 10px;
    color: {COLOR['TEXT_PRIMARY']};
}}
QComboBox::drop-down {{ border: none; width: 20px; }}
QComboBox QAbstractItemView {{
    background-color: {COLOR['BG_CARD']};
    border: 1px solid {COLOR['BORDER']};
    selection-background-color: #264f78;
}}
QDateEdit {{
    background-color: {COLOR['BG_CARD']};
    border: 1px solid {COLOR['BORDER']};
    border-radius: 4px;
    padding: 5px 10px;
    color: {COLOR['TEXT_PRIMARY']};
}}
QScrollBar:vertical {{
    background: {COLOR['BG_DARK']};
    width: 8px;
    border-radius: 4px;
}}
QScrollBar::handle:vertical {{
    background: {COLOR['BORDER']};
    border-radius: 4px;
    min-height: 20px;
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0px; }}
QGroupBox {{
    border: 1px solid {COLOR['BORDER']};
    border-radius: 6px;
    margin-top: 10px;
    padding-top: 6px;
    color: {COLOR['TEXT_MUTED']};
    font-size: 11px;
    letter-spacing: 1px;
}}
QGroupBox::title {{
    subcontrol-origin: margin;
    left: 10px;
    padding: 0 6px;
}}
QStatusBar {{
    background-color: {COLOR['BG_PANEL']};
    border-top: 1px solid {COLOR['BORDER']};
    color: {COLOR['TEXT_MUTED']};
    font-size: 11px;
}}
QLineEdit {{
    background-color: {COLOR['BG_CARD']};
    border: 1px solid {COLOR['BORDER']};
    border-radius: 4px;
    padding: 5px 10px;
    color: {COLOR['TEXT_PRIMARY']};
}}
QSplitter::handle {{
    background-color: {COLOR['BORDER']};
    width: 2px;
}}
"""

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  더미 데이터 생성 (실제 구현 시 BluetoothWorker + DB로 교체)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def make_dummy_event():
    risks = ["SAFE", "SAFE", "SAFE", "CAUTION", "CAUTION",
             "NEAR_MISS", "HUMAN_RISK", "HUMAN_NEAR_MISS"]
    states = ["FORWARD", "FORWARD", "FORWARD", "STOP", "TURN_LEFT", "TURN_RIGHT", "BACKWARD"]
    risk = random.choice(risks)
    dist = random.randint(8, 80)
    pir = 1 if "HUMAN" in risk else random.choice([0, 0, 1])
    return {
        "time":      datetime.now().strftime("%H:%M:%S"),
        "distance":  dist,
        "pir":       pir,
        "state":     random.choice(states),
        "risk_level": risk,
    }

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  블루투스 수신 워커 (실제 연결 시 pyserial로 교체)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class BluetoothWorker(QThread):
    data_received = pyqtSignal(dict)
    connection_changed = pyqtSignal(bool)

    def __init__(self):
        super().__init__()
        self.running = False
        self.connected = False
        self.port = "COM5"      # ← 실제 포트로 교체
        self.baudrate = 9600    # ← 실제 보드레이트로 교체

    def connect_device(self):
        # TODO: 실제 연결 코드
        # import serial
        # self.ser = serial.Serial(self.port, self.baudrate, timeout=1)
        self.connected = True
        self.connection_changed.emit(True)
        self.running = True
        self.start()

    def disconnect_device(self):
        self.running = False
        self.connected = False
        self.connection_changed.emit(False)

    def run(self):
        """수신 루프 — 실제 구현 시 serial.readline() 파싱으로 교체"""
        import time
        while self.running:
            # TODO: 실제 데이터 수신
            # line = self.ser.readline().decode('utf-8').strip()
            # event = self.parse_line(line)
            event = make_dummy_event()   # 더미 데이터
            self.data_received.emit(event)
            time.sleep(0.5)

    def parse_line(self, line: str) -> dict | None:
        """TIME:xxx,DIST:xx,PIR:x,STATE:xxx,RISK:xxx 형식 파싱"""
        try:
            parts = {k: v for k, v in (p.split(":") for p in line.split(","))}
            return {
                "time":       parts.get("TIME", ""),
                "distance":   int(parts.get("DIST", 0)),
                "pir":        int(parts.get("PIR", 0)),
                "state":      parts.get("STATE", "UNKNOWN"),
                "risk_level": parts.get("RISK", "SAFE"),
            }
        except Exception:
            return None

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  좌측 실시간 상태 패널
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class StatusPanel(QWidget):
    connect_requested    = pyqtSignal()
    disconnect_requested = pyqtSignal()

    def __init__(self):
        super().__init__()
        self.setFixedWidth(240)
        self.blink_state = False
        self.current_risk = "SAFE"
        self._build_ui()
        self._setup_blink_timer()

    # ── UI 구성 ──────────────────────────────────────────────────────────────
    def _build_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(12, 12, 12, 12)
        layout.setSpacing(10)

        # 타이틀
        title = QLabel("◈ LIVE STATUS")
        title.setFont(QFont("Consolas", 11, QFont.Bold))
        title.setStyleSheet(f"color: {COLOR['ACCENT']}; letter-spacing: 2px;")
        title.setAlignment(Qt.AlignCenter)
        layout.addWidget(title)
        layout.addWidget(self._divider())

        # 거리 카드
        layout.addWidget(self._section_label("DISTANCE"))
        self.lbl_distance = self._value_label("-- cm", size=28)
        layout.addWidget(self.lbl_distance)

        # 주행 상태 카드
        layout.addWidget(self._section_label("DRIVE STATE"))
        self.lbl_state = self._badge_label("--")
        layout.addWidget(self.lbl_state)

        # PIR 사람 감지
        layout.addWidget(self._section_label("PIR SENSOR"))
        self.lbl_pir = self._badge_label("감지 없음", bg="#1c2128")
        layout.addWidget(self.lbl_pir)

        # 위험 등급
        layout.addWidget(self._section_label("RISK LEVEL"))
        self.lbl_risk = self._badge_label("SAFE", bg=COLOR["SAFE"])
        self.lbl_risk.setFont(QFont("Consolas", 14, QFont.Bold))
        layout.addWidget(self.lbl_risk)

        # 누적 위험 점수
        layout.addWidget(self._section_label("DANGER SCORE (누적)"))
        self.lbl_score = self._value_label("0 pt", size=18)
        self.lbl_score.setStyleSheet(f"color: {COLOR['CAUTION']}; font-weight: bold;")
        layout.addWidget(self.lbl_score)

        # 진행 바 (위험 지수 시각화)
        self.score_bar = QProgressBar()
        self.score_bar.setRange(0, 200)
        self.score_bar.setValue(0)
        self.score_bar.setTextVisible(False)
        self.score_bar.setFixedHeight(6)
        self.score_bar.setStyleSheet(f"""
            QProgressBar {{ background: {COLOR['BG_CARD']}; border-radius: 3px; border: none; }}
            QProgressBar::chunk {{ background: {COLOR['CAUTION']}; border-radius: 3px; }}
        """)
        layout.addWidget(self.score_bar)

        layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding))
        layout.addWidget(self._divider())

        # 연결 정보
        layout.addWidget(self._section_label("BLUETOOTH"))
        self.lbl_port = QLabel("포트: --")
        self.lbl_port.setStyleSheet(f"color: {COLOR['TEXT_MUTED']}; font-size: 11px;")
        layout.addWidget(self.lbl_port)

        self.lbl_conn_status = QLabel("● 연결 안됨")
        self.lbl_conn_status.setStyleSheet(f"color: {COLOR['TEXT_MUTED']}; font-size: 12px;")
        layout.addWidget(self.lbl_conn_status)

        # 연결/해제 버튼
        btn_row = QHBoxLayout()
        self.btn_connect = QPushButton("연결")
        self.btn_connect.setStyleSheet(
            f"background: #1a7f37; border-color: {COLOR['SAFE']}; color: white;")
        self.btn_connect.clicked.connect(self.connect_requested.emit)

        self.btn_disconnect = QPushButton("해제")
        self.btn_disconnect.setStyleSheet(
            f"background: #6e1a1a; border-color: {COLOR['HUMAN_RISK']}; color: white;")
        self.btn_disconnect.setEnabled(False)
        self.btn_disconnect.clicked.connect(self.disconnect_requested.emit)

        btn_row.addWidget(self.btn_connect)
        btn_row.addWidget(self.btn_disconnect)
        layout.addLayout(btn_row)

    # ── 데이터 업데이트 ───────────────────────────────────────────────────────
    def update_data(self, event: dict, total_score: int):
        risk = event.get("risk_level", "SAFE")
        dist = event.get("distance", 0)
        pir  = event.get("pir", 0)
        state = event.get("state", "--")
        self.current_risk = risk

        self.lbl_distance.setText(f"{dist} cm")
        self.lbl_state.setText(state)

        if pir:
            self.lbl_pir.setText("👤 감지됨")
            self.lbl_pir.setStyleSheet(
                f"background: #6e1a1a; color: white; padding: 6px; border-radius: 4px; font-weight: bold;")
        else:
            self.lbl_pir.setText("감지 없음")
            self.lbl_pir.setStyleSheet(
                f"background: {COLOR['BG_CARD']}; color: {COLOR['TEXT_MUTED']}; padding: 6px; border-radius: 4px;")

        color = RISK_COLOR.get(risk, COLOR["SAFE"])
        self.lbl_risk.setText(risk)
        self.lbl_risk.setStyleSheet(
            f"background: {color}22; color: {color}; padding: 8px; border-radius: 4px; "
            f"font-weight: bold; border: 1px solid {color};")

        self.lbl_score.setText(f"{total_score} pt")
        self.score_bar.setValue(min(total_score, 200))

    def update_connection(self, connected: bool, port: str = ""):
        if connected:
            self.lbl_conn_status.setText("● 연결됨")
            self.lbl_conn_status.setStyleSheet(f"color: {COLOR['SAFE']}; font-size: 12px;")
            self.lbl_port.setText(f"포트: {port}")
            self.btn_connect.setEnabled(False)
            self.btn_disconnect.setEnabled(True)
        else:
            self.lbl_conn_status.setText("● 연결 안됨")
            self.lbl_conn_status.setStyleSheet(f"color: {COLOR['TEXT_MUTED']}; font-size: 12px;")
            self.btn_connect.setEnabled(True)
            self.btn_disconnect.setEnabled(False)

    # ── 위험 점멸 애니메이션 ──────────────────────────────────────────────────
    def _setup_blink_timer(self):
        self.blink_timer = QTimer()
        self.blink_timer.timeout.connect(self._blink)

    def start_blink(self):
        self.blink_timer.start(400)

    def stop_blink(self):
        self.blink_timer.stop()

    def _blink(self):
        self.blink_state = not self.blink_state
        if self.blink_state:
            self.lbl_risk.setStyleSheet(
                f"background: {COLOR['HUMAN_NM']}; color: white; padding: 8px; "
                f"border-radius: 4px; font-weight: bold;")
        else:
            self.lbl_risk.setStyleSheet(
                f"background: {COLOR['BG_CARD']}; color: {COLOR['HUMAN_NM']}; padding: 8px; "
                f"border-radius: 4px; font-weight: bold;")

    # ── 헬퍼 위젯 ────────────────────────────────────────────────────────────
    def _section_label(self, text):
        lbl = QLabel(text)
        lbl.setStyleSheet(
            f"color: {COLOR['TEXT_MUTED']}; font-size: 10px; letter-spacing: 1px;")
        return lbl

    def _value_label(self, text, size=20):
        lbl = QLabel(text)
        lbl.setFont(QFont("Consolas", size, QFont.Bold))
        lbl.setAlignment(Qt.AlignCenter)
        lbl.setStyleSheet(f"color: {COLOR['TEXT_PRIMARY']}; padding: 4px;")
        return lbl

    def _badge_label(self, text, bg=None):
        lbl = QLabel(text)
        bg = bg or COLOR["BG_CARD"]
        lbl.setAlignment(Qt.AlignCenter)
        lbl.setStyleSheet(
            f"background: {bg}; color: white; padding: 6px; border-radius: 4px; font-weight: bold;")
        return lbl

    def _divider(self):
        line = QFrame()
        line.setFrameShape(QFrame.HLine)
        line.setStyleSheet(f"color: {COLOR['BORDER']};")
        return line

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  탭1 — 이벤트 로그 테이블
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class LogTab(QWidget):
    COLUMNS = ["시간", "거리 (cm)", "PIR", "주행 상태", "위험 등급"]

    def __init__(self):
        super().__init__()
        self.auto_scroll = True
        self.event_count = 0
        self.danger_count = 0
        self._build_ui()

    def _build_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(12, 12, 12, 12)
        layout.setSpacing(8)

        # 헤더 툴바
        toolbar = QHBoxLayout()
        title = QLabel("📋  EVENT LOG")
        title.setFont(QFont("Consolas", 12, QFont.Bold))
        title.setStyleSheet(f"color: {COLOR['ACCENT']};")
        toolbar.addWidget(title)
        toolbar.addStretch()

        self.chk_auto_scroll = QCheckBox("자동 스크롤")
        self.chk_auto_scroll.setChecked(True)
        self.chk_auto_scroll.toggled.connect(lambda v: setattr(self, 'auto_scroll', v))
        toolbar.addWidget(self.chk_auto_scroll)

        self.chk_danger_only = QCheckBox("위험만 보기")
        self.chk_danger_only.toggled.connect(self._filter_rows)
        toolbar.addWidget(self.chk_danger_only)

        btn_clear = QPushButton("지우기")
        btn_clear.clicked.connect(self._clear_table)
        toolbar.addWidget(btn_clear)

        btn_export = QPushButton("📤 CSV 저장")
        btn_export.clicked.connect(self._export_csv)
        toolbar.addWidget(btn_export)

        layout.addLayout(toolbar)

        # 테이블
        self.table = QTableWidget(0, len(self.COLUMNS))
        self.table.setHorizontalHeaderLabels(self.COLUMNS)
        self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
        self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
        self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
        self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
        self.table.setSelectionBehavior(QTableWidget.SelectRows)
        self.table.setEditTriggers(QTableWidget.NoEditTriggers)
        self.table.setAlternatingRowColors(False)
        self.table.verticalHeader().setVisible(False)
        self.table.setShowGrid(True)
        layout.addWidget(self.table)

        # 하단 요약 바
        self.lbl_summary = QLabel("총 이벤트: 0건  |  위험 이벤트: 0건 (0.0%)")
        self.lbl_summary.setStyleSheet(
            f"color: {COLOR['TEXT_MUTED']}; font-size: 11px; padding: 4px;")
        layout.addWidget(self.lbl_summary)

    def add_event(self, event: dict):
        risk = event.get("risk_level", "SAFE")
        pir  = event.get("pir", 0)
        self.event_count += 1
        if risk not in ("SAFE",):
            self.danger_count += 1

        row = self.table.rowCount()
        self.table.insertRow(row)

        items = [
            event.get("time", "--"),
            str(event.get("distance", "--")),
            "✅" if pir else "❌",
            event.get("state", "--"),
            risk,
        ]

        color = QColor(RISK_COLOR.get(risk, COLOR["SAFE"]))
        bg = QColor(color)
        bg.setAlpha(30)

        for col, text in enumerate(items):
            item = QTableWidgetItem(text)
            item.setTextAlignment(Qt.AlignCenter)
            item.setBackground(QBrush(bg))
            if col == 4:  # 위험 등급 열만 진하게
                item.setForeground(QBrush(color))
                font = item.font()
                font.setBold(True)
                item.setFont(font)
            self.table.setItem(row, col, item)

        if self.auto_scroll:
            self.table.scrollToBottom()

        # 최대 500행 유지
        if self.table.rowCount() > 500:
            self.table.removeRow(0)

        ratio = (self.danger_count / self.event_count * 100) if self.event_count else 0
        self.lbl_summary.setText(
            f"총 이벤트: {self.event_count}건  |  "
            f"위험 이벤트: {self.danger_count}건 ({ratio:.1f}%)")

        # 위험 등급 필터 적용
        if self.chk_danger_only.isChecked() and risk == "SAFE":
            self.table.setRowHidden(row, True)

    def _filter_rows(self, danger_only: bool):
        for row in range(self.table.rowCount()):
            item = self.table.item(row, 4)
            if item:
                hidden = danger_only and item.text() == "SAFE"
                self.table.setRowHidden(row, hidden)

    def _clear_table(self):
        self.table.setRowCount(0)
        self.event_count = 0
        self.danger_count = 0
        self.lbl_summary.setText("총 이벤트: 0건  |  위험 이벤트: 0건 (0.0%)")

    def _export_csv(self):
        # TODO: QFileDialog + csv.writer로 저장
        QMessageBox.information(self, "CSV 저장", "저장 기능은 DB 연결 후 구현 예정입니다.")

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  탭2 — 통계 그래프
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ChartTab(QWidget):
    def __init__(self):
        super().__init__()
        self._build_ui()

    def _build_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(12, 12, 12, 12)
        layout.setSpacing(8)

        # 헤더
        header = QHBoxLayout()
        title = QLabel("📊  STATISTICS")
        title.setFont(QFont("Consolas", 12, QFont.Bold))
        title.setStyleSheet(f"color: {COLOR['ACCENT']};")
        header.addWidget(title)
        header.addStretch()
        btn_refresh = QPushButton("🔄 새로고침")
        btn_refresh.clicked.connect(self.refresh_charts)
        header.addWidget(btn_refresh)
        layout.addLayout(header)

        if MATPLOTLIB_AVAILABLE:
            self._build_charts(layout)
        else:
            lbl = QLabel("⚠  matplotlib이 설치되지 않았습니다.\n\npip install matplotlib")
            lbl.setAlignment(Qt.AlignCenter)
            lbl.setStyleSheet(f"color: {COLOR['CAUTION']}; font-size: 14px;")
            layout.addWidget(lbl)

    def _build_charts(self, layout):
        # 상단: 막대 + 파이 나란히
        top_row = QHBoxLayout()

        # 그래프1 — 시간대별 Near-Miss 막대 그래프
        self.fig1 = Figure(figsize=(5, 3), facecolor=COLOR['BG_PANEL'])
        self.canvas1 = FigureCanvas(self.fig1)
        self.ax1 = self.fig1.add_subplot(111)
        self._style_ax(self.ax1)
        self.ax1.set_title("시간대별 Near-Miss 발생 빈도", color=COLOR['TEXT_PRIMARY'], fontsize=10)
        self.ax1.set_xlabel("시간대 (Hour)", color=COLOR['TEXT_MUTED'], fontsize=8)
        self.ax1.set_ylabel("발생 횟수", color=COLOR['TEXT_MUTED'], fontsize=8)
        top_row.addWidget(self.canvas1)

        # 그래프2 — 위험 등급 파이 차트
        self.fig2 = Figure(figsize=(4, 3), facecolor=COLOR['BG_PANEL'])
        self.canvas2 = FigureCanvas(self.fig2)
        self.ax2 = self.fig2.add_subplot(111)
        self.ax2.set_facecolor(COLOR['BG_PANEL'])
        self.ax2.set_title("위험 등급 분포", color=COLOR['TEXT_PRIMARY'], fontsize=10)
        top_row.addWidget(self.canvas2)
        layout.addLayout(top_row)

        # 하단: 시간대별 평균 위험 점수 선 그래프
        self.fig3 = Figure(figsize=(10, 2.5), facecolor=COLOR['BG_PANEL'])
        self.canvas3 = FigureCanvas(self.fig3)
        self.ax3 = self.fig3.add_subplot(111)
        self._style_ax(self.ax3)
        self.ax3.set_title("시간대별 평균 위험 점수", color=COLOR['TEXT_PRIMARY'], fontsize=10)
        self.ax3.set_xlabel("시간대 (Hour)", color=COLOR['TEXT_MUTED'], fontsize=8)
        self.ax3.set_ylabel("평균 점수", color=COLOR['TEXT_MUTED'], fontsize=8)
        layout.addWidget(self.canvas3)

        # 초기 빈 데이터 렌더
        self._draw_empty_charts()

    def _style_ax(self, ax):
        ax.set_facecolor(COLOR['BG_PANEL'])
        ax.tick_params(colors=COLOR['TEXT_MUTED'], labelsize=8)
        for spine in ax.spines.values():
            spine.set_color(COLOR['BORDER'])

    def _draw_empty_charts(self):
        """초기 빈 차트 렌더링 (DB 연결 전 플레이스홀더)"""
        hours = list(range(24))
        zeros = [0] * 24

        self.ax1.clear()
        self._style_ax(self.ax1)
        self.ax1.bar(hours, zeros, color=COLOR['NEAR_MISS'], alpha=0.7, width=0.6)
        self.ax1.set_title("시간대별 Near-Miss 발생 빈도", color=COLOR['TEXT_PRIMARY'], fontsize=10)
        self.ax1.set_xticks(range(0, 24, 2))
        self.fig1.tight_layout()
        self.canvas1.draw()

        self.ax2.clear()
        labels = list(RISK_COLOR.keys())
        sizes  = [1] * len(labels)
        colors = list(RISK_COLOR.values())
        self.ax2.pie(sizes, labels=labels, colors=colors,
                     autopct='%1.1f%%', startangle=90,
                     textprops={'color': COLOR['TEXT_MUTED'], 'fontsize': 8})
        self.ax2.set_title("위험 등급 분포 (샘플)", color=COLOR['TEXT_PRIMARY'], fontsize=10)
        self.fig2.tight_layout()
        self.canvas2.draw()

        self.ax3.clear()
        self._style_ax(self.ax3)
        self.ax3.plot(hours, zeros, color=COLOR['ACCENT'], linewidth=2, marker='o', markersize=3)
        self.ax3.fill_between(hours, zeros, alpha=0.15, color=COLOR['ACCENT'])
        self.ax3.set_title("시간대별 평균 위험 점수", color=COLOR['TEXT_PRIMARY'], fontsize=10)
        self.ax3.set_xticks(range(0, 24, 2))
        self.fig3.tight_layout()
        self.canvas3.draw()

    def refresh_charts(self):
        """DB에서 집계 데이터 로드 후 그래프 갱신 — 실제 구현 시 DB 쿼리로 교체"""
        # TODO: DB 쿼리 결과로 교체
        # hours_data = db.query("SELECT HOUR(event_time), COUNT(*) ...")
        hours = list(range(24))
        nm_counts = [random.randint(0, 20) for _ in hours]
        avg_scores = [random.uniform(0, 6) for _ in hours]
        risk_dist = {k: random.randint(5, 100) for k in RISK_COLOR}

        if not MATPLOTLIB_AVAILABLE:
            return

        # 막대 그래프 갱신
        self.ax1.clear()
        self._style_ax(self.ax1)
        bars = self.ax1.bar(hours, nm_counts, color=COLOR['NEAR_MISS'], alpha=0.8, width=0.6)
        self.ax1.set_title("시간대별 Near-Miss 발생 빈도", color=COLOR['TEXT_PRIMARY'], fontsize=10)
        self.ax1.set_xlabel("시간대", color=COLOR['TEXT_MUTED'], fontsize=8)
        self.ax1.set_ylabel("횟수", color=COLOR['TEXT_MUTED'], fontsize=8)
        self.ax1.set_xticks(range(0, 24, 2))
        self.fig1.tight_layout()
        self.canvas1.draw()

        # 파이 차트 갱신
        self.ax2.clear()
        labels = list(risk_dist.keys())
        sizes  = list(risk_dist.values())
        colors = [RISK_COLOR[k] for k in labels]
        wedges, texts, autotexts = self.ax2.pie(
            sizes, labels=labels, colors=colors, autopct='%1.1f%%',
            startangle=90, textprops={'color': COLOR['TEXT_MUTED'], 'fontsize': 7})
        for at in autotexts:
            at.set_color(COLOR['TEXT_PRIMARY'])
        self.ax2.set_title("위험 등급 분포", color=COLOR['TEXT_PRIMARY'], fontsize=10)
        self.fig2.tight_layout()
        self.canvas2.draw()

        # 선 그래프 갱신
        self.ax3.clear()
        self._style_ax(self.ax3)
        self.ax3.plot(hours, avg_scores, color=COLOR['ACCENT'], linewidth=2,
                      marker='o', markersize=4)
        self.ax3.fill_between(hours, avg_scores, alpha=0.15, color=COLOR['ACCENT'])
        self.ax3.set_title("시간대별 평균 위험 점수", color=COLOR['TEXT_PRIMARY'], fontsize=10)
        self.ax3.set_xlabel("시간대", color=COLOR['TEXT_MUTED'], fontsize=8)
        self.ax3.set_ylabel("점수", color=COLOR['TEXT_MUTED'], fontsize=8)
        self.ax3.set_xticks(range(0, 24, 2))
        self.fig3.tight_layout()
        self.canvas3.draw()

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  탭3 — 날짜별 조회
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class SearchTab(QWidget):
    def __init__(self):
        super().__init__()
        self._build_ui()

    def _build_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(12, 12, 12, 12)
        layout.setSpacing(10)

        # 헤더
        title = QLabel("📅  DATE SEARCH")
        title.setFont(QFont("Consolas", 12, QFont.Bold))
        title.setStyleSheet(f"color: {COLOR['ACCENT']};")
        layout.addWidget(title)

        # 검색 조건 패널
        search_box = QGroupBox("조회 조건")
        search_layout = QHBoxLayout(search_box)
        search_layout.setSpacing(12)

        search_layout.addWidget(QLabel("시작일:"))
        self.date_start = QDateEdit(QDate.currentDate().addDays(-7))
        self.date_start.setCalendarPopup(True)
        self.date_start.setDisplayFormat("yyyy-MM-dd")
        search_layout.addWidget(self.date_start)

        search_layout.addWidget(QLabel("종료일:"))
        self.date_end = QDateEdit(QDate.currentDate())
        self.date_end.setCalendarPopup(True)
        self.date_end.setDisplayFormat("yyyy-MM-dd")
        search_layout.addWidget(self.date_end)

        search_layout.addWidget(QLabel("위험 등급:"))
        self.combo_risk = QComboBox()
        self.combo_risk.addItems(
            ["전체", "SAFE", "CAUTION", "NEAR_MISS", "HUMAN_RISK", "HUMAN_NEAR_MISS", "EMERGENCY_STOP"])
        self.combo_risk.setMinimumWidth(160)
        search_layout.addWidget(self.combo_risk)

        search_layout.addStretch()

        btn_search = QPushButton("🔍 조회")
        btn_search.setStyleSheet(
            f"background: #1f6feb; border-color: {COLOR['ACCENT']}; color: white; padding: 7px 20px;")
        btn_search.clicked.connect(self._run_search)
        search_layout.addWidget(btn_search)

        layout.addWidget(search_box)

        # 요약 카드 4개
        self.summary_box = QGroupBox("조회 결과 요약")
        summary_layout = QGridLayout(self.summary_box)
        summary_layout.setSpacing(8)

        self.card_total     = self._summary_card("총 이벤트",     "--건",  COLOR['ACCENT'])
        self.card_near_miss = self._summary_card("NEAR_MISS",    "--건",  COLOR['NEAR_MISS'])
        self.card_human     = self._summary_card("HUMAN 위험",   "--건",  COLOR['HUMAN_RISK'])
        self.card_avg_score = self._summary_card("평균 위험 점수","--점",  COLOR['CAUTION'])

        summary_layout.addWidget(self.card_total,     0, 0)
        summary_layout.addWidget(self.card_near_miss, 0, 1)
        summary_layout.addWidget(self.card_human,     0, 2)
        summary_layout.addWidget(self.card_avg_score, 0, 3)
        layout.addWidget(self.summary_box)

        # 결과 테이블
        result_label = QLabel("조회 결과")
        result_label.setStyleSheet(f"color: {COLOR['TEXT_MUTED']}; font-size: 11px;")
        layout.addWidget(result_label)

        self.result_table = QTableWidget(0, 5)
        self.result_table.setHorizontalHeaderLabels(
            ["시간", "거리 (cm)", "PIR", "주행 상태", "위험 등급"])
        self.result_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
        self.result_table.setSelectionBehavior(QTableWidget.SelectRows)
        self.result_table.setEditTriggers(QTableWidget.NoEditTriggers)
        self.result_table.verticalHeader().setVisible(False)
        layout.addWidget(self.result_table)

        # 내보내기
        footer = QHBoxLayout()
        footer.addStretch()
        btn_export = QPushButton("📤 CSV 내보내기")
        btn_export.clicked.connect(self._export_csv)
        footer.addWidget(btn_export)
        layout.addLayout(footer)

    def _summary_card(self, label: str, value: str, color: str) -> QGroupBox:
        box = QGroupBox()
        box.setStyleSheet(
            f"QGroupBox {{ border: 1px solid {color}44; border-radius: 6px; "
            f"background: {color}11; }}")
        v = QVBoxLayout(box)
        v.setContentsMargins(12, 8, 12, 8)

        lbl = QLabel(label)
        lbl.setStyleSheet(f"color: {COLOR['TEXT_MUTED']}; font-size: 10px; letter-spacing: 1px;")
        lbl.setAlignment(Qt.AlignCenter)
        v.addWidget(lbl)

        val = QLabel(value)
        val.setFont(QFont("Consolas", 16, QFont.Bold))
        val.setStyleSheet(f"color: {color};")
        val.setAlignment(Qt.AlignCenter)
        val.setObjectName("value_label")
        v.addWidget(val)
        return box

    def _update_card(self, card: QGroupBox, value: str):
        lbl = card.findChild(QLabel, "value_label")
        if lbl:
            lbl.setText(value)

    def _run_search(self):
        """DB 쿼리 실행 — 실제 구현 시 MySQL 쿼리로 교체"""
        start = self.date_start.date().toString("yyyy-MM-dd")
        end   = self.date_end.date().toString("yyyy-MM-dd")
        risk_filter = self.combo_risk.currentText()

        # TODO: 실제 DB 쿼리
        # SELECT * FROM event_log WHERE DATE(event_time) BETWEEN '{start}' AND '{end}'
        # AND (risk_level = '{risk_filter}' OR '{risk_filter}' = '전체')

        # 더미 결과 표시
        dummy_results = [make_dummy_event() for _ in range(random.randint(10, 30))]
        if risk_filter != "전체":
            dummy_results = [e for e in dummy_results if e["risk_level"] == risk_filter]

        self._populate_result_table(dummy_results)

        near_miss = sum(1 for e in dummy_results if "MISS" in e["risk_level"])
        human     = sum(1 for e in dummy_results if "HUMAN" in e["risk_level"])
        avg_score = sum(RISK_SCORE.get(e["risk_level"], 0) for e in dummy_results)
        avg_score = avg_score / len(dummy_results) if dummy_results else 0

        self._update_card(self.card_total,     f"{len(dummy_results)}건")
        self._update_card(self.card_near_miss, f"{near_miss}건")
        self._update_card(self.card_human,     f"{human}건")
        self._update_card(self.card_avg_score, f"{avg_score:.1f}점")

    def _populate_result_table(self, events: list):
        self.result_table.setRowCount(0)
        for event in events:
            risk = event.get("risk_level", "SAFE")
            row  = self.result_table.rowCount()
            self.result_table.insertRow(row)
            items = [
                event.get("time", "--"),
                str(event.get("distance", "--")),
                "✅" if event.get("pir") else "❌",
                event.get("state", "--"),
                risk,
            ]
            color = QColor(RISK_COLOR.get(risk, COLOR["SAFE"]))
            bg    = QColor(color); bg.setAlpha(25)
            for col, text in enumerate(items):
                item = QTableWidgetItem(text)
                item.setTextAlignment(Qt.AlignCenter)
                item.setBackground(QBrush(bg))
                if col == 4:
                    item.setForeground(QBrush(color))
                    f = item.font(); f.setBold(True); item.setFont(f)
                self.result_table.setItem(row, col, item)

    def _export_csv(self):
        QMessageBox.information(self, "CSV 내보내기", "저장 기능은 DB 연결 후 구현 예정입니다.")

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  연결 설정 다이얼로그
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ConnectionDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("블루투스 연결 설정")
        self.setFixedSize(320, 200)
        self.setStyleSheet(GLOBAL_STYLESHEET)
        self._build_ui()

    def _build_ui(self):
        layout = QFormLayout(self)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(12)

        self.port_edit = QLineEdit("COM5")
        self.port_edit.setPlaceholderText("예: COM5 또는 /dev/rfcomm0")
        layout.addRow("포트:", self.port_edit)

        self.baud_combo = QComboBox()
        self.baud_combo.addItems(["9600", "19200", "38400", "57600", "115200"])
        layout.addRow("보드레이트:", self.baud_combo)

        self.db_host = QLineEdit("localhost")
        layout.addRow("DB 호스트:", self.db_host)

        self.db_name = QLineEdit("safety_db")
        layout.addRow("DB 이름:", self.db_name)

        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addRow(buttons)

    def get_config(self):
        return {
            "port":     self.port_edit.text(),
            "baudrate": int(self.baud_combo.currentText()),
            "db_host":  self.db_host.text(),
            "db_name":  self.db_name.text(),
        }

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  메인 윈도우
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("물류센터 자율주행 로봇 안전 분석 시스템")
        self.setMinimumSize(1200, 720)
        self.resize(1400, 820)
        self.setStyleSheet(GLOBAL_STYLESHEET)

        self.total_score = 0
        self.recv_count  = 0
        self.bt_worker   = BluetoothWorker()

        self._build_central()
        self._build_menu()
        self._build_statusbar()
        self._connect_signals()

        # 상태바 갱신 타이머
        self.status_timer = QTimer()
        self.status_timer.timeout.connect(self._update_statusbar)
        self.status_timer.start(1000)

    # ── 메뉴바 ────────────────────────────────────────────────────────────────
    def _build_menu(self):
        menubar = self.menuBar()
        menubar.setStyleSheet(
            f"QMenuBar {{ background: {COLOR['BG_PANEL']}; border-bottom: 1px solid {COLOR['BORDER']}; }}"
            f"QMenuBar::item:selected {{ background: {COLOR['BG_CARD']}; color: {COLOR['ACCENT']}; }}"
            f"QMenu {{ background: {COLOR['BG_CARD']}; border: 1px solid {COLOR['BORDER']}; }}"
            f"QMenu::item:selected {{ background: #264f78; }}")

        # 파일 메뉴
        file_menu = menubar.addMenu("파일")
        act_export = QAction("전체 데이터 내보내기", self)
        act_exit   = QAction("종료", self)
        act_exit.triggered.connect(self.close)
        file_menu.addAction(act_export)
        file_menu.addSeparator()
        file_menu.addAction(act_exit)

        # 연결 메뉴
        conn_menu = menubar.addMenu("연결")
        act_setting = QAction("연결 설정...", self)
        act_setting.triggered.connect(self._open_connection_dialog)
        act_connect    = QAction("연결", self)
        act_connect.triggered.connect(self._on_connect)
        act_disconnect = QAction("해제", self)
        act_disconnect.triggered.connect(self._on_disconnect)
        conn_menu.addAction(act_setting)
        conn_menu.addSeparator()
        conn_menu.addAction(act_connect)
        conn_menu.addAction(act_disconnect)

        # 보기 메뉴
        view_menu = menubar.addMenu("보기")
        act_chart_refresh = QAction("그래프 새로고침", self)
        act_chart_refresh.triggered.connect(self.chart_tab.refresh_charts)
        view_menu.addAction(act_chart_refresh)

        # 도움말 메뉴
        help_menu = menubar.addMenu("도움말")
        act_about = QAction("프로젝트 정보", self)
        act_about.triggered.connect(self._show_about)
        help_menu.addAction(act_about)

    # ── 중앙 위젯 ──────────────────────────────────────────────────────────────
    def _build_central(self):
        central = QWidget()
        self.setCentralWidget(central)
        main_layout = QHBoxLayout(central)
        main_layout.setContentsMargins(8, 8, 8, 8)
        main_layout.setSpacing(0)

        # 좌측 상태 패널
        self.status_panel = StatusPanel()
        self.status_panel.connect_requested.connect(self._on_connect)
        self.status_panel.disconnect_requested.connect(self._on_disconnect)

        # 구분선
        divider = QFrame()
        divider.setFrameShape(QFrame.VLine)
        divider.setStyleSheet(f"color: {COLOR['BORDER']};")

        # 우측 탭
        self.log_tab    = LogTab()
        self.chart_tab  = ChartTab()
        self.search_tab = SearchTab()

        self.tab_widget = QTabWidget()
        self.tab_widget.addTab(self.log_tab,    "📋  이벤트 로그")
        self.tab_widget.addTab(self.chart_tab,  "📊  통계 분석")
        self.tab_widget.addTab(self.search_tab, "📅  날짜 조회")

        # 탭 변경 시 그래프 자동 새로고침
        self.tab_widget.currentChanged.connect(self._on_tab_changed)

        main_layout.addWidget(self.status_panel)
        main_layout.addWidget(divider)
        main_layout.addWidget(self.tab_widget)

    # ── 상태바 ────────────────────────────────────────────────────────────────
    def _build_statusbar(self):
        sb = self.statusBar()

        self.sb_bt_status  = QLabel("  ● 블루투스: 연결 안됨  ")
        self.sb_recv_count = QLabel("  수신: 0건  ")
        self.sb_last_recv  = QLabel("  마지막 수신: --  ")
        self.sb_time       = QLabel("")

        for lbl in [self.sb_bt_status, self.sb_recv_count, self.sb_last_recv]:
            lbl.setStyleSheet(
                f"color: {COLOR['TEXT_MUTED']}; padding: 0 8px; "
                f"border-right: 1px solid {COLOR['BORDER']};")

        sb.addPermanentWidget(self.sb_bt_status)
        sb.addPermanentWidget(self.sb_recv_count)
        sb.addPermanentWidget(self.sb_last_recv)
        sb.addPermanentWidget(self.sb_time)

    # ── 시그널 연결 ───────────────────────────────────────────────────────────
    def _connect_signals(self):
        self.bt_worker.data_received.connect(self._on_data_received)
        self.bt_worker.connection_changed.connect(self._on_connection_changed)

    # ── 슬롯: 데이터 수신 ──────────────────────────────────────────────────────
    def _on_data_received(self, event: dict):
        self.recv_count += 1
        self.total_score += RISK_SCORE.get(event.get("risk_level", "SAFE"), 0)
        self.last_recv_time = datetime.now().strftime("%H:%M:%S")

        # 각 탭 업데이트
        self.status_panel.update_data(event, self.total_score)
        self.log_tab.add_event(event)

        # HUMAN_NEAR_MISS 경고 점멸
        risk = event.get("risk_level", "SAFE")
        if risk in ("HUMAN_NEAR_MISS", "HUMAN_RISK"):
            self.status_panel.start_blink()
        else:
            self.status_panel.stop_blink()

        self.sb_recv_count.setText(f"  수신: {self.recv_count}건  ")
        self.sb_last_recv.setText(f"  마지막 수신: {self.last_recv_time}  ")

    # ── 슬롯: 연결 상태 변경 ──────────────────────────────────────────────────
    def _on_connection_changed(self, connected: bool):
        self.status_panel.update_connection(connected, self.bt_worker.port)
        if connected:
            self.sb_bt_status.setText("  ● 블루투스: 연결됨  ")
            self.sb_bt_status.setStyleSheet(
                f"color: {COLOR['SAFE']}; padding: 0 8px; border-right: 1px solid {COLOR['BORDER']};")
        else:
            self.sb_bt_status.setText("  ● 블루투스: 연결 안됨  ")
            self.sb_bt_status.setStyleSheet(
                f"color: {COLOR['TEXT_MUTED']}; padding: 0 8px; border-right: 1px solid {COLOR['BORDER']};")

    # ── 슬롯: 탭 전환 ──────────────────────────────────────────────────────────
    def _on_tab_changed(self, idx: int):
        if idx == 1:  # 통계 탭으로 이동 시 그래프 갱신
            self.chart_tab.refresh_charts()

    # ── 연결 / 해제 ──────────────────────────────────────────────────────────
    def _on_connect(self):
        self.bt_worker.connect_device()

    def _on_disconnect(self):
        self.bt_worker.disconnect_device()

    def _open_connection_dialog(self):
        dlg = ConnectionDialog(self)
        if dlg.exec_() == QDialog.Accepted:
            cfg = dlg.get_config()
            self.bt_worker.port     = cfg["port"]
            self.bt_worker.baudrate = cfg["baudrate"]
            # TODO: DB 설정 적용

    # ── 상태바 시계 갱신 ──────────────────────────────────────────────────────
    def _update_statusbar(self):
        self.sb_time.setText(f"  {datetime.now().strftime('%Y-%m-%d  %H:%M:%S')}  ")

    # ── 도움말 ────────────────────────────────────────────────────────────────
    def _show_about(self):
        QMessageBox.information(
            self, "프로젝트 정보",
            "물류센터 자율주행 로봇 안전 분석 시스템\n\n"
            "Near-Miss Analytics Dashboard\n"
            "Arduino + Bluetooth + MySQL + PyQt5\n\n"
            "팀 구성: 길민준, 김아영\n"
            "기획일: 2025-02-26"
        )

    def closeEvent(self, event):
        self.bt_worker.disconnect_device()
        super().closeEvent(event)

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  엔트리포인트
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if __name__ == "__main__":
    app = QApplication(sys.argv)
    app.setStyle("Fusion")

    # 다크 팔레트
    palette = QPalette()
    palette.setColor(QPalette.Window,          QColor(COLOR['BG_DARK']))
    palette.setColor(QPalette.WindowText,      QColor(COLOR['TEXT_PRIMARY']))
    palette.setColor(QPalette.Base,            QColor(COLOR['BG_PANEL']))
    palette.setColor(QPalette.AlternateBase,   QColor(COLOR['BG_CARD']))
    palette.setColor(QPalette.Text,            QColor(COLOR['TEXT_PRIMARY']))
    palette.setColor(QPalette.Button,          QColor(COLOR['BG_CARD']))
    palette.setColor(QPalette.ButtonText,      QColor(COLOR['TEXT_PRIMARY']))
    palette.setColor(QPalette.Highlight,       QColor("#264f78"))
    palette.setColor(QPalette.HighlightedText, QColor(COLOR['TEXT_PRIMARY']))
    app.setPalette(palette)

    window = MainWindow()
    window.show()
    sys.exit(app.exec_())

계획서.docx
0.46MB