using System.Globalization; using System.IO; using System.Text; using System.Text.RegularExpressions; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Threading; using Housing.Login; using Housing.Models; using Housing.Services; namespace Housing; public partial class MainWindow : Window { private const string BarcodeRequiredMessage = "스캐너로 PCB_Barcode를 스캔해주세요."; private const string BoardReadyMessage = "스캔 완료. 시료 투입 후 Housing 장비 시작 버튼을 눌러주세요."; private const string BarcodeInvalidMessage = "스캔 형식이 올바르지 않습니다. PCBA S/N 바코드를 스캔해주세요."; private const string DefaultOutputValue = "0.00"; private const int ScannerInputMinimumLength = 4; private static readonly TimeSpan ScannerInputMaxGap = TimeSpan.FromMilliseconds(80); private static readonly TimeSpan ScannerInputIdleCommitDelay = TimeSpan.FromMilliseconds(250); private readonly StringBuilder _scannerInputBuffer = new(); private readonly DispatcherTimer _scannerInputCommitTimer; private DateTime _lastScannerInputAt = DateTime.MinValue; private StartupLoginWindowResult _loginResult = new(); private InspectionJudgementSettings _inspectionSettings; private bool _isStartupLoginShown; private bool _isBoardTestRunning; public MainWindow() { _inspectionSettings = InspectionSettingsStore.Load(GetInspectionSettingsIniPath()); InitializeComponent(); _scannerInputCommitTimer = new DispatcherTimer { Interval = ScannerInputIdleCommitDelay }; _scannerInputCommitTimer.Tick += ScannerInputCommitTimer_Tick; CommandManager.AddPreviewExecutedHandler(BarcodeTextBox, BarcodeTextBox_PreviewExecuted); ApplyInspectionSettingsToTextBoxes(); ApplyLoginResult(); ResetScreen(); } protected override void OnContentRendered(EventArgs e) { base.OnContentRendered(e); if (_isStartupLoginShown) { return; } _isStartupLoginShown = true; if (!ShowLoginPopup()) { Close(); } } private void BarcodeTextBox_TextChanged(object sender, TextChangedEventArgs e) { if (string.IsNullOrWhiteSpace(BarcodeTextBox.Text)) { IcSnOutputTextBox.Clear(); ResultOutputTextBox.Text = BarcodeRequiredMessage; return; } ResultOutputTextBox.Text = BoardReadyMessage; } private void BarcodeTextBox_PreviewTextInput(object sender, TextCompositionEventArgs e) { e.Handled = true; AppendScannerText(e.Text); } private async void BarcodeTextBox_PreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Enter || e.Key == Key.Return || e.Key == Key.Tab) { e.Handled = true; await CompleteScannerInputAsync(runBoardSequence: true); return; } if (e.Key == Key.Back || e.Key == Key.Delete || e.Key == Key.Escape || Keyboard.Modifiers.HasFlag(ModifierKeys.Control) || Keyboard.Modifiers.HasFlag(ModifierKeys.Alt)) { e.Handled = true; ClearPendingScannerInput(); return; } } private void BarcodeTextBox_PreviewExecuted(object sender, ExecutedRoutedEventArgs e) { e.Handled = true; } private void ScannerInputCommitTimer_Tick(object? sender, EventArgs e) { _scannerInputCommitTimer.Stop(); _ = CompleteScannerInputAsync(runBoardSequence: true); } private void AppendScannerText(string text) { var now = DateTime.UtcNow; if (_scannerInputBuffer.Length > 0 && now - _lastScannerInputAt > ScannerInputMaxGap) { _scannerInputBuffer.Clear(); } _lastScannerInputAt = now; _scannerInputBuffer.Append(text); _scannerInputCommitTimer.Stop(); _scannerInputCommitTimer.Start(); } private async Task CompleteScannerInputAsync(bool runBoardSequence) { _scannerInputCommitTimer.Stop(); var scannerText = _scannerInputBuffer.ToString().Trim(); _scannerInputBuffer.Clear(); var hasPendingScannerInput = scannerText.Length > 0; var acceptedScannerInput = TryExtractPcbBarcode(scannerText, out var scannedBarcode); if (acceptedScannerInput) { BarcodeTextBox.Text = scannedBarcode; BarcodeTextBox.CaretIndex = BarcodeTextBox.Text.Length; } else if (hasPendingScannerInput && scannerText.Length >= ScannerInputMinimumLength) { BarcodeTextBox.Clear(); ResultOutputTextBox.Text = BarcodeInvalidMessage; FocusBarcodeInput(); } if (runBoardSequence) { if (!hasPendingScannerInput || acceptedScannerInput) { await RunBoardSequenceAsync(); } return; } FocusBarcodeInput(); } private static bool TryExtractPcbBarcode(string scannerText, out string pcbBarcode) { pcbBarcode = string.Empty; if (string.IsNullOrWhiteSpace(scannerText)) { return false; } var pcbaSerialMatch = Regex.Match( scannerText, @"PCBA\s*S\/N\s*:\s*(?[A-Za-z0-9-]{4,50})", RegexOptions.IgnoreCase); if (pcbaSerialMatch.Success) { pcbBarcode = pcbaSerialMatch.Groups["serial"].Value.Trim(); return true; } var fields = scannerText .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(part => part.Split(':', 2, StringSplitOptions.TrimEntries)) .Where(parts => parts.Length == 2) .ToDictionary(parts => parts[0], parts => parts[1], StringComparer.OrdinalIgnoreCase); if (!fields.ContainsKey("MODEL") || !fields.TryGetValue("SERIAL", out var serial) || !fields.ContainsKey("OPTION") || string.IsNullOrWhiteSpace(serial)) { return false; } pcbBarcode = serial.Trim(); return pcbBarcode.Length <= 50; } private void ClearPendingScannerInput() { _scannerInputCommitTimer.Stop(); _scannerInputBuffer.Clear(); } public async Task OnEquipmentStartButtonPressedAsync() { await RunBoardSequenceAsync(); } private async Task RunBoardSequenceAsync() { if (_isBoardTestRunning) { return; } if (string.IsNullOrWhiteSpace(BarcodeTextBox.Text)) { ResultOutputTextBox.Text = BarcodeRequiredMessage; FocusBarcodeInput(); return; } if (!TryApplyInspectionSettingsFromTextBoxes()) { return; } try { _isBoardTestRunning = true; SetInputEnabled(false); var boardSettings = BoardHardwareSettings.Load(GetHardwareIniPath()); var equipmentSettings = EquipmentMeasurementSettings.Load(GetHardwareIniPath()); var startSignalSettings = StartSignalSettings.Load(GetHardwareIniPath()); var startSignalReceivedAt = await WaitForEquipmentStartSignalAsync(startSignalSettings); var measurementNotBeforeUtc = startSignalReceivedAt?.AddMilliseconds(startSignalSettings.PostSignalDelayMilliseconds); ResultOutputTextBox.Text = "보드 테스트 시작\r\n" + "1. 시료 연결 확인\r\n" + "2. IC_SN 읽기\r\n" + "3. CAL DEFAULT 조건 설정\r\n" + "4. 시작 신호 기준 지연 후 계측 장비에서 V/A 읽기"; var service = new BoardTestService(boardSettings, equipmentSettings); string? dbHeaderSaveError = null; var measurement = await service.RunAsync( async icSn => { IcSnOutputTextBox.Text = icSn; dbHeaderSaveError = await SaveHeaderStatusAsync(icSn); }, measurementNotBeforeUtc); IcSnOutputTextBox.Text = measurement.IcSn; VOutputTextBox.Text = FormatDecimal(measurement.Voltage); AOutputTextBox.Text = FormatDecimal(measurement.Current); var resultCode = _inspectionSettings.IsPass(measurement.Voltage, measurement.Current) ? "OK" : "NG"; ResultOutputTextBox.Text = resultCode; var dbMeasurementSaveError = await SaveMeasurementStatusAsync(resultCode); ResultOutputTextBox.Text = $"{resultCode}\r\n{BuildDbSaveMessage(dbHeaderSaveError, dbMeasurementSaveError)}"; } catch (Exception ex) { ResultOutputTextBox.Text = $"보드 테스트 실패: {ex.Message}"; } finally { _isBoardTestRunning = false; SetInputEnabled(true); FocusBarcodeInput(); } } private async Task WaitForEquipmentStartSignalAsync(StartSignalSettings settings) { if (!settings.Enabled) { return null; } ResultOutputTextBox.Text = "장비 시작 신호 대기\r\n" + $"{StartSignalWatcher.Describe(settings)}\r\n" + "시료를 넣고 Housing 장비 시작 버튼을 눌러주세요."; await StartSignalWatcher.WaitForStartAsync(settings); var receivedAt = DateTime.UtcNow; if (settings.PostSignalDelayMilliseconds <= 0) { ResultOutputTextBox.Text = "장비 시작 신호 수신 완료"; return receivedAt; } ResultOutputTextBox.Text = "장비 시작 신호 수신 완료\r\n" + $"전압 측정은 신호 수신 후 {settings.PostSignalDelayMilliseconds / 1000.0:0.###}초 이후 시작합니다."; return receivedAt; } private async void LogoutButton_Click(object sender, RoutedEventArgs e) { LogoutButton.IsEnabled = false; await RunEquipmentLogoutCommandAsync(); ResetScreen(); _loginResult = new StartupLoginWindowResult(); OperatorTextBlock.Text = "-"; LogoutButton.Visibility = Visibility.Collapsed; if (!ShowLoginPopup()) { Close(); } } private async Task RunEquipmentLogoutCommandAsync() { try { var equipmentSettings = EquipmentMeasurementSettings.Load(GetHardwareIniPath()); var equipmentService = new EquipmentMeasurementService(equipmentSettings); await equipmentService.RunLogoutCommandAsync(); } catch (Exception ex) { MessageBox.Show(this, $"파워서플라이 logout 전원 OFF 실패: {ex.Message}", "Logout", MessageBoxButton.OK, MessageBoxImage.Warning); } } private bool ShowLoginPopup() { var loginWindow = new StartupLoginWindow { Owner = this }; if (loginWindow.ShowDialog() != true) { return false; } _loginResult = loginWindow.Result; ApplyLoginResult(); ResetScreen(); return true; } private void ApplyLoginResult() { OperatorTextBlock.Text = string.IsNullOrWhiteSpace(_loginResult.Operator) ? "-" : _loginResult.Operator; var isLoggedIn = !string.IsNullOrWhiteSpace(_loginResult.Operator); LogoutButton.Visibility = isLoggedIn ? Visibility.Visible : Visibility.Collapsed; LogoutButton.IsEnabled = isLoggedIn; } private void ResetScreen() { BarcodeTextBox.Clear(); IcSnOutputTextBox.Clear(); VOutputTextBox.Text = DefaultOutputValue; AOutputTextBox.Text = DefaultOutputValue; ResultOutputTextBox.Text = BarcodeRequiredMessage; FocusBarcodeInput(); } private void SetInputEnabled(bool isEnabled) { BarcodeTextBox.IsEnabled = isEnabled; VMinTextBox.IsEnabled = isEnabled; VMaxTextBox.IsEnabled = isEnabled; AMinTextBox.IsEnabled = isEnabled; AMaxTextBox.IsEnabled = isEnabled; LogoutButton.IsEnabled = isEnabled; } private void FocusBarcodeInput() { Dispatcher.BeginInvoke(new Action(() => { BarcodeTextBox.Focus(); Keyboard.Focus(BarcodeTextBox); BarcodeTextBox.SelectAll(); }), DispatcherPriority.Input); } private async Task SaveHeaderStatusAsync(string icSn) { try { var repository = new HousingAssemblyRepository(DatabaseSettings.Load(GetDatabaseIniPath())); var record = CreateHeaderRecord(icSn); await repository.UpsertHeaderAsync(record); return null; } catch (Exception ex) { return ex.Message; } } private async Task SaveMeasurementStatusAsync(string resultCode) { try { var repository = new HousingAssemblyRepository(DatabaseSettings.Load(GetDatabaseIniPath())); var record = CreateMeasurementRecord(resultCode); await repository.UpdateMeasurementAsync(record); return null; } catch (Exception ex) { return ex.Message; } } private static string BuildDbSaveMessage(params string?[] errors) { var failureMessages = new List(); foreach (var error in errors) { if (!string.IsNullOrWhiteSpace(error)) { failureMessages.Add(error); } } return failureMessages.Count == 0 ? "DB 저장 완료" : $"DB 저장 실패 ({string.Join("; ", failureMessages)})"; } private HousingAssemblyRecord CreateHeaderRecord(string icSn) { var barcode = BarcodeTextBox.Text.Trim(); return new HousingAssemblyRecord { IcSn = icSn, PcbBarcode = barcode, Maker = _loginResult.Maker, Model = _loginResult.Model, Variant1 = _loginResult.Variant1, Variant2 = _loginResult.Variant2, Operator = _loginResult.Operator, Password = _loginResult.Password, ProductionDate = DateTime.Now, Line = _loginResult.LineNo, LotNo = _loginResult.LotNo, JigNo = _loginResult.JigNo }; } private HousingAssemblyRecord CreateMeasurementRecord(string resultCode) { var barcode = BarcodeTextBox.Text.Trim(); var icSn = IcSnOutputTextBox.Text.Trim(); if (string.IsNullOrWhiteSpace(icSn)) { icSn = barcode; IcSnOutputTextBox.Text = icSn; } return new HousingAssemblyRecord { IcSn = icSn, PcbBarcode = barcode, PtVol1 = ParseDecimal(VOutputTextBox.Text), PtCurrent1 = ParseDecimal(AOutputTextBox.Text), Result = resultCode, Spare1 = null, Spare2 = null, Spare3 = null, Spare4 = null }; } private void ApplyInspectionSettingsToTextBoxes() { VMinTextBox.Text = FormatDecimal(_inspectionSettings.VMin); VMaxTextBox.Text = FormatDecimal(_inspectionSettings.VMax); AMinTextBox.Text = FormatDecimal(_inspectionSettings.AMin); AMaxTextBox.Text = FormatDecimal(_inspectionSettings.AMax); } private bool TryApplyInspectionSettingsFromTextBoxes() { if (!TryReadRangeValue(VMinTextBox, "V 최소값", out var vMin) || !TryReadRangeValue(VMaxTextBox, "V 최대값", out var vMax) || !TryReadRangeValue(AMinTextBox, "A 최소값", out var aMin) || !TryReadRangeValue(AMaxTextBox, "A 최대값", out var aMax)) { return false; } if (vMin > vMax) { MessageBox.Show(this, "V 최소값은 최대값보다 클 수 없습니다.", "판정 범위", MessageBoxButton.OK, MessageBoxImage.Warning); VMinTextBox.Focus(); return false; } if (aMin > aMax) { MessageBox.Show(this, "A 최소값은 최대값보다 클 수 없습니다.", "판정 범위", MessageBoxButton.OK, MessageBoxImage.Warning); AMinTextBox.Focus(); return false; } _inspectionSettings = new InspectionJudgementSettings { VMin = vMin, VMax = vMax, AMin = aMin, AMax = aMax }; InspectionSettingsStore.Save(GetInspectionSettingsIniPath(), _inspectionSettings); return true; } private bool TryReadRangeValue(TextBox textBox, string fieldName, out decimal value) { if (decimal.TryParse(textBox.Text, NumberStyles.Number, CultureInfo.InvariantCulture, out value)) { return true; } if (decimal.TryParse(textBox.Text, out value)) { return true; } MessageBox.Show(this, $"{fieldName}은 숫자로 입력하세요.", "판정 범위", MessageBoxButton.OK, MessageBoxImage.Warning); textBox.Focus(); return false; } private static decimal ParseDecimal(string value) { return decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var result) ? result : 0m; } private static string FormatDecimal(decimal value) { return value.ToString("0.00", CultureInfo.InvariantCulture); } private static string GetDatabaseIniPath() { return Path.Combine(AppContext.BaseDirectory, "Database.ini"); } private static string GetHardwareIniPath() { return Path.Combine(AppContext.BaseDirectory, "Hardware.ini"); } private static string GetInspectionSettingsIniPath() { return Path.Combine(AppContext.BaseDirectory, "InspectionSettings.ini"); } }