import json import os import sys from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QSize from PyQt5.QtGui import QColor from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout, QPushButton, QLineEdit, QSpinBox, QDoubleSpinBox, QLabel, QMessageBox, QApplication, QFileDialog, QDialog, QTabWidget, QFrame, QListWidget, QListWidgetItem, ) from camera.insight import InSightCamera from camera.basler import BaslerCamera from ai.detector import Detector from db.sql_client import SQLClient from plc.plc_client import PLCClient from paths import resolve_path, to_project_relative from utils.path_helper import get_path from utils.touch_keyboard import show_touch_keyboard, hide_touch_keyboard from logger import log_action ADMIN_PASSWORD = "1234" # ── 카드 스타일 ──────────────────────────────────────────────────────── # _CARD = ( "QFrame {" " background:#1a1a1a;" " border:1px solid #2a2a2a;" " border-radius:8px;" "}" ) _STATUS_OK = "color:#1D9E75; font-size:12px; background:transparent; border:none;" _STATUS_FAIL = "color:#E24B4A; font-size:12px; background:transparent; border:none;" _BTN_CARD_CONNECT = ( "QPushButton {" " background:#1D9E75; color:#E1F5EE;" " border:none; border-radius:4px;" " min-height:43px; max-height:43px;" " padding:0 19px; font-size:14px;" "}" "QPushButton:hover { background:#20b585; }" "QPushButton:disabled { background:#145f48; color:#5a9e7e; }" ) _BTN_CARD_DISCONNECT = ( "QPushButton {" " background:#3D1515; color:#F09595;" " border:none; border-radius:4px;" " min-height:43px; max-height:43px;" " padding:0 19px; font-size:14px;" "}" "QPushButton:hover { background:#4a1818; }" "QPushButton:disabled { background:#222222; color:#666666; }" ) _BTN_ADMIN = ( "QPushButton {" " background:#333333; color:#aaaaaa;" " border:1px solid #555555; border-radius:4px;" " font-size:13px; padding:0 16px;" "}" "QPushButton:hover { background:#444444; color:#ffffff; }" ) # ── 관리자 다이얼로그 내부 스타일 ────────────────────────────────────── # _GRP = ( "QGroupBox {" " background:#222222; border:1px solid #333333; border-radius:6px;" " margin-top:14px; padding:14px 12px 12px 12px;" "}" "QGroupBox::title { color:#aaaaaa; subcontrol-origin:margin; left:10px; padding:0 4px; }" ) _BTN_DLG = ( "QPushButton {" " background:#333333; color:#ffffff; border:1px solid #555555;" " border-radius:4px; min-height:56px; font-size:14px;" "}" "QPushButton:hover { background:#444444; }" ) _BTN_DLG_PRIMARY = ( "QPushButton {" " background:#1D9E75; color:#ffffff; border:none;" " border-radius:4px; min-height:56px; font-size:14px; font-weight:bold;" "}" "QPushButton:hover { background:#20b585; }" ) # ══════════════════════════════════════════════════════════════════════ # # PasswordDialog # ══════════════════════════════════════════════════════════════════════ # class PasswordDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("관리자 인증") self.setModal(True) self.setFixedSize(360, 570) self.setStyleSheet("background:#1a1a1a;") self._pw = "" self._locked = False # 오입력 후 잠시 입력 차단 self._build_ui() # ── UI ───────────────────────────────────────────────────────────── # def _build_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(24, 28, 24, 24) layout.setSpacing(12) title = QLabel("관리자 인증") title.setAlignment(Qt.AlignCenter) title.setStyleSheet("color:#ffffff; font-size:17px; font-weight:bold;") layout.addWidget(title) desc = QLabel("비밀번호를 입력하세요.") desc.setAlignment(Qt.AlignCenter) desc.setStyleSheet("color:#888888; font-size:13px;") layout.addWidget(desc) # 4자리 점 표시 self._dot_lbl = QLabel("○ ○ ○ ○") self._dot_lbl.setAlignment(Qt.AlignCenter) self._dot_lbl.setStyleSheet( "color:#444444; font-size:30px; background:transparent;" ) layout.addWidget(self._dot_lbl) layout.addSpacing(6) # 키패드 pad = QGridLayout() pad.setSpacing(8) pad.setContentsMargins(0, 0, 0, 0) for idx, digit in enumerate("123456789"): btn = self._pad_btn(digit, font_size=22) btn.clicked.connect(lambda _, d=digit: self._on_digit(d)) pad.addWidget(btn, idx // 3, idx % 3) btn_del = self._pad_btn("⌫", bg="#252525", fg="#888888", font_size=20) btn_del.clicked.connect(self._on_backspace) btn_0 = self._pad_btn("0", font_size=22) btn_0.clicked.connect(lambda: self._on_digit("0")) btn_ok = self._pad_btn("확인", bg="#1D9E75", fg="#E1F5EE", font_size=16) btn_ok.clicked.connect(self._on_confirm) pad.addWidget(btn_del, 3, 0) pad.addWidget(btn_0, 3, 1) pad.addWidget(btn_ok, 3, 2) layout.addLayout(pad) layout.addSpacing(4) btn_cancel = QPushButton("취소") btn_cancel.setFixedHeight(52) btn_cancel.setStyleSheet( "QPushButton { background:#222222; color:#777777; border:none;" " border-radius:6px; font-size:15px; }" "QPushButton:pressed { background:#1a1a1a; }" ) btn_cancel.clicked.connect(self.reject) layout.addWidget(btn_cancel) @staticmethod def _pad_btn(text: str, bg="#2a2a2a", fg="#ffffff", font_size=22) -> QPushButton: btn = QPushButton(text) btn.setFixedHeight(68) btn.setStyleSheet( f"QPushButton {{ background:{bg}; color:{fg}; border:none;" f" border-radius:6px; font-size:{font_size}px; }}" f"QPushButton:pressed {{ background:#111111; }}" ) return btn # ── 입력 로직 ────────────────────────────────────────────────────── # def _on_digit(self, digit: str): if self._locked or len(self._pw) >= 4: return self._pw += digit self._refresh_dots() if len(self._pw) == 4: # 4자리 완성 → 120ms 후 자동 확인 (시각 피드백 확보) QTimer.singleShot(120, self._on_confirm) def _on_backspace(self): if self._locked: return self._pw = self._pw[:-1] self._refresh_dots() def _on_confirm(self): if self._pw == ADMIN_PASSWORD: self.accept() return # 오입력: 빨간 점 → 600ms 후 초기화 self._locked = True self._pw = "" self._dot_lbl.setText("● ● ● ●") self._dot_lbl.setStyleSheet( "color:#E24B4A; font-size:30px; background:transparent;" ) QTimer.singleShot(600, self._reset_dots) def _reset_dots(self): self._locked = False self._refresh_dots() def _refresh_dots(self): n = len(self._pw) dots = " ".join("●" if i < n else "○" for i in range(4)) color = "#1D9E75" if n > 0 else "#444444" self._dot_lbl.setText(dots) self._dot_lbl.setStyleSheet( f"color:{color}; font-size:30px; background:transparent;" ) def keyPressEvent(self, event): # 터치 전용 — 키보드 입력 차단 (Escape 제외) if event.key() == Qt.Key_Escape: self.reject() # ══════════════════════════════════════════════════════════════════════ # # AdminSettingsDialog # ══════════════════════════════════════════════════════════════════════ # class _TouchCheckList(QListWidget): """행 전체 탭으로 체크 토글 (터치 화면용).""" def mousePressEvent(self, event): item = self.itemAt(event.pos()) if item is not None: item.setCheckState( Qt.Unchecked if item.checkState() == Qt.Checked else Qt.Checked ) return super().mousePressEvent(event) class AdminSettingsDialog(QDialog): def __init__(self, settings_page, parent=None): super().__init__(parent) self._sp = settings_page self.setWindowTitle("관리자 설정") self.setModal(True) self.setFixedSize(900, 700) self.setStyleSheet("background:#1a1a1a;") self._build_ui() self._populate() # ── UI 뼈대 ─────────────────────────────────────────────────────── # def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) self._tabs = QTabWidget() self._tabs.setStyleSheet( "QTabWidget::pane { border:none; background:#1a1a1a; }" "QTabBar::tab {" " background:#222222; color:#888888;" " padding:10px 24px; font-size:14px; border:none;" " border-right:1px solid #333333;" "}" "QTabBar::tab:selected { background:#2e2e2e; color:#ffffff; font-weight:bold; }" "QTabBar::tab:hover { background:#2a2a2a; color:#cccccc; }" ) self._tabs.addTab(self._build_tab_cognex(), "코그넥스") self._tabs.addTab(self._build_tab_basler(), "Basler") self._tabs.addTab(self._build_tab_db(), "DB") self._tabs.addTab(self._build_tab_mes(), "MES 제품") self._tabs.addTab(self._build_tab_ai(), "AI 모델") self._tabs.addTab(self._build_tab_conveyor(), "컨베이어") self._tabs.addTab(self._build_tab_plc(), "PLC") root.addWidget(self._tabs, stretch=1) root.addWidget(self._build_bottom_bar()) @staticmethod def _tab_wrap(inner_widget) -> QWidget: w = QWidget() w.setStyleSheet("background:#1a1a1a;") layout = QVBoxLayout(w) layout.setContentsMargins(32, 24, 32, 20) layout.setSpacing(16) layout.addWidget(inner_widget) layout.addStretch() return w @staticmethod def _btn_pair(*btns) -> QWidget: w = QWidget() row = QHBoxLayout(w) row.setContentsMargins(0, 0, 0, 0) row.setSpacing(8) for b in btns: row.addWidget(b, stretch=1) return w # ── 탭 1 — 코그넥스 ─────────────────────────────────────────────── # def _build_tab_cognex(self) -> QWidget: g = self._make_group("코그넥스 In-Sight 2000C") form = QFormLayout(g) form.setHorizontalSpacing(16) form.setVerticalSpacing(12) self._cognex_ip = QLineEdit() self._cognex_ip.setPlaceholderText("예: 169.254.0.1") self._cognex_ip.setFixedHeight(42) self._cognex_port = QSpinBox() self._cognex_port.setRange(1, 65535) self._cognex_port.setFixedHeight(42) btn_connect = QPushButton("연결") btn_connect.setFixedHeight(56) btn_connect.setStyleSheet(_BTN_DLG) btn_connect.clicked.connect(self._on_cognex_connect) btn_save = QPushButton("저장") btn_save.setFixedHeight(56) btn_save.setStyleSheet(_BTN_DLG) btn_save.clicked.connect(self._on_cognex_save) form.addRow("IP 주소", self._cognex_ip) form.addRow("포트", self._cognex_port) form.addRow("", self._btn_pair(btn_connect, btn_save)) return self._tab_wrap(g) # ── 탭 2 — Basler ───────────────────────────────────────────────── # def _build_tab_basler(self) -> QWidget: g = self._make_group("Basler USB 카메라") form = QFormLayout(g) form.setHorizontalSpacing(16) form.setVerticalSpacing(12) self._basler_exposure = QSpinBox() self._basler_exposure.setRange(100, 1000000) self._basler_exposure.setFixedHeight(42) self._basler_gain = QSpinBox() self._basler_gain.setRange(0, 100) self._basler_gain.setFixedHeight(42) btn_connect = QPushButton("연결") btn_connect.setFixedHeight(56) btn_connect.setStyleSheet(_BTN_DLG) btn_connect.clicked.connect(self._sp._on_basler_connect) btn_apply = QPushButton("설정 적용") btn_apply.setFixedHeight(56) btn_apply.setStyleSheet(_BTN_DLG) btn_apply.clicked.connect(self._on_basler_apply) form.addRow("노출 (µs)", self._basler_exposure) form.addRow("게인", self._basler_gain) form.addRow("", self._btn_pair(btn_connect, btn_apply)) return self._tab_wrap(g) # ── 탭 3 — DB ───────────────────────────────────────────────────── # def _build_tab_db(self) -> QWidget: g = self._make_group("MS SQL Server DB") form = QFormLayout(g) form.setHorizontalSpacing(16) form.setVerticalSpacing(12) self._db_server = QLineEdit() self._db_server.setPlaceholderText("예: Wizis.iptime.org,20220") self._db_server.setFixedHeight(42) self._db_database = QLineEdit() self._db_database.setFixedHeight(42) self._db_username = QLineEdit() self._db_username.setFixedHeight(42) self._db_password = QLineEdit() self._db_password.setEchoMode(QLineEdit.Password) self._db_password.setFixedHeight(42) btn_connect = QPushButton("연결") btn_connect.setFixedHeight(56) btn_connect.setStyleSheet(_BTN_DLG) btn_connect.clicked.connect(self._on_db_connect) btn_save = QPushButton("저장") btn_save.setFixedHeight(56) btn_save.setStyleSheet(_BTN_DLG) btn_save.clicked.connect(self._on_db_save) form.addRow("서버", self._db_server) form.addRow("DB명", self._db_database) form.addRow("사용자명", self._db_username) form.addRow("비밀번호", self._db_password) form.addRow("", self._btn_pair(btn_connect, btn_save)) return self._tab_wrap(g) # ── 탭 3b — MES 제품 선택 ───────────────────────────────────────── # def _build_tab_mes(self) -> QWidget: w = QWidget() w.setStyleSheet("background:#1a1a1a;") layout = QVBoxLayout(w) layout.setContentsMargins(32, 24, 32, 20) layout.setSpacing(12) hint = QLabel( "제품 등록 탭의 'MES 불러오기'에 표시할 제품을 선택합니다.\n" "DB 연결 후 목록을 불러온 뒤 체크하고 저장하세요." ) hint.setStyleSheet("color:#888888; font-size:13px; background:transparent;") hint.setWordWrap(True) layout.addWidget(hint) btn_row = QHBoxLayout() btn_row.setSpacing(8) btn_load = QPushButton("목록 불러오기") btn_load.setFixedHeight(42) btn_load.setStyleSheet(_BTN_DLG.replace("min-height:56px", "min-height:42px")) btn_load.clicked.connect(self._on_mes_load) btn_all = QPushButton("전체 선택") btn_all.setFixedHeight(42) btn_all.setStyleSheet(_BTN_DLG.replace("min-height:56px", "min-height:42px")) btn_all.clicked.connect(lambda: self._on_mes_set_all(True)) btn_none = QPushButton("전체 해제") btn_none.setFixedHeight(42) btn_none.setStyleSheet(_BTN_DLG.replace("min-height:56px", "min-height:42px")) btn_none.clicked.connect(lambda: self._on_mes_set_all(False)) btn_row.addWidget(btn_load, stretch=2) btn_row.addWidget(btn_all, stretch=1) btn_row.addWidget(btn_none, stretch=1) layout.addLayout(btn_row) self._mes_list = _TouchCheckList() self._mes_list.setSelectionMode(QListWidget.NoSelection) self._mes_list.setMinimumHeight(380) self._mes_list.setStyleSheet(""" QListWidget { background:#1a1a1a; border:1px solid #333333; border-radius:4px; outline:none; font-size:15px; } QListWidget::item { padding:12px 16px; border-bottom:1px solid #2a2a2a; } QListWidget::indicator { width:0px; height:0px; } """) self._mes_list.itemChanged.connect(self._on_mes_item_changed) layout.addWidget(self._mes_list, stretch=1) self._mes_count_lbl = QLabel("선택: 0 / 0") self._mes_count_lbl.setStyleSheet("color:#888888; font-size:13px; background:transparent;") layout.addWidget(self._mes_count_lbl) btn_save = QPushButton("선택 저장") btn_save.setFixedHeight(56) btn_save.setStyleSheet(_BTN_DLG_PRIMARY) btn_save.clicked.connect(self._on_mes_save) layout.addWidget(btn_save) return w # ── 탭 4 — AI 모델 ──────────────────────────────────────────────── # def _build_tab_ai(self) -> QWidget: g = self._make_group("AI 모델 (YOLOv8)") layout = QVBoxLayout(g) layout.setSpacing(10) path_row = QHBoxLayout() self._ai_path_edit = QLineEdit() self._ai_path_edit.setReadOnly(True) self._ai_path_edit.setFixedHeight(42) self._ai_path_edit.setPlaceholderText("모델 경로 미설정") btn_browse = QPushButton("파일 선택") btn_browse.setFixedHeight(42) btn_browse.setFixedWidth(120) btn_browse.setStyleSheet(_BTN_DLG.replace("min-height:56px", "min-height:42px")) btn_browse.clicked.connect(self._on_ai_browse) path_row.addWidget(self._ai_path_edit, stretch=1) path_row.addWidget(btn_browse) btn_load = QPushButton("로드") btn_load.setFixedHeight(56) btn_load.setStyleSheet(_BTN_DLG_PRIMARY) btn_load.clicked.connect(self._on_ai_load) btn_unload = QPushButton("해제") btn_unload.setFixedHeight(56) btn_unload.setStyleSheet(_BTN_DLG) btn_unload.clicked.connect(self._on_ai_unload) self._ai_info_lbl = QLabel("") self._ai_info_lbl.setStyleSheet("color:#888888; font-size:12px; background:transparent;") self._ai_info_lbl.setWordWrap(True) layout.addLayout(path_row) layout.addWidget(self._btn_pair(btn_load, btn_unload)) layout.addWidget(self._ai_info_lbl) return self._tab_wrap(g) # ── 탭 5 — 컨베이어 ─────────────────────────────────────────────── # def _build_tab_conveyor(self) -> QWidget: g = self._make_group("컨베이어 설정") form = QFormLayout(g) form.setHorizontalSpacing(16) form.setVerticalSpacing(12) self._conv_distance = QDoubleSpinBox() self._conv_distance.setRange(1.0, 10000.0) self._conv_distance.setDecimals(1) self._conv_distance.setSuffix(" cm") self._conv_distance.setValue(100.0) self._conv_distance.setFixedHeight(42) self._conv_speed = QDoubleSpinBox() self._conv_speed.setRange(0.1, 10000.0) self._conv_speed.setDecimals(1) self._conv_speed.setSuffix(" cm/s") self._conv_speed.setValue(30.0) self._conv_speed.setFixedHeight(42) self._conv_delay_lbl = QLabel("3.33 s") self._conv_delay_lbl.setStyleSheet( "color:#44cc88; font-size:14px; font-weight:bold; background:transparent;" ) self._conv_distance.valueChanged.connect(self._on_conveyor_changed) self._conv_speed.valueChanged.connect(self._on_conveyor_changed) btn_apply = QPushButton("적용") btn_apply.setFixedHeight(56) btn_apply.setStyleSheet(_BTN_DLG_PRIMARY) btn_apply.clicked.connect(self._on_conveyor_apply) form.addRow("카메라 간 거리", self._conv_distance) form.addRow("벨트 속도", self._conv_speed) form.addRow("계산된 딜레이", self._conv_delay_lbl) form.addRow("", btn_apply) return self._tab_wrap(g) # ── 탭 6 — PLC ──────────────────────────────────────────────────── # def _build_tab_plc(self) -> QWidget: g = self._make_group("PLC 설정 (MELSEC Q06UDEHCPU · MC 프로토콜 3E)") form = QFormLayout(g) form.setHorizontalSpacing(16) form.setVerticalSpacing(12) self._plc_ip = QLineEdit() self._plc_ip.setPlaceholderText("예: 192.168.3.39") self._plc_ip.setFixedHeight(42) self._plc_port = QSpinBox() self._plc_port.setRange(1, 65535) self._plc_port.setFixedHeight(42) btn_connect = QPushButton("연결") btn_connect.setFixedHeight(56) btn_connect.setStyleSheet(_BTN_DLG) btn_connect.clicked.connect(self._on_plc_connect) form.addRow("IP", self._plc_ip) form.addRow("포트", self._plc_port) form.addRow("", btn_connect) return self._tab_wrap(g) def _on_plc_connect(self): ip = self._plc_ip.text().strip() port = self._plc_port.value() log_action(f"[설정] PLC 연결 시도: {ip}:{port}") if not ip: QMessageBox.warning(self, "입력 오류", "IP 주소를 입력하세요.") return if self._sp._plc_client and self._sp._plc_client.is_connected(): self._sp._plc_client.disconnect() client = PLCClient() ok = client.connect(ip, port) if ok: self._sp._plc_client = client self._sp._set_plc_connected(True) self._sp._save_config({"plc": {"ip": ip, "port": port}}) self._sp._config.setdefault("plc", {}).update({"ip": ip, "port": port}) if self._sp._update_plc_cb: self._sp._update_plc_cb(client) self._sp.plc_status_changed.emit(True) QMessageBox.information(self, "연결 성공", f"PLC 연결 성공\n{ip}:{port}") else: QMessageBox.critical(self, "연결 실패", f"PLC에 연결할 수 없습니다.\n{ip}:{port}") # ── 하단 버튼 바 ─────────────────────────────────────────────────── # def _build_bottom_bar(self) -> QWidget: bar = QWidget() bar.setStyleSheet("background:#111111;") bar.setFixedHeight(72) row = QHBoxLayout(bar) row.setContentsMargins(24, 8, 24, 8) row.setSpacing(12) btn_save_all = QPushButton("전체 설정 저장") btn_save_all.setFixedHeight(56) btn_save_all.setStyleSheet(_BTN_DLG_PRIMARY) btn_save_all.clicked.connect(self._on_save_all) btn_close = QPushButton("닫기") btn_close.setFixedHeight(56) btn_close.setStyleSheet(_BTN_DLG) btn_close.clicked.connect(self.accept) # 터치 키보드 표시/숨김 토글 버튼 (물리 키보드 없는 터치 모니터용) self._kb_btn = QPushButton("\u2328") # ⌨ 키보드 글리프 self._kb_btn.setCheckable(True) self._kb_btn.setFixedSize(56, 56) self._kb_btn.setToolTip("터치 키보드 표시 / 숨기기") self._kb_btn.setStyleSheet( "QPushButton {" " background:#2a2a2a; color:#cccccc; border:1px solid #555555;" " border-radius:4px; font-size:24px;" "}" "QPushButton:hover { background:#333333; color:#ffffff; }" "QPushButton:checked { background:#1D9E75; color:#ffffff; border:none; }" ) self._kb_btn.toggled.connect(self._on_toggle_keyboard) row.addWidget(self._kb_btn) row.addWidget(btn_save_all, stretch=2) row.addWidget(btn_close, stretch=1) return bar # ── 터치 키보드 토글 ─────────────────────────────────────────────── # def _on_toggle_keyboard(self, checked: bool): if checked: if self._show_touch_keyboard(): log_action("[설정] 터치 키보드 표시") else: # 실패 시 토글 상태 원복 (시그널 재발생 방지) self._kb_btn.blockSignals(True) self._kb_btn.setChecked(False) self._kb_btn.blockSignals(False) QMessageBox.warning( self, "터치 키보드", "터치 키보드를 열 수 없습니다.\n" "Windows 터치 키보드 또는 화상 키보드를 사용할 수 있는지 확인하세요.", ) else: self._hide_touch_keyboard() log_action("[설정] 터치 키보드 숨김") def _show_touch_keyboard(self) -> bool: """Windows 터치 키보드(TabTip)를 ITipInvocation COM으로 표시한다.""" return show_touch_keyboard() def _hide_touch_keyboard(self): """표시 중인 터치 키보드를 숨긴다.""" hide_touch_keyboard() def done(self, result: int): # 키보드를 켠 상태에서만 숨김 (Toggle은 반전이라 미사용 시 닫으면 오히려 켜짐) if self._kb_btn.isChecked(): self._hide_touch_keyboard() super().done(result) @staticmethod def _make_group(title: str): from PyQt5.QtWidgets import QGroupBox g = QGroupBox(title) g.setStyleSheet(_GRP) return g # ── 필드 채우기 ──────────────────────────────────────────────────── # def _populate(self): cfg = self._sp._config cognex = cfg.get("cognex", {}) self._cognex_ip.setText(cognex.get("ip", "")) self._cognex_port.setValue(cognex.get("port", 23)) basler = cfg.get("basler", {}) self._basler_exposure.setValue(basler.get("exposure", 10000)) self._basler_gain.setValue(basler.get("gain", 20)) conv = cfg.get("conveyor", {}) self._conv_distance.setValue(conv.get("distance_cm", 100.0)) self._conv_speed.setValue(conv.get("speed_cms", 30.0)) self._on_conveyor_changed() db = cfg.get("db", {}) self._db_server.setText(db.get("server", "")) self._db_database.setText(db.get("database", "")) self._db_username.setText(db.get("username", "")) self._db_password.setText(db.get("password", "")) ai = cfg.get("ai", {}) if "model_path" in ai: self._ai_path_edit.setText(to_project_relative(ai["model_path"])) if self._sp._detector and self._sp._detector.is_loaded(): names = ", ".join(self._sp._detector.class_names) self._ai_info_lbl.setText( f"클래스: {names} ({len(self._sp._detector.class_names)}개)" ) plc = cfg.get("plc", {}) self._plc_ip.setText(plc.get("ip", "")) self._plc_port.setValue(plc.get("port", 5010)) # ── 코그넥스 탭 슬롯 ─────────────────────────────────────────────── # def _on_cognex_connect(self): ip = self._cognex_ip.text().strip() port = self._cognex_port.value() log_action(f"[설정] Cognex 연결 시도: {ip}:{port}") if not ip: QMessageBox.warning(self, "입력 오류", "IP 주소를 입력하세요.") return if self._sp._insight and self._sp._insight._sock is not None: self._sp._insight.disconnect() new_cam = InSightCamera() new_cam.connect(ip, port) if new_cam._sock is None: QMessageBox.critical( self, "연결 실패", f"In-Sight 카메라에 연결할 수 없습니다.\nIP: {ip} 포트: {port}", ) return self._sp._insight = new_cam if self._sp._update_insight_cb: self._sp._update_insight_cb(new_cam) self._sp._set_cognex_connected(True) self._sp._save_cognex_config(ip, port) self._sp.cognex_status_changed.emit(True) QMessageBox.information(self, "연결 성공", f"In-Sight 카메라 연결 성공\n{ip}:{port}") def _on_cognex_save(self): ip = self._cognex_ip.text().strip() port = self._cognex_port.value() self._sp._save_cognex_config(ip, port) QMessageBox.information(self, "저장", "코그넥스 설정이 저장되었습니다.") # ── Basler 탭 슬롯 ───────────────────────────────────────────────── # def _on_basler_apply(self): log_action(f"[설정] Basler 설정 적용: 노출={self._basler_exposure.value()}µs 게인={self._basler_gain.value()}") if not self._sp._basler or self._sp._basler.camera is None: QMessageBox.warning(self, "경고", "Basler 카메라가 연결되어 있지 않습니다.") return exposure = self._basler_exposure.value() gain = self._basler_gain.value() errors = [] try: self._sp._basler.camera.ExposureAuto.SetValue("Off") self._sp._basler.camera.ExposureTime.SetValue(float(exposure)) except Exception as e: errors.append(f"노출: {e}") try: self._sp._basler.camera.Gain.SetValue(float(gain)) except Exception as e: errors.append(f"게인: {e}") if errors: QMessageBox.warning(self, "설정 적용 오류", "\n".join(errors)) else: self._sp._save_config({"basler": {"exposure": exposure, "gain": gain}}) self._sp._config.setdefault("basler", {}).update( {"exposure": exposure, "gain": gain} ) QMessageBox.information(self, "적용 완료", f"노출: {exposure} µs 게인: {gain}") # ── DB 탭 슬롯 ───────────────────────────────────────────────────── # def _on_db_connect(self): server = self._db_server.text().strip() database = self._db_database.text().strip() username = self._db_username.text().strip() password = self._db_password.text() log_action(f"[설정] DB 연결 시도: {server}/{database}") if not server or not database: QMessageBox.warning(self, "경고", "서버와 DB명을 입력해주세요.") return client = SQLClient() ok = client.connect(server, database, username, password) if ok: self._sp._db_client = client self._sp._set_db_connected(True) cfg = {"server": server, "database": database, "username": username, "password": password} self._sp._save_config({"db": cfg}) self._sp._config.setdefault("db", {}).update(cfg) if self._sp._update_db_cb: self._sp._update_db_cb(client) QMessageBox.information(self, "연결 성공", f"DB 연결 성공\n{server}/{database}") else: QMessageBox.critical( self, "연결 실패", "DB 연결에 실패했습니다.\n서버 주소, DB명, 계정 정보를 확인하세요.", ) def _on_db_save(self): cfg = { "server": self._db_server.text().strip(), "database": self._db_database.text().strip(), "username": self._db_username.text().strip(), "password": self._db_password.text(), } self._sp._save_config({"db": cfg}) self._sp._config.setdefault("db", {}).update(cfg) QMessageBox.information(self, "저장", "DB 설정이 저장되었습니다.") # ── MES 제품 탭 슬롯 ─────────────────────────────────────────────── # def _get_mes_selected_ids(self) -> list: ids = [] for i in range(self._mes_list.count()): item = self._mes_list.item(i) if item.checkState() == Qt.Checked: ids.append(item.data(Qt.UserRole)) return ids def _update_mes_count(self): total = self._mes_list.count() selected = len(self._get_mes_selected_ids()) self._mes_count_lbl.setText(f"선택: {selected} / {total}") @staticmethod def _style_mes_item(item): name = item.data(Qt.UserRole + 1) or item.text().lstrip("✓ ").lstrip(" ") item.setData(Qt.UserRole + 1, name) if item.checkState() == Qt.Checked: item.setText(f"✓ {name}") item.setBackground(QColor("#666666")) item.setForeground(QColor("#ffffff")) else: item.setText(f" {name}") item.setBackground(QColor("#1e1e1e")) item.setForeground(QColor("#888888")) def _on_mes_item_changed(self, item): self._style_mes_item(item) self._update_mes_count() def _on_mes_set_all(self, checked: bool): state = Qt.Checked if checked else Qt.Unchecked self._mes_list.blockSignals(True) for i in range(self._mes_list.count()): item = self._mes_list.item(i) item.setCheckState(state) self._style_mes_item(item) self._mes_list.blockSignals(False) self._update_mes_count() def _on_mes_load(self): client = self._sp._db_client if not client or not client.is_connected(): QMessageBox.warning( self, "경고", "DB가 연결되어 있지 않습니다.\n" "DB 탭에서 먼저 연결해주세요.", ) return items = client.get_all_articles() if not items: QMessageBox.warning(self, "경고", "조회된 제품이 없습니다.") return saved_ids = set(self._sp._config.get("mes", {}).get("selected_article_ids", [])) self._mes_list.blockSignals(True) self._mes_list.clear() for item in items: li = QListWidgetItem(item["article"]) li.setSizeHint(QSize(0, 52)) li.setFlags(li.flags() | Qt.ItemIsUserCheckable) li.setData(Qt.UserRole, item["article_id"]) li.setData(Qt.UserRole + 1, item["article"]) li.setToolTip( f"ID: {item['article_id']} | 모델: {item.get('buyer_article_no', '')}" ) li.setCheckState( Qt.Checked if item["article_id"] in saved_ids else Qt.Unchecked ) self._style_mes_item(li) self._mes_list.addItem(li) self._mes_list.blockSignals(False) self._update_mes_count() log_action(f"[설정] MES 제품 목록 불러오기: {len(items)}개") def _on_mes_save(self): selected_ids = self._get_mes_selected_ids() log_action(f"[설정] MES 제품 선택 저장: {len(selected_ids)}개") self._sp._save_config({"mes": {"selected_article_ids": selected_ids}}) self._sp._config.setdefault("mes", {})["selected_article_ids"] = selected_ids QMessageBox.information( self, "저장", f"{len(selected_ids)}개 제품이 MES 불러오기 목록에 저장되었습니다.", ) # ── AI 탭 슬롯 ───────────────────────────────────────────────────── # def _on_ai_browse(self): path, _ = QFileDialog.getOpenFileName( self, "YOLOv8 모델 선택", "", "YOLOv8 모델 (*.pt)" ) if path: self._ai_path_edit.setText(to_project_relative(path)) def _on_ai_load(self): path = self._ai_path_edit.text().strip() log_action(f"[설정] AI 모델 로드: {path}") if not path: QMessageBox.warning(self, "경고", "모델 경로를 먼저 선택하세요.") return ok = self._sp._do_load_model(path) if ok: names = ", ".join(self._sp._detector.class_names) self._ai_info_lbl.setText( f"클래스: {names} ({len(self._sp._detector.class_names)}개)" ) self._sp._set_ai_loaded(True) QMessageBox.information(self, "로드 완료", f"모델 로드 완료\n{path}") else: self._sp._set_ai_loaded(False) QMessageBox.critical(self, "로드 실패", f"모델을 로드할 수 없습니다.\n{path}") def _on_ai_unload(self): log_action("[설정] AI 모델 해제") if self._sp._detector: self._sp._detector._model = None self._sp._detector.model_path = None self._sp._set_ai_loaded(False) self._ai_info_lbl.setText("") # ── 컨베이어 탭 슬롯 ─────────────────────────────────────────────── # def _on_conveyor_changed(self): dist = self._conv_distance.value() speed = self._conv_speed.value() self._conv_delay_lbl.setText(f"{dist / speed:.2f} s" if speed > 0 else "—") def _on_conveyor_apply(self): dist = self._conv_distance.value() speed = self._conv_speed.value() delay = dist / speed if speed > 0 else 0.0 log_action(f"[설정] 컨베이어 적용: {dist}cm / {speed}cm·s → 딜레이 {delay:.2f}s") try: self._sp._save_config({"conveyor": {"distance_cm": dist, "speed_cms": speed}}) self._sp._config.setdefault("conveyor", {}).update( {"distance_cm": dist, "speed_cms": speed} ) self._sp.belt_settings_changed.emit(delay) QMessageBox.information( self, "적용 완료", f"거리: {dist} cm / 속도: {speed} cm/s\n딜레이: {delay:.2f}s", ) except Exception as e: QMessageBox.critical(self, "저장 실패", str(e)) # ── 전체 저장 ────────────────────────────────────────────────────── # def _on_save_all(self): log_action("[설정] 전체 설정 저장") data = { "cognex": { "ip": self._cognex_ip.text().strip(), "port": self._cognex_port.value(), }, "basler": { "exposure": self._basler_exposure.value(), "gain": self._basler_gain.value(), }, "db": { "server": self._db_server.text().strip(), "database": self._db_database.text().strip(), "username": self._db_username.text().strip(), "password": self._db_password.text(), }, "ai": { "model_path": to_project_relative(self._ai_path_edit.text().strip()), }, "conveyor": { "distance_cm": self._conv_distance.value(), "speed_cms": self._conv_speed.value(), }, "plc": { "ip": self._plc_ip.text().strip(), "port": self._plc_port.value(), }, "mes": { "selected_article_ids": ( self._get_mes_selected_ids() if self._mes_list.count() > 0 else self._sp._config.get("mes", {}).get("selected_article_ids", []) ), }, } try: self._sp._save_config(data) for k, v in data.items(): if isinstance(self._sp._config.get(k), dict): self._sp._config[k].update(v) else: self._sp._config[k] = v QMessageBox.information(self, "저장 완료", "전체 설정이 저장되었습니다.") except Exception as e: QMessageBox.critical(self, "저장 실패", str(e)) # ══════════════════════════════════════════════════════════════════════ # # SettingsPage (작업자 메인 화면) # ══════════════════════════════════════════════════════════════════════ # class SettingsPage(QWidget): cognex_status_changed = pyqtSignal(bool) basler_status_changed = pyqtSignal(bool) belt_settings_changed = pyqtSignal(float) plc_status_changed = pyqtSignal(bool) def __init__(self, insight_cam, basler_cam, config: dict, detector=None, update_insight_cb=None, update_basler_cb=None, update_detector_cb=None, update_db_cb=None, update_plc_cb=None, plc_client=None, parent=None): super().__init__(parent) self._insight = insight_cam self._basler = basler_cam self._config = config self._detector = detector self._update_insight_cb = update_insight_cb self._update_basler_cb = update_basler_cb self._update_detector_cb = update_detector_cb self._update_db_cb = update_db_cb self._update_plc_cb = update_plc_cb self._db_client = None self._plc_client = plc_client self._build_ui() self._sync_connection_status() self._auto_load_model() # ── UI 구성 ──────────────────────────────────────────────────────── # def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(40, 32, 40, 32) root.setSpacing(0) # 상단 행: 페이지 제목 + 관리자 버튼 top_row = QHBoxLayout() top_row.setContentsMargins(0, 0, 0, 16) title_lbl = QLabel("연결 상태") title_lbl.setStyleSheet("color:#888888; font-size:14px;") top_row.addWidget(title_lbl) top_row.addStretch() admin_btn = QPushButton("⚙ 관리자 설정") admin_btn.setFixedHeight(40) admin_btn.setStyleSheet(_BTN_ADMIN) admin_btn.clicked.connect(self._on_admin_settings) top_row.addWidget(admin_btn) root.addLayout(top_row) # 2×2 카드 그리드 — 카드 고정 너비 500px, 왼쪽 정렬 self._cognex_card = self._build_card( "코그넥스 In-Sight 2000C", icon_color="#042C53", icon_text="IN", connect_cb=self._on_cognex_connect_quick, disconnect_cb=self._on_cognex_disconnect, ) self._basler_card = self._build_card( "Basler USB 카메라", icon_color="#26215C", icon_text="BA", connect_cb=self._on_basler_connect, disconnect_cb=self._on_basler_disconnect, ) self._db_card = self._build_card( "MS SQL Server DB", icon_color="#412402", icon_text="DB", connect_cb=self._on_db_connect_quick, disconnect_cb=self._on_db_disconnect, ) self._ai_card = self._build_card( "AI 모델 (YOLOv8)", icon_color="#085041", icon_text="AI", connect_label="로드", disconnect_label="해제", connect_cb=self._on_ai_load_quick, disconnect_cb=self._on_ai_unload, ) self._plc_card = self._build_card( "PLC (MELSEC Q06UDEHCPU)", icon_color="#3B1A04", icon_text="PLC", connect_cb=self._on_plc_connect_quick, disconnect_cb=self._on_plc_disconnect, ) grid = QGridLayout() grid.setSpacing(10) grid.setContentsMargins(0, 0, 0, 0) grid.addWidget(self._cognex_card["frame"], 0, 0) grid.addWidget(self._basler_card["frame"], 0, 1) grid.addWidget(self._db_card["frame"], 1, 0) grid.addWidget(self._ai_card["frame"], 1, 1) grid.addWidget(self._plc_card["frame"], 2, 0) # 그리드를 왼쪽 정렬로 감싸는 컨테이너 grid_container = QWidget() grid_container.setLayout(grid) outer = QHBoxLayout() outer.setContentsMargins(0, 0, 0, 0) outer.addWidget(grid_container) outer.addStretch() root.addLayout(outer) root.addStretch() def _build_card(self, title: str, icon_color: str = "#333333", icon_text: str = "", connect_label: str = "연결", disconnect_label: str = "연결 해제", connect_cb=None, disconnect_cb=None) -> dict: frame = QFrame() frame.setStyleSheet(_CARD) frame.setFixedSize(500, 96) row = QHBoxLayout(frame) row.setContentsMargins(16, 0, 16, 0) row.setSpacing(14) # 아이콘 박스 icon = QLabel(icon_text) icon.setFixedSize(36, 36) icon.setAlignment(Qt.AlignCenter) icon.setStyleSheet( f"background:{icon_color}; color:#ffffff;" "border-radius:6px; font-size:13px; font-weight:500;" ) # 제목 + 상태 (세로 스택) info_w = QWidget() info_w.setStyleSheet("background:transparent;") info_v = QVBoxLayout(info_w) info_v.setContentsMargins(0, 0, 0, 0) info_v.setSpacing(2) title_lbl = QLabel(title) title_lbl.setStyleSheet( "color:#cccccc; font-size:14px; background:transparent; border:none;" ) status_lbl = QLabel("● 연결 안됨") status_lbl.setStyleSheet(_STATUS_FAIL) info_v.addWidget(title_lbl) info_v.addWidget(status_lbl) # 버튼 쌍 btn_connect = QPushButton(connect_label) btn_connect.setFixedHeight(43) btn_connect.setStyleSheet(_BTN_CARD_CONNECT) if connect_cb: btn_connect.clicked.connect(connect_cb) btn_disconnect = QPushButton(disconnect_label) btn_disconnect.setFixedHeight(43) btn_disconnect.setStyleSheet(_BTN_CARD_DISCONNECT) btn_disconnect.setEnabled(False) if disconnect_cb: btn_disconnect.clicked.connect(disconnect_cb) row.addWidget(icon) row.addWidget(info_w, stretch=1) row.addWidget(btn_connect) row.addWidget(btn_disconnect) return { "frame": frame, "status_lbl": status_lbl, "btn_connect": btn_connect, "btn_disconnect": btn_disconnect, } # ── 관리자 설정 ──────────────────────────────────────────────────── # def _on_admin_settings(self): dlg = PasswordDialog(self) if dlg.exec_() == QDialog.Accepted: AdminSettingsDialog(self, self).exec_() # ── 연결 상태 동기화 (외부에서도 호출 가능) ──────────────────────── # def _sync_connection_status(self): self._set_cognex_connected(bool(self._insight and self._insight.is_connected())) self._set_basler_connected(bool(self._basler and self._basler.is_connected())) self._set_db_connected(bool(self._db_client and self._db_client.is_connected())) if self._detector: self._set_ai_loaded(self._detector.is_loaded()) self._set_plc_connected(bool(self._plc_client and self._plc_client.is_connected())) def _set_card_state(self, card: dict, connected: bool, ok_text: str, fail_text: str): if connected: card["status_lbl"].setText(f"● {ok_text}") card["status_lbl"].setStyleSheet(_STATUS_OK) else: card["status_lbl"].setText(f"● {fail_text}") card["status_lbl"].setStyleSheet(_STATUS_FAIL) card["btn_connect"].setEnabled(not connected) card["btn_disconnect"].setEnabled(connected) def _set_cognex_connected(self, connected: bool): self._set_card_state(self._cognex_card, connected, "연결됨", "연결 안됨") def _set_basler_connected(self, connected: bool): self._set_card_state(self._basler_card, connected, "연결됨", "연결 안됨") def _set_db_connected(self, connected: bool): self._set_card_state(self._db_card, connected, "연결됨", "연결 안됨") def _set_ai_loaded(self, loaded: bool): self._set_card_state(self._ai_card, loaded, "로드됨", "로드 안됨") def _set_plc_connected(self, connected: bool): self._set_card_state(self._plc_card, connected, "연결됨", "연결 안됨") # ── 빠른 연결 (카드 버튼 — config.json 값 그대로 사용) ──────────── # def _on_cognex_connect_quick(self): cfg = self._config.get("cognex", {}) ip = cfg.get("ip", "").strip() port = cfg.get("port", 23) log_action(f"[설정] Cognex 연결: {ip}:{port}") if not ip: QMessageBox.warning( self, "설정 필요", "관리자 설정에서 코그넥스 IP를 먼저 설정해주세요.", ) return if self._insight and self._insight._sock is not None: self._insight.disconnect() new_cam = InSightCamera() new_cam.connect(ip, port) if new_cam._sock is None: QMessageBox.critical( self, "연결 실패", f"In-Sight 카메라에 연결할 수 없습니다.\n{ip}:{port}", ) return self._insight = new_cam if self._update_insight_cb: self._update_insight_cb(new_cam) self._set_cognex_connected(True) self.cognex_status_changed.emit(True) def _on_cognex_disconnect(self): log_action("[설정] Cognex 연결 해제") if self._insight: self._insight.disconnect() self._set_cognex_connected(False) self.cognex_status_changed.emit(False) def _on_basler_connect(self): log_action("[설정] Basler 연결") if self._basler and self._basler.camera is not None: self._basler.disconnect() new_cam = BaslerCamera() new_cam.connect() if new_cam.camera is None: QMessageBox.critical( self, "연결 실패", "Basler 카메라에 연결할 수 없습니다.\nUSB 연결 및 전원을 확인하세요.", ) return self._basler = new_cam if self._update_basler_cb: self._update_basler_cb(new_cam) self._set_basler_connected(True) self.basler_status_changed.emit(True) def _on_basler_disconnect(self): log_action("[설정] Basler 연결 해제") if self._basler: self._basler.disconnect() self._set_basler_connected(False) self.basler_status_changed.emit(False) def _on_db_connect_quick(self): cfg = self._config.get("db", {}) server = cfg.get("server", "").strip() log_action(f"[설정] DB 연결: {server}") database = cfg.get("database", "").strip() username = cfg.get("username", "").strip() password = cfg.get("password", "") if not server or not database: QMessageBox.warning( self, "설정 필요", "관리자 설정에서 DB 서버 정보를 먼저 설정해주세요.", ) return client = SQLClient() ok = client.connect(server, database, username, password) if ok: self._db_client = client self._set_db_connected(True) if self._update_db_cb: self._update_db_cb(client) else: QMessageBox.critical( self, "연결 실패", "DB 연결에 실패했습니다.\n관리자 설정에서 서버 정보를 확인하세요.", ) def _on_db_disconnect(self): log_action("[설정] DB 연결 해제") if self._db_client: self._db_client.disconnect() self._db_client = None if self._update_db_cb: self._update_db_cb(None) self._set_db_connected(False) def _on_ai_load_quick(self): path = self._config.get("ai", {}).get("model_path", "").strip() log_action(f"[설정] AI 모델 로드: {path}") if not path: QMessageBox.warning( self, "설정 필요", "관리자 설정에서 AI 모델 경로를 먼저 설정해주세요.", ) return ok = self._do_load_model(path) if ok: self._set_ai_loaded(True) else: QMessageBox.critical(self, "로드 실패", f"모델을 로드할 수 없습니다.\n{path}") def _on_ai_unload(self): log_action("[설정] AI 모델 해제") if self._detector: self._detector._model = None self._detector.model_path = None self._set_ai_loaded(False) def _on_plc_connect_quick(self): cfg = self._config.get("plc", {}) ip = cfg.get("ip", "").strip() port = cfg.get("port", 5010) log_action(f"[설정] PLC 연결: {ip}:{port}") if not ip: QMessageBox.warning( self, "설정 필요", "관리자 설정에서 PLC IP를 먼저 설정해주세요.", ) return client = PLCClient() ok = client.connect(ip, port) if ok: self._plc_client = client self._set_plc_connected(True) if self._update_plc_cb: self._update_plc_cb(client) self.plc_status_changed.emit(True) else: QMessageBox.critical( self, "연결 실패", f"PLC에 연결할 수 없습니다.\n{ip}:{port}", ) def _on_plc_disconnect(self): log_action("[설정] PLC 연결 해제") if self._plc_client: self._plc_client.disconnect() self._plc_client = None if self._update_plc_cb: self._update_plc_cb(None) self._set_plc_connected(False) self.plc_status_changed.emit(False) # ── AI 모델 로드 공통 ────────────────────────────────────────────── # def _do_load_model(self, path: str) -> bool: if self._detector is None: self._detector = Detector() abs_path = resolve_path(path) ok = self._detector.load_model(abs_path) if ok: saved_path = to_project_relative(abs_path) self._save_config({"ai": {"model_path": saved_path}}) self._config.setdefault("ai", {})["model_path"] = saved_path if self._update_detector_cb: self._update_detector_cb(self._detector) return ok def _auto_load_model(self): path = self._config.get("ai", {}).get("model_path", "") abs_path = resolve_path(path) if abs_path and os.path.exists(abs_path): ok = self._do_load_model(abs_path) if ok: self._set_ai_loaded(True) # ── config 읽기 / 쓰기 ───────────────────────────────────────────── # def _load_config(self) -> dict: try: with open(get_path("config.json"), "r", encoding="utf-8") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {} def _save_config(self, data: dict): cfg_path = get_path("config.json") try: with open(cfg_path, "r", encoding="utf-8") as f: existing = json.load(f) except (FileNotFoundError, json.JSONDecodeError): existing = {} for key, val in data.items(): if isinstance(val, dict) and isinstance(existing.get(key), dict): existing[key].update(val) else: existing[key] = val with open(cfg_path, "w", encoding="utf-8") as f: json.dump(existing, f, ensure_ascii=False, indent=2) def _save_cognex_config(self, ip: str, port: int): try: self._save_config({"cognex": {"ip": ip, "port": port}}) self._config.setdefault("cognex", {}).update({"ip": ip, "port": port}) except Exception as e: print(f"[설정] config.json 저장 실패: {e}")