Files
ant-vision-inspector/gui/pages/settings_page.py
2026-06-18 13:38:27 +09:00

1451 lines
58 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}")