commit 9fc060c14b88c681e680a45bfb73a3c64a7ad717 Author: seuuung0 Date: Thu Mar 26 16:39:09 2026 +0900 지그 체결 테스트 gui diff --git a/.vs/ProjectEvaluation/jig_test.metadata.v10.bin b/.vs/ProjectEvaluation/jig_test.metadata.v10.bin new file mode 100644 index 0000000..2beb8e3 Binary files /dev/null and b/.vs/ProjectEvaluation/jig_test.metadata.v10.bin differ diff --git a/.vs/ProjectEvaluation/jig_test.projects.v10.bin b/.vs/ProjectEvaluation/jig_test.projects.v10.bin new file mode 100644 index 0000000..84a43df Binary files /dev/null and b/.vs/ProjectEvaluation/jig_test.projects.v10.bin differ diff --git a/.vs/ProjectEvaluation/jig_test.strings.v10.bin b/.vs/ProjectEvaluation/jig_test.strings.v10.bin new file mode 100644 index 0000000..541d989 Binary files /dev/null and b/.vs/ProjectEvaluation/jig_test.strings.v10.bin differ diff --git a/.vs/jig_test.slnx/DesignTimeBuild/.dtbcache.v2 b/.vs/jig_test.slnx/DesignTimeBuild/.dtbcache.v2 new file mode 100644 index 0000000..2e9bdf6 Binary files /dev/null and b/.vs/jig_test.slnx/DesignTimeBuild/.dtbcache.v2 differ diff --git a/.vs/jig_test.slnx/FileContentIndex/1778f25e-c2e8-43d7-ac46-f653d50679a5.vsidx b/.vs/jig_test.slnx/FileContentIndex/1778f25e-c2e8-43d7-ac46-f653d50679a5.vsidx new file mode 100644 index 0000000..cf50d41 Binary files /dev/null and b/.vs/jig_test.slnx/FileContentIndex/1778f25e-c2e8-43d7-ac46-f653d50679a5.vsidx differ diff --git a/.vs/jig_test.slnx/FileContentIndex/5fabddf0-19aa-484d-a15c-462efdd4df5f.vsidx b/.vs/jig_test.slnx/FileContentIndex/5fabddf0-19aa-484d-a15c-462efdd4df5f.vsidx new file mode 100644 index 0000000..bc35e4f Binary files /dev/null and b/.vs/jig_test.slnx/FileContentIndex/5fabddf0-19aa-484d-a15c-462efdd4df5f.vsidx differ diff --git a/.vs/jig_test.slnx/FileContentIndex/986118bf-a4fb-48d1-a5e9-3bb44449a246.vsidx b/.vs/jig_test.slnx/FileContentIndex/986118bf-a4fb-48d1-a5e9-3bb44449a246.vsidx new file mode 100644 index 0000000..eb35ccf Binary files /dev/null and b/.vs/jig_test.slnx/FileContentIndex/986118bf-a4fb-48d1-a5e9-3bb44449a246.vsidx differ diff --git a/.vs/jig_test.slnx/FileContentIndex/bb3355a2-2a23-4eee-81ec-4048c24d55cd.vsidx b/.vs/jig_test.slnx/FileContentIndex/bb3355a2-2a23-4eee-81ec-4048c24d55cd.vsidx new file mode 100644 index 0000000..4fcd619 Binary files /dev/null and b/.vs/jig_test.slnx/FileContentIndex/bb3355a2-2a23-4eee-81ec-4048c24d55cd.vsidx differ diff --git a/.vs/jig_test.slnx/FileContentIndex/ea7fd6cd-e6e1-4a39-b539-3266a443f665.vsidx b/.vs/jig_test.slnx/FileContentIndex/ea7fd6cd-e6e1-4a39-b539-3266a443f665.vsidx new file mode 100644 index 0000000..1b49d8e Binary files /dev/null and b/.vs/jig_test.slnx/FileContentIndex/ea7fd6cd-e6e1-4a39-b539-3266a443f665.vsidx differ diff --git a/.vs/jig_test.slnx/v18/.futdcache.v2 b/.vs/jig_test.slnx/v18/.futdcache.v2 new file mode 100644 index 0000000..13f1450 Binary files /dev/null and b/.vs/jig_test.slnx/v18/.futdcache.v2 differ diff --git a/.vs/jig_test.slnx/v18/.suo b/.vs/jig_test.slnx/v18/.suo new file mode 100644 index 0000000..8ec5695 Binary files /dev/null and b/.vs/jig_test.slnx/v18/.suo differ diff --git a/.vs/jig_test.slnx/v18/DocumentLayout.backup.json b/.vs/jig_test.slnx/v18/DocumentLayout.backup.json new file mode 100644 index 0000000..c7ebed9 --- /dev/null +++ b/.vs/jig_test.slnx/v18/DocumentLayout.backup.json @@ -0,0 +1,83 @@ +{ + "Version": 1, + "WorkspaceRootPath": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\", + "Documents": [ + { + "AbsoluteMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|c:\\users\\computer1\\desktop\\mobi\\jig_test\\jig_test\\views\\mainwindow.xaml||{F11ACC28-31D1-4C80-A34B-F4E09D3D753C}", + "RelativeMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|solutionrelative:jig_test\\views\\mainwindow.xaml||{F11ACC28-31D1-4C80-A34B-F4E09D3D753C}" + }, + { + "AbsoluteMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|c:\\users\\computer1\\desktop\\mobi\\jig_test\\jig_test\\viewmodels\\mainviewmodel.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}", + "RelativeMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|solutionrelative:jig_test\\viewmodels\\mainviewmodel.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}" + }, + { + "AbsoluteMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|c:\\users\\computer1\\desktop\\mobi\\jig_test\\jig_test\\viewmodels\\parameterviewmodel.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}", + "RelativeMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|solutionrelative:jig_test\\viewmodels\\parameterviewmodel.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}" + }, + { + "AbsoluteMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|c:\\users\\computer1\\desktop\\mobi\\jig_test\\jig_test\\bin\\release\\net472\\jig_test.exe||{177559E0-D141-11D0-92DF-00A0C9138C45}", + "RelativeMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|solutionrelative:jig_test\\bin\\release\\net472\\jig_test.exe||{177559E0-D141-11D0-92DF-00A0C9138C45}" + } + ], + "DocumentGroupContainers": [ + { + "Orientation": 0, + "VerticalTabListWidth": 256, + "DocumentGroups": [ + { + "DockedWidth": 200, + "SelectedChildIndex": 3, + "Children": [ + { + "$type": "Document", + "DocumentIndex": 3, + "Title": "jig_test.exe", + "DocumentMoniker": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\bin\\Release\\net472\\jig_test.exe", + "RelativeDocumentMoniker": "jig_test\\bin\\Release\\net472\\jig_test.exe", + "ToolTip": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\bin\\Release\\net472\\jig_test.exe", + "RelativeToolTip": "jig_test\\bin\\Release\\net472\\jig_test.exe", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000165|", + "WhenOpened": "2026-03-19T02:31:29.291Z" + }, + { + "$type": "Document", + "DocumentIndex": 2, + "Title": "ParameterViewModel.cs", + "DocumentMoniker": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\ViewModels\\ParameterViewModel.cs", + "RelativeDocumentMoniker": "jig_test\\ViewModels\\ParameterViewModel.cs", + "ToolTip": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\ViewModels\\ParameterViewModel.cs", + "RelativeToolTip": "jig_test\\ViewModels\\ParameterViewModel.cs", + "ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|", + "WhenOpened": "2026-03-18T01:19:24.151Z" + }, + { + "$type": "Document", + "DocumentIndex": 1, + "Title": "MainViewModel.cs", + "DocumentMoniker": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\ViewModels\\MainViewModel.cs", + "RelativeDocumentMoniker": "jig_test\\ViewModels\\MainViewModel.cs", + "ToolTip": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\ViewModels\\MainViewModel.cs", + "RelativeToolTip": "jig_test\\ViewModels\\MainViewModel.cs", + "ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|", + "WhenOpened": "2026-03-18T01:19:22.881Z" + }, + { + "$type": "Document", + "DocumentIndex": 0, + "Title": "MainWindow.xaml", + "DocumentMoniker": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\Views\\MainWindow.xaml", + "RelativeDocumentMoniker": "jig_test\\Views\\MainWindow.xaml", + "ToolTip": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\Views\\MainWindow.xaml", + "RelativeToolTip": "jig_test\\Views\\MainWindow.xaml", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003549|", + "WhenOpened": "2026-03-18T01:19:19.351Z", + "EditorCaption": "" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.vs/jig_test.slnx/v18/DocumentLayout.json b/.vs/jig_test.slnx/v18/DocumentLayout.json new file mode 100644 index 0000000..c7ebed9 --- /dev/null +++ b/.vs/jig_test.slnx/v18/DocumentLayout.json @@ -0,0 +1,83 @@ +{ + "Version": 1, + "WorkspaceRootPath": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\", + "Documents": [ + { + "AbsoluteMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|c:\\users\\computer1\\desktop\\mobi\\jig_test\\jig_test\\views\\mainwindow.xaml||{F11ACC28-31D1-4C80-A34B-F4E09D3D753C}", + "RelativeMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|solutionrelative:jig_test\\views\\mainwindow.xaml||{F11ACC28-31D1-4C80-A34B-F4E09D3D753C}" + }, + { + "AbsoluteMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|c:\\users\\computer1\\desktop\\mobi\\jig_test\\jig_test\\viewmodels\\mainviewmodel.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}", + "RelativeMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|solutionrelative:jig_test\\viewmodels\\mainviewmodel.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}" + }, + { + "AbsoluteMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|c:\\users\\computer1\\desktop\\mobi\\jig_test\\jig_test\\viewmodels\\parameterviewmodel.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}", + "RelativeMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|solutionrelative:jig_test\\viewmodels\\parameterviewmodel.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}" + }, + { + "AbsoluteMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|c:\\users\\computer1\\desktop\\mobi\\jig_test\\jig_test\\bin\\release\\net472\\jig_test.exe||{177559E0-D141-11D0-92DF-00A0C9138C45}", + "RelativeMoniker": "D:0:0:{B6E92BCB-5515-4599-A710-A56894B63761}|jig_test\\jig_test.csproj|solutionrelative:jig_test\\bin\\release\\net472\\jig_test.exe||{177559E0-D141-11D0-92DF-00A0C9138C45}" + } + ], + "DocumentGroupContainers": [ + { + "Orientation": 0, + "VerticalTabListWidth": 256, + "DocumentGroups": [ + { + "DockedWidth": 200, + "SelectedChildIndex": 3, + "Children": [ + { + "$type": "Document", + "DocumentIndex": 3, + "Title": "jig_test.exe", + "DocumentMoniker": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\bin\\Release\\net472\\jig_test.exe", + "RelativeDocumentMoniker": "jig_test\\bin\\Release\\net472\\jig_test.exe", + "ToolTip": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\bin\\Release\\net472\\jig_test.exe", + "RelativeToolTip": "jig_test\\bin\\Release\\net472\\jig_test.exe", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000165|", + "WhenOpened": "2026-03-19T02:31:29.291Z" + }, + { + "$type": "Document", + "DocumentIndex": 2, + "Title": "ParameterViewModel.cs", + "DocumentMoniker": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\ViewModels\\ParameterViewModel.cs", + "RelativeDocumentMoniker": "jig_test\\ViewModels\\ParameterViewModel.cs", + "ToolTip": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\ViewModels\\ParameterViewModel.cs", + "RelativeToolTip": "jig_test\\ViewModels\\ParameterViewModel.cs", + "ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|", + "WhenOpened": "2026-03-18T01:19:24.151Z" + }, + { + "$type": "Document", + "DocumentIndex": 1, + "Title": "MainViewModel.cs", + "DocumentMoniker": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\ViewModels\\MainViewModel.cs", + "RelativeDocumentMoniker": "jig_test\\ViewModels\\MainViewModel.cs", + "ToolTip": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\ViewModels\\MainViewModel.cs", + "RelativeToolTip": "jig_test\\ViewModels\\MainViewModel.cs", + "ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|", + "WhenOpened": "2026-03-18T01:19:22.881Z" + }, + { + "$type": "Document", + "DocumentIndex": 0, + "Title": "MainWindow.xaml", + "DocumentMoniker": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\Views\\MainWindow.xaml", + "RelativeDocumentMoniker": "jig_test\\Views\\MainWindow.xaml", + "ToolTip": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\jig_test\\jig_test\\Views\\MainWindow.xaml", + "RelativeToolTip": "jig_test\\Views\\MainWindow.xaml", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003549|", + "WhenOpened": "2026-03-18T01:19:19.351Z", + "EditorCaption": "" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/KN-2000W_KO_TCD210154AG_20251217_MANUAL_W.pdf b/KN-2000W_KO_TCD210154AG_20251217_MANUAL_W.pdf new file mode 100644 index 0000000..3151e9d Binary files /dev/null and b/KN-2000W_KO_TCD210154AG_20251217_MANUAL_W.pdf differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..40e382c --- /dev/null +++ b/README.md @@ -0,0 +1,320 @@ +# 지그 압력 및 누출 검사 애플리케이션 (Jig Pressure & Leak Test App) + +## 1. 프로젝트 개요 + +본 프로젝트는 특정 지그(Jig)에 제품을 고정하고 압력을 인가한 뒤, 유지 시간 동안 내부 압력의 변화를 측정하여 누출(Leak) 여부를 **자동으로 검사**하는 데스크톱 전용 모니터링 애플리케이션입니다. + +| 항목 | 내용 | +|------|------| +| **프레임워크** | C# WPF (.NET Framework 4.7.2) | +| **아키텍처** | MVVM (Model-View-ViewModel) + Service Layer | +| **솔루션 파일** | `jig_test.slnx` | +| **NuGet 패키지** | `System.IO.Ports` (10.0.4), `NModbus` / `NModbus.Serial` (3.0.81) | + +### 주요 기능 + +* **수동 검사**: 물리 스위치(i1, i2) 조작에 의해 자동으로 누출 검사 시퀀스가 트리거됨 +* **자동 검사**: 버튼 한 번으로 인가 → 목표 압력 대기 → 고정 → 유지 → 배기까지 전 공정을 자동 수행 +* **실시간 모니터링**: 100ms 폴링으로 I/O 상태 및 압력값을 실시간 표시 +* **통신 분리 및 안정성**: `ConnectionManager`를 통한 통신 인프라 캡슐화 및 `bool` 기반 상태 관리로 안정성 확보 +* **자가 복구**: 통신 끊김 감지 시 독립적 자동 재연결 +* **설정 관리**: 통신 포트, 기기 ID, 검사 파라미터를 XML 파일(`config.xml`)로 저장/불러오기 + +--- + +## 2. 폴더 구조 + +``` +jig_test/ ← 솔루션 루트 +├── jig_test.slnx ← 솔루션 파일 +├── README.md ← 본 문서 +├── KN-2000W_..._MANUAL_W.pdf ← 압력 센서 매뉴얼 +├── SemiIOLite_Manual.pdf ← I/O 보드 매뉴얼 +│ +└── jig_test/ ← 프로젝트 폴더 + ├── jig_test.csproj ← 프로젝트 설정 (.NET 4.7.2, WPF) + ├── App.xaml / App.xaml.cs ← 앱 진입점, 글로벌 예외 처리 + │ + ├── Models/ ← 데이터 모델 + │ └── AppConfig.cs ← 모든 설정값 (포트, 속도, 검사파라미터) + │ + ├── Services/ ← 핵심 서비스 서비스 (Infrastructure 계층) + │ ├── ConfigService.cs ← XML 직렬화 기반 설정 저장/로드 + │ ├── ConnectionManager.cs ← ★ 통신 인프라 (포트 수명, 폴링, 재연결 관리) + │ ├── SemiIOLiteController.cs ← I/O 보드 제어 (LS산전 ASCII 프로토콜) + │ └── PressureSensorController.cs ← 압력 센서 통신 (Modbus RTU) + │ + ├── ViewModels/ ← MVVM ViewModel 계층 + │ ├── Base/ + │ │ ├── ObservableObject.cs ← INotifyPropertyChanged 구현 기반 클래스 + │ │ └── RelayCommand.cs ← ICommand 구현 (View 이벤트→ViewModel 바인딩) + │ ├── Converters/ + │ │ └── InverseBooleanConverter.cs ← bool 반전 변환기 + │ ├── MainViewModel.cs ← ★ 핵심 비즈니스 로직 (검사 시퀀스, UI 바인딩) + │ ├── SettingsViewModel.cs ← 통신 설정 창 로직 + │ └── ParameterViewModel.cs ← 검사 파라미터 창 로직 + │ + └── Views/ ← MVVM View 계층 (XAML UI) + ├── MainWindow.xaml / .cs ← 메인 대시보드 UI + ├── SettingsWindow.xaml / .cs ← 통신 설정 다이얼로그 + └── ParameterWindow.xaml / .cs ← 검사 파라미터 다이얼로그 +``` + +--- + +## 3. 아키텍처 (MVVM 패턴) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ App.xaml.cs │ +│ (글로벌 예외 처리: UI / AppDomain / Task) │ +└──────────────────────┬───────────────────────────────────────┘ + │ StartupUri +┌──────────────────────▼───────────────────────────────────────┐ +│ VIEW (XAML) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ MainWindow.xaml ← DataBinding → MainViewModel.cs │ │ +│ │ SettingsWindow ← DataBinding → SettingsViewModel │ │ +│ │ ParameterWindow ← DataBinding → ParameterViewModel │ │ +│ └───────────────────┬─────────────────────────────────────┘ │ +└──────────────────────┼───────────────────────────────────────┘ + │ Event / Method +┌──────────────────────▼───────────────────────────────────────┐ +│ INFRASTRUCTURE / SERVICES │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ConnectionManager │ │ +│ │ (Timer, SerialPort LifeCycle, Reconnect, Event Dispatch)│ │ +│ └───────┬────────────────────────────┬────────────────────┘ │ +│ │ Composition │ Composition │ +│ ┌───────▼──────────┐ ┌───────▼──────────┐ │ +│ │ SemiIOLite │ │ PressureSensor │ │ +│ │ Controller │ │ Controller │ │ +│ └───────┬──────────┘ └───────┬──────────┘ │ +│ │ COM Port │ COM Port │ +└──────────┼────────────────────────────┼──────────────────────┘ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ Semi IO Lite │ │ KN-2240W │ + │ I/O Board │ │ Pressure │ + │ (SIO-0201A) │ │ Sensor │ + └──────────────┘ └──────────────┘ +``` + +### 데이터 흐름 요약 + +1. **ConnectionManager → Service**: 100ms 타이머로 각 컨트롤러의 `RequestXxx` 메서드 호출 및 송신 HEX 로그 발행 +2. **Service → Hardware**: 시리얼 포트를 통해 실제 물리 레이어로 데이터 송신 +3. **Hardware → ConnectionManager**: `DataReceived` 이벤트 발생 시 바이트 버퍼링 및 패킷 완성 검사 +4. **ConnectionManager → ViewModel**: 패킷이 완성되면 이벤트를 통해 ViewModel에 전달 (`IOPacketReceived` 등) +5. **ViewModel → View**: 수신 패킷을 파싱하여 `lock` 기반으로 압력값 갱신 및 UI 상태 프로퍼티 변경 → 바인딩 자동 반영 + +--- + +## 4. 핵심 파일 상세 + +### 4.1. `Models/AppConfig.cs` + +모든 애플리케이션 설정을 담는 POCO 모델 클래스입니다. + +| 프로퍼티 | 타입 | 기본값 | 용도 | +|----------|------|--------|------| +| `PortName` | `string` | `"COM1"` | I/O 보드 COM 포트 | +| `BaudRate` | `int` | `9600` | I/O 보드 통신 속도 | +| `PressurePortName` | `string` | `"COM2"` | 압력 센서 COM 포트 | +| `PressureBaudRate` | `int` | `9600` | 압력 센서 통신 속도 | +| `IOEnabled` | `bool` | `true` | I/O 보드 사용 여부 | +| `IOStationId` | `int` | `2` | I/O 보드 국번 ID | +| `PressureEnabled` | `bool` | `true` | 압력 센서 사용 여부 | +| `PressureSlaveId` | `int` | `1` | Modbus 슬레이브 주소 | +| `HoldTime` | `int` | `30` | 누출 검사 유지 시간 (초) | +| `AllowedErrorRange` | `double` | `0.3` | 허용 압력 오차 (bar) | +| `AutoTestTargetPressure` | `double` | `4.0` | 자동 검사 목표 압력 (bar) | +| `IsLogVisible` | `bool` | `false` | 하단 로그 창 표시 여부 | + +### 4.2. `Services/ConfigService.cs` + +`AppConfig`를 XML 직렬화 방식으로 저장/로드합니다. + +* `Load()`: `config.xml` 파일이 없거나 손상 시 기본 `AppConfig` 반환 (안전한 폴백) +* `Save()`: 실행 파일 위치의 `config.xml`에 현재 설정을 즉시 저장 + +### 4.3. `Services/ConnectionManager.cs` — ★ 통신 인프라 관리자 + +`MainViewModel`에서 통신 관련 저수준 책임을 분리하여 캡슐화한 클래스입니다. + +| 기능 | 상세 내용 | +|------|-----------| +| **포트 관리** | IO 및 압력 포트의 생성, 오픈, 클로즈, Dispose 주기 관리 | +| **폴링 루프** | 100ms `DispatcherTimer`를 구동하여 장비 상태 체크 명령 주기적 송신 | +| **버퍼 관리** | `DataReceived` 이벤트로 들어오는 단편화된 바이트를 패킷 단위로 조립 | +| **연결 상태** | 응답 타임아웃(1.5초) 감지 시 `bool` 기반 상태 갱신 및 이벤트 발행 | +| **자가 복구** | 포트 닫힘이나 연속 실패(10회) 감지 시 3초 간격 자동 재연결 시도 | +| **이벤트 발행** | 패킷 수신, 로그 발생, 연결 상태 변경 등을 이벤트를 통해 외부에 알림 | + +### 4.4. `Services/SemiIOLiteController.cs` — I/O 보드 제어 + +LS산전 ASCII 프로토콜로 릴레이 출력을 제어하고 입력(i1, i2)을 읽습니다. + +#### 릴레이 상태 (`RelayState` enum) + +| 값 | 이름 | 동작 | +|----|------|------| +| `0` | `None` | 모든 릴레이 OFF (고정/밀폐 상태) | +| `1` | `Exhaust` | 릴레이1 ON — 배기 밸브 작동 | +| `2` | `Pressurize` | 릴레이2 ON — 인가 밸브 작동 | +| `3` | `Clamp` | 릴레이1+2 ON — 지그 물리 고정 | + +#### 주요 메서드 + +| 메서드 | 역할 | +|--------|------| +| `SetStateAsync(RelayState)` | 릴레이 상태 변경 쓰기 명령 전송 + ACK 응답 대기 (최대 500ms) | +| `RequestInputState(onTx)` | 주소 `0A07`의 입력 상태 읽기 요청 전송 (비동기) | +| `ParseInputBuffer(buffer, out i1, out i2)` | 수신 버퍼를 파싱하여 i1, i2 디지털 입력 감지 | +| `CheckConnectionAsync()` | 주소 `0001` 읽기로 통신 유효성 확인 (핸드셰이크) | + +### 4.5. `Services/PressureSensorController.cs` — 압력 센서 통신 + +KN-2240W 디지털 센서와 **Modbus RTU** 프로토콜로 통신합니다. + +#### Modbus RTU 프레임 구조 + +| 구분 | 바이트 구성 | +|------|-------------| +| **요청 (8바이트)** | `[Slave Addr]` `[Func 0x04]` `[Start Addr Hi]` `[Start Addr Lo]` `[Count Hi]` `[Count Lo]` `[CRC Lo]` `[CRC Hi]` | +| **응답 (7바이트)** | `[Slave Addr]` `[Func 0x04]` `[Byte Count]` `[Data Hi]` `[Data Lo]` `[CRC Lo]` `[CRC Hi]` | + +#### 주요 메서드 + +| 메서드 | 역할 | +|--------|------| +| `RequestCurrentPressure(onTx)` | 레지스터 `0x0000` 1개 읽기 요청 전송 | +| `ParsePressureResponse(buffer, out pressureValue)` | CRC 검증 + 데이터 파싱 후 원시 압력값 반환 | +| `BuildReadRequest(startAddress, count)` | Modbus RTU Read Input Registers 프레임 동적 생성 | + +### 4.6. `ViewModels/MainViewModel.cs` — ★ 비즈니스 로직 및 UI 바인딩 + +통신 인프라를 제외한 순수 검사 시퀀스와 사용자 인터페이스 로직을 담당합니다. (~800줄) + +#### 4.6.1. 이벤트 기반 데이터 처리 + +* `_connManager.IOPacketReceived` 구독: IO 보드 응답 시 `UpdateJigLamps` 호출 +* `_connManager.PressurePacketReceived` 구독: 압력 센서 응답 시 압력값 갱신 및 UI 반영 + +#### 4.6.2. 자가 복구 (Self-Healing) 알고리즘 (ConnectionManager 내 구현) + +``` +[정상 동작] → 100ms 폴링 중 응답 수신 + ↓ 1.5초 무응답 +[경고] → 램프 Red + 실패 카운터 증가 + ↓ 10회 연속 무응답 +[단절 선언] → 포트 닫기 및 리소스 정리 + ↓ 즉시 +[재연결 시도] → 3초 주기로 포트 재오픈 시도 +``` + +#### 4.6.3. 수동 검사 시퀀스 (StartAutoHoldSequence) + +``` +[스위치 → 인가 위치] _wasPressurized = true + ↓ +[스위치 → 고정 위치] && _wasPressurized + ↓ +StartAutoHoldSequence() 트리거 + ↓ +(1) 현재 압력을 "검사 시작 압력"으로 저장 +(2) HoldTime 초 동안 1초 간격 대기 +(3) |최종 압력 - 시작 압력| vs AllowedErrorRange 비교 + ├── 이내 → PASS (LimeGreen) + └── 초과 → FAIL (Red) +``` + +#### 4.6.4. 자동 검사 시퀀스 (StartAutoCycleSequence) + +``` +[시작 버튼 클릭] (고정 상태에서만 가능) + ↓ +단계 1: 인가(Pressurize) → 목표 압력 도달 대기 + ↓ +단계 2: 고정(None) → 밸브 폐쇄 + ↓ +단계 3: HoldTime 초 동안 압력 유지 검사 + ↓ +단계 4: 배기(Exhaust) → 0.0bar 도달 대기 (60초 타임아웃) + ↓ +단계 5: 다시 고정(None) → 종료 +``` + +#### 4.6.5. 주요 바인딩 프로퍼티 요약 + +| 카테고리 | 프로퍼티 | 용도 | +|----------|----------|------| +| **장비 램프** | `LampIOStatus`, `LampPressureStatus` | I/O / 압력 센서 연결 표시등 | +| **압력** | `CurrentPressureText`, `CurrentPressureColor` | 실시간 압력값 표시 | +| **수동 결과** | `ResultText`, `ResultColor` | PASS/FAIL 결과 표시 | +| **자동 결과** | `AutoResultText`, `AutoResultColor` | 자동 검사 PASS/FAIL | +| **로그** | `IOLogText`, `PrLogText` | I/O / 압력 센서 HEX 통신 로그 | + +### 4.7. `ViewModels/SettingsViewModel` / `ParameterViewModel` + +* 설정 복사본(`Clone`)을 사용한 원본 보호 및 취소 기능 +* 시스템의 COM 포트 자동 감지 및 폴백 처리 +* 입력 유효성 검증 (정규식 필터링 및 범위 체크) + +### 4.8. `Views/MainWindow.xaml` — 메인 UI 구조 + +* `Viewbox Stretch="Uniform"`: 창 크기 변화에도 UI 비율 유지 +* 커스텀 타이틀 바 + 탭 컨트롤 기반 화면 구성 +* 실시간 로그 창 (I/O 녹색, 압력 파란색) 접이식 구현 및 자동 스크롤 + +--- + +## 5. 안정성 메커니즘 + +### 5.1. `bool` 기반 상태 관리 (Refactoring #11) + +기존에는 UI 램프의 `Brush` 색상을 비교하여 통신 상태를 판단했으나, 리액터링을 통해 `_isIOConnected`, `_isPressureConnected` 명시적 필드를 도입하여 로직의 안정성과 가독성을 높였습니다. + +### 5.2. 책임 분리 (Refactoring #10) + +`ConnectionManager`를 통해 통신 인프라를 캡슐화했습니다. 메인 로직은 장치와의 직접적인 포트 관리에서 자유로워졌으며, 이벤트 기반으로 응답을 처리하여 UI 스레드 정지 현상을 방지합니다. + +### 5.3. 글로벌 예외 처리 (`App.xaml.cs`) + +`DispatcherUnhandledException`, `AppDomain.UnhandledException`, `TaskScheduler.UnobservedTaskException`을 모두 처리하여 예기치 못한 에러 시에도 프로그램이 강제 종료되지 않도록 보호합니다. + +--- + +## 6. 설정 파일 (`config.xml`) + +실행 파일 위치에 XML 형식으로 저장되며, 손상 시 자동으로 기본값으로 복원됩니다. + +```xml + + + COM3 + 9600 + COM4 + 9600 + true + 2 + true + 30 + 0.3 + 4.0 + false + +``` + +--- + +## 7. 유지보수 및 확장 가이드 + +### 새 검사 파라미터 추가 시 +1. `Models/AppConfig.cs` 데이터 추가 및 `Clone()` 메서드 업데이트 +2. `Views/ParameterWindow.xaml` 입력 UI 추가 +3. `ViewModels/MainViewModel.UpdateParamDisplay()`에 반영 + +### 새 기기 추가 시 +1. `Services/` 컨트롤러 구현 → `ConnectionManager.cs`에 인스턴스/폴링 추가 +2. `MainViewModel.cs`에서 이벤트 구독 및 파싱 로직 연결 diff --git a/SemiIOLite_Manual.pdf b/SemiIOLite_Manual.pdf new file mode 100644 index 0000000..708c9ba Binary files /dev/null and b/SemiIOLite_Manual.pdf differ diff --git a/jig_test.slnx b/jig_test.slnx new file mode 100644 index 0000000..1470044 --- /dev/null +++ b/jig_test.slnx @@ -0,0 +1,3 @@ + + + diff --git a/jig_test/App.config b/jig_test/App.config new file mode 100644 index 0000000..56efbc7 --- /dev/null +++ b/jig_test/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/jig_test/App.xaml b/jig_test/App.xaml new file mode 100644 index 0000000..5fc22a3 --- /dev/null +++ b/jig_test/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/jig_test/App.xaml.cs b/jig_test/App.xaml.cs new file mode 100644 index 0000000..f522101 --- /dev/null +++ b/jig_test/App.xaml.cs @@ -0,0 +1,49 @@ +using System; +using System.Windows; + +namespace jig_test +{ + /// + /// App.xaml에 대한 상호 작용 논리 + /// + public partial class App : Application + { + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + // 1. UI 스레드 예외 처리 + this.DispatcherUnhandledException += (s, ex) => + { + HandleException(ex.Exception, "UI 스레드"); + ex.Handled = true; // 프로그램이 즉시 종료되지 않도록 함 + }; + + // 2. 백그라운드 스레드 예외 처리 + AppDomain.CurrentDomain.UnhandledException += (s, ex) => + { + HandleException(ex.ExceptionObject as Exception, "AppDomain"); + }; + + // 3. 비동기 Task 예외 처리 + System.Threading.Tasks.TaskScheduler.UnobservedTaskException += (s, ex) => + { + HandleException(ex.Exception, "TaskScheduler"); + ex.SetObserved(); + }; + } + + private void HandleException(Exception ex, string source) + { + if (ex == null) return; + + string errorMsg = $"[치명적 오류 - {source}] {ex.Message}\n\n{ex.StackTrace}"; + System.Diagnostics.Debug.WriteLine(errorMsg); + + // 중요: 사용자에게 알림을 표시하되 프로그램을 멈추지 않음 + MessageBox.Show("프로그램 실행 중 문제가 발생했습니다. 로그를 확인해 주세요.\n\n" + ex.Message, + "안정성 경고", MessageBoxButton.OK, MessageBoxImage.Error); + } + } +} + diff --git a/jig_test/Models/AppConfig.cs b/jig_test/Models/AppConfig.cs new file mode 100644 index 0000000..3a3289c --- /dev/null +++ b/jig_test/Models/AppConfig.cs @@ -0,0 +1,54 @@ +using System; + +namespace jig_test.Models +{ + /// + /// 애플리케이션의 모든 설정 정보를 담는 모델 클래스 + /// + public class AppConfig + { + // 통신 관련 + public string PortName { get; set; } = "COM1"; // IO 보드 전용 포트 + public int BaudRate { get; set; } = 9600; + + public string PressurePortName { get; set; } = "COM2"; // 압력 센서 전용 포트 + public int PressureBaudRate { get; set; } = 9600; + + // 기기 활성화 및 ID (정석적인 int 방식) + public bool IOEnabled { get; set; } = true; + public int IOStationId { get; set; } = 2; + public bool PressureEnabled { get; set; } = true; + public int PressureSlaveId { get; set; } = 1; + + // 테스트 파라미터 + public int HoldTime { get; set; } = 30; // 유지 시간 (초) + public double AllowedErrorRange { get; set; } = 0.3; // 허용 압력 오차 (bar) + public double AutoTestTargetPressure { get; set; } = 4.0; // 자동 검사 목표 압력 (bar) + + // UI 상태 + public bool IsLogVisible { get; set; } = false; // 로그 창 표시 여부 + + /// + /// 현재 설정의 깊은 복사본을 생성합니다. + /// 설정 창에 전달하여 '취소' 시 원본 보호에 사용합니다. + /// + public AppConfig Clone() + { + return new AppConfig + { + PortName = this.PortName, + BaudRate = this.BaudRate, + PressurePortName = this.PressurePortName, + PressureBaudRate = this.PressureBaudRate, + IOEnabled = this.IOEnabled, + IOStationId = this.IOStationId, + PressureEnabled = this.PressureEnabled, + PressureSlaveId = this.PressureSlaveId, + HoldTime = this.HoldTime, + AllowedErrorRange = this.AllowedErrorRange, + AutoTestTargetPressure = this.AutoTestTargetPressure, + IsLogVisible = this.IsLogVisible, + }; + } + } +} diff --git a/jig_test/Models/LogData.cs b/jig_test/Models/LogData.cs new file mode 100644 index 0000000..0565547 --- /dev/null +++ b/jig_test/Models/LogData.cs @@ -0,0 +1,17 @@ +using System; + +namespace jig_test.Models +{ + /// + /// 검사 결과 로그 데이터를 담는 클래스 + /// + public class LogData + { + public int Index { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public double TargetPressure { get; set; } + public double ErrorValue { get; set; } + public string Result { get; set; } // "PASS" or "FAIL" + } +} diff --git a/jig_test/Properties/PublishProfiles/FolderProfile.pubxml b/jig_test/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..69f9d2f --- /dev/null +++ b/jig_test/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,13 @@ + + + + + Release + Any CPU + C:\Users\COMPUTER1\Desktop\mobi\jig_exe + FileSystem + <_TargetId>Folder + net472 + win-x86 + + \ No newline at end of file diff --git a/jig_test/Properties/PublishProfiles/FolderProfile.pubxml.user b/jig_test/Properties/PublishProfiles/FolderProfile.pubxml.user new file mode 100644 index 0000000..2b677bb --- /dev/null +++ b/jig_test/Properties/PublishProfiles/FolderProfile.pubxml.user @@ -0,0 +1,8 @@ + + + + + True|2026-03-19T08:41:16.2563200Z||;True|2026-03-19T17:27:36.4622370+09:00||;True|2026-03-19T13:43:30.1971093+09:00||;True|2026-03-19T12:19:34.5998380+09:00||;True|2026-03-19T12:13:28.5236966+09:00||; + + + \ No newline at end of file diff --git a/jig_test/Properties/Resources.Designer.cs b/jig_test/Properties/Resources.Designer.cs new file mode 100644 index 0000000..14bec95 --- /dev/null +++ b/jig_test/Properties/Resources.Designer.cs @@ -0,0 +1,71 @@ +//------------------------------------------------------------------------------ +// +// 이 코드는 도구를 사용하여 생성되었습니다. +// 런타임 버전:4.0.30319.42000 +// +// 파일 내용을 변경하면 잘못된 동작이 발생할 수 있으며, 코드를 다시 생성하면 +// 이러한 변경 내용이 손실됩니다. +// +//------------------------------------------------------------------------------ + +namespace jig_test.Properties +{ + + + /// + /// 지역화된 문자열 등을 찾기 위한 강력한 형식의 리소스 클래스입니다. + /// + // 이 클래스는 ResGen 또는 Visual Studio와 같은 도구를 통해 StronglyTypedResourceBuilder + // 클래스에서 자동으로 생성되었습니다. + // 멤버를 추가하거나 제거하려면 .ResX 파일을 편집한 다음 /str 옵션을 사용하여 + // ResGen을 다시 실행하거나 VS 프로젝트를 다시 빌드하십시오. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources + { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() + { + } + + /// + /// 이 클래스에서 사용하는 캐시된 ResourceManager 인스턴스를 반환합니다. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if ((resourceMan == null)) + { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("jig_test.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// 이 강력한 형식의 리소스 클래스를 사용하여 모든 리소스 조회에 대해 현재 스레드의 CurrentUICulture 속성을 + /// 재정의합니다. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture + { + get + { + return resourceCulture; + } + set + { + resourceCulture = value; + } + } + } +} diff --git a/jig_test/Properties/Resources.resx b/jig_test/Properties/Resources.resx new file mode 100644 index 0000000..af7dbeb --- /dev/null +++ b/jig_test/Properties/Resources.resx @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/jig_test/Properties/Settings.Designer.cs b/jig_test/Properties/Settings.Designer.cs new file mode 100644 index 0000000..d668b88 --- /dev/null +++ b/jig_test/Properties/Settings.Designer.cs @@ -0,0 +1,30 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace jig_test.Properties +{ + + + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")] + internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase + { + + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); + + public static Settings Default + { + get + { + return defaultInstance; + } + } + } +} diff --git a/jig_test/Properties/Settings.settings b/jig_test/Properties/Settings.settings new file mode 100644 index 0000000..033d7a5 --- /dev/null +++ b/jig_test/Properties/Settings.settings @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/jig_test/Services/ConfigService.cs b/jig_test/Services/ConfigService.cs new file mode 100644 index 0000000..9052d14 --- /dev/null +++ b/jig_test/Services/ConfigService.cs @@ -0,0 +1,66 @@ +using System; +using System.IO; +using System.Xml.Serialization; +using jig_test.Models; + +namespace jig_test.Services +{ + /// + /// 프로그램 설정(AppConfig)을 파일로 저장하고 불러오는 기능을 담당하는 서비스 클래스 + /// XML 직렬화(Serialization) 방식을 사용하여 실행 파일 폴더의 config.xml 파일에 저장합니다. + /// + public class ConfigService + { + // 설정 파일이 저장될 경로 (실행 파일과 동일한 위치의 config.xml) + private static readonly string ConfigPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.xml"); + + /// + /// 설정 파일을 읽어와 AppConfig 객체로 변환합니다. + /// 파일이 없거나 읽기 오류가 발생하면 기본값을 가진 새 객체를 반환합니다. + /// + /// 불러온 설정 객체 + public static AppConfig Load() + { + // 파일이 존재하지 않으면 기본 설정으로 시작 + if (!File.Exists(ConfigPath)) return new AppConfig(); + + try + { + var serializer = new XmlSerializer(typeof(AppConfig)); + using (var reader = new StreamReader(ConfigPath)) + { + // XML 파일을 읽어 C# 객체(AppConfig)로 변환(역직렬화) + return (AppConfig)serializer.Deserialize(reader); + } + } + catch + { + // 파일 손상 등 예외 발생 시 기본값 반환 + return new AppConfig(); + } + } + + /// + /// 현재 설정(AppConfig) 객체를 XML 파일로 저장합니다. + /// 프로그램 종료 시나 설정 변경 시 호출됩니다. + /// + /// 저장할 설정 객체 + public static void Save(AppConfig config) + { + try + { + var serializer = new XmlSerializer(typeof(AppConfig)); + using (var writer = new StreamWriter(ConfigPath)) + { + // C# 객체를 XML 형식의 텍스트 파일로 저장(직렬화) + serializer.Serialize(writer, config); + } + } + catch (Exception ex) + { + // 저장 실패 시 디버그 출력 + System.Diagnostics.Debug.WriteLine($"[Config] 설정 저장 실패: {ex.Message}"); + } + } + } +} diff --git a/jig_test/Services/ConnectionManager.cs b/jig_test/Services/ConnectionManager.cs new file mode 100644 index 0000000..a1faf5c --- /dev/null +++ b/jig_test/Services/ConnectionManager.cs @@ -0,0 +1,500 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO.Ports; +using System.Threading.Tasks; +using System.Windows.Threading; +using jig_test.Models; + +namespace jig_test.Services +{ + /// + /// 시리얼 포트 수명 관리, 폴링, 재연결, DataReceived 이벤트 처리를 담당하는 서비스. + /// MainViewModel에서 통신 인프라 책임을 분리하여 유지보수성을 향상시킵니다. + /// + public class ConnectionManager : IDisposable + { + // ========================================== + // 외부 의존성 + // ========================================== + private AppConfig _config; + private readonly Dispatcher _uiDispatcher; + + // ========================================== + // 시리얼 포트 및 컨트롤러 + // ========================================== + private SerialPort _ioPort; + private SerialPort _pressurePort; + private SemiIOLiteController _ioController; + private PressureSensorController _pressureController; + + // ========================================== + // 폴링 타이머 및 상태 추적 + // ========================================== + private DispatcherTimer _pollingTimer; + private bool _isPolling = false; + + private List _serialBuffer = new List(); + private List _prSerialBuffer = new List(); + + private DateTime _lastIOResponseTime = DateTime.MinValue; + private DateTime _lastPrResponseTime = DateTime.MinValue; + private DateTime _lastIOReconnectAttempt = DateTime.MinValue; + private DateTime _lastPrReconnectAttempt = DateTime.MinValue; + + private int _ioFailCount = 0; + private int _prFailCount = 0; + private bool _prErrorLogged = false; + + // ========================================== + // 공개 상태 프로퍼티 + // ========================================== + public bool IsIOConnected { get; private set; } = false; + public bool IsPressureConnected { get; private set; } = false; + public SemiIOLiteController IOController => _ioController; + public PressureSensorController PressureController => _pressureController; + + // ========================================== + // ViewModel에 알리는 이벤트/콜백 + // ========================================== + + /// I/O 패킷 완성 시 (buffer). UI 스레드에서 호출됨. + public event Action IOPacketReceived; + /// 압력 패킷 완성 시 (buffer). UI 스레드에서 호출됨. + public event Action PressurePacketReceived; + /// IO 연결 상태 변경 시 (isConnected). 폴링/DataReceived 스레드에서 호출될 수 있음. + public event Action IOConnectionChanged; + /// 압력 연결 상태 변경 시 (isConnected). 폴링/DataReceived 스레드에서 호출될 수 있음. + public event Action PressureConnectionChanged; + /// IO 시스템 로그 메시지 (msg) + public event Action LogMessage; + /// 압력 시스템 로그 메시지 (msg) + public event Action PrLogMessage; + /// HEX 로그 메시지 (prefix, buffer) + public event Action HexLogMessage; + /// IO 폴링 틱 상태 (ioFailCount, prFailCount, prErrorLogged) + public event Action PrTimeoutLogMessage; + + + // ========================================== + // 생성자 + // ========================================== + public ConnectionManager(AppConfig config, Dispatcher uiDispatcher) + { + _config = config; + _uiDispatcher = uiDispatcher; + InitPollingTimer(); + } + + /// 설정이 변경되었을 때 호출하여 내부 config 참조를 갱신합니다. + public void UpdateConfig(AppConfig config) + { + _config = config; + } + + // ========================================== + // 폴링 타이머 + // ========================================== + private void InitPollingTimer() + { + _pollingTimer = new DispatcherTimer(); + _pollingTimer.Interval = TimeSpan.FromMilliseconds(100); + _pollingTimer.Tick += async (s, e) => + { + if (_isPolling) return; + _isPolling = true; + try + { + await OnPollingTickAsync(); + } + finally + { + _isPolling = false; + } + }; + } + + // ========================================== + // 연결 / 재연결 / 해제 + // ========================================== + public async Task TryConnectAsync() + { + _ioFailCount = 0; + _prFailCount = 0; + _lastIOReconnectAttempt = DateTime.Now; + _lastPrReconnectAttempt = DateTime.Now; + _lastIOResponseTime = DateTime.MinValue; + _lastPrResponseTime = DateTime.MinValue; + + try + { + DisconnectIO(); + DisconnectPressure(); + await Task.Delay(200); + + bool anyOpened = false; + + if (_config.IOEnabled) + { + anyOpened |= await ReconnectIOAsync(false); + } + + if (_config.PressureEnabled) + { + anyOpened |= await ReconnectPressureAsync(false); + } + + // 연결 결과를 ViewModel에서 처리하도록 이벤트만 발행 + if (anyOpened) + { + if (_config.IOEnabled) LogMessage?.Invoke(">>> 기기 응답 대기 중... (폴링으로 자동 감지)"); + if (_config.PressureEnabled) PrLogMessage?.Invoke(">>> 기기 응답 대기 중... (폴링으로 자동 감지)"); + } + } + catch (Exception ex) + { + LogMessage?.Invoke($"연결 프로세스 에러: {ex.Message}"); + throw; // ViewModel에서 처리 + } + finally + { + _pollingTimer.Start(); + } + } + + public async Task ReconnectIOAsync(bool isRetry = true) + { + DisconnectIO(); + if (isRetry) await Task.Delay(200); + + try + { + _ioPort = new SerialPort(_config.PortName, _config.BaudRate, Parity.None, 8, StopBits.One); + _ioPort.DataReceived += IOPort_DataReceived; + _ioPort.Open(); + _ioController = new SemiIOLiteController(_ioPort, _config.IOStationId); + LogMessage?.Invoke($">>> IO 포트 {_config.PortName} 열기 성공 (응답 대기)"); + return true; + } + catch (Exception ex) + { + if (isRetry) LogMessage?.Invoke($"IO 포트 재연결 시도 실패: {ex.Message}"); + else LogMessage?.Invoke($"IO 포트 열기 실패: {ex.Message}"); + return false; + } + } + + public async Task ReconnectPressureAsync(bool isRetry = true) + { + DisconnectPressure(); + if (isRetry) await Task.Delay(200); + + try + { + _pressurePort = new SerialPort(_config.PressurePortName, _config.PressureBaudRate, Parity.None, 8, StopBits.One); + _pressurePort.DataReceived += PressurePort_DataReceived; + _pressurePort.Open(); + _pressureController = new PressureSensorController(_pressurePort, _config.PressureSlaveId); + PrLogMessage?.Invoke($">>> 압력 포트 {_config.PressurePortName} 열기 성공 (응답 대기)"); + return true; + } + catch (Exception ex) + { + if (isRetry) PrLogMessage?.Invoke($"압력 포트 재연결 시도 실패: {ex.Message}"); + else PrLogMessage?.Invoke($"압력 포트 열기 실패: {ex.Message}"); + return false; + } + } + + public void DisconnectIO() + { + _ioController?.Dispose(); + _ioController = null; + if (_ioPort != null) + { + try + { + _ioPort.DataReceived -= IOPort_DataReceived; + if (_ioPort.IsOpen) _ioPort.Close(); + } + catch { } + _ioPort.Dispose(); + _ioPort = null; + } + SetIOConnected(false); + _serialBuffer.Clear(); + } + + public void DisconnectPressure() + { + _pressureController?.Dispose(); + _pressureController = null; + if (_pressurePort != null) + { + try + { + _pressurePort.DataReceived -= PressurePort_DataReceived; + if (_pressurePort.IsOpen) _pressurePort.Close(); + } + catch { } + _pressurePort.Dispose(); + _pressurePort = null; + } + SetPressureConnected(false); + _prSerialBuffer.Clear(); + } + + public void DisconnectAll() + { + _pollingTimer?.Stop(); + DisconnectIO(); + DisconnectPressure(); + } + + // ========================================== + // DataReceived 이벤트 핸들러 + // ========================================== + private void IOPort_DataReceived(object sender, SerialDataReceivedEventArgs e) + { + if (_ioPort == null || !_ioPort.IsOpen) return; + try + { + int bytesToRead = _ioPort.BytesToRead; + byte[] tempBuffer = new byte[bytesToRead]; + _ioPort.Read(tempBuffer, 0, bytesToRead); + + lock (_serialBuffer) + { + _serialBuffer.AddRange(tempBuffer); + + if (_serialBuffer.Contains(0x04)) + { + byte[] fullPacket = _serialBuffer.ToArray(); + _serialBuffer.Clear(); + + _uiDispatcher.BeginInvoke(new Action(() => + { + IOPacketReceived?.Invoke(fullPacket); + })); + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[ConnectionManager] IO DataReceived 에러: {ex.Message}"); + } + } + + private void PressurePort_DataReceived(object sender, SerialDataReceivedEventArgs e) + { + if (_pressurePort == null || !_pressurePort.IsOpen) return; + try + { + int bytesToRead = _pressurePort.BytesToRead; + byte[] tempBuffer = new byte[bytesToRead]; + _pressurePort.Read(tempBuffer, 0, bytesToRead); + + lock (_prSerialBuffer) + { + _prSerialBuffer.AddRange(tempBuffer); + + if (_prSerialBuffer.Count >= 7) + { + if (_prSerialBuffer[0] == _config.PressureSlaveId && _prSerialBuffer[1] == 0x04) + { + byte[] fullPacket = _prSerialBuffer.ToArray(); + _prSerialBuffer.Clear(); + + _uiDispatcher.BeginInvoke(new Action(() => + { + PressurePacketReceived?.Invoke(fullPacket); + })); + } + else + { + _prSerialBuffer.RemoveAt(0); + } + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[ConnectionManager] PR DataReceived 에러: {ex.Message}"); + } + } + + // ========================================== + // IO 파싱 결과 수신 (ViewModel에서 호출) + // ========================================== + /// IO 패킷 파싱 성공 시 ViewModel에서 호출하여 응답 시간과 연결 상태를 갱신합니다. + public void NotifyIOResponseReceived() + { + _lastIOResponseTime = DateTime.Now; + SetIOConnected(true); + } + + /// 압력 패킷 파싱 성공 시 ViewModel에서 호출하여 응답 시간과 연결 상태를 갱신합니다. + public void NotifyPressureResponseReceived() + { + _lastPrResponseTime = DateTime.Now; + _prErrorLogged = false; + _prFailCount = 0; + SetPressureConnected(true); + } + + // ========================================== + // 폴링 틱 (100ms마다 호출) + // ========================================== + private async Task OnPollingTickAsync() + { + try + { + bool hasIo = !_config.IOEnabled || (_ioPort != null && _ioPort.IsOpen); + bool hasPr = !_config.PressureEnabled || (_pressurePort != null && _pressurePort.IsOpen); + + // 포트 재연결 시도 + if (!hasIo) + { + if ((DateTime.Now - _lastIOReconnectAttempt).TotalSeconds >= 3) + { + _lastIOReconnectAttempt = DateTime.Now; + _ = ReconnectIOAsync(); + } + } + + if (!hasPr) + { + if ((DateTime.Now - _lastPrReconnectAttempt).TotalSeconds >= 3) + { + _lastPrReconnectAttempt = DateTime.Now; + _ = ReconnectPressureAsync(); + } + } + + if (!hasIo && !hasPr) return; + + // IO 폴링 + if (_ioController != null && _ioPort != null && _ioPort.IsOpen) + { + _ioController.RequestInputState((txBytes) => + { + HexLogMessage?.Invoke("[IO TX]", txBytes); + }); + + double elapsedIo = (DateTime.Now - _lastIOResponseTime).TotalMilliseconds; + if (elapsedIo > 1500) + { + _ioFailCount++; + SetIOConnected(false); + } + else + { + _ioFailCount = 0; + SetIOConnected(true); + } + } + else + { + SetIOConnected(false); + } + + // 압력 폴링 + if (_pressureController != null && _pressurePort != null && _pressurePort.IsOpen) + { + _pressureController.RequestCurrentPressure((txBytes) => + { + HexLogMessage?.Invoke("[PR TX]", txBytes); + }); + + double elapsedPr = (DateTime.Now - _lastPrResponseTime).TotalMilliseconds; + if (elapsedPr > 1500) + { + _prFailCount++; + if (!_prErrorLogged && _lastPrResponseTime != DateTime.MinValue) + { + PrTimeoutLogMessage?.Invoke("[압력 센서] 무응답 타임아웃"); + _prErrorLogged = true; + } + SetPressureConnected(false); + } + else + { + _prFailCount = 0; + _prErrorLogged = false; + SetPressureConnected(true); + } + } + else + { + SetPressureConnected(false); + } + + // 임계치 초과 재연결 + int threshold = 10; + + if (_config.IOEnabled && _ioFailCount >= threshold) + { + if (_ioPort != null) + { + LogMessage?.Invoke("!!! [시스템] I/O 포트 응답 없음 (10회). 자동 재연결 시도 중."); + _ = _uiDispatcher.BeginInvoke(new Action(async () => + { + DisconnectIO(); + _ioFailCount = 0; + _lastIOReconnectAttempt = DateTime.Now; + await ReconnectIOAsync(); + })); + } + } + + if (_config.PressureEnabled && _prFailCount >= threshold) + { + if (_pressurePort != null) + { + PrLogMessage?.Invoke("!!! [시스템] 압력 센서 응답 없음 (10회). 자동 재연결 시도 중."); + _ = _uiDispatcher.BeginInvoke(new Action(async () => + { + DisconnectPressure(); + _prFailCount = 0; + _lastPrReconnectAttempt = DateTime.Now; + await ReconnectPressureAsync(); + })); + } + } + } + catch (Exception ex) + { + LogMessage?.Invoke($"[시스템 장애] 폴링 루프 치명적 에러: {ex.Message}"); + } + } + + // ========================================== + // 내부 헬퍼 + // ========================================== + private void SetIOConnected(bool value) + { + if (IsIOConnected != value) + { + IsIOConnected = value; + IOConnectionChanged?.Invoke(value); + } + } + + private void SetPressureConnected(bool value) + { + if (IsPressureConnected != value) + { + IsPressureConnected = value; + PressureConnectionChanged?.Invoke(value); + } + } + + // ========================================== + // IDisposable + // ========================================== + public void Dispose() + { + DisconnectAll(); + } + } +} diff --git a/jig_test/Services/LogService.cs b/jig_test/Services/LogService.cs new file mode 100644 index 0000000..c0792b1 --- /dev/null +++ b/jig_test/Services/LogService.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.Text; +using jig_test.Models; + +namespace jig_test.Services +{ + /// + /// 검사 결과를 CSV 파일로 저장하는 서비스 + /// + public class LogService + { + private static readonly string LogDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); + + /// + /// 검사 데이터를 CSV 파일에 추가합니다. + /// 파일명은 yyyy-MM-dd.csv 형식을 따릅니다. + /// + /// 저장 성공 여부 + public static bool AppendLog(LogData data) + { + try + { + // Logs 폴더가 없으면 생성 + if (!Directory.Exists(LogDirectory)) + { + Directory.CreateDirectory(LogDirectory); + } + + string fileName = DateTime.Now.ToString("yyyy-MM-dd") + ".csv"; + string filePath = Path.Combine(LogDirectory, fileName); + + bool isNewFile = !File.Exists(filePath); + + using (var stream = new FileStream(filePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) + using (var writer = new StreamWriter(stream, new UTF8Encoding(true))) + { + // 새 파일이면 헤더 추가 + if (isNewFile) + { + writer.WriteLine("순번,검사 시작 시간,검사 완료 시간,검사 기준 압력(bar),오차(bar),최종 결과"); + } + + string line = string.Format("{0},{1:yyyy-MM-dd HH:mm:ss},{2:yyyy-MM-dd HH:mm:ss},{3:F1},{4:F1},{5}", + data.Index, + data.StartTime, + data.EndTime, + data.TargetPressure, + data.ErrorValue, + data.Result); + + writer.WriteLine(line); + } + return true; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[LogService] 로그 저장 실패: {ex.Message}"); + return false; + } + } + + /// + /// 오늘 날짜의 로그 파일에서 PASS/FAIL 개수를 분석하여 반환합니다. + /// + public static (int pass, int fail) GetTodaySummary() + { + int pass = 0; + int fail = 0; + + try + { + string fileName = DateTime.Now.ToString("yyyy-MM-dd") + ".csv"; + string filePath = Path.Combine(LogDirectory, fileName); + + if (!File.Exists(filePath)) return (0, 0); + + using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var reader = new StreamReader(stream, new UTF8Encoding(true))) + { + string header = reader.ReadLine(); // 헤더 건너뛰기 + while (!reader.EndOfStream) + { + string line = reader.ReadLine(); + if (string.IsNullOrWhiteSpace(line)) continue; + + string[] parts = line.Split(','); + if (parts.Length < 6) continue; + + string result = parts[5].Trim().ToUpper(); + if (result == "PASS") pass++; + else if (result == "FAIL") fail++; + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[LogService] 로그 요약 읽기 실패: {ex.Message}"); + } + + return (pass, fail); + } + } +} diff --git a/jig_test/Services/PressureSensorController.cs b/jig_test/Services/PressureSensorController.cs new file mode 100644 index 0000000..95a4c91 --- /dev/null +++ b/jig_test/Services/PressureSensorController.cs @@ -0,0 +1,150 @@ +using System; +using System.Diagnostics; +using System.IO.Ports; +using System.Threading.Tasks; + +namespace jig_test.Services +{ + /// + /// KN-2000W 시리즈 압력 디스플레이와 Modbus RTU 통신을 통해 실시간 압력값을 읽어오는 서비스 + /// I/O 보드 컨트롤러와 동일한 Raw Serial 통신 방식으로 구현됨 + /// + public class PressureSensorController : IDisposable + { + private SerialPort _serialPort; + private readonly byte _slaveAddress; + + public PressureSensorController(SerialPort sharedPort, int slaveAddress = 1) + { + _serialPort = sharedPort; + _slaveAddress = (byte)slaveAddress; + } + + /// + /// 시리얼 포트가 닫혀있을 경우 오픈을 시도합니다. + /// + public bool Connect() + { + try + { + if (!_serialPort.IsOpen) _serialPort.Open(); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"[Pressure Sensor] 연결 실패: {ex.Message}"); + return false; + } + } + + /// + /// 비동기 폴링을 위해 압력값 읽기(Tx)를 요청합니다. (이후 수신은 DataReceived 이벤트에서 처리) + /// + public void RequestCurrentPressure(Action onTx = null) + { + if (!_serialPort.IsOpen) return; + try + { + byte[] request = BuildReadRequest(0x0000, 1); + onTx?.Invoke(request); + _serialPort.Write(request, 0, request.Length); + } + catch (Exception ex) + { + Debug.WriteLine($"[Pressure Sensor] Request Error: {ex.Message}"); + } + } + + /// + /// 수신된 바이트 버퍼를 해석하여 현재 압력값을 반환합니다. + /// + public bool ParsePressureResponse(byte[] buffer, out int pressureValue) + { + pressureValue = 0; + if (buffer == null || buffer.Length < 7) return false; + + try + { + // CRC 검증 (최소 7바이트: Slave(1) + Func(1) + Count(1) + Data(2) + CRC(2)) + // 실제 Modbus RTU Read Input Registers 응답 길이는 3 + (N*2) + 2 + // N=1인 경우 7바이트 + if (!ValidateCRC(buffer, buffer.Length)) return false; + + // 0: Slave ID, 1: Function Code (0x04), 2: Byte Count (0x02) + if (buffer[0] == _slaveAddress && buffer[1] == 0x04 && buffer.Length >= 5) + { + byte bCount = buffer[2]; + if (buffer.Length >= bCount + 5) + { + pressureValue = (short)((buffer[3] << 8) | buffer[4]); + return true; + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[Pressure Sensor] Parse Error: {ex.Message}"); + } + return false; + } + + /// + /// Modbus RTU 읽기 요청 프레임을 동적으로 생성합니다. + /// #2: 슬레이브 주소, 시작 주소, 레지스터 수를 파라미터로 사용하며 CRC를 자동 계산합니다. + /// + private byte[] BuildReadRequest(ushort startAddress, ushort count) + { + byte[] frame = new byte[8]; + frame[0] = _slaveAddress; // 슬레이브 주소 (동적) + frame[1] = 0x04; // Function Code: Read Input Registers + frame[2] = (byte)(startAddress >> 8); // 시작 주소 High + frame[3] = (byte)(startAddress & 0xFF); // 시작 주소 Low + frame[4] = (byte)(count >> 8); // 레지스터 수 High + frame[5] = (byte)(count & 0xFF); // 레지스터 수 Low + + ushort crc = CalculateCRC(frame, 6); + frame[6] = (byte)(crc & 0xFF); // CRC Low + frame[7] = (byte)(crc >> 8); // CRC High + + return frame; + } + + private bool ValidateCRC(byte[] frame, int length) + { + if (length < 4) return false; + ushort expected = CalculateCRC(frame, length - 2); + ushort actual = (ushort)(frame[length - 2] | (frame[length - 1] << 8)); + return expected == actual; + } + + private ushort CalculateCRC(byte[] buffer, int length) + { + ushort crc = 0xFFFF; + for (int i = 0; i < length; i++) + { + crc ^= buffer[i]; + for (int j = 0; j < 8; j++) + { + if ((crc & 0x0001) != 0) + { + crc >>= 1; + crc ^= 0xA001; + } + else + { + crc >>= 1; + } + } + } + return crc; + } + + /// + /// #3: 포트 수명은 호출자(MainWindow)에서 관리하므로, 여기서는 참조만 정리합니다. + /// + public void Dispose() + { + _serialPort = null; + } + } +} diff --git a/jig_test/Services/SemiIOLiteController.cs b/jig_test/Services/SemiIOLiteController.cs new file mode 100644 index 0000000..11d265b --- /dev/null +++ b/jig_test/Services/SemiIOLiteController.cs @@ -0,0 +1,320 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Ports; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace jig_test.Services +{ + /// + /// Semi IO Lite 보드 (SIO-0201A) 제어 서비스 클래스 + /// 이 클래스는 LS산전 SV-iG5 인버터 호환 프로토콜(ASCII)을 사용하여 릴레이 출력을 제어하고 입력을 읽습니다. + /// + public class SemiIOLiteController : IDisposable + { + private SerialPort _serialPort; // MainWindow에서 생성한 공유 시리얼 포트 인스턴스 + private readonly string _stationId; // 기기의 고유 국번 (기본값 "04") + + // 프로토콜 특수 문자 정의 (ASCII 제어 문자) + private const char ENQ = (char)0x05; // 시작 문자 (Enquiry) - 요청용 + private const char EOT = (char)0x04; // 종료 문자 (End of Transmission) + private const char ACK = (char)0x06; // 응답 문자 (Acknowledge) - 응답용 + + /// + /// 릴레이 상태 정의 (비트 조합 방식) + /// 릴레이 1과 2의 조합으로 공정 상태(고정, 인가, 배기)를 결정합니다. + /// + public enum RelayState + { + None = 0, // 00: 모든 릴레이 OFF + Exhaust = 1, // 01: 릴레이 1번 ON (배기 밸브 작동) + Pressurize = 2, // 10: 릴레이 2번 ON (인가 밸브 작동) + Clamp = 3 // 11: 릴레이 1, 2번 모두 ON (지그 고정 및 폐쇄) + } + + public SemiIOLiteController(SerialPort sharedPort, int stationId = 2) + { + _serialPort = sharedPort; + _stationId = stationId.ToString("D2"); // 정수 ID를 2자리 문자열로 변환 (02 등) + } + + /// + /// 시리얼 포트가 닫혀있을 경우 오픈을 시도합니다. + /// + public bool Connect() + { + try + { + if (!_serialPort.IsOpen) _serialPort.Open(); + return true; + } + catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException || ex is InvalidOperationException) + { + Debug.WriteLine($"[IO Board] 연결 실패 (포트 접근/상태 오류): {ex.Message}"); + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"[IO Board] 연결 실패 (기타): {ex.Message}"); + return false; + } + } + + /// + /// 릴레이 상태를 물리적으로 변경하는 명령을 보냅니다. + /// 형식: ENQ + 국번 + W(Write) + 주소(0001) + 갯수(1) + 데이터(000X) + SUM + EOT + /// + public async Task SetStateAsync(RelayState state) + { + if (_serialPort == null || !_serialPort.IsOpen) return false; + + string commandStr = BuildCommand(state); + + try + { + _serialPort.DiscardInBuffer(); // 기존 버퍼 비우기 + _serialPort.Write(commandStr); + + // 최대 500ms 동안 응답 대기 및 ACK 확인 + byte[] buffer = new byte[1024]; + int totalRead = 0; + var sw = Stopwatch.StartNew(); + + while (sw.ElapsedMilliseconds < 500) + { + if (_serialPort.BytesToRead > 0) + { + int count = _serialPort.Read(buffer, totalRead, buffer.Length - totalRead); + totalRead += count; + if (Array.IndexOf(buffer, (byte)0x04, 0, totalRead) != -1) break; // EOT 감지 + } + await Task.Delay(10); + } + + if (totalRead == 0) return false; + + string response = Encoding.ASCII.GetString(buffer, 0, totalRead); + return ValidateResponse(response); + } + catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException || ex is InvalidOperationException) + { + Debug.WriteLine($"[IO Board] 쓰기 명령 전송 실패 (포트 오류): {ex.Message}"); + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"[IO Board] 쓰기 명령 전송 에러: {ex.Message}"); + return false; + } + } + + /// + /// 통신 유효성을 확인하기 위해 기기의 현재 상태를 읽어봅니다. (핸드셰이크) + /// + public async Task CheckConnectionAsync() + { + if (!_serialPort.IsOpen) return false; + + try + { + // 주소 0001의 데이터를 1개 읽어오는 명령 생성 + string readCmd = BuildReadCommand(); + _serialPort.DiscardInBuffer(); // 기존 버퍼 비우기 + _serialPort.Write(readCmd); + + // 최대 500ms 동안 응답 대기 + byte[] buffer = new byte[1024]; + int totalRead = 0; + var sw = Stopwatch.StartNew(); // #13: DateTime.Now 대신 Stopwatch 사용 + + while (sw.ElapsedMilliseconds < 500) + { + if (_serialPort.BytesToRead > 0) + { + int count = _serialPort.Read(buffer, totalRead, buffer.Length - totalRead); + totalRead += count; + // EOT 문자가 들어오면 한 프레임 완성으로 간주 + if (Array.IndexOf(buffer, (byte)0x04, 0, totalRead) != -1) break; + } + await Task.Delay(10); + } + + if (totalRead == 0) return false; + + string response = Encoding.ASCII.GetString(buffer, 0, totalRead); + return ValidateResponse(response); + } + catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException || ex is InvalidOperationException) + { + Debug.WriteLine($"[IO Board] CheckConnection 포트 접속 에러: {ex.Message}"); + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"[IO Board] CheckConnection 에러: {ex.Message}"); + return false; + } + } + + /// + /// 읽기용 프로토콜 패킷을 조립합니다. + /// + private string BuildReadCommand() + { + string body = $"{_stationId}R00011"; // 국번 + R + 주소 + 갯수 + int sum = 0; + foreach (char c in body) sum += (int)c; + return $"{ENQ}{body}{(sum & 0xFF).ToString("X2")}{EOT}"; + } + + /// + /// 응답 패킷의 무결성을 검증합니다. + /// IO 보드 응답은 ACK(0x06)로 시작하고 EOT(0x04)로 끝납니다. + /// + private bool ValidateResponse(string response) + { + if (string.IsNullOrEmpty(response) || response.Length < 7) return false; + + try + { + // 응답은 ACK 또는 ENQ로 시작할 수 있음 (기기에 따라 다름) + int startIdx = response.IndexOf(ACK); + if (startIdx == -1) startIdx = response.IndexOf(ENQ); + + int eotIdx = response.LastIndexOf(EOT); // 마지막 EOT를 찾음 + + return (startIdx != -1 && eotIdx != -1 && eotIdx > startIdx); + } + catch + { + return false; + } + } + + /// + /// 쓰기용 프로토콜 패킷을 조립합니다. (국번 + W + 주소 + 갯수 + 데이터) + /// + private string BuildCommand(RelayState state) + { + string data = ((int)state).ToString("X1"); + string body = $"{_stationId}W00011000{data}"; + int sum = 0; + foreach (char c in body) sum += (int)c; + return $"{ENQ}{body}{(sum & 0xFF).ToString("X2")}{EOT}"; + } + + /// + /// 지그 앞의 물리 버튼(i1 단자)이 눌렸는지 확인하는 요청을 보냅니다. + /// 주소 0A07번의 데이터를 1개 읽어옵니다. + /// + public bool RequestInputState(Action onTx = null) + { + if (_serialPort == null || !_serialPort.IsOpen) return false; + try + { + // 입력 상태 읽기 (주소 0A07) + string body = $"{_stationId}R0A071"; + int sum = 0; + foreach (char c in body) sum += (int)c; + // [ENQ] + body + [SUM(2자리)] + [EOT] + string command = $"{ENQ}{body}{(sum & 0xFF).ToString("X2")}{EOT}"; + byte[] cmdBytes = Encoding.ASCII.GetBytes(command); + + onTx?.Invoke(cmdBytes); // 송신 로그 콜백 호출 + + _serialPort.Write(command); + return true; + } + catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException || ex is InvalidOperationException) + { + Debug.WriteLine($"[IO Board] RequestInputState 포트 에러: {ex.Message}"); + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"[IO Board] RequestInputState 기타 에러: {ex.Message}"); + return false; + } + } + + /// + /// 수신된 바이트 버퍼를 해석하여 디지털 입력(i1, i2) 상태를 반환합니다. + /// + public bool ParseInputBuffer(byte[] buffer, out bool i1, out bool i2) + { + i1 = i2 = false; + if (buffer == null || buffer.Length == 0) return false; + + string res = Encoding.ASCII.GetString(buffer); + + // 정규식을 활용하여 응답 포맷 검증 + // 'R' 이후에 오는 3자리 Hex 다음 1자리 값만 추출 + var match = Regex.Match(res, @"R[0-9A-Fa-f]{3}([0-9A-Fa-f])"); + if (match.Success) + { + char val = match.Groups[1].Value[0]; + if (val == '1' || val == '3') i1 = true; + if (val == '2' || val == '3') i2 = true; + return true; + } + + return false; + } + + /// + /// 16진수 문자열 형태(예: 05303257...)의 Raw 커맨드들을 직접 바이트 배열로 변환하여 전송합니다. + /// + public async Task SendRawHexCommandAsync(string hexString, Action onTx = null) + { + if (_serialPort == null || !_serialPort.IsOpen) return false; + try + { + // Hex 문자열을 바이트 배열로 변환 + byte[] buffer = new byte[hexString.Length / 2]; + for (int i = 0; i < buffer.Length; i++) + buffer[i] = Convert.ToByte(hexString.Substring(i * 2, 2), 16); + + onTx?.Invoke(buffer); + + _serialPort.DiscardInBuffer(); + _serialPort.Write(buffer, 0, buffer.Length); + + // EOT까지 응답을 대기 (간단한 형태) + byte[] rxBuffer = new byte[1024]; + int totalRead = 0; + var sw = Stopwatch.StartNew(); + + while (sw.ElapsedMilliseconds < 500) + { + if (_serialPort.BytesToRead > 0) + { + int count = _serialPort.Read(rxBuffer, totalRead, rxBuffer.Length - totalRead); + totalRead += count; + if (Array.IndexOf(rxBuffer, (byte)0x04, 0, totalRead) != -1) break; // EOT 감지 + } + await Task.Delay(10); + } + + if (totalRead == 0) return false; + + string response = Encoding.ASCII.GetString(rxBuffer, 0, totalRead); + return ValidateResponse(response); + } + catch (Exception ex) + { + Debug.WriteLine($"[IO Board] SendRawHexCommand 에러: {ex.Message}"); + return false; + } + } + /// + /// 포트 수명은 호출자(MainViewModel)에서 관리하므로, 여기서는 참조만 정리합니다. + /// + public void Dispose() + { + _serialPort = null; + } + + } +} diff --git a/jig_test/ViewModels/Base/ObservableObject.cs b/jig_test/ViewModels/Base/ObservableObject.cs new file mode 100644 index 0000000..8eb82aa --- /dev/null +++ b/jig_test/ViewModels/Base/ObservableObject.cs @@ -0,0 +1,39 @@ +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace jig_test.ViewModels.Base +{ + /// + /// MVVM의 ViewModel을 위한 기본 클래스 (INotifyPropertyChanged 구현) + /// 프로퍼티 변경 시 UI(View)에 자동으로 변경 사항을 알립니다. + /// + public abstract class ObservableObject : INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + /// + /// 프로퍼티의 변경을 통지합니다. + /// + /// 메서드를 호출한 프로퍼티 이름 (CallerMemberName으로 자동 삽입) + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + /// + /// 필드의 값이 변경될 경우에만 값을 업데이트하고 이벤트를 발생시킵니다. + /// + protected bool SetProperty(ref T storage, T value, [CallerMemberName] string propertyName = null) + { + if (Equals(storage, value)) + { + return false; + } + + storage = value; + OnPropertyChanged(propertyName); + return true; + } + } +} diff --git a/jig_test/ViewModels/Base/RelayCommand.cs b/jig_test/ViewModels/Base/RelayCommand.cs new file mode 100644 index 0000000..d79c6ed --- /dev/null +++ b/jig_test/ViewModels/Base/RelayCommand.cs @@ -0,0 +1,46 @@ +using System; +using System.Windows.Input; + +namespace jig_test.ViewModels.Base +{ + /// + /// ICommand를 구현하여 View의 Event(Click 등)를 ViewModel의 메서드(동작)에 바인딩하는 클래스 + /// + public class RelayCommand : ICommand + { + private readonly Action _execute; + private readonly Predicate _canExecute; + + public RelayCommand(Action execute, Predicate canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + public RelayCommand(Action execute, Func canExecute = null) + { + if (execute == null) throw new ArgumentNullException(nameof(execute)); + _execute = _ => execute(); + if (canExecute != null) + { + _canExecute = _ => canExecute(); + } + } + + public event EventHandler CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + + public bool CanExecute(object parameter) + { + return _canExecute == null || _canExecute(parameter); + } + + public void Execute(object parameter) + { + _execute(parameter); + } + } +} diff --git a/jig_test/ViewModels/Converters/BooleanToBrushConverter.cs b/jig_test/ViewModels/Converters/BooleanToBrushConverter.cs new file mode 100644 index 0000000..15cbc71 --- /dev/null +++ b/jig_test/ViewModels/Converters/BooleanToBrushConverter.cs @@ -0,0 +1,27 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace jig_test.ViewModels.Converters +{ + public class BooleanToBrushConverter : IValueConverter + { + public Brush TrueBrush { get; set; } = Brushes.LimeGreen; + public Brush FalseBrush { get; set; } = Brushes.Gray; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool b) + { + return b ? TrueBrush : FalseBrush; + } + return FalseBrush; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/jig_test/ViewModels/Converters/InverseBooleanConverter.cs b/jig_test/ViewModels/Converters/InverseBooleanConverter.cs new file mode 100644 index 0000000..fb3a11e --- /dev/null +++ b/jig_test/ViewModels/Converters/InverseBooleanConverter.cs @@ -0,0 +1,27 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace jig_test.ViewModels.Converters +{ + public class InverseBooleanConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool b) + { + return !b; + } + return false; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool b) + { + return !b; + } + return false; + } + } +} diff --git a/jig_test/ViewModels/MainViewModel.cs b/jig_test/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..54d343f --- /dev/null +++ b/jig_test/ViewModels/MainViewModel.cs @@ -0,0 +1,1016 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using System.IO; +using jig_test.Models; +using jig_test.Services; +using jig_test.ViewModels.Base; + +namespace jig_test.ViewModels +{ + public class MainViewModel : ObservableObject, IDisposable + { + // ========================================== + // 1. 모델(Config) 및 서비스 + // ========================================== + private AppConfig _config; + public AppConfig Config + { + get => _config; + set => SetProperty(ref _config, value); + } + + private ConnectionManager _connManager; + + // ========================================== + // 2. 바인딩용 UI 상태 프로퍼티들 + // ========================================== + + // 시간 + private string _dateTimeText = "--"; + public string DateTimeText { get => _dateTimeText; set => SetProperty(ref _dateTimeText, value); } + + // 통신 상태 (상단) + private string _connStatusText = "대기 중"; + public string ConnStatusText { get => _connStatusText; set => SetProperty(ref _connStatusText, value); } + + private Brush _connStatusColor = Brushes.Gray; + public Brush ConnStatusColor { get => _connStatusColor; set => SetProperty(ref _connStatusColor, value); } + + private string _summaryPortText = "포트 미설정"; + public string SummaryPortText { get => _summaryPortText; set => SetProperty(ref _summaryPortText, value); } + + // 장비 연동 램프 (녹색/회색) + private Brush _lampIOStatus = Brushes.Gray; + public Brush LampIOStatus { get => _lampIOStatus; set => SetProperty(ref _lampIOStatus, value); } + + private Brush _lampPressureStatus = Brushes.Gray; + public Brush LampPressureStatus { get => _lampPressureStatus; set => SetProperty(ref _lampPressureStatus, value); } + + // 현재 압력값 + private string _currentPressureText = "0.0"; + public string CurrentPressureText { get => _currentPressureText; set => SetProperty(ref _currentPressureText, value); } + + private Brush _currentPressureColor = Brushes.Black; + public Brush CurrentPressureColor { get => _currentPressureColor; set => SetProperty(ref _currentPressureColor, value); } + + private readonly object _pressureLock = new object(); + private double _currentPressureValue = 0.0; + + // 지그 공정 램프 상태 + private Brush _lampClampHold = Brushes.LightGray; + public Brush LampClampHold { get => _lampClampHold; set => SetProperty(ref _lampClampHold, value); } + + private Brush _lampPressurize = Brushes.LightGray; + public Brush LampPressurize { get => _lampPressurize; set => SetProperty(ref _lampPressurize, value); } + + private Brush _lampExhaust = Brushes.LightGray; + public Brush LampExhaust { get => _lampExhaust; set => SetProperty(ref _lampExhaust, value); } + + // 검사 정보 영역 + private string _basePressureText = "0.0"; + public string BasePressureText { get => _basePressureText; set => SetProperty(ref _basePressureText, value); } + + private string _currentStatusText = "[ 대기 중 ]"; + public string CurrentStatusText { get => _currentStatusText; set => SetProperty(ref _currentStatusText, value); } + + private string _resultText = "대기 중"; + public string ResultText { get => _resultText; set => SetProperty(ref _resultText, value); } + + private Brush _resultColor = Brushes.Gray; + public Brush ResultColor { get => _resultColor; set => SetProperty(ref _resultColor, value); } + + private string _startInspectionTimeText = "검사 시작 일시: --"; + public string StartInspectionTimeText { get => _startInspectionTimeText; set => SetProperty(ref _startInspectionTimeText, value); } + + private string _lastInspectionTimeText = "검사 완료 일시: --"; + public string LastInspectionTimeText { get => _lastInspectionTimeText; set => SetProperty(ref _lastInspectionTimeText, value); } + + private string _holdTimeParamText = "시간: 0s"; + public string HoldTimeParamText { get => _holdTimeParamText; set => SetProperty(ref _holdTimeParamText, value); } + + private string _holdErrorParamText = "오차: 0.00 bar"; + public string HoldErrorParamText { get => _holdErrorParamText; set => SetProperty(ref _holdErrorParamText, value); } + + private string _autoTargetParamText; + public string AutoTargetParamText { get => _autoTargetParamText; set => SetProperty(ref _autoTargetParamText, value); } + + // 로그 및 UI 토글 + private Visibility _logGroupVisibility = Visibility.Collapsed; + public Visibility LogGroupVisibility { get => _logGroupVisibility; set => SetProperty(ref _logGroupVisibility, value); } + + private string _toggleLogButtonText = "▼ 로그 창 열기"; + public string ToggleLogButtonText { get => _toggleLogButtonText; set => SetProperty(ref _toggleLogButtonText, value); } + + private string _ioLogText = ""; + public string IOLogText { get => _ioLogText; set => SetProperty(ref _ioLogText, value); } + + private string _prLogText = ""; + public string PrLogText { get => _prLogText; set => SetProperty(ref _prLogText, value); } + + // 오늘 검사 요약 + private int _todayPassCount = 0; + public int TodayPassCount { get => _todayPassCount; set => SetProperty(ref _todayPassCount, value); } + + private int _todayFailCount = 0; + public int TodayFailCount { get => _todayFailCount; set => SetProperty(ref _todayFailCount, value); } + + + // ========================================== + // 3. ICommand 바인딩 커맨드 + // ========================================== + public ICommand SettingsCommand { get; } + public ICommand ParameterCommand { get; } + public ICommand ToggleLogCommand { get; } + public ICommand AutoStartCommand { get; } + public ICommand OpenLogCommand { get; } + + // ========================================== + // 자동 검사 전용 바인딩 프로퍼티들 + // ========================================== + private int _autoTestSelectedIndex = 0; + public int AutoTestSelectedIndex { get => _autoTestSelectedIndex; set { SetProperty(ref _autoTestSelectedIndex, value); OnPropertyChanged(nameof(IsAutoMode)); } } + public bool IsAutoMode => _autoTestSelectedIndex == 1; + + private Brush _autoLampClampHold = Brushes.LightGray; + public Brush AutoLampClampHold { get => _autoLampClampHold; set => SetProperty(ref _autoLampClampHold, value); } + private Brush _autoLampPressurize = Brushes.LightGray; + public Brush AutoLampPressurize { get => _autoLampPressurize; set => SetProperty(ref _autoLampPressurize, value); } + private Brush _autoLampExhaust = Brushes.LightGray; + public Brush AutoLampExhaust { get => _autoLampExhaust; set => SetProperty(ref _autoLampExhaust, value); } + private string _autoTestStatusText = "[ 대기 중 ]"; + public string AutoTestStatusText { get => _autoTestStatusText; set => SetProperty(ref _autoTestStatusText, value); } + private string _autoResultText = "대기 중"; + public string AutoResultText { get => _autoResultText; set => SetProperty(ref _autoResultText, value); } + private Brush _autoResultColor = Brushes.Gray; + public Brush AutoResultColor { get => _autoResultColor; set => SetProperty(ref _autoResultColor, value); } + private string _autoStartInspectionTimeText = "검사 시작 일시: --"; + public string AutoStartInspectionTimeText { get => _autoStartInspectionTimeText; set => SetProperty(ref _autoStartInspectionTimeText, value); } + private string _autoLastInspectionTimeText = "검사 완료 일시: --"; + public string AutoLastInspectionTimeText { get => _autoLastInspectionTimeText; set => SetProperty(ref _autoLastInspectionTimeText, value); } + + private string _autoStartButtonText = "🚀 자동 검사 시작"; + public string AutoStartButtonText { get => _autoStartButtonText; set => SetProperty(ref _autoStartButtonText, value); } + private Brush _autoStartButtonColor = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0078D7")); + public Brush AutoStartButtonColor { get => _autoStartButtonColor; set => SetProperty(ref _autoStartButtonColor, value); } + + private bool _isAutoSequenceRunning = false; + private CancellationTokenSource _autoCycleCts; + + // ========================================== + // 4. 검사 로직 변수들 + // ========================================== + private enum LeverPos { None, Clamp, Pressurize, Exhaust } + private LeverPos _lastPos = LeverPos.None; + private bool _wasPressurized = false; + private CancellationTokenSource _holdCts; + private DispatcherTimer _clockTimer; + private DateTime _manualStartTime; + private DateTime _autoStartTime; + private DateTime _lastSummaryDate; + + // UI 디스패처 + private readonly Dispatcher _uiDispatcher; + + + // ========================================== + // 생성자 + // ========================================== + public MainViewModel() + { + _uiDispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + + // 설정 로드 및 UI 초기화 + Config = ConfigService.Load(); + ApplyLogVisibility(); + UpdateParamDisplay(); + + // 커맨드 연결 + SettingsCommand = new RelayCommand(OpenSettings); + ParameterCommand = new RelayCommand(OpenParameter); + ToggleLogCommand = new RelayCommand(ToggleLog); + AutoStartCommand = new RelayCommand(ExecuteAutoStartCommand); + OpenLogCommand = new RelayCommand(OpenLog); + + // 초기 요약 데이터 로드 + RefreshTodaySummary(); + + // 디자이너 모드에서는 실제 장비 연결을 생략 + if (System.ComponentModel.DesignerProperties.GetIsInDesignMode(new DependencyObject())) + { + return; + } + + // 시계 타이머 (100ms) + _clockTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) }; + _clockTimer.Tick += (s, e) => { + var now = DateTime.Now; + DateTimeText = now.ToString("yyyy-MM-dd HH:mm:ss"); + + // 날짜 변경 감지 시 요약 리셋 + if (now.Date != _lastSummaryDate) + { + RefreshTodaySummary(); + } + }; + _clockTimer.Start(); + + // ConnectionManager 초기화 및 이벤트 구독 + _connManager = new ConnectionManager(Config, _uiDispatcher); + SubscribeConnectionEvents(); + _ = StartConnectionAsync(); + } + + // ========================================== + // ConnectionManager 이벤트 구독 + // ========================================== + private void SubscribeConnectionEvents() + { + _connManager.IOPacketReceived += OnIOPacketReceived; + _connManager.PressurePacketReceived += OnPressurePacketReceived; + _connManager.IOConnectionChanged += OnIOConnectionChanged; + _connManager.PressureConnectionChanged += OnPressureConnectionChanged; + _connManager.LogMessage += (msg) => LogSys(msg); + _connManager.PrLogMessage += (msg) => LogPrSys(msg); + _connManager.HexLogMessage += (prefix, buffer) => LogHex(prefix, buffer); + _connManager.PrTimeoutLogMessage += (msg) => + { + _ = _uiDispatcher.BeginInvoke(new Action(() => + { + string time = DateTime.Now.ToString("HH:mm:ss"); + string entry = $"[{time}] {msg}\n"; + PrLogText = AppendAndTrimLog(PrLogText, entry); + OnPropertyChanged(nameof(PrLogText)); + })); + }; + } + + private async Task StartConnectionAsync() + { + SummaryPortText = $"IO:{Config.PortName} / PR:{Config.PressurePortName}"; + ConnStatusText = "연결 중..."; + ConnStatusColor = Brushes.Orange; + + try + { + await _connManager.TryConnectAsync(); + ConnStatusText = "응답 대기 중..."; + ConnStatusColor = Brushes.Orange; + } + catch + { + ConnStatusText = "포트 열기 실패"; + ConnStatusColor = Brushes.Red; + } + } + + // ========================================== + // 연결 상태 변경 이벤트 핸들러 + // ========================================== + private void OnIOConnectionChanged(bool isConnected) + { + _ = _uiDispatcher.BeginInvoke(new Action(() => + { + if (isConnected) + { + if (LampIOStatus != Brushes.LimeGreen) LampIOStatus = Brushes.LimeGreen; + } + else + { + // Red vs Gray: 포트 자체가 없으면 Gray, 포트는 있지만 무응답이면 Red + // ConnectionManager가 적절한 시점에 이벤트를 발생시킴 + if (LampIOStatus == Brushes.LimeGreen) LampIOStatus = Brushes.Red; + else if (LampIOStatus != Brushes.Red) LampIOStatus = Brushes.Gray; + } + UpdateOverallConnectionStatus(); + })); + } + + private void OnPressureConnectionChanged(bool isConnected) + { + _ = _uiDispatcher.BeginInvoke(new Action(() => + { + if (isConnected) + { + if (LampPressureStatus != Brushes.LimeGreen) LampPressureStatus = Brushes.LimeGreen; + } + else + { + if (LampPressureStatus == Brushes.LimeGreen) LampPressureStatus = Brushes.Red; + else if (LampPressureStatus != Brushes.Red) LampPressureStatus = Brushes.Gray; + + if (CurrentPressureText != "ERR") CurrentPressureText = "ERR"; + if (CurrentPressureColor != Brushes.Red) CurrentPressureColor = Brushes.Red; + } + UpdateOverallConnectionStatus(); + })); + } + + private void UpdateOverallConnectionStatus() + { + bool allActiveConnected = true; + bool anyActiveConnected = false; + + if (Config.IOEnabled) + { + if (_connManager.IsIOConnected) anyActiveConnected = true; + else allActiveConnected = false; + } + if (Config.PressureEnabled) + { + if (_connManager.IsPressureConnected) anyActiveConnected = true; + else allActiveConnected = false; + } + + if (anyActiveConnected) + { + string targetText = allActiveConnected ? "모니터링 중" : "일부 기기 연결됨"; + if (ConnStatusText != targetText) ConnStatusText = targetText; + if (ConnStatusColor != Brushes.LimeGreen) ConnStatusColor = Brushes.LimeGreen; + } + else + { + if (ConnStatusText != "연결 끊김") ConnStatusText = "연결 끊김"; + if (ConnStatusColor != Brushes.Red) ConnStatusColor = Brushes.Red; + } + } + + // ========================================== + // 수신 패킷 처리 (ConnectionManager 이벤트) + // ========================================== + private void OnIOPacketReceived(byte[] buffer) + { + LogHex("[IO RX]", buffer); + + if (_connManager.IOController != null) + { + bool i1, i2; + if (_connManager.IOController.ParseInputBuffer(buffer, out i1, out i2)) + { + _connManager.NotifyIOResponseReceived(); + UpdateJigLamps(i1, i2); + } + } + } + + private void OnPressurePacketReceived(byte[] buffer) + { + LogHex("[PR RX]", buffer); + + if (_connManager.PressureController != null) + { + if (_connManager.PressureController.ParsePressureResponse(buffer, out int rawValue)) + { + _connManager.NotifyPressureResponseReceived(); + lock (_pressureLock) { _currentPressureValue = rawValue / 10.0; } + + _ = _uiDispatcher.BeginInvoke(new Action(() => + { + CurrentPressureText = _currentPressureValue.ToString("F1"); + if (CurrentPressureColor != Brushes.DeepSkyBlue) CurrentPressureColor = Brushes.DeepSkyBlue; + })); + } + } + } + + // ========================================== + // 로직 함수들 + // ========================================== + private void UpdateParamDisplay() + { + HoldTimeParamText = $"시간: {Config.HoldTime}s"; + HoldErrorParamText = $"오차: {Config.AllowedErrorRange:F1} bar"; + AutoTargetParamText = $"목표: {Config.AutoTestTargetPressure:F1} bar"; + } + + private void ApplyLogVisibility() + { + LogGroupVisibility = Config.IsLogVisible ? Visibility.Visible : Visibility.Collapsed; + ToggleLogButtonText = Config.IsLogVisible ? "▲ 로그 창 닫기" : "▼ 로그 창 열기"; + } + + private void ToggleLog() + { + Config.IsLogVisible = !Config.IsLogVisible; + ConfigService.Save(Config); + ApplyLogVisibility(); + } + + private void OpenLog() + { + try + { + string logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); + if (!Directory.Exists(logPath)) Directory.CreateDirectory(logPath); + Process.Start("explorer.exe", logPath); + } + catch (Exception ex) + { + MessageBox.Show($"로그 폴더를 열 수 없습니다: {ex.Message}", "에러", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void RefreshTodaySummary() + { + _lastSummaryDate = DateTime.Now.Date; + var summary = LogService.GetTodaySummary(); + TodayPassCount = summary.pass; + TodayFailCount = summary.fail; + } + + // ========================================== + // 지그 상태 관리 (스위치 입력 처리) + // ========================================== + private void UpdateJigLamps(bool i1, bool i2) + { + LeverPos currentPos = LeverPos.None; + if (i1 && i2) currentPos = LeverPos.Clamp; + else if (i1 && !i2) currentPos = LeverPos.Exhaust; + else if (!i1 && i2) currentPos = LeverPos.Pressurize; + + if (currentPos != _lastPos) + { + if (currentPos == LeverPos.Pressurize) + { + _wasPressurized = true; + if (!IsAutoMode) + { + ResultText = "대기 중"; + ResultColor = Brushes.Gray; + StartInspectionTimeText = "검사 시작 일시: --"; + LastInspectionTimeText = "검사 완료 일시: --"; + BasePressureText = "0.0"; + } + } + + if (currentPos == LeverPos.Clamp && _wasPressurized) + { + _wasPressurized = false; + if (!IsAutoMode) + { + StartAutoHoldSequence(); + } + } + else if (currentPos != LeverPos.Clamp) + { + if (currentPos != LeverPos.Pressurize) _wasPressurized = false; + _holdCts?.Cancel(); + } + + if (!IsAutoMode) + { + UpdateStatusByPos(currentPos); + } + else + { + UpdateAutoStatusByPos(currentPos); + } + _lastPos = currentPos; + } + } + + private void UpdateStatusByPos(LeverPos pos) + { + LampClampHold = Brushes.LightGray; LampPressurize = Brushes.LightGray; LampExhaust = Brushes.LightGray; + + switch (pos) + { + case LeverPos.Clamp: + CurrentStatusText = "[ 지그 고정 상태 ]"; + LampClampHold = Brushes.LimeGreen; + break; + case LeverPos.Pressurize: + CurrentStatusText = "[ 압력 인가 중 ]"; + LampPressurize = Brushes.LimeGreen; + break; + case LeverPos.Exhaust: + CurrentStatusText = "[ 압력 배기 중 ]"; + LampExhaust = Brushes.LimeGreen; + break; + default: + CurrentStatusText = "[ 대기 상태 (해제) ]"; + break; + } + } + + private void UpdateAutoStatusByPos(LeverPos pos) + { + if (_isAutoSequenceRunning) return; + + AutoLampClampHold = Brushes.LightGray; AutoLampPressurize = Brushes.LightGray; AutoLampExhaust = Brushes.LightGray; + + switch (pos) + { + case LeverPos.Clamp: + AutoTestStatusText = "[ 스위치 고정 (시작가능) ]"; + AutoLampClampHold = Brushes.LimeGreen; + break; + case LeverPos.Pressurize: + AutoTestStatusText = "[ 압력 인가 스위치 ]"; + AutoLampPressurize = Brushes.LimeGreen; + break; + case LeverPos.Exhaust: + AutoTestStatusText = "[ 압력 배기 스위치 ]"; + AutoLampExhaust = Brushes.LimeGreen; + break; + default: + AutoTestStatusText = "[ 스위치 대기 (해제) ]"; + break; + } + } + + // ========================================== + // 자동 검사 시퀀스 + // ========================================== + private void ExecuteAutoStartCommand() + { + if (_isAutoSequenceRunning) + { + _autoCycleCts?.Cancel(); + return; + } + if (_lastPos != LeverPos.Clamp) + { + MessageBox.Show("I/O 스위치가 '고정' 상태일 때만 자동 검사를 시작할 수 있습니다.", "경고", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + _ = StartAutoCycleSequence(); + } + + private async Task StartAutoCycleSequence() + { + if (_connManager.IOController == null || _connManager.PressureController == null) + { + MessageBox.Show("장치 연결이 완료되지 않았습니다.", "경고", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + _isAutoSequenceRunning = true; + _autoCycleCts?.Cancel(); + _autoCycleCts?.Dispose(); + _autoCycleCts = new CancellationTokenSource(); + var token = _autoCycleCts.Token; + const int ExhaustTimeoutMs = 60000; + + try + { + _uiDispatcher.Invoke(() => + { + AutoStartButtonText = "🛑 검사 강제 종료"; + AutoStartButtonColor = Brushes.Red; + AutoResultText = "진행 중..."; + AutoResultColor = Brushes.Orange; + _autoStartTime = DateTime.Now; + AutoStartInspectionTimeText = $"검사 시작: {_autoStartTime:yyyy-MM-dd HH:mm:ss}"; + AutoLastInspectionTimeText = "--"; + AutoLampClampHold = Brushes.LightGray; AutoLampPressurize = Brushes.LightGray; AutoLampExhaust = Brushes.LightGray; + }); + + // 1. 인가 (릴레이2) 전송 + _uiDispatcher.Invoke(() => { AutoTestStatusText = "[ 단계 1: 인가명령 전송 및 대기 ]"; AutoLampPressurize = Brushes.LimeGreen; }); + LogSys("[자동검사] 인가(릴레이2) 명령 송신"); + await _connManager.IOController.SetStateAsync(SemiIOLiteController.RelayState.Pressurize); + LogSys("[자동검사] 인가(Pressurize) 명령 완료"); + + // 2. 파라미터 목표 도달 대기 + double currentPv; + lock (_pressureLock) { currentPv = _currentPressureValue; } + while (currentPv < Config.AutoTestTargetPressure) + { + token.ThrowIfCancellationRequested(); + _uiDispatcher.Invoke(() => AutoTestStatusText = $"[ 단계 1: {Config.AutoTestTargetPressure:F1}bar 대기 중... 현재 {currentPv:F1}bar ]"); + await Task.Delay(500, token); + lock (_pressureLock) { currentPv = _currentPressureValue; } + } + + // 3. 고정(전부 끔) 전송 + _uiDispatcher.Invoke(() => { AutoTestStatusText = "[ 단계 2: 목표 도달. 고정명령 전송 ]"; AutoLampPressurize = Brushes.LightGray; AutoLampClampHold = Brushes.LimeGreen; }); + LogSys($"[자동검사] {Config.AutoTestTargetPressure:F1}bar 도달 완료. 고정 릴레이 명령 송신"); + await _connManager.IOController.SetStateAsync(SemiIOLiteController.RelayState.None); + LogSys("[자동검사] 고정(None) 명령 완료"); + + // 4. 수동검사 로직(딜레이 및 오차 판단) + double startPressure; + lock (_pressureLock) { startPressure = _currentPressureValue; } + int remaining = Config.HoldTime; + + _uiDispatcher.Invoke(() => { + BasePressureText = startPressure.ToString("F1"); + }); + + while (remaining > 0) + { + token.ThrowIfCancellationRequested(); + await Task.Delay(1000, token); + remaining--; + double curPv; + lock (_pressureLock) { curPv = _currentPressureValue; } + double delta = Math.Abs(startPressure - curPv); + _uiDispatcher.Invoke(() => AutoTestStatusText = $"[ 단계 3: 압력 유지 및 확인 ({remaining}s) | 변동: {delta:F1} ]"); + } + + double finalPressure; + lock (_pressureLock) { finalPressure = _currentPressureValue; } + double diff = Math.Abs(startPressure - finalPressure); + bool isPass = diff <= Config.AllowedErrorRange; + + // 5. 배기 명령 송신 + _uiDispatcher.Invoke(() => { AutoTestStatusText = "[ 단계 4: 검사 완료. 배기명령 전송 ]"; AutoLampClampHold = Brushes.LightGray; AutoLampExhaust = Brushes.LimeGreen; }); + LogSys($"[자동검사] 누출 검사 판정 도출. 배기(릴레이1) 명령 송신"); + await _connManager.IOController.SetStateAsync(SemiIOLiteController.RelayState.Exhaust); + LogSys("[자동검사] 배기(Exhaust) 명령 완료"); + + // 6. 0bar 도달 대기 (최대 60초 타임아웃) + var exhaustWatchNormal = Stopwatch.StartNew(); + lock (_pressureLock) { currentPv = _currentPressureValue; } + while (currentPv > 0.0) + { + token.ThrowIfCancellationRequested(); + if (exhaustWatchNormal.ElapsedMilliseconds > ExhaustTimeoutMs) + { + LogSys($"[자동검사] 배기 타임아웃 ({ExhaustTimeoutMs / 1000}초). 현재 압력: {_currentPressureValue:F1}bar. 다음 단계로 진행합니다."); + break; + } + _uiDispatcher.Invoke(() => AutoTestStatusText = $"[ 단계 5: 0.0bar 대기 중... 현재 {currentPv:F1}bar ]"); + await Task.Delay(500, token); + lock (_pressureLock) { currentPv = _currentPressureValue; } + } + + // 7. 다시 고정 전송 후 1사이클 완료 + _uiDispatcher.Invoke(() => { AutoTestStatusText = "[ 단계 6: 시스템 초기화. 고정명령 전송 ]"; }); + LogSys("[자동검사] 0.0bar 도달 완료. 고정 명령 송신 및 사이클 정상 종료"); + await _connManager.IOController.SetStateAsync(SemiIOLiteController.RelayState.None); + LogSys("[자동검사] 고정(None) 명령 완료 - 사이클 종료"); + + _uiDispatcher.Invoke(() => + { + AutoLampExhaust = Brushes.LightGray; + AutoLampClampHold = Brushes.LimeGreen; + + if (isPass) + { + AutoTestStatusText = "[ 1사이클 정상 완료 (PASS) ]"; + AutoResultText = $"PASS (오차: {diff:F1} bar)"; + AutoResultColor = Brushes.LimeGreen; + } + else + { + AutoTestStatusText = "[ 1사이클 완료 (누출 FAIL) ]"; + AutoResultText = $"FAIL (오차: {diff:F1} bar)"; + AutoResultColor = Brushes.Red; + } + + AutoLastInspectionTimeText = $"종료: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"; + }); + + // CSV 로그 기록 + bool success = LogService.AppendLog(new LogData + { + Index = TodayPassCount + TodayFailCount + 1, + StartTime = _autoStartTime, + EndTime = DateTime.Now, + TargetPressure = Config.AutoTestTargetPressure, + ErrorValue = diff, + Result = isPass ? "PASS" : "FAIL" + }); + + if (success) + { + if (isPass) TodayPassCount++; + else TodayFailCount++; + } + else + { + LogSys("!!! [자료저장 실패] CSV 로그 기록 중 오류가 발생했습니다. (엑셀에서 파일이 열려있는지 확인하세요)"); + } + + } + catch (OperationCanceledException) + { + LogSys("[자동검사] 강제 종료 요청됨. 배기 및 초기화 시퀀스 시작."); + _uiDispatcher.Invoke(() => { + AutoTestStatusText = $"[ 강제 종료 중: 배기 ]"; + AutoResultText = "취소됨"; + AutoResultColor = Brushes.Orange; + AutoStartButtonText = "종료 처리 중..."; + AutoStartButtonColor = Brushes.Gray; + AutoLampClampHold = Brushes.LightGray; AutoLampPressurize = Brushes.LightGray; AutoLampExhaust = Brushes.LimeGreen; + AutoLastInspectionTimeText = $"강제 종료: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"; + }); + + try + { + _uiDispatcher.Invoke(() => AutoTestStatusText = "[ 강제 종료: 배기명령 전송 ]"); + await _connManager.IOController.SetStateAsync(SemiIOLiteController.RelayState.Exhaust); + LogSys("[자동검사] 강제 배기(Exhaust) 명령 완료"); + + var exhaustWatchCancel = Stopwatch.StartNew(); + double cancelPv; + lock (_pressureLock) { cancelPv = _currentPressureValue; } + while (cancelPv > 0.0) + { + if (exhaustWatchCancel.ElapsedMilliseconds > ExhaustTimeoutMs) + { + LogSys($"[자동검사] 강제종료 배기 타임아웃 ({ExhaustTimeoutMs / 1000}초). 현재 압력: {_currentPressureValue:F1}bar. 다음 단계로 진행합니다."); + break; + } + _uiDispatcher.Invoke(() => AutoTestStatusText = $"[ 강제 종료: 0.0bar 대기 중... 현재 {cancelPv:F1}bar ]"); + await Task.Delay(500); + lock (_pressureLock) { cancelPv = _currentPressureValue; } + } + + _uiDispatcher.Invoke(() => AutoTestStatusText = "[ 강제 종료: 고정명령 전송 ]"); + await _connManager.IOController.SetStateAsync(SemiIOLiteController.RelayState.None); + LogSys("[자동검사] 강제 고정(None) 명령 완료"); + + _uiDispatcher.Invoke(() => { + AutoTestStatusText = "[ 강제 종료 완료. 장치 대기 상태 ]"; + AutoLampExhaust = Brushes.LightGray; + AutoLampClampHold = Brushes.LimeGreen; + }); + LogSys("[자동검사] 강제 종료 정상 처리 완료"); + } + catch (Exception ex) + { + LogSys($"[자동검사] 강제종료 처리 중 에러: {ex.Message}"); + } + } + catch (Exception ex) + { + LogSys($"[자동검사] 심각한 에러 발생: {ex.Message}"); + _uiDispatcher.Invoke(() => { + AutoTestStatusText = $"[ 에러 발생: 사이클 중단됨 ]"; + AutoResultText = "ERROR"; + AutoResultColor = Brushes.Red; + }); + } + finally + { + _isAutoSequenceRunning = false; + _uiDispatcher.Invoke(() => + { + AutoStartButtonText = "🚀 자동 검사 시작"; + AutoStartButtonColor = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0078D7")); + }); + } + } + + // ========================================== + // 수동 검사 시퀀스 + // ========================================== + private async void StartAutoHoldSequence() + { + _holdCts?.Cancel(); + _holdCts?.Dispose(); + _holdCts = new CancellationTokenSource(); + var token = _holdCts.Token; + + try + { + // 압력 센서 통신 상태 체크 (시작 시) + bool isPrConnected = !Config.PressureEnabled || _connManager.IsPressureConnected; + if (!isPrConnected) + { + _ = _uiDispatcher.BeginInvoke(new Action(() => { + CurrentStatusText = "[ 압력센서 통신 불가 ]"; + LampClampHold = Brushes.Red; + ResultText = "FAIL (통신 오류)"; + ResultColor = Brushes.Red; + LastInspectionTimeText = $"검사 중단: {DateTime.Now:yyyy-MM-dd HH:mm:ss} (통신 불가)"; + })); + LogPrSys("!!! [검사 중단] 압력 센서 통신 불가 상태에서 검사가 시작되었습니다."); + return; + } + + double startPressure; + lock (_pressureLock) { startPressure = _currentPressureValue; } + int remaining = Config.HoldTime; + _manualStartTime = DateTime.Now; + + _ = _uiDispatcher.BeginInvoke(new Action(() => { + BasePressureText = startPressure.ToString("F1"); + StartInspectionTimeText = $"검사 시작 일시: {_manualStartTime:yyyy-MM-dd HH:mm:ss}"; + LastInspectionTimeText = "검사 중..."; + + CurrentStatusText = $"[ 누출 확인 중 ({remaining}s) ]"; + LampClampHold = Brushes.Orange; + + ResultText = "검사 중..."; + ResultColor = Brushes.Orange; + })); + + while (remaining > 0) + { + token.ThrowIfCancellationRequested(); + + // 압력 센서 통신 상태 체크 (검사 중) + isPrConnected = !Config.PressureEnabled || _connManager.IsPressureConnected; + if (!isPrConnected) + { + _ = _uiDispatcher.BeginInvoke(new Action(() => { + CurrentStatusText = "[ 압력센서 통신 불가 ]"; + LampClampHold = Brushes.Red; + ResultText = "FAIL (통신 유실)"; + ResultColor = Brushes.Red; + LastInspectionTimeText = $"검사 중단: {DateTime.Now:yyyy-MM-dd HH:mm:ss} (통신 유실)"; + })); + LogPrSys("!!! [검사 중단] 검사 도중 압력 센서 통신이 끊겼습니다."); + return; + } + + await Task.Delay(1000, token); + remaining--; + + double holdCurPv; + lock (_pressureLock) { holdCurPv = _currentPressureValue; } + double delta = Math.Abs(startPressure - holdCurPv); + CurrentStatusText = $"[ 누출 확인 중 ({remaining}s) | 변동: {delta:F1} ]"; + } + + double finalPressure; + lock (_pressureLock) { finalPressure = _currentPressureValue; } + double diff = Math.Abs(startPressure - finalPressure); + bool isPass = diff <= Config.AllowedErrorRange; + string now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + + if (isPass) + { + CurrentStatusText = "[ 누출 확인 완료 (PASS) ]"; + LampClampHold = Brushes.LimeGreen; + ResultText = $"PASS (오차: {diff:F1} bar)"; + ResultColor = Brushes.LimeGreen; + LogSys($">>> 결과: PASS (오차: {diff:F1} bar)"); + _uiDispatcher.Invoke(() => LastInspectionTimeText = $"검사 완료 일시: {now} (PASS)"); + } + else + { + CurrentStatusText = "[ 누출 감지됨 (FAIL) ]"; + LampClampHold = Brushes.Red; + ResultText = $"FAIL (오차: {diff:F1} bar)"; + ResultColor = Brushes.Red; + LogSys($">>> 결과: FAIL (오차: {diff:F1} bar - 설정 허용치 초과)"); + _uiDispatcher.Invoke(() => LastInspectionTimeText = $"검사 완료 일시: {now} (FAIL)"); + } + + // CSV 로그 기록 + bool success = LogService.AppendLog(new LogData + { + Index = TodayPassCount + TodayFailCount + 1, + StartTime = _manualStartTime, + EndTime = DateTime.Now, + TargetPressure = startPressure, + ErrorValue = diff, + Result = isPass ? "PASS" : "FAIL" + }); + + if (success) + { + if (isPass) TodayPassCount++; + else TodayFailCount++; + } + else + { + LogSys("!!! [자료저장 실패] CSV 로그 기록 중 오류가 발생했습니다. (엑셀에서 파일이 열려있는지 확인하세요)"); + } + } + catch (OperationCanceledException) + { + _ = _uiDispatcher.BeginInvoke(new Action(() => + { + if (ResultText == "검사 중...") + { + ResultText = "취소됨"; + ResultColor = Brushes.Gray; + CurrentStatusText = "[ 검사 취소됨 ]"; + LastInspectionTimeText = $"검사 취소: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"; + } + })); + } + } + + // ========================================== + // 로그 + // ========================================== + private void LogSys(string msg) + { + _ = _uiDispatcher.BeginInvoke(new Action(() => { + string time = DateTime.Now.ToString("HH:mm:ss"); + string entry = $"[{time}] {msg}\n"; + IOLogText = AppendAndTrimLog(IOLogText, entry); + OnPropertyChanged(nameof(IOLogText)); + })); + } + + private void LogPrSys(string msg) + { + _ = _uiDispatcher.BeginInvoke(new Action(() => { + string time = DateTime.Now.ToString("HH:mm:ss"); + string entry = $"[{time}] {msg}\n"; + PrLogText = AppendAndTrimLog(PrLogText, entry); + OnPropertyChanged(nameof(PrLogText)); + })); + } + + private void LogHex(string prefix, byte[] buffer) + { + _ = _uiDispatcher.BeginInvoke(new Action(() => { + string hex = BitConverter.ToString(buffer).Replace("-", " "); + string time = DateTime.Now.ToString("HH:mm:ss.fff"); + string entry = $"[{time}] {prefix} {hex}\n"; + + if (prefix.Contains("[PR")) + { + PrLogText = AppendAndTrimLog(PrLogText, entry); + OnPropertyChanged(nameof(PrLogText)); + } + else + { + IOLogText = AppendAndTrimLog(IOLogText, entry); + OnPropertyChanged(nameof(IOLogText)); + } + })); + } + + private string AppendAndTrimLog(string currentLog, string newEntry) + { + const int MaxChars = 50000; + const int KeepChars = 30000; + + string combined = currentLog + newEntry; + if (combined.Length > MaxChars) + { + string trimmed = combined.Substring(combined.Length - KeepChars); + int firstNewLine = trimmed.IndexOf('\n'); + if (firstNewLine >= 0 && firstNewLine < trimmed.Length - 1) + { + return trimmed.Substring(firstNewLine + 1); + } + return trimmed; + } + return combined; + } + + // ========================================== + // 설정 / 파라미터 창 + // ========================================== + private void OpenSettings() + { + var vm = new SettingsViewModel(Config); + var win = new Views.SettingsWindow { DataContext = vm }; + vm.RequestClose = (isSaved) => { + if (isSaved) + { + Config = ConfigService.Load(); + _connManager.UpdateConfig(Config); + ApplyLogVisibility(); + _ = StartConnectionAsync(); + } + win.Close(); + }; + win.ShowDialog(); + } + + private void OpenParameter() + { + var vm = new ParameterViewModel(Config); + var win = new Views.ParameterWindow { DataContext = vm }; + vm.RequestClose = (isSaved) => { + if (isSaved) + { + Config = ConfigService.Load(); + UpdateParamDisplay(); + } + win.Close(); + }; + win.ShowDialog(); + } + + // ========================================== + // IDisposable + // ========================================== + public void Dispose() + { + try + { + _clockTimer?.Stop(); + _holdCts?.Cancel(); + _holdCts?.Dispose(); + _autoCycleCts?.Cancel(); + _autoCycleCts?.Dispose(); + + _connManager?.Dispose(); + LogSys(">>> [시스템] 프로그램 종료로 인한 모든 자원 해제 완료"); + } + catch (Exception ex) + { + Debug.WriteLine($"[MainViewModel] Dispose 중 예외: {ex.Message}"); + } + } + } +} diff --git a/jig_test/ViewModels/ParameterViewModel.cs b/jig_test/ViewModels/ParameterViewModel.cs new file mode 100644 index 0000000..18a045d --- /dev/null +++ b/jig_test/ViewModels/ParameterViewModel.cs @@ -0,0 +1,69 @@ +using System; +using System.Windows; +using System.Windows.Input; +using jig_test.Models; +using jig_test.Services; +using jig_test.ViewModels.Base; + +namespace jig_test.ViewModels +{ + public class ParameterViewModel : ObservableObject + { + private AppConfig _config; + + public AppConfig Config + { + get => _config; + set => SetProperty(ref _config, value); + } + + public ICommand SaveCommand { get; } + public ICommand CancelCommand { get; } + + public Action RequestClose; + + public ParameterViewModel(AppConfig currentConfig) + { + Config = currentConfig.Clone(); + + SaveCommand = new RelayCommand(SaveParameters); + CancelCommand = new RelayCommand(CancelParameters); + } + + private void SaveParameters() + { + if (Config.HoldTime <= 0 || Config.HoldTime >= 3600) + { + MessageBox.Show("유지 시간은 1초 이상, 3600초 미만이어야 합니다.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + if (Config.AllowedErrorRange < 0.0 || Config.AllowedErrorRange > 5.0) + { + MessageBox.Show("허용 오차는 0.0 이상 5.0 이하의 값이어야 합니다.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + if (Config.AutoTestTargetPressure <= 0.0 || Config.AutoTestTargetPressure > 10.0) + { + MessageBox.Show("자동검사 목표 압력은 0.1 이상 10.0 이하의 값이어야 합니다.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + try + { + ConfigService.Save(Config); + RequestClose?.Invoke(true); + } + catch (Exception ex) + { + MessageBox.Show($"파라미터 저장 중 오류가 발생했습니다: {ex.Message}"); + } + } + + private void CancelParameters() + { + RequestClose?.Invoke(false); + } + } +} diff --git a/jig_test/ViewModels/SettingsViewModel.cs b/jig_test/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..234a340 --- /dev/null +++ b/jig_test/ViewModels/SettingsViewModel.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.IO.Ports; +using System.Linq; +using System.Windows.Input; +using jig_test.Models; +using jig_test.Services; +using jig_test.ViewModels.Base; +using System.Windows; + +namespace jig_test.ViewModels +{ + public class SettingsViewModel : ObservableObject + { + private AppConfig _config; + + public AppConfig Config + { + get => _config; + set => SetProperty(ref _config, value); + } + + public List AvailablePorts { get; } + public List AvailableBaudRates { get; } = new List { 9600, 19200, 38400, 57600, 115200 }; + + public ICommand SaveCommand { get; } + public ICommand CancelCommand { get; } + + // 창 닫기를 위한 델리게이트 이벤트 (View에서 구독하여 창 닫기) + public Action RequestClose; + + public SettingsViewModel(AppConfig currentConfig) + { + // 취소를 위해 복사본 사용 + Config = currentConfig.Clone(); + AvailablePorts = SerialPort.GetPortNames().ToList(); + + // 시스템에 COM 포트가 전혀 없는 경우 현재 설정값을 폴백으로 추가 + if (AvailablePorts.Count == 0) + { + if (!string.IsNullOrEmpty(Config.PortName)) AvailablePorts.Add(Config.PortName); + if (!string.IsNullOrEmpty(Config.PressurePortName) && !AvailablePorts.Contains(Config.PressurePortName)) + AvailablePorts.Add(Config.PressurePortName); + } + + SaveCommand = new RelayCommand(SaveSettings); + CancelCommand = new RelayCommand(CancelSettings); + + // 초기 포트 값이 리스트에 없을 수도 있으므로 (연결 해제 등), 안전하게 설정 + if (!AvailablePorts.Contains(Config.PortName)) Config.PortName = AvailablePorts.FirstOrDefault() ?? Config.PortName; + if (!AvailablePorts.Contains(Config.PressurePortName)) Config.PressurePortName = AvailablePorts.FirstOrDefault() ?? Config.PressurePortName; + } + + private void SaveSettings() + { + try + { + ConfigService.Save(Config); + RequestClose?.Invoke(true); // 저장성공 + } + catch (Exception ex) + { + MessageBox.Show($"저장 중 오류가 발생했습니다: {ex.Message}"); + } + } + + private void CancelSettings() + { + RequestClose?.Invoke(false); // 취소 + } + } +} diff --git a/jig_test/Views/MainWindow.xaml b/jig_test/Views/MainWindow.xaml new file mode 100644 index 0000000..546edff --- /dev/null +++ b/jig_test/Views/MainWindow.xaml @@ -0,0 +1,294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jig_test/Views/MainWindow.xaml.cs b/jig_test/Views/MainWindow.xaml.cs new file mode 100644 index 0000000..841ce46 --- /dev/null +++ b/jig_test/Views/MainWindow.xaml.cs @@ -0,0 +1,66 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using jig_test.ViewModels; + +namespace jig_test.Views +{ + public partial class MainWindow : Window + { + private MainViewModel _viewModel; + + public MainWindow() + { + InitializeComponent(); + _viewModel = new MainViewModel(); + this.DataContext = _viewModel; + + // 윈도우가 닫힐 때 자원 해제 보장 + this.Closed += (s, e) => _viewModel?.Dispose(); + } + + private void Window_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton == MouseButton.Left) + this.DragMove(); + } + + private void BtnMinimize_Click(object sender, RoutedEventArgs e) + { + this.WindowState = WindowState.Minimized; + } + + private void BtnMaximizeRestore_Click(object sender, RoutedEventArgs e) + { + if (this.WindowState == WindowState.Maximized) + this.WindowState = WindowState.Normal; + else + this.WindowState = WindowState.Maximized; + } + + private void BtnClose_Click(object sender, RoutedEventArgs e) + { + _viewModel?.Dispose(); + Application.Current.Shutdown(); + } + + /// + /// TextBox의 Text가 바인딩으로 변경될 때 항상 가장 아래로 스크롤되도록 처리 (UI전용 편의기능) + /// + private void TextBox_TextChanged(object sender, TextChangedEventArgs e) + { + if (sender is TextBox tb && tb.Parent is ScrollViewer sv) + { + bool isAtBottom = sv.VerticalOffset >= sv.ScrollableHeight - 5.0; + if (isAtBottom) + { + sv.ScrollToEnd(); + } + } + else if (sender is TextBox tbx) + { + tbx.ScrollToEnd(); + } + } + } +} diff --git a/jig_test/Views/ParameterWindow.xaml b/jig_test/Views/ParameterWindow.xaml new file mode 100644 index 0000000..68ffd8b --- /dev/null +++ b/jig_test/Views/ParameterWindow.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +