Browse Source

modify: login GUI

main
gudae 2 months ago
parent
commit
fb5d135619
  1. BIN
      .vs/Housing/DesignTimeBuild/.dtbcache.v2
  2. 9
      Database.ini
  3. 13
      Hardware.ini
  4. 5
      Login/StartupLoginWindow.xaml
  5. 313
      Login/StartupLoginWindow.xaml.cs
  6. 4
      MainWindow.xaml
  7. 191
      MainWindow.xaml.cs
  8. 14
      Services/BoardTestService.cs
  9. 85
      Services/HousingAssemblyRepository.cs
  10. 58
      Services/LoginAccountRepository.cs
  11. 99
      Services/LoginHistoryStore.cs
  12. 86
      Services/LoginSettings.cs
  13. 191
      Services/Ni6501StartSignalWatcher.cs
  14. 125
      Services/StartSignalSettings.cs
  15. 40
      Services/StartSignalWatcher.cs
  16. 151
      Services/TcpStartSignalWatcher.cs

BIN
.vs/Housing/DesignTimeBuild/.dtbcache.v2

Binary file not shown.

9
Database.ini

@ -6,3 +6,12 @@ DbPw=your_password
Encrypt=True Encrypt=True
TrustServerCertificate=True TrustServerCertificate=True
Timeout=5 Timeout=5
[Login]
; 로그인 승인에 사용할 DB 테이블/컬럼입니다.
; 별도 작업자 테이블이 있으면 이 값을 해당 테이블/컬럼으로 바꾸세요.
Table=dbo.Housing_Assembly
IdColumn=Operator
PasswordColumn=Password
; DB 연결이 안 되는 상태에서 화면만 확인할 때 true로 둡니다. 운영 시 false로 바꾸세요.
OfflinePreview=true

13
Hardware.ini

@ -10,12 +10,21 @@ EndToken=<end>
DtrEnable=false DtrEnable=false
RtsEnable=false RtsEnable=false
[StartSignal]
; USB-6501
Enabled=true
Connection=Ni6501
PhysicalChannel=Dev1/port0/line0
ActiveState=High
PollIntervalMilliseconds=50
TimeoutMilliseconds=30000
PostSignalDelayMilliseconds=2000
RequireInactiveBeforeStart=true
[Equipment] [Equipment]
; 목표: 장비 화면에 표시되는 현재 측정값을 읽습니다.
; V 출력: Keysight 34465A DMM 값 ; V 출력: Keysight 34465A DMM 값
; A 출력: Keysight E36233A Power Supply CH1 Current 값 ; A 출력: Keysight E36233A Power Supply CH1 Current 값
; USB 장비는 Keysight Connection Expert 또는 NI MAX에서 보이는 VISA 주소를 Resource에 넣으세요. ; USB 장비는 Keysight Connection Expert 또는 NI MAX에서 보이는 VISA 주소를 Resource에 넣으세요.
; 예: USB0::0x2A8D::0x1301::MY12345678::INSTR
Timeout=5000 Timeout=5000
SettleMilliseconds=300 SettleMilliseconds=300
PreBoardCommand=VOLT 5, (@2);OUTP ON, (@2) PreBoardCommand=VOLT 5, (@2);OUTP ON, (@2)

5
Login/StartupLoginWindow.xaml

@ -513,7 +513,8 @@
</Border> </Border>
</Grid> </Grid>
<Button Grid.Row="9" <Button x:Name="LoginButton"
Grid.Row="9"
Content="ACCESS / LOG IN" Content="ACCESS / LOG IN"
Style="{StaticResource CommandButtonStyle}" Style="{StaticResource CommandButtonStyle}"
Click="LogInButton_Click"/> Click="LogInButton_Click"/>
@ -521,4 +522,4 @@
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>
</Window> </Window>

313
Login/StartupLoginWindow.xaml.cs

@ -1,5 +1,10 @@
using System; using System;
using System.IO;
using System.Windows; using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Threading;
using Housing.Services;
namespace Housing.Login; namespace Housing.Login;
@ -18,59 +23,311 @@ public sealed class StartupLoginWindowResult
public partial class StartupLoginWindow : Window public partial class StartupLoginWindow : Window
{ {
private const string MasterId = "su"; private const string OperatorHistoryFieldName = "Operator";
private const string MasterPassword = "su"; private const string SuppressedOperatorHistoryValue = "su";
private readonly LoginHistoryStore _loginHistoryStore;
private Popup? _openHistoryPopup;
public StartupLoginWindowResult Result { get; private set; } = new(); public StartupLoginWindowResult Result { get; private set; } = new();
public StartupLoginWindow() public StartupLoginWindow()
{ {
_loginHistoryStore = LoginHistoryStore.Load(GetLoginHistoryPath());
RemoveSuppressedOperatorHistory();
InitializeComponent(); InitializeComponent();
AttachLoginHistoryMenus();
PasswordBox.GotKeyboardFocus += (_, _) => CloseHistoryPopup();
LoginButton.GotKeyboardFocus += (_, _) => CloseHistoryPopup();
OperatorTextBox.Focus(); OperatorTextBox.Focus();
} }
private void LogInButton_Click(object sender, RoutedEventArgs e) private async void LogInButton_Click(object sender, RoutedEventArgs e)
{ {
var loginId = OperatorTextBox.Text.Trim(); CloseHistoryPopup();
var password = PasswordBox.Password;
if (string.IsNullOrWhiteSpace(loginId)) if (!TryReadRequiredFields(out var loginResult))
{ {
MessageBox.Show(this, "ID를 입력하세요.", "Login", MessageBoxButton.OK, MessageBoxImage.Warning);
OperatorTextBox.Focus();
return; return;
} }
if (string.IsNullOrWhiteSpace(password)) if (sender is Button button)
{ {
MessageBox.Show(this, "PW를 입력하세요.", "Login", MessageBoxButton.OK, MessageBoxImage.Warning); button.IsEnabled = false;
PasswordBox.Focus();
return;
} }
if (!string.Equals(loginId, MasterId, StringComparison.Ordinal) || try
!string.Equals(password, MasterPassword, StringComparison.Ordinal))
{ {
MessageBox.Show(this, "마스터 계정만 로그인할 수 있습니다.", "Login", MessageBoxButton.OK, MessageBoxImage.Error); var loginSettings = LoginSettings.Load(GetDatabaseIniPath());
PasswordBox.Clear(); if (loginSettings.OfflinePreview)
PasswordBox.Focus(); {
return; Result = loginResult;
SaveLoginHistory(loginResult);
DialogResult = true;
Close();
return;
}
var repository = new LoginAccountRepository(
DatabaseSettings.Load(GetDatabaseIniPath()),
loginSettings);
if (!await repository.ExistsAsync(loginResult.Operator, loginResult.Password))
{
MessageBox.Show(this, "DB에 등록된 ID/PW가 아닙니다.", "Login", MessageBoxButton.OK, MessageBoxImage.Error);
PasswordBox.Clear();
PasswordBox.Focus();
return;
}
Result = loginResult;
SaveLoginHistory(loginResult);
DialogResult = true;
Close();
} }
catch (Exception ex)
{
MessageBox.Show(this, $"로그인 DB 확인 실패: {ex.Message}", "Login", MessageBoxButton.OK, MessageBoxImage.Error);
OperatorTextBox.Focus();
OperatorTextBox.SelectAll();
}
finally
{
if (sender is Button loginButton)
{
loginButton.IsEnabled = true;
}
}
}
Result = new StartupLoginWindowResult private bool TryReadRequiredFields(out StartupLoginWindowResult result)
{
result = new StartupLoginWindowResult();
if (!TryReadTextBox(MakerTextBox, "Maker", out var maker) ||
!TryReadTextBox(ModelTextBox, "Model", out var model) ||
!TryReadTextBox(Variant1TextBox, "Variant 1", out var variant1) ||
!TryReadTextBox(Variant2TextBox, "Variant 2", out var variant2) ||
!TryReadTextBox(OperatorTextBox, "ID", out var loginId) ||
!TryReadPasswordBox(PasswordBox, "PW", out var password) ||
!TryReadTextBox(LineNoTextBox, "Line", out var lineNo) ||
!TryReadTextBox(LotNoTextBox, "Lot No", out var lotNo) ||
!TryReadTextBox(JigNoTextBox, "Jig No", out var jigNo))
{
return false;
}
result = new StartupLoginWindowResult
{ {
Maker = MakerTextBox.Text.Trim(), Maker = maker,
Model = ModelTextBox.Text.Trim(), Model = model,
Variant1 = Variant1TextBox.Text.Trim(), Variant1 = variant1,
Variant2 = Variant2TextBox.Text.Trim(), Variant2 = variant2,
Operator = loginId, Operator = loginId,
Password = password, Password = password,
LineNo = LineNoTextBox.Text.Trim(), LineNo = lineNo,
LotNo = LotNoTextBox.Text.Trim(), LotNo = lotNo,
JigNo = JigNoTextBox.Text.Trim() JigNo = jigNo
}; };
DialogResult = true; return true;
Close(); }
private void AttachLoginHistoryMenus()
{
AttachHistoryMenu(MakerTextBox, "Maker", ModelTextBox);
AttachHistoryMenu(ModelTextBox, "Model", Variant1TextBox);
AttachHistoryMenu(Variant1TextBox, "Variant1", Variant2TextBox);
AttachHistoryMenu(Variant2TextBox, "Variant2", OperatorTextBox);
AttachHistoryMenu(OperatorTextBox, OperatorHistoryFieldName, PasswordBox);
AttachHistoryMenu(LineNoTextBox, "LineNo", LotNoTextBox);
AttachHistoryMenu(LotNoTextBox, "LotNo", JigNoTextBox);
AttachHistoryMenu(JigNoTextBox, "JigNo", LoginButton);
}
private void AttachHistoryMenu(TextBox textBox, string fieldName, Control nextFocusControl)
{
textBox.GotKeyboardFocus += (_, _) => ShowHistoryMenu(textBox, fieldName, nextFocusControl);
textBox.PreviewMouseLeftButtonDown += (_, _) =>
{
if (textBox.IsKeyboardFocusWithin)
{
ShowHistoryMenu(textBox, fieldName, nextFocusControl);
}
};
}
private void ShowHistoryMenu(TextBox textBox, string fieldName, Control nextFocusControl)
{
var values = _loginHistoryStore.GetValues(fieldName);
if (values.Count == 0)
{
CloseHistoryPopup();
return;
}
CloseHistoryPopup();
var menuWidth = Math.Max(160, textBox.ActualWidth);
var stackPanel = new StackPanel();
var popup = new Popup
{
PlacementTarget = textBox,
Placement = PlacementMode.Bottom,
HorizontalOffset = 0,
StaysOpen = true,
AllowsTransparency = true,
Focusable = false
};
foreach (var value in values)
{
var item = new Button
{
Content = value,
MinWidth = menuWidth,
Padding = new Thickness(10, 6, 10, 6),
BorderThickness = new Thickness(0),
Background = textBox.Background,
Foreground = textBox.Foreground,
HorizontalContentAlignment = HorizontalAlignment.Left
};
item.Click += (_, _) =>
{
textBox.Text = value;
textBox.CaretIndex = textBox.Text.Length;
CloseHistoryPopup();
FocusControl(nextFocusControl);
};
stackPanel.Children.Add(item);
}
popup.Child = new Border
{
Width = menuWidth,
MaxHeight = 180,
Background = textBox.Background,
BorderBrush = textBox.BorderBrush,
BorderThickness = new Thickness(1),
Child = new ScrollViewer
{
MaxHeight = 180,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Content = stackPanel
}
};
_openHistoryPopup = popup;
popup.IsOpen = true;
}
private void CloseHistoryPopup()
{
if (_openHistoryPopup is not null)
{
_openHistoryPopup.IsOpen = false;
_openHistoryPopup = null;
}
}
private void FocusControl(Control control)
{
Dispatcher.BeginInvoke(new Action(() =>
{
control.Focus();
if (control is TextBox textBox)
{
textBox.CaretIndex = textBox.Text.Length;
}
else if (control is PasswordBox passwordBox)
{
passwordBox.Focus();
}
}), DispatcherPriority.Input);
}
private void SaveLoginHistory(StartupLoginWindowResult result)
{
_loginHistoryStore.Remember("Maker", result.Maker);
_loginHistoryStore.Remember("Model", result.Model);
_loginHistoryStore.Remember("Variant1", result.Variant1);
_loginHistoryStore.Remember("Variant2", result.Variant2);
RememberOperatorHistory(result.Operator);
_loginHistoryStore.Remember("LineNo", result.LineNo);
_loginHistoryStore.Remember("LotNo", result.LotNo);
_loginHistoryStore.Remember("JigNo", result.JigNo);
_loginHistoryStore.Save();
}
private void RememberOperatorHistory(string operatorId)
{
if (IsSuppressedOperatorHistoryValue(operatorId))
{
_loginHistoryStore.Forget(OperatorHistoryFieldName, operatorId);
return;
}
_loginHistoryStore.Remember(OperatorHistoryFieldName, operatorId);
}
private void RemoveSuppressedOperatorHistory()
{
try
{
if (_loginHistoryStore.Forget(OperatorHistoryFieldName, SuppressedOperatorHistoryValue))
{
_loginHistoryStore.Save();
}
}
catch
{
// Login history cleanup is optional and should not block the login window.
}
}
private static bool IsSuppressedOperatorHistoryValue(string value)
{
return string.Equals(value.Trim(), SuppressedOperatorHistoryValue, StringComparison.OrdinalIgnoreCase);
}
private bool TryReadTextBox(TextBox textBox, string fieldName, out string value)
{
value = textBox.Text.Trim();
if (!string.IsNullOrWhiteSpace(value))
{
return true;
}
MessageBox.Show(this, $"{fieldName}을(를) 입력하세요.", "Login", MessageBoxButton.OK, MessageBoxImage.Warning);
textBox.Focus();
textBox.SelectAll();
return false;
}
private bool TryReadPasswordBox(PasswordBox passwordBox, string fieldName, out string value)
{
value = passwordBox.Password;
if (!string.IsNullOrWhiteSpace(value))
{
return true;
}
MessageBox.Show(this, $"{fieldName}을(를) 입력하세요.", "Login", MessageBoxButton.OK, MessageBoxImage.Warning);
passwordBox.Focus();
passwordBox.SelectAll();
return false;
}
private static string GetDatabaseIniPath()
{
return Path.Combine(AppContext.BaseDirectory, "Database.ini");
}
private static string GetLoginHistoryPath()
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Housing",
"LoginHistory.json");
} }
} }

4
MainWindow.xaml

@ -400,7 +400,9 @@
<TextBox x:Name="BarcodeTextBox" <TextBox x:Name="BarcodeTextBox"
Grid.Row="1" Grid.Row="1"
Style="{StaticResource InputBoxStyle}" Style="{StaticResource InputBoxStyle}"
KeyDown="BarcodeTextBox_KeyDown" PreviewKeyDown="BarcodeTextBox_PreviewKeyDown"
PreviewTextInput="BarcodeTextBox_PreviewTextInput"
InputMethod.IsInputMethodEnabled="False"
TextChanged="BarcodeTextBox_TextChanged"/> TextChanged="BarcodeTextBox_TextChanged"/>
</Grid> </Grid>
</Border> </Border>

191
MainWindow.xaml.cs

@ -1,5 +1,7 @@
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
@ -12,10 +14,17 @@ namespace Housing;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private const string BarcodeRequiredMessage = "PCB_Barcode를 입력하고 Enter를 눌러주세요."; private const string BarcodeRequiredMessage = "스캐너로 PCB_Barcode를 스캔해주세요.";
private const string BoardReadyMessage = "Enter를 누르면 보드 연결 테스트를 시작합니다."; private const string BoardReadyMessage = "스캔 완료. 시료 투입 후 Housing 장비 시작 버튼을 눌러주세요.";
private const string BarcodeInvalidMessage = "스캔 형식이 올바르지 않습니다. PCBA S/N 바코드를 스캔해주세요.";
private const string DefaultOutputValue = "0.00"; 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 StartupLoginWindowResult _loginResult = new();
private InspectionJudgementSettings _inspectionSettings; private InspectionJudgementSettings _inspectionSettings;
private bool _isStartupLoginShown; private bool _isStartupLoginShown;
@ -26,6 +35,10 @@ public partial class MainWindow : Window
_inspectionSettings = InspectionSettingsStore.Load(GetInspectionSettingsIniPath()); _inspectionSettings = InspectionSettingsStore.Load(GetInspectionSettingsIniPath());
InitializeComponent(); InitializeComponent();
_scannerInputCommitTimer = new DispatcherTimer { Interval = ScannerInputIdleCommitDelay };
_scannerInputCommitTimer.Tick += ScannerInputCommitTimer_Tick;
CommandManager.AddPreviewExecutedHandler(BarcodeTextBox, BarcodeTextBox_PreviewExecuted);
ApplyInspectionSettingsToTextBoxes(); ApplyInspectionSettingsToTextBoxes();
ApplyLoginResult(); ApplyLoginResult();
ResetScreen(); ResetScreen();
@ -59,15 +72,131 @@ public partial class MainWindow : Window
ResultOutputTextBox.Text = BoardReadyMessage; ResultOutputTextBox.Text = BoardReadyMessage;
} }
private async void BarcodeTextBox_KeyDown(object sender, KeyEventArgs e) 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) if (e.Key == Key.Enter || e.Key == Key.Return || e.Key == Key.Tab)
{ {
e.Handled = true;
await CompleteScannerInputAsync(runBoardSequence: true);
return; 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; e.Handled = true;
await RunBoardSequenceAsync(); }
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*(?<serial>[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() public async Task OnEquipmentStartButtonPressedAsync()
@ -99,22 +228,29 @@ public partial class MainWindow : Window
_isBoardTestRunning = true; _isBoardTestRunning = true;
SetInputEnabled(false); 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 = ResultOutputTextBox.Text =
"보드 테스트 시작\r\n" + "보드 테스트 시작\r\n" +
"1. 시료 연결 확인\r\n" + "1. 시료 연결 확인\r\n" +
"2. IC_SN 읽기\r\n" + "2. IC_SN 읽기\r\n" +
"3. CAL DEFAULT 조건 설정\r\n" + "3. CAL DEFAULT 조건 설정\r\n" +
"4. 계측 장비에서 V/A 읽기"; "4. 시작 신호 기준 지연 후 계측 장비에서 V/A 읽기";
var boardSettings = BoardHardwareSettings.Load(GetHardwareIniPath());
var equipmentSettings = EquipmentMeasurementSettings.Load(GetHardwareIniPath());
var service = new BoardTestService(boardSettings, equipmentSettings); var service = new BoardTestService(boardSettings, equipmentSettings);
string? dbHeaderSaveError = null; string? dbHeaderSaveError = null;
var measurement = await service.RunAsync(async icSn => var measurement = await service.RunAsync(
{ async icSn =>
IcSnOutputTextBox.Text = icSn; {
dbHeaderSaveError = await SaveHeaderStatusAsync(icSn); IcSnOutputTextBox.Text = icSn;
}); dbHeaderSaveError = await SaveHeaderStatusAsync(icSn);
},
measurementNotBeforeUtc);
IcSnOutputTextBox.Text = measurement.IcSn; IcSnOutputTextBox.Text = measurement.IcSn;
VOutputTextBox.Text = FormatDecimal(measurement.Voltage); VOutputTextBox.Text = FormatDecimal(measurement.Voltage);
@ -138,6 +274,33 @@ public partial class MainWindow : Window
} }
} }
private async Task<DateTime?> 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) private async void LogoutButton_Click(object sender, RoutedEventArgs e)
{ {
LogoutButton.IsEnabled = false; LogoutButton.IsEnabled = false;
@ -232,7 +395,7 @@ public partial class MainWindow : Window
{ {
var repository = new HousingAssemblyRepository(DatabaseSettings.Load(GetDatabaseIniPath())); var repository = new HousingAssemblyRepository(DatabaseSettings.Load(GetDatabaseIniPath()));
var record = CreateHeaderRecord(icSn); var record = CreateHeaderRecord(icSn);
await repository.InsertHeaderAsync(record); await repository.UpsertHeaderAsync(record);
return null; return null;
} }

14
Services/BoardTestService.cs

@ -15,7 +15,9 @@ public sealed class BoardTestService
_equipmentSettings = equipmentSettings; _equipmentSettings = equipmentSettings;
} }
public async Task<BoardMeasurementResult> RunAsync(Func<string, Task>? onIcSnReadAsync = null) public async Task<BoardMeasurementResult> RunAsync(
Func<string, Task>? onIcSnReadAsync = null,
DateTime? measurementNotBeforeUtc = null)
{ {
var log = new StringBuilder(); var log = new StringBuilder();
var equipmentService = new EquipmentMeasurementService(_equipmentSettings); var equipmentService = new EquipmentMeasurementService(_equipmentSettings);
@ -52,6 +54,16 @@ public sealed class BoardTestService
await SendAndLogAsync(boardClient, "CAL_DEFAULT", _boardSettings.CalDefaultCommand, _boardSettings.ReadTimeout, false, log); await SendAndLogAsync(boardClient, "CAL_DEFAULT", _boardSettings.CalDefaultCommand, _boardSettings.ReadTimeout, false, log);
if (measurementNotBeforeUtc.HasValue)
{
var remainingDelay = measurementNotBeforeUtc.Value - DateTime.UtcNow;
if (remainingDelay > TimeSpan.Zero)
{
log.AppendLine($"> MEASUREMENT START DELAY: {(int)remainingDelay.TotalMilliseconds} ms");
await Task.Delay(remainingDelay);
}
}
var equipmentResult = await equipmentService.ReadAsync(); var equipmentResult = await equipmentService.ReadAsync();
log.Append(equipmentResult.RawLog); log.Append(equipmentResult.RawLog);

85
Services/HousingAssemblyRepository.cs

@ -67,6 +67,85 @@ VALUES
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
public async Task UpsertHeaderAsync(HousingAssemblyRecord record)
{
using var connection = new SqlConnection(CreateConnectionString());
using var command = connection.CreateCommand();
command.CommandText = @"
SET XACT_ABORT ON;
BEGIN TRANSACTION;
UPDATE dbo.Housing_Assembly WITH (UPDLOCK, HOLDLOCK)
SET
[PCB_Barcode] = @PCB_Barcode,
[Maker] = @Maker,
[Model] = @Model,
[Variant_1] = @Variant_1,
[Variant_2] = @Variant_2,
[Operator] = @Operator,
[Password] = @Password,
[Production_Date] = @Production_Date,
[Line] = @Line,
[Lot_No] = @Lot_No,
[Jig_No] = @Jig_No
WHERE
[IC_SN] = @IC_SN;
IF @@ROWCOUNT = 0
BEGIN
INSERT INTO dbo.Housing_Assembly
(
[IC_SN],
[PCB_Barcode],
[Maker],
[Model],
[Variant_1],
[Variant_2],
[Operator],
[Password],
[Production_Date],
[Line],
[Lot_No],
[Jig_No]
)
VALUES
(
@IC_SN,
@PCB_Barcode,
@Maker,
@Model,
@Variant_1,
@Variant_2,
@Operator,
@Password,
@Production_Date,
@Line,
@Lot_No,
@Jig_No
);
END;
COMMIT TRANSACTION;";
AddNVarChar(command, "@IC_SN", 50, record.IcSn);
AddNVarChar(command, "@PCB_Barcode", 50, record.PcbBarcode);
AddNVarChar(command, "@Maker", 10, record.Maker);
AddNVarChar(command, "@Model", 10, record.Model);
AddNVarChar(command, "@Variant_1", 10, record.Variant1);
AddNVarChar(command, "@Variant_2", 10, record.Variant2);
AddNVarChar(command, "@Operator", 10, record.Operator);
AddNVarChar(command, "@Password", 10, record.Password);
command.Parameters.Add(new SqlParameter("@Production_Date", SqlDbType.DateTime2) { Value = record.ProductionDate });
AddNVarChar(command, "@Line", 5, record.Line);
AddNVarChar(command, "@Lot_No", 8, record.LotNo);
AddNVarChar(command, "@Jig_No", 10, record.JigNo);
await connection.OpenAsync();
await command.ExecuteNonQueryAsync();
}
public async Task UpdateMeasurementAsync(HousingAssemblyRecord record) public async Task UpdateMeasurementAsync(HousingAssemblyRecord record)
{ {
using var connection = new SqlConnection(CreateConnectionString()); using var connection = new SqlConnection(CreateConnectionString());
@ -83,11 +162,9 @@ SET
[Spare_3] = @Spare_3, [Spare_3] = @Spare_3,
[Spare_4] = @Spare_4 [Spare_4] = @Spare_4
WHERE WHERE
[IC_SN] = @IC_SN [IC_SN] = @IC_SN;";
AND [PCB_Barcode] = @PCB_Barcode;";
AddNVarChar(command, "@IC_SN", 50, record.IcSn); AddNVarChar(command, "@IC_SN", 50, record.IcSn);
AddNVarChar(command, "@PCB_Barcode", 50, record.PcbBarcode);
AddDecimal(command, "@PT_Vol_1", record.PtVol1); AddDecimal(command, "@PT_Vol_1", record.PtVol1);
AddDecimal(command, "@PT_Current_1", record.PtCurrent1); AddDecimal(command, "@PT_Current_1", record.PtCurrent1);
AddNVarChar(command, "@Result", 5, record.Result); AddNVarChar(command, "@Result", 5, record.Result);
@ -106,7 +183,7 @@ WHERE
public async Task InsertAsync(HousingAssemblyRecord record) public async Task InsertAsync(HousingAssemblyRecord record)
{ {
await InsertHeaderAsync(record); await UpsertHeaderAsync(record);
await UpdateMeasurementAsync(record); await UpdateMeasurementAsync(record);
} }

58
Services/LoginAccountRepository.cs

@ -0,0 +1,58 @@
using System.Data;
using Microsoft.Data.SqlClient;
namespace Housing.Services;
public sealed class LoginAccountRepository
{
private readonly DatabaseSettings _databaseSettings;
private readonly LoginSettings _loginSettings;
public LoginAccountRepository(DatabaseSettings databaseSettings, LoginSettings loginSettings)
{
_databaseSettings = databaseSettings;
_loginSettings = loginSettings;
}
public async Task<bool> ExistsAsync(string loginId, string password)
{
using var connection = new SqlConnection(CreateConnectionString());
using var command = connection.CreateCommand();
command.CommandText = $@"
SELECT TOP (1) 1
FROM {_loginSettings.GetQuotedTableName()}
WHERE {_loginSettings.GetQuotedIdColumn()} = @LoginId
AND {_loginSettings.GetQuotedPasswordColumn()} = @Password;";
command.Parameters.Add(new SqlParameter("@LoginId", SqlDbType.NVarChar, 50) { Value = loginId });
command.Parameters.Add(new SqlParameter("@Password", SqlDbType.NVarChar, 50) { Value = password });
await connection.OpenAsync();
var result = await command.ExecuteScalarAsync();
return result is not null;
}
private string CreateConnectionString()
{
if (string.IsNullOrWhiteSpace(_databaseSettings.Ip) ||
string.IsNullOrWhiteSpace(_databaseSettings.Database) ||
string.IsNullOrWhiteSpace(_databaseSettings.DbId))
{
throw new InvalidOperationException("Database.ini의 IP, Database, DbId 값을 확인하세요.");
}
var builder = new SqlConnectionStringBuilder
{
DataSource = _databaseSettings.Ip,
InitialCatalog = _databaseSettings.Database,
UserID = _databaseSettings.DbId,
Password = _databaseSettings.DbPw,
Encrypt = _databaseSettings.Encrypt,
TrustServerCertificate = _databaseSettings.TrustServerCertificate,
ConnectTimeout = _databaseSettings.Timeout
};
return builder.ConnectionString;
}
}

99
Services/LoginHistoryStore.cs

@ -0,0 +1,99 @@
using System.IO;
using System.Text.Json;
namespace Housing.Services;
public sealed class LoginHistoryStore
{
private const int MaxHistoryCount = 3;
private readonly string _filePath;
private readonly LoginHistoryData _data;
private LoginHistoryStore(string filePath, LoginHistoryData data)
{
_filePath = filePath;
_data = data;
}
public static LoginHistoryStore Load(string filePath)
{
try
{
if (!File.Exists(filePath))
{
return new LoginHistoryStore(filePath, new LoginHistoryData());
}
var json = File.ReadAllText(filePath);
var data = JsonSerializer.Deserialize<LoginHistoryData>(json) ?? new LoginHistoryData();
data.Fields ??= new Dictionary<string, List<string>>();
return new LoginHistoryStore(filePath, data);
}
catch
{
return new LoginHistoryStore(filePath, new LoginHistoryData());
}
}
public IReadOnlyList<string> GetValues(string fieldName)
{
return _data.Fields.TryGetValue(fieldName, out var values)
? values.Where(value => !string.IsNullOrWhiteSpace(value)).Take(MaxHistoryCount).ToArray()
: Array.Empty<string>();
}
public void Remember(string fieldName, string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
if (!_data.Fields.TryGetValue(fieldName, out var values))
{
values = new List<string>();
_data.Fields[fieldName] = values;
}
values.RemoveAll(item => string.Equals(item, value, StringComparison.OrdinalIgnoreCase));
values.Insert(0, value);
if (values.Count > MaxHistoryCount)
{
values.RemoveRange(MaxHistoryCount, values.Count - MaxHistoryCount);
}
}
public bool Forget(string fieldName, string value)
{
if (string.IsNullOrWhiteSpace(value) ||
!_data.Fields.TryGetValue(fieldName, out var values))
{
return false;
}
var removedCount = values.RemoveAll(item => string.Equals(item, value, StringComparison.OrdinalIgnoreCase));
if (values.Count == 0)
{
_data.Fields.Remove(fieldName);
}
return removedCount > 0;
}
public void Save()
{
var directory = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(_data, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_filePath, json);
}
private sealed class LoginHistoryData
{
public Dictionary<string, List<string>> Fields { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
}

86
Services/LoginSettings.cs

@ -0,0 +1,86 @@
using System.IO;
using System.Text.RegularExpressions;
namespace Housing.Services;
public sealed class LoginSettings
{
public string Table { get; set; } = "dbo.Housing_Assembly";
public string IdColumn { get; set; } = "Operator";
public string PasswordColumn { get; set; } = "Password";
public bool OfflinePreview { get; set; }
public static LoginSettings Load(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException("Database.ini 파일을 찾을 수 없습니다.", filePath);
}
var values = IniFile.LoadSection(filePath, "Login");
return new LoginSettings
{
Table = GetString(values, "Table", "dbo.Housing_Assembly"),
IdColumn = GetString(values, "IdColumn", "Operator"),
PasswordColumn = GetString(values, "PasswordColumn", "Password"),
OfflinePreview = GetBool(values, "OfflinePreview", false)
};
}
public string GetQuotedTableName()
{
var parts = Table
.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(QuoteIdentifier)
.ToArray();
if (parts.Length is 0 or > 3)
{
throw new InvalidOperationException("Database.ini [Login] Table 값을 확인하세요.");
}
return string.Join(".", parts);
}
public string GetQuotedIdColumn()
{
return QuoteIdentifier(IdColumn);
}
public string GetQuotedPasswordColumn()
{
return QuoteIdentifier(PasswordColumn);
}
private static string GetString(Dictionary<string, string> values, string key, string defaultValue)
{
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value.Trim()
: defaultValue;
}
private static bool GetBool(Dictionary<string, string> values, string key, bool defaultValue)
{
if (!values.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return defaultValue;
}
return value.Trim().ToUpperInvariant() switch
{
"1" or "TRUE" or "YES" or "ON" => true,
"0" or "FALSE" or "NO" or "OFF" => false,
_ => defaultValue
};
}
private static string QuoteIdentifier(string identifier)
{
if (Regex.IsMatch(identifier, @"^[A-Za-z_][A-Za-z0-9_]*$"))
{
return $"[{identifier}]";
}
throw new InvalidOperationException($"DB 식별자 값이 올바르지 않습니다: {identifier}");
}
}

191
Services/Ni6501StartSignalWatcher.cs

@ -0,0 +1,191 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
namespace Housing.Services;
public sealed class Ni6501StartSignalWatcher
{
private readonly StartSignalSettings _settings;
public Ni6501StartSignalWatcher(StartSignalSettings settings)
{
_settings = settings;
}
public async Task WaitForStartAsync(CancellationToken cancellationToken = default)
{
if (!_settings.Enabled)
{
return;
}
try
{
await Task.Run(() => WaitForStart(cancellationToken), cancellationToken);
}
catch (DllNotFoundException ex)
{
throw new InvalidOperationException("NI-DAQmx 드라이버(nicaiu.dll)를 찾을 수 없습니다. NI-DAQmx Runtime 설치와 NI-6501 인식 상태를 확인하세요.", ex);
}
catch (EntryPointNotFoundException ex)
{
throw new InvalidOperationException("NI-DAQmx DLL에서 필요한 함수가 보이지 않습니다. NI-DAQmx Runtime 버전을 확인하세요.", ex);
}
catch (BadImageFormatException ex)
{
throw new InvalidOperationException("NI-DAQmx DLL 비트 수가 현재 프로그램과 맞지 않습니다. x64/x86 Runtime과 실행 설정을 확인하세요.", ex);
}
}
private void WaitForStart(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_settings.PhysicalChannel))
{
throw new InvalidOperationException("Hardware.ini [StartSignal] PhysicalChannel 값을 설정하세요.");
}
using var input = new DaqmxDigitalInput(_settings.PhysicalChannel);
var stopwatch = Stopwatch.StartNew();
if (_settings.RequireInactiveBeforeStart)
{
while (_settings.IsActive(input.ReadSingleLine()))
{
ThrowIfTimedOut(stopwatch, "기존 신호 해제 대기");
Delay(cancellationToken);
}
}
while (!_settings.IsActive(input.ReadSingleLine()))
{
ThrowIfTimedOut(stopwatch, "시작 신호 대기");
Delay(cancellationToken);
}
}
private void ThrowIfTimedOut(Stopwatch stopwatch, string state)
{
if (_settings.TimeoutMilliseconds <= 0 ||
stopwatch.ElapsedMilliseconds <= _settings.TimeoutMilliseconds)
{
return;
}
throw new TimeoutException(
$"NI-6501 시작 신호 대기 시간 초과: {state}, 채널={_settings.PhysicalChannel}, 제한={_settings.TimeoutMilliseconds}ms");
}
private void Delay(CancellationToken cancellationToken)
{
if (cancellationToken.WaitHandle.WaitOne(_settings.PollIntervalMilliseconds))
{
throw new OperationCanceledException(cancellationToken);
}
}
private sealed class DaqmxDigitalInput : IDisposable
{
private const int DaqmxValChanPerLine = 0;
private const int DaqmxValGroupByChannel = 0;
private nint _taskHandle;
public DaqmxDigitalInput(string physicalChannel)
{
Check(DaqmxCreateTask("", out _taskHandle), "NI-DAQmx Task 생성 실패");
try
{
Check(DaqmxCreateDIChan(_taskHandle, physicalChannel, "", DaqmxValChanPerLine), "NI-6501 DI 채널 생성 실패");
Check(DaqmxStartTask(_taskHandle), "NI-6501 DI Task 시작 실패");
}
catch
{
Dispose();
throw;
}
}
public bool ReadSingleLine()
{
var readArray = new byte[1];
Check(
DaqmxReadDigitalLines(
_taskHandle,
1,
1.0,
DaqmxValGroupByChannel,
readArray,
(uint)readArray.Length,
out _,
out _,
nint.Zero),
"NI-6501 DI 읽기 실패");
return readArray[0] != 0;
}
public void Dispose()
{
if (_taskHandle == nint.Zero)
{
return;
}
DaqmxStopTask(_taskHandle);
DaqmxClearTask(_taskHandle);
_taskHandle = nint.Zero;
}
private static void Check(int errorCode, string message)
{
if (errorCode >= 0)
{
return;
}
var detail = GetExtendedErrorInfo();
throw new InvalidOperationException(string.IsNullOrWhiteSpace(detail)
? message
: $"{message}: {detail}");
}
private static string GetExtendedErrorInfo()
{
var errorMessage = new StringBuilder(2048);
var errorCode = DaqmxGetExtendedErrorInfo(errorMessage, (uint)errorMessage.Capacity);
return errorCode == 0 ? errorMessage.ToString().Trim() : string.Empty;
}
[DllImport("nicaiu.dll", EntryPoint = "DAQmxCreateTask", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
private static extern int DaqmxCreateTask(string taskName, out nint taskHandle);
[DllImport("nicaiu.dll", EntryPoint = "DAQmxCreateDIChan", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
private static extern int DaqmxCreateDIChan(nint taskHandle, string lines, string nameToAssignToLines, int lineGrouping);
[DllImport("nicaiu.dll", EntryPoint = "DAQmxStartTask", CallingConvention = CallingConvention.Cdecl)]
private static extern int DaqmxStartTask(nint taskHandle);
[DllImport("nicaiu.dll", EntryPoint = "DAQmxReadDigitalLines", CallingConvention = CallingConvention.Cdecl)]
private static extern int DaqmxReadDigitalLines(
nint taskHandle,
int numSampsPerChan,
double timeout,
int fillMode,
byte[] readArray,
uint arraySizeInBytes,
out int sampsPerChanRead,
out int numBytesPerSamp,
nint reserved);
[DllImport("nicaiu.dll", EntryPoint = "DAQmxStopTask", CallingConvention = CallingConvention.Cdecl)]
private static extern int DaqmxStopTask(nint taskHandle);
[DllImport("nicaiu.dll", EntryPoint = "DAQmxClearTask", CallingConvention = CallingConvention.Cdecl)]
private static extern int DaqmxClearTask(nint taskHandle);
[DllImport("nicaiu.dll", EntryPoint = "DAQmxGetExtendedErrorInfo", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
private static extern int DaqmxGetExtendedErrorInfo(StringBuilder errorString, uint bufferSize);
}
}

125
Services/StartSignalSettings.cs

@ -0,0 +1,125 @@
using System.IO;
namespace Housing.Services;
public sealed class StartSignalSettings
{
public bool Enabled { get; set; }
public string Connection { get; set; } = "Ni6501";
public string PhysicalChannel { get; set; } = "Dev1/port0/line0";
public string ActiveState { get; set; } = "High";
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 5000;
public string ReadCommand { get; set; } = string.Empty;
public string ActiveResponse { get; set; } = "START,1,ON";
public string ResponseMatch { get; set; } = "Contains";
public string Terminator { get; set; } = "CRLF";
public int ReadTimeoutMilliseconds { get; set; } = 1000;
public int PollIntervalMilliseconds { get; set; } = 50;
public int TimeoutMilliseconds { get; set; } = 30000;
public int PostSignalDelayMilliseconds { get; set; } = 2000;
public bool RequireInactiveBeforeStart { get; set; } = true;
public bool IsActive(bool lineValue)
{
return string.Equals(ActiveState, "Low", StringComparison.OrdinalIgnoreCase)
? !lineValue
: lineValue;
}
public static StartSignalSettings Load(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException("Hardware.ini 파일을 찾을 수 없습니다.", filePath);
}
var values = IniFile.LoadSection(filePath, "StartSignal");
return new StartSignalSettings
{
Enabled = GetBool(values, "Enabled", false),
Connection = GetString(values, "Connection", "Ni6501"),
PhysicalChannel = GetString(values, "PhysicalChannel", "Dev1/port0/line0"),
ActiveState = GetString(values, "ActiveState", "High"),
Host = GetString(values, "Host", string.Empty),
Port = GetInt(values, "Port", 5000),
ReadCommand = GetString(values, "ReadCommand", string.Empty),
ActiveResponse = GetString(values, "ActiveResponse", "START,1,ON"),
ResponseMatch = GetString(values, "ResponseMatch", "Contains"),
Terminator = GetString(values, "Terminator", "CRLF"),
ReadTimeoutMilliseconds = Math.Max(100, GetInt(values, "ReadTimeoutMilliseconds", 1000)),
PollIntervalMilliseconds = Math.Max(10, GetInt(values, "PollIntervalMilliseconds", 50)),
TimeoutMilliseconds = Math.Max(0, GetInt(values, "TimeoutMilliseconds", 30000)),
PostSignalDelayMilliseconds = Math.Max(0, GetInt(values, "PostSignalDelayMilliseconds", 2000)),
RequireInactiveBeforeStart = GetBool(values, "RequireInactiveBeforeStart", true)
};
}
public string[] GetActiveResponses()
{
return ActiveResponse
.Split(',', ';')
.Select(value => value.Trim())
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToArray();
}
public string GetTerminator()
{
return Terminator.Trim().ToUpperInvariant() switch
{
"CRLF" => "\r\n",
"CR" => "\r",
"LF" => "\n",
"NONE" or "EMPTY" => string.Empty,
_ => Terminator
};
}
public bool IsActive(string response)
{
if (string.IsNullOrWhiteSpace(response))
{
return false;
}
var activeResponses = GetActiveResponses();
if (activeResponses.Length == 0)
{
return false;
}
return string.Equals(ResponseMatch, "Equals", StringComparison.OrdinalIgnoreCase)
? activeResponses.Any(value => string.Equals(response.Trim(), value, StringComparison.OrdinalIgnoreCase))
: activeResponses.Any(value => response.Contains(value, StringComparison.OrdinalIgnoreCase));
}
private static string GetString(Dictionary<string, string> values, string key, string defaultValue)
{
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value.Trim()
: defaultValue;
}
private static int GetInt(Dictionary<string, string> values, string key, int defaultValue)
{
return values.TryGetValue(key, out var value) && int.TryParse(value, out var result)
? result
: defaultValue;
}
private static bool GetBool(Dictionary<string, string> values, string key, bool defaultValue)
{
if (!values.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return defaultValue;
}
return value.Trim().ToUpperInvariant() switch
{
"1" or "TRUE" or "YES" or "ON" => true,
"0" or "FALSE" or "NO" or "OFF" => false,
_ => defaultValue
};
}
}

40
Services/StartSignalWatcher.cs

@ -0,0 +1,40 @@
namespace Housing.Services;
public static class StartSignalWatcher
{
public static Task WaitForStartAsync(StartSignalSettings settings, CancellationToken cancellationToken = default)
{
if (!settings.Enabled)
{
return Task.CompletedTask;
}
var connection = GetConnection(settings);
return connection switch
{
"NI6501" or "NI-6501" or "NIDAQ" or "DAQ" => new Ni6501StartSignalWatcher(settings).WaitForStartAsync(cancellationToken),
"TCP" or "LAN" or "ETHERNET" => new TcpStartSignalWatcher(settings).WaitForStartAsync(cancellationToken),
_ => throw new InvalidOperationException("Hardware.ini [StartSignal] Connection은 Ni6501 또는 Tcp로 설정하세요.")
};
}
public static string Describe(StartSignalSettings settings)
{
var connection = GetConnection(settings);
return connection switch
{
"NI6501" or "NI-6501" or "NIDAQ" or "DAQ" => $"NI-6501 {settings.PhysicalChannel}",
"TCP" or "LAN" or "ETHERNET" => string.IsNullOrWhiteSpace(settings.ReadCommand)
? $"LAN {settings.Host}:{settings.Port} 수신 대기"
: $"LAN {settings.Host}:{settings.Port} 명령: {settings.ReadCommand}",
_ => connection
};
}
private static string GetConnection(StartSignalSettings settings)
{
return string.IsNullOrWhiteSpace(settings.Connection)
? "NI6501"
: settings.Connection.Trim().ToUpperInvariant();
}
}

151
Services/TcpStartSignalWatcher.cs

@ -0,0 +1,151 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text;
namespace Housing.Services;
public sealed class TcpStartSignalWatcher
{
private readonly StartSignalSettings _settings;
public TcpStartSignalWatcher(StartSignalSettings settings)
{
_settings = settings;
}
public async Task WaitForStartAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(_settings.Host))
{
throw new InvalidOperationException("Hardware.ini [StartSignal] Host 값을 LAN 장비 IP로 설정하세요.");
}
using var client = new TcpClient();
await ConnectAsync(client, cancellationToken);
using var stream = client.GetStream();
var stopwatch = Stopwatch.StartNew();
if (string.IsNullOrWhiteSpace(_settings.ReadCommand))
{
await WaitForEventMessageAsync(stream, stopwatch, cancellationToken);
return;
}
await WaitForPolledResponseAsync(stream, stopwatch, cancellationToken);
}
private async Task ConnectAsync(TcpClient client, CancellationToken cancellationToken)
{
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (_settings.TimeoutMilliseconds > 0)
{
timeout.CancelAfter(_settings.TimeoutMilliseconds);
}
await client.ConnectAsync(_settings.Host, _settings.Port, timeout.Token);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException($"LAN 시작 신호 장비 연결 시간 초과: {_settings.Host}:{_settings.Port}", ex);
}
catch (SocketException ex)
{
throw new InvalidOperationException($"LAN 시작 신호 장비 연결 실패: {_settings.Host}:{_settings.Port}, {ex.Message}", ex);
}
}
private async Task WaitForEventMessageAsync(NetworkStream stream, Stopwatch stopwatch, CancellationToken cancellationToken)
{
while (true)
{
ThrowIfTimedOut(stopwatch, "LAN 메시지 수신 대기");
var response = await ReadResponseAsync(stream, cancellationToken);
if (_settings.IsActive(response))
{
return;
}
}
}
private async Task WaitForPolledResponseAsync(NetworkStream stream, Stopwatch stopwatch, CancellationToken cancellationToken)
{
if (_settings.RequireInactiveBeforeStart)
{
while (_settings.IsActive(await QueryAsync(stream, cancellationToken)))
{
ThrowIfTimedOut(stopwatch, "기존 LAN 시작 상태 해제 대기");
await DelayAsync(cancellationToken);
}
}
while (!_settings.IsActive(await QueryAsync(stream, cancellationToken)))
{
ThrowIfTimedOut(stopwatch, "LAN 시작 응답 대기");
await DelayAsync(cancellationToken);
}
}
private async Task<string> QueryAsync(NetworkStream stream, CancellationToken cancellationToken)
{
var command = _settings.ReadCommand.Trim() + _settings.GetTerminator();
var bytes = Encoding.ASCII.GetBytes(command);
await stream.WriteAsync(bytes, cancellationToken);
await stream.FlushAsync(cancellationToken);
return await ReadResponseAsync(stream, cancellationToken);
}
private async Task<string> ReadResponseAsync(NetworkStream stream, CancellationToken cancellationToken)
{
var buffer = new byte[1024];
var response = new StringBuilder();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_settings.ReadTimeoutMilliseconds);
try
{
while (true)
{
var count = await stream.ReadAsync(buffer, timeout.Token);
if (count == 0)
{
break;
}
var chunk = Encoding.ASCII.GetString(buffer, 0, count);
response.Append(chunk);
if (chunk.Contains('\n') || chunk.Contains('\r') || !stream.DataAvailable)
{
break;
}
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return response.ToString();
}
return response.ToString();
}
private async Task DelayAsync(CancellationToken cancellationToken)
{
await Task.Delay(_settings.PollIntervalMilliseconds, cancellationToken);
}
private void ThrowIfTimedOut(Stopwatch stopwatch, string state)
{
if (_settings.TimeoutMilliseconds <= 0 ||
stopwatch.ElapsedMilliseconds <= _settings.TimeoutMilliseconds)
{
return;
}
throw new TimeoutException(
$"LAN 시작 신호 대기 시간 초과: {state}, 대상={_settings.Host}:{_settings.Port}, 제한={_settings.TimeoutMilliseconds}ms");
}
}
Loading…
Cancel
Save