commit
ddbf638cff
55 changed files with 8010 additions and 0 deletions
@ -0,0 +1,8 @@ |
|||||
|
.vs/ |
||||
|
bin/ |
||||
|
obj/ |
||||
|
.wix/ |
||||
|
installer/output/ |
||||
|
publish/ |
||||
|
UpgradeLog*.htm |
||||
|
*.Backup.tmp |
||||
@ -0,0 +1,55 @@ |
|||||
|
<Application x:Class="Housing.App" |
||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" |
||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> |
||||
|
<Application.Resources> |
||||
|
<SolidColorBrush x:Key="HmiScreenBackgroundBrush" Color="#07111C"/> |
||||
|
<SolidColorBrush x:Key="HmiPanelBorderBrush" Color="#355369"/> |
||||
|
<SolidColorBrush x:Key="HmiPanelInnerBorderBrush" Color="#597285"/> |
||||
|
<SolidColorBrush x:Key="HmiAccentBrush" Color="#16C7E8"/> |
||||
|
<SolidColorBrush x:Key="HmiAccentBlueBrush" Color="#2F86FF"/> |
||||
|
<SolidColorBrush x:Key="HmiWarningBrush" Color="#FF9A24"/> |
||||
|
<SolidColorBrush x:Key="HmiRunBrush" Color="#27D17F"/> |
||||
|
<SolidColorBrush x:Key="HmiDangerBrush" Color="#E65252"/> |
||||
|
<SolidColorBrush x:Key="HmiTextBrush" Color="#F2F7FA"/> |
||||
|
<SolidColorBrush x:Key="HmiMutedTextBrush" Color="#A9BBC6"/> |
||||
|
|
||||
|
<LinearGradientBrush x:Key="HmiPanelBrush" StartPoint="0,0" EndPoint="1,1"> |
||||
|
<GradientStop Color="#27323B" Offset="0"/> |
||||
|
<GradientStop Color="#17202A" Offset="0.46"/> |
||||
|
<GradientStop Color="#0D151E" Offset="1"/> |
||||
|
</LinearGradientBrush> |
||||
|
|
||||
|
<LinearGradientBrush x:Key="HmiPanelHeaderBrush" StartPoint="0,0" EndPoint="1,0"> |
||||
|
<GradientStop Color="#1E3E53" Offset="0"/> |
||||
|
<GradientStop Color="#102433" Offset="0.58"/> |
||||
|
<GradientStop Color="#182029" Offset="1"/> |
||||
|
</LinearGradientBrush> |
||||
|
|
||||
|
<LinearGradientBrush x:Key="HmiInputBrush" StartPoint="0,0" EndPoint="0,1"> |
||||
|
<GradientStop Color="#111C25" Offset="0"/> |
||||
|
<GradientStop Color="#050A10" Offset="1"/> |
||||
|
</LinearGradientBrush> |
||||
|
|
||||
|
<LinearGradientBrush x:Key="HmiOutputBrush" StartPoint="0,0" EndPoint="0,1"> |
||||
|
<GradientStop Color="#071E28" Offset="0"/> |
||||
|
<GradientStop Color="#02070B" Offset="1"/> |
||||
|
</LinearGradientBrush> |
||||
|
|
||||
|
<LinearGradientBrush x:Key="HmiButtonBrush" StartPoint="0,0" EndPoint="0,1"> |
||||
|
<GradientStop Color="#3C4C58" Offset="0"/> |
||||
|
<GradientStop Color="#17242E" Offset="0.5"/> |
||||
|
<GradientStop Color="#0D141B" Offset="1"/> |
||||
|
</LinearGradientBrush> |
||||
|
|
||||
|
<LinearGradientBrush x:Key="HmiButtonHoverBrush" StartPoint="0,0" EndPoint="0,1"> |
||||
|
<GradientStop Color="#275978" Offset="0"/> |
||||
|
<GradientStop Color="#18364A" Offset="0.55"/> |
||||
|
<GradientStop Color="#101D28" Offset="1"/> |
||||
|
</LinearGradientBrush> |
||||
|
|
||||
|
<LinearGradientBrush x:Key="HmiButtonPressedBrush" StartPoint="0,0" EndPoint="0,1"> |
||||
|
<GradientStop Color="#0A141B" Offset="0"/> |
||||
|
<GradientStop Color="#142C3A" Offset="1"/> |
||||
|
</LinearGradientBrush> |
||||
|
</Application.Resources> |
||||
|
</Application> |
||||
@ -0,0 +1,16 @@ |
|||||
|
using System.Windows; |
||||
|
|
||||
|
namespace Housing; |
||||
|
|
||||
|
public partial class App : Application |
||||
|
{ |
||||
|
protected override void OnStartup(StartupEventArgs e) |
||||
|
{ |
||||
|
base.OnStartup(e); |
||||
|
ShutdownMode = ShutdownMode.OnMainWindowClose; |
||||
|
|
||||
|
var mainWindow = new MainWindow(); |
||||
|
MainWindow = mainWindow; |
||||
|
mainWindow.Show(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
[Database] |
||||
|
IP=192.168.10.10,1433 |
||||
|
Database=NE1aW_PT_Sensor |
||||
|
DbId=su_user |
||||
|
DbPw=amotech821 |
||||
|
Encrypt=False |
||||
|
TrustServerCertificate=True |
||||
|
Timeout=15 |
||||
|
|
||||
|
[Login] |
||||
|
Procedure=dbo.CheckOperator |
||||
|
ProcessName=Housing |
||||
|
OfflinePreview=True |
||||
@ -0,0 +1,97 @@ |
|||||
|
[Board] |
||||
|
PortName=COM5 |
||||
|
BaudRate=115200 |
||||
|
ReadTimeout=5000 |
||||
|
PreConnectCommand=x00o |
||||
|
ConnectCommand=x00c_001101:owt28006727ea97c7801 |
||||
|
ReadIdCommand=x00c_001101:ow2800326003e |
||||
|
PostReadIdCommand=x00o |
||||
|
CalDefaultCommand=x00c_001001:owt28006727ea97c7801 |
||||
|
EndToken=<end> |
||||
|
DtrEnable=false |
||||
|
RtsEnable=false |
||||
|
|
||||
|
[BarcodeScanner] |
||||
|
Enabled=true |
||||
|
PortName=Auto |
||||
|
BaudRate=9600 |
||||
|
ReadTimeout=500 |
||||
|
IdleCommitMilliseconds=250 |
||||
|
DtrEnable=true |
||||
|
RtsEnable=true |
||||
|
|
||||
|
[StartSignal] |
||||
|
; USB-6501 |
||||
|
Enabled=true |
||||
|
Connection=Ni6501 |
||||
|
PhysicalChannel=Dev1/port0/line0 |
||||
|
ActiveState=Low |
||||
|
PollIntervalMilliseconds=50 |
||||
|
; 0 = wait until the equipment start button is pressed, without a time limit. |
||||
|
TimeoutMilliseconds=0 |
||||
|
PostSignalDelayMilliseconds=2000 |
||||
|
RequireInactiveBeforeStart=true |
||||
|
|
||||
|
[Equipment] |
||||
|
; V 출력: Keysight 34465A DMM 값 |
||||
|
; A 출력: Keysight E36233A Power Supply CH1 Current 값 |
||||
|
; USB 장비는 Keysight Connection Expert 또는 NI MAX에서 보이는 VISA 주소를 Resource |
||||
|
Timeout=5000 |
||||
|
SettleMilliseconds=300 |
||||
|
PreBoardCommand=VOLT 5, (@2);OUTP ON, (@2) |
||||
|
PreBoardDelayMilliseconds=2000 |
||||
|
LogoutCommand=OUTP OFF, (@1) |
||||
|
|
||||
|
; 34465A DMM |
||||
|
VoltageConnection=LAN |
||||
|
VoltageResource= |
||||
|
VoltageIdnMatch=34465A |
||||
|
VoltageHost=192.168.10.13 |
||||
|
VoltagePort=5025 |
||||
|
VoltageSetupCommand= |
||||
|
VoltageReadCommand=MEAS:VOLT:DC? |
||||
|
|
||||
|
; Separate resistance meter. Change its address/commands here. |
||||
|
; Read after V/current and CH1 output OFF. Normal response: Ohm -> kOhm; overload: 999 + OL status. |
||||
|
; Resistance is required for inspection; Disabled stops the inspection with an error. |
||||
|
ResistanceConnection=LAN |
||||
|
ResistanceResource= |
||||
|
; Optional model identifier to validate with *IDN? (leave blank until the model is known). |
||||
|
ResistanceIdnMatch= |
||||
|
ResistanceHost=192.168.10.34 |
||||
|
ResistancePort=5025 |
||||
|
ResistancePortName= |
||||
|
ResistanceBaudRate=115200 |
||||
|
ResistanceTerminator=LF |
||||
|
ResistanceReadCommand=MEAS:RES? |
||||
|
ResistanceSetupCommand= |
||||
|
; Query the CURRENT power supply to confirm CH1 output is OFF before resistance measurement. |
||||
|
ResistancePowerOffQuery=OUTP? (@1) |
||||
|
ResistanceSettleMilliseconds=300 |
||||
|
|
||||
|
; E36233A Power Supply |
||||
|
CurrentConnection=LAN |
||||
|
CurrentResource= |
||||
|
CurrentIdnMatch=E36233A |
||||
|
; Change this to the E36233A LAN IP if the instrument uses a different address. |
||||
|
CurrentHost=192.168.10.12 |
||||
|
CurrentPort=5025 |
||||
|
CurrentPortName= |
||||
|
CurrentBaudRate=9600 |
||||
|
CurrentTerminator=LF |
||||
|
CurrentSetupCommand=OUTP ON, (@1) |
||||
|
CurrentCleanupCommand=OUTP OFF, (@1) |
||||
|
CurrentReadCommand=MEAS:CURR? (@1) |
||||
|
|
||||
|
; COM 포트로 잡히는 장비 예시: |
||||
|
; VoltageConnection=Serial |
||||
|
; VoltagePortName=Auto |
||||
|
; VoltageBaudRate=115200 |
||||
|
|
||||
|
; LAN 장비 예시: |
||||
|
; VoltageConnection=Tcp |
||||
|
; VoltageHost=192.168.0.10 |
||||
|
; VoltagePort=5025 |
||||
|
|
||||
|
; E36233A 파워서플라이에서 CH1 전압까지 읽고 싶을 때 예시: |
||||
|
; VoltageReadCommand=MEAS:VOLT? CH1 |
||||
@ -0,0 +1,40 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
<PropertyGroup> |
||||
|
<OutputType>WinExe</OutputType> |
||||
|
<TargetFramework>net9.0-windows</TargetFramework> |
||||
|
<UseWPF>true</UseWPF> |
||||
|
<RootNamespace>Housing</RootNamespace> |
||||
|
<Nullable>enable</Nullable> |
||||
|
<ImplicitUsings>enable</ImplicitUsings> |
||||
|
<ApplicationIcon>Resources\Housing_voltage_current_icon.ico</ApplicationIcon> |
||||
|
</PropertyGroup> |
||||
|
<ItemGroup> |
||||
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" /> |
||||
|
<PackageReference Include="System.IO.Ports" Version="9.0.10" /> |
||||
|
</ItemGroup> |
||||
|
<ItemGroup> |
||||
|
<Compile Remove="tools\MeasurementChecks\**\*.cs" /> |
||||
|
<Compile Remove="tools\SettingsPathChecks\**\*.cs" /> |
||||
|
</ItemGroup> |
||||
|
<ItemGroup> |
||||
|
<None Remove="Resources\mobidigm-logo.png" /> |
||||
|
<Resource Include="Resources\mobidigm-logo.png" /> |
||||
|
</ItemGroup> |
||||
|
<ItemGroup> |
||||
|
<None Update="Database.ini"> |
||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> |
||||
|
</None> |
||||
|
<None Update="Hardware.ini"> |
||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> |
||||
|
</None> |
||||
|
<None Update="InspectionSettings.ini"> |
||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> |
||||
|
</None> |
||||
|
<None Update="LoginOptions.json"> |
||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> |
||||
|
</None> |
||||
|
<None Update="init.sql"> |
||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> |
||||
|
</None> |
||||
|
</ItemGroup> |
||||
|
</Project> |
||||
@ -0,0 +1,37 @@ |
|||||
|
|
||||
|
Microsoft Visual Studio Solution File, Format Version 12.00 |
||||
|
# Visual Studio Version 18 |
||||
|
VisualStudioVersion = 18.3.11520.95 |
||||
|
MinimumVisualStudioVersion = 10.0.40219.1 |
||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Housing", "Housing.csproj", "{7364A567-F115-4ACE-9207-C6FDEEF0A394}" |
||||
|
EndProject |
||||
|
Global |
||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution |
||||
|
Debug|Any CPU = Debug|Any CPU |
||||
|
Debug|x64 = Debug|x64 |
||||
|
Debug|x86 = Debug|x86 |
||||
|
Release|Any CPU = Release|Any CPU |
||||
|
Release|x64 = Release|x64 |
||||
|
Release|x86 = Release|x86 |
||||
|
EndGlobalSection |
||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Debug|Any CPU.ActiveCfg = Debug|Any CPU |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Debug|Any CPU.Build.0 = Debug|Any CPU |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Debug|x64.ActiveCfg = Debug|Any CPU |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Debug|x64.Build.0 = Debug|Any CPU |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Debug|x86.ActiveCfg = Debug|Any CPU |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Debug|x86.Build.0 = Debug|Any CPU |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Release|Any CPU.ActiveCfg = Release|Any CPU |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Release|Any CPU.Build.0 = Release|Any CPU |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Release|x64.ActiveCfg = Release|Any CPU |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Release|x64.Build.0 = Release|Any CPU |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Release|x86.ActiveCfg = Release|Any CPU |
||||
|
{7364A567-F115-4ACE-9207-C6FDEEF0A394}.Release|x86.Build.0 = Release|Any CPU |
||||
|
EndGlobalSection |
||||
|
GlobalSection(SolutionProperties) = preSolution |
||||
|
HideSolutionNode = FALSE |
||||
|
EndGlobalSection |
||||
|
GlobalSection(ExtensibilityGlobals) = postSolution |
||||
|
SolutionGuid = {3209F145-2010-45EC-8A1A-998F1D3CAE2F} |
||||
|
EndGlobalSection |
||||
|
EndGlobal |
||||
@ -0,0 +1,8 @@ |
|||||
|
[Judgement] |
||||
|
VMin=0.00 |
||||
|
VMax=9.99 |
||||
|
AMin=0.00 |
||||
|
AMax=9.99 |
||||
|
; Resistance limits in kOhm. Set both values in the UI before the first inspection. |
||||
|
RMin= |
||||
|
RMax= |
||||
@ -0,0 +1,678 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<Window x:Class="Housing.Login.StartupLoginWindow" |
||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" |
||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
||||
|
mc:Ignorable="d" |
||||
|
Title="Housing HMI Login" |
||||
|
Width="760" |
||||
|
Height="760" |
||||
|
MinWidth="560" |
||||
|
MinHeight="620" |
||||
|
d:DesignWidth="760" |
||||
|
d:DesignHeight="760" |
||||
|
WindowStartupLocation="CenterScreen" |
||||
|
Background="{StaticResource HmiScreenBackgroundBrush}" |
||||
|
UseLayoutRounding="True" |
||||
|
SnapsToDevicePixels="True"> |
||||
|
|
||||
|
<Window.Resources> |
||||
|
<Style x:Key="FieldLabelStyle" TargetType="TextBlock"> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiMutedTextBrush}"/> |
||||
|
<Setter Property="FontSize" Value="13"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="Margin" Value="0,0,0,6"/> |
||||
|
<Setter Property="TextWrapping" Value="Wrap"/> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="InputStyle" TargetType="TextBox"> |
||||
|
<Setter Property="Height" Value="42"/> |
||||
|
<Setter Property="MinWidth" Value="0"/> |
||||
|
<Setter Property="Background" Value="{StaticResource HmiInputBrush}"/> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiTextBrush}"/> |
||||
|
<Setter Property="BorderBrush" Value="#3F6178"/> |
||||
|
<Setter Property="BorderThickness" Value="1"/> |
||||
|
<Setter Property="FontSize" Value="19"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="CaretBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter Property="SelectionBrush" Value="{StaticResource HmiAccentBlueBrush}"/> |
||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="Padding" Value="8,0"/> |
||||
|
<Setter Property="Template"> |
||||
|
<Setter.Value> |
||||
|
<ControlTemplate TargetType="{x:Type TextBox}"> |
||||
|
<Border x:Name="FieldChrome" |
||||
|
Background="{TemplateBinding Background}" |
||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||
|
CornerRadius="3" |
||||
|
SnapsToDevicePixels="True"> |
||||
|
<Grid> |
||||
|
<Rectangle Height="2" |
||||
|
VerticalAlignment="Top" |
||||
|
Fill="{StaticResource HmiAccentBrush}" |
||||
|
Opacity="0.42"/> |
||||
|
<ScrollViewer x:Name="PART_ContentHost" |
||||
|
Margin="{TemplateBinding Padding}" |
||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" |
||||
|
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<ControlTemplate.Triggers> |
||||
|
<Trigger Property="IsKeyboardFocused" Value="True"> |
||||
|
<Setter TargetName="FieldChrome" Property="BorderBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter TargetName="FieldChrome" Property="BorderThickness" Value="2"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsEnabled" Value="False"> |
||||
|
<Setter TargetName="FieldChrome" Property="Opacity" Value="0.55"/> |
||||
|
</Trigger> |
||||
|
</ControlTemplate.Triggers> |
||||
|
</ControlTemplate> |
||||
|
</Setter.Value> |
||||
|
</Setter> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="PasswordInputStyle" TargetType="PasswordBox"> |
||||
|
<Setter Property="Height" Value="42"/> |
||||
|
<Setter Property="MinWidth" Value="0"/> |
||||
|
<Setter Property="Background" Value="{StaticResource HmiInputBrush}"/> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiTextBrush}"/> |
||||
|
<Setter Property="BorderBrush" Value="#3F6178"/> |
||||
|
<Setter Property="BorderThickness" Value="1"/> |
||||
|
<Setter Property="FontSize" Value="19"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="CaretBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter Property="SelectionBrush" Value="{StaticResource HmiAccentBlueBrush}"/> |
||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="Padding" Value="8,0"/> |
||||
|
<Setter Property="Template"> |
||||
|
<Setter.Value> |
||||
|
<ControlTemplate TargetType="{x:Type PasswordBox}"> |
||||
|
<Border x:Name="FieldChrome" |
||||
|
Background="{TemplateBinding Background}" |
||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||
|
CornerRadius="3" |
||||
|
SnapsToDevicePixels="True"> |
||||
|
<Grid> |
||||
|
<Rectangle Height="2" |
||||
|
VerticalAlignment="Top" |
||||
|
Fill="{StaticResource HmiAccentBrush}" |
||||
|
Opacity="0.42"/> |
||||
|
<ScrollViewer x:Name="PART_ContentHost" |
||||
|
Margin="{TemplateBinding Padding}" |
||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" |
||||
|
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<ControlTemplate.Triggers> |
||||
|
<Trigger Property="IsKeyboardFocused" Value="True"> |
||||
|
<Setter TargetName="FieldChrome" Property="BorderBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter TargetName="FieldChrome" Property="BorderThickness" Value="2"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsEnabled" Value="False"> |
||||
|
<Setter TargetName="FieldChrome" Property="Opacity" Value="0.55"/> |
||||
|
</Trigger> |
||||
|
</ControlTemplate.Triggers> |
||||
|
</ControlTemplate> |
||||
|
</Setter.Value> |
||||
|
</Setter> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="ComboInputItemStyle" TargetType="{x:Type ComboBoxItem}"> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiTextBrush}"/> |
||||
|
<Setter Property="Background" Value="{StaticResource HmiInputBrush}"/> |
||||
|
<Setter Property="FontSize" Value="17"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="Padding" Value="10,8"/> |
||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="Template"> |
||||
|
<Setter.Value> |
||||
|
<ControlTemplate TargetType="{x:Type ComboBoxItem}"> |
||||
|
<Border x:Name="ItemChrome" |
||||
|
Background="{TemplateBinding Background}" |
||||
|
BorderBrush="#263D4F" |
||||
|
BorderThickness="0,0,0,1" |
||||
|
Padding="{TemplateBinding Padding}"> |
||||
|
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> |
||||
|
</Border> |
||||
|
|
||||
|
<ControlTemplate.Triggers> |
||||
|
<Trigger Property="IsHighlighted" Value="True"> |
||||
|
<Setter TargetName="ItemChrome" Property="Background" Value="{StaticResource HmiButtonHoverBrush}"/> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiTextBrush}"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsSelected" Value="True"> |
||||
|
<Setter TargetName="ItemChrome" Property="Background" Value="#163A4A"/> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsEnabled" Value="False"> |
||||
|
<Setter TargetName="ItemChrome" Property="Opacity" Value="0.55"/> |
||||
|
</Trigger> |
||||
|
</ControlTemplate.Triggers> |
||||
|
</ControlTemplate> |
||||
|
</Setter.Value> |
||||
|
</Setter> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="ComboInputStyle" TargetType="{x:Type ComboBox}"> |
||||
|
<Setter Property="Height" Value="42"/> |
||||
|
<Setter Property="MinWidth" Value="0"/> |
||||
|
<Setter Property="Background" Value="{StaticResource HmiInputBrush}"/> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiTextBrush}"/> |
||||
|
<Setter Property="BorderBrush" Value="#3F6178"/> |
||||
|
<Setter Property="BorderThickness" Value="1"/> |
||||
|
<Setter Property="FontSize" Value="19"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="Padding" Value="8,0,34,0"/> |
||||
|
<Setter Property="IsTextSearchEnabled" Value="True"/> |
||||
|
<Setter Property="MaxDropDownHeight" Value="220"/> |
||||
|
<Setter Property="ItemContainerStyle" Value="{StaticResource ComboInputItemStyle}"/> |
||||
|
<Setter Property="Template"> |
||||
|
<Setter.Value> |
||||
|
<ControlTemplate TargetType="{x:Type ComboBox}"> |
||||
|
<Grid> |
||||
|
<Border x:Name="FieldChrome" |
||||
|
Background="{TemplateBinding Background}" |
||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||
|
CornerRadius="3" |
||||
|
SnapsToDevicePixels="True"> |
||||
|
<Grid> |
||||
|
<Rectangle Height="2" |
||||
|
VerticalAlignment="Top" |
||||
|
Fill="{StaticResource HmiAccentBrush}" |
||||
|
Opacity="0.42"/> |
||||
|
|
||||
|
<ContentPresenter x:Name="ContentSite" |
||||
|
Margin="{TemplateBinding Padding}" |
||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" |
||||
|
Content="{TemplateBinding SelectionBoxItem}" |
||||
|
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}" |
||||
|
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}" |
||||
|
RecognizesAccessKey="True" |
||||
|
IsHitTestVisible="False"/> |
||||
|
|
||||
|
<ToggleButton x:Name="DropDownToggle" |
||||
|
Background="Transparent" |
||||
|
BorderThickness="0" |
||||
|
ClickMode="Press" |
||||
|
Focusable="False" |
||||
|
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"> |
||||
|
<ToggleButton.Template> |
||||
|
<ControlTemplate TargetType="{x:Type ToggleButton}"> |
||||
|
<Border Background="Transparent"> |
||||
|
<Path x:Name="Arrow" |
||||
|
Width="12" |
||||
|
Height="7" |
||||
|
Margin="0,0,12,0" |
||||
|
HorizontalAlignment="Right" |
||||
|
VerticalAlignment="Center" |
||||
|
Data="M 0 0 L 6 7 L 12 0 Z" |
||||
|
Fill="{StaticResource HmiAccentBrush}" |
||||
|
Stretch="Fill"/> |
||||
|
</Border> |
||||
|
|
||||
|
<ControlTemplate.Triggers> |
||||
|
<Trigger Property="IsMouseOver" Value="True"> |
||||
|
<Setter TargetName="Arrow" Property="Fill" Value="#76EAFF"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsChecked" Value="True"> |
||||
|
<Setter TargetName="Arrow" Property="Fill" Value="{StaticResource HmiRunBrush}"/> |
||||
|
</Trigger> |
||||
|
</ControlTemplate.Triggers> |
||||
|
</ControlTemplate> |
||||
|
</ToggleButton.Template> |
||||
|
</ToggleButton> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Popup x:Name="PART_Popup" |
||||
|
AllowsTransparency="True" |
||||
|
Focusable="False" |
||||
|
IsOpen="{TemplateBinding IsDropDownOpen}" |
||||
|
Placement="Bottom" |
||||
|
PopupAnimation="Slide"> |
||||
|
<Border Width="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}" |
||||
|
MaxHeight="{TemplateBinding MaxDropDownHeight}" |
||||
|
Background="#07111C" |
||||
|
BorderBrush="{StaticResource HmiAccentBrush}" |
||||
|
BorderThickness="1"> |
||||
|
<ScrollViewer MaxHeight="{TemplateBinding MaxDropDownHeight}" |
||||
|
SnapsToDevicePixels="True" |
||||
|
VerticalScrollBarVisibility="Auto"> |
||||
|
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained"/> |
||||
|
</ScrollViewer> |
||||
|
</Border> |
||||
|
</Popup> |
||||
|
</Grid> |
||||
|
|
||||
|
<ControlTemplate.Triggers> |
||||
|
<Trigger Property="IsMouseOver" Value="True"> |
||||
|
<Setter TargetName="FieldChrome" Property="BorderBrush" Value="#76EAFF"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsKeyboardFocusWithin" Value="True"> |
||||
|
<Setter TargetName="FieldChrome" Property="BorderBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter TargetName="FieldChrome" Property="BorderThickness" Value="2"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsDropDownOpen" Value="True"> |
||||
|
<Setter TargetName="FieldChrome" Property="BorderBrush" Value="{StaticResource HmiRunBrush}"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsEnabled" Value="False"> |
||||
|
<Setter TargetName="FieldChrome" Property="Opacity" Value="0.55"/> |
||||
|
</Trigger> |
||||
|
</ControlTemplate.Triggers> |
||||
|
</ControlTemplate> |
||||
|
</Setter.Value> |
||||
|
</Setter> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="CommandButtonStyle" TargetType="Button"> |
||||
|
<Setter Property="Height" Value="58"/> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiTextBrush}"/> |
||||
|
<Setter Property="Background" Value="{StaticResource HmiButtonBrush}"/> |
||||
|
<Setter Property="BorderBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter Property="BorderThickness" Value="2"/> |
||||
|
<Setter Property="FontSize" Value="23"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="Cursor" Value="Hand"/> |
||||
|
<Setter Property="Template"> |
||||
|
<Setter.Value> |
||||
|
<ControlTemplate TargetType="{x:Type Button}"> |
||||
|
<Border x:Name="ButtonChrome" |
||||
|
Background="{TemplateBinding Background}" |
||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||
|
CornerRadius="4" |
||||
|
SnapsToDevicePixels="True"> |
||||
|
<Grid> |
||||
|
<Border BorderBrush="#FFFFFF" |
||||
|
BorderThickness="0,1,0,0" |
||||
|
Opacity="0.18"/> |
||||
|
<Rectangle Height="4" |
||||
|
VerticalAlignment="Bottom" |
||||
|
Fill="{StaticResource HmiAccentBrush}" |
||||
|
Opacity="0.65"/> |
||||
|
<ContentPresenter RecognizesAccessKey="True" |
||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<ControlTemplate.Triggers> |
||||
|
<Trigger Property="IsMouseOver" Value="True"> |
||||
|
<Setter TargetName="ButtonChrome" Property="Background" Value="{StaticResource HmiButtonHoverBrush}"/> |
||||
|
<Setter TargetName="ButtonChrome" Property="BorderBrush" Value="#76EAFF"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsPressed" Value="True"> |
||||
|
<Setter TargetName="ButtonChrome" Property="Background" Value="{StaticResource HmiButtonPressedBrush}"/> |
||||
|
<Setter TargetName="ButtonChrome" Property="BorderBrush" Value="{StaticResource HmiRunBrush}"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsEnabled" Value="False"> |
||||
|
<Setter TargetName="ButtonChrome" Property="Opacity" Value="0.45"/> |
||||
|
</Trigger> |
||||
|
</ControlTemplate.Triggers> |
||||
|
</ControlTemplate> |
||||
|
</Setter.Value> |
||||
|
</Setter> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="SectionCaptionStyle" TargetType="TextBlock"> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter Property="FontSize" Value="13"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="Margin" Value="0,0,0,8"/> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="FieldTileStyle" TargetType="Border"> |
||||
|
<Setter Property="Background" Value="#121D26"/> |
||||
|
<Setter Property="BorderBrush" Value="#30475A"/> |
||||
|
<Setter Property="BorderThickness" Value="1"/> |
||||
|
<Setter Property="Padding" Value="12,9"/> |
||||
|
</Style> |
||||
|
</Window.Resources> |
||||
|
|
||||
|
<Grid> |
||||
|
<Grid.Background> |
||||
|
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1"> |
||||
|
<GradientStop Color="#07111C" Offset="0"/> |
||||
|
<GradientStop Color="#0A1B2A" Offset="0.48"/> |
||||
|
<GradientStop Color="#02060A" Offset="1"/> |
||||
|
</LinearGradientBrush> |
||||
|
</Grid.Background> |
||||
|
|
||||
|
<Border HorizontalAlignment="Stretch" |
||||
|
VerticalAlignment="Stretch" |
||||
|
Background="{StaticResource HmiPanelBrush}" |
||||
|
BorderBrush="{StaticResource HmiPanelInnerBorderBrush}" |
||||
|
BorderThickness="0"> |
||||
|
|
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="*"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<Border Grid.Row="0" |
||||
|
Background="{StaticResource HmiPanelHeaderBrush}" |
||||
|
BorderBrush="{StaticResource HmiPanelInnerBorderBrush}" |
||||
|
BorderThickness="0,0,0,1" |
||||
|
Padding="24,16"> |
||||
|
<Grid> |
||||
|
<Grid.ColumnDefinitions> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
<ColumnDefinition Width="Auto"/> |
||||
|
</Grid.ColumnDefinitions> |
||||
|
|
||||
|
<StackPanel> |
||||
|
<TextBlock Text="AMO HOUSING ASSEMBLY" |
||||
|
Foreground="{StaticResource HmiTextBrush}" |
||||
|
FontSize="27" |
||||
|
FontWeight="Bold" |
||||
|
TextWrapping="Wrap"/> |
||||
|
<TextBlock Text="CONTROL SYSTEM ACCESS" |
||||
|
Foreground="{StaticResource HmiAccentBrush}" |
||||
|
FontSize="13" |
||||
|
FontWeight="SemiBold" |
||||
|
Margin="0,3,0,0"/> |
||||
|
</StackPanel> |
||||
|
|
||||
|
<StackPanel Grid.Column="1" |
||||
|
Orientation="Horizontal" |
||||
|
VerticalAlignment="Center" |
||||
|
Margin="18,0,0,0"> |
||||
|
<StackPanel Margin="0,0,12,0"> |
||||
|
<Ellipse Width="11" |
||||
|
Height="11" |
||||
|
Fill="{StaticResource HmiRunBrush}" |
||||
|
Stroke="#94F7C5" |
||||
|
StrokeThickness="1"/> |
||||
|
<TextBlock Text="PWR" |
||||
|
Foreground="{StaticResource HmiMutedTextBrush}" |
||||
|
FontSize="10" |
||||
|
Margin="0,4,0,0"/> |
||||
|
</StackPanel> |
||||
|
|
||||
|
<StackPanel Margin="0,0,12,0"> |
||||
|
<Ellipse Width="11" |
||||
|
Height="11" |
||||
|
Fill="{StaticResource HmiAccentBrush}" |
||||
|
Stroke="#8BEFFF" |
||||
|
StrokeThickness="1"/> |
||||
|
<TextBlock Text="PLC" |
||||
|
Foreground="{StaticResource HmiMutedTextBrush}" |
||||
|
FontSize="10" |
||||
|
Margin="0,4,0,0"/> |
||||
|
</StackPanel> |
||||
|
|
||||
|
<StackPanel> |
||||
|
<Ellipse Width="11" |
||||
|
Height="11" |
||||
|
Fill="{StaticResource HmiWarningBrush}" |
||||
|
Stroke="#FFD391" |
||||
|
StrokeThickness="1"/> |
||||
|
<TextBlock Text="AUTH" |
||||
|
Foreground="{StaticResource HmiMutedTextBrush}" |
||||
|
FontSize="10" |
||||
|
Margin="0,4,0,0"/> |
||||
|
</StackPanel> |
||||
|
</StackPanel> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Row="1" |
||||
|
Background="#0A121B" |
||||
|
BorderBrush="#253849" |
||||
|
BorderThickness="0,0,0,1" |
||||
|
Padding="22,8"> |
||||
|
<DockPanel LastChildFill="True"> |
||||
|
<TextBlock Text="SYSTEM READY" |
||||
|
Foreground="{StaticResource HmiRunBrush}" |
||||
|
FontSize="12" |
||||
|
FontWeight="Bold" |
||||
|
Margin="0,0,16,0" |
||||
|
VerticalAlignment="Center"/> |
||||
|
|
||||
|
<Rectangle Width="1" |
||||
|
Fill="#304454" |
||||
|
Margin="0,0,16,0"/> |
||||
|
|
||||
|
<TextBlock Text="Operator verification required before line operation" |
||||
|
Foreground="{StaticResource HmiMutedTextBrush}" |
||||
|
FontSize="12" |
||||
|
VerticalAlignment="Center" |
||||
|
TextWrapping="Wrap"/> |
||||
|
</DockPanel> |
||||
|
</Border> |
||||
|
|
||||
|
<Grid Grid.Row="2" Margin="26,22"> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="14"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="14"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="18"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<TextBlock Grid.Row="0" |
||||
|
Text="PRODUCT CONFIGURATION" |
||||
|
Style="{StaticResource SectionCaptionStyle}"/> |
||||
|
|
||||
|
<Grid Grid.Row="1"> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="8"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<Grid.ColumnDefinitions> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
<ColumnDefinition Width="10"/> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
</Grid.ColumnDefinitions> |
||||
|
|
||||
|
<Border Grid.Row="0" |
||||
|
Grid.Column="0" |
||||
|
Style="{StaticResource FieldTileStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<TextBlock Text="Maker" |
||||
|
Style="{StaticResource FieldLabelStyle}"/> |
||||
|
<ComboBox x:Name="MakerComboBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource ComboInputStyle}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Row="0" |
||||
|
Grid.Column="2" |
||||
|
Style="{StaticResource FieldTileStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<TextBlock Text="Model" |
||||
|
Style="{StaticResource FieldLabelStyle}"/> |
||||
|
<ComboBox x:Name="ModelComboBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource ComboInputStyle}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Row="2" |
||||
|
Grid.Column="0" |
||||
|
Style="{StaticResource FieldTileStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<TextBlock Text="Variant 1" |
||||
|
Style="{StaticResource FieldLabelStyle}"/> |
||||
|
<ComboBox x:Name="Variant1ComboBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource ComboInputStyle}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Row="2" |
||||
|
Grid.Column="2" |
||||
|
Style="{StaticResource FieldTileStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<TextBlock Text="Variant 2" |
||||
|
Style="{StaticResource FieldLabelStyle}"/> |
||||
|
<ComboBox x:Name="Variant2ComboBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource ComboInputStyle}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
</Grid> |
||||
|
|
||||
|
<TextBlock Grid.Row="3" |
||||
|
Text="OPERATOR AUTHENTICATION" |
||||
|
Style="{StaticResource SectionCaptionStyle}"/> |
||||
|
|
||||
|
<Grid Grid.Row="4"> |
||||
|
<Grid.ColumnDefinitions> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
<ColumnDefinition Width="10"/> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
</Grid.ColumnDefinitions> |
||||
|
|
||||
|
<Border Grid.Column="0" |
||||
|
Style="{StaticResource FieldTileStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<TextBlock Text="ID" |
||||
|
Style="{StaticResource FieldLabelStyle}"/> |
||||
|
<TextBox x:Name="OperatorTextBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource InputStyle}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Column="2" |
||||
|
Style="{StaticResource FieldTileStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<TextBlock Text="PW" |
||||
|
Style="{StaticResource FieldLabelStyle}"/> |
||||
|
<PasswordBox x:Name="PasswordBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource PasswordInputStyle}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
</Grid> |
||||
|
|
||||
|
<TextBlock Grid.Row="6" |
||||
|
Text="LINE INFORMATION" |
||||
|
Style="{StaticResource SectionCaptionStyle}"/> |
||||
|
|
||||
|
<Grid Grid.Row="7"> |
||||
|
<Grid.ColumnDefinitions> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
<ColumnDefinition Width="10"/> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
<ColumnDefinition Width="10"/> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
</Grid.ColumnDefinitions> |
||||
|
|
||||
|
<Border Grid.Column="0" |
||||
|
Style="{StaticResource FieldTileStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<TextBlock Text="Line" |
||||
|
Style="{StaticResource FieldLabelStyle}"/> |
||||
|
<ComboBox x:Name="LineNoComboBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource ComboInputStyle}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Column="2" |
||||
|
Style="{StaticResource FieldTileStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<TextBlock Text="Lot No" |
||||
|
Style="{StaticResource FieldLabelStyle}"/> |
||||
|
<TextBox x:Name="LotNoTextBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource InputStyle}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Column="4" |
||||
|
Style="{StaticResource FieldTileStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<TextBlock Text="Jig No" |
||||
|
Style="{StaticResource FieldLabelStyle}"/> |
||||
|
<ComboBox x:Name="JigNoComboBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource ComboInputStyle}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
</Grid> |
||||
|
|
||||
|
<Button x:Name="LoginButton" |
||||
|
Grid.Row="9" |
||||
|
Content="ACCESS / LOG IN" |
||||
|
Style="{StaticResource CommandButtonStyle}" |
||||
|
Click="LogInButton_Click"/> |
||||
|
</Grid> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
</Grid> |
||||
|
</Window> |
||||
@ -0,0 +1,476 @@ |
|||||
|
using System; |
||||
|
using System.IO; |
||||
|
using System.Windows; |
||||
|
using System.Windows.Controls; |
||||
|
using System.Windows.Controls.Primitives; |
||||
|
using System.Windows.Threading; |
||||
|
using Housing.Services; |
||||
|
|
||||
|
namespace Housing.Login; |
||||
|
|
||||
|
public sealed class StartupLoginWindowResult |
||||
|
{ |
||||
|
public string Maker { get; set; } = string.Empty; |
||||
|
public string Model { get; set; } = string.Empty; |
||||
|
public string Variant1 { get; set; } = string.Empty; |
||||
|
public string Variant2 { get; set; } = string.Empty; |
||||
|
public string Operator { get; set; } = string.Empty; |
||||
|
public string LineNo { get; set; } = string.Empty; |
||||
|
public string LotNo { get; set; } = string.Empty; |
||||
|
public string JigNo { get; set; } = string.Empty; |
||||
|
} |
||||
|
|
||||
|
public partial class StartupLoginWindow : Window |
||||
|
{ |
||||
|
private const string OperatorHistoryFieldName = "Operator"; |
||||
|
private const string SuppressedOperatorHistoryValue = "su"; |
||||
|
private readonly LoginHistoryStore _loginHistoryStore; |
||||
|
private readonly LoginSelectionOptions _loginOptions; |
||||
|
private Popup? _openHistoryPopup; |
||||
|
|
||||
|
public StartupLoginWindowResult Result { get; private set; } = new(); |
||||
|
|
||||
|
public StartupLoginWindow() |
||||
|
{ |
||||
|
_loginHistoryStore = LoginHistoryStore.Load(GetLoginHistoryPath()); |
||||
|
_loginOptions = LoginSelectionOptions.Load(GetLoginOptionsPath()); |
||||
|
RemoveSuppressedOperatorHistory(); |
||||
|
InitializeComponent(); |
||||
|
ApplyLoginOptions(); |
||||
|
AttachLoginHistoryMenus(); |
||||
|
AttachSelectionDropDowns(); |
||||
|
PasswordBox.GotKeyboardFocus += (_, _) => CloseHistoryPopup(); |
||||
|
LoginButton.GotKeyboardFocus += (_, _) => CloseHistoryPopup(); |
||||
|
MakerComboBox.Focus(); |
||||
|
} |
||||
|
|
||||
|
private async void LogInButton_Click(object sender, RoutedEventArgs e) |
||||
|
{ |
||||
|
CloseHistoryPopup(); |
||||
|
|
||||
|
if (!TryReadRequiredFields(out var loginResult, out var password)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (sender is Button button) |
||||
|
{ |
||||
|
button.IsEnabled = false; |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
if (!await IsValidLoginAsync(loginResult.Operator, password)) |
||||
|
{ |
||||
|
MessageBox.Show(this, "등록되지 않은 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, $"로그인 확인 실패: {ex.Message}", "Login", MessageBoxButton.OK, MessageBoxImage.Error); |
||||
|
OperatorTextBox.Focus(); |
||||
|
OperatorTextBox.SelectAll(); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
if (sender is Button loginButton) |
||||
|
{ |
||||
|
loginButton.IsEnabled = true; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task<bool> IsValidLoginAsync(string loginId, string password) |
||||
|
{ |
||||
|
if (_loginOptions.LoginAccounts.Count > 0) |
||||
|
{ |
||||
|
return _loginOptions.LoginAccounts.Any(account => |
||||
|
string.Equals(account.Id, loginId, StringComparison.OrdinalIgnoreCase) && |
||||
|
string.Equals(account.Password, password, StringComparison.Ordinal)); |
||||
|
} |
||||
|
|
||||
|
var loginSettings = LoginSettings.Load(GetDatabaseIniPath()); |
||||
|
if (loginSettings.OfflinePreview) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
var repository = new LoginAccountRepository( |
||||
|
DatabaseSettings.Load(GetDatabaseIniPath()), |
||||
|
loginSettings); |
||||
|
|
||||
|
return await repository.ExistsAsync(loginId, password); |
||||
|
} |
||||
|
|
||||
|
private bool TryReadRequiredFields(out StartupLoginWindowResult result, out string password) |
||||
|
{ |
||||
|
result = new StartupLoginWindowResult(); |
||||
|
password = string.Empty; |
||||
|
|
||||
|
if (!TryReadRequiredComboBox(MakerComboBox, "Maker", out var maker) || |
||||
|
!TryReadRequiredComboBox(ModelComboBox, "Model", out var model) || |
||||
|
!TryReadTextBox(OperatorTextBox, "ID", out var loginId) || |
||||
|
!TryReadPasswordBox(PasswordBox, "PW", out password) || |
||||
|
!TryReadRequiredComboBox(LineNoComboBox, "Line", out var lineNo) || |
||||
|
!TryReadTextBox(LotNoTextBox, "Lot No", out var lotNo) || |
||||
|
!TryReadRequiredComboBox(JigNoComboBox, "Jig No", out var jigNo)) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
var variant1 = ReadOptionalComboBox(Variant1ComboBox); |
||||
|
var variant2 = ReadOptionalComboBox(Variant2ComboBox); |
||||
|
|
||||
|
result = new StartupLoginWindowResult |
||||
|
{ |
||||
|
Maker = maker, |
||||
|
Model = model, |
||||
|
Variant1 = variant1, |
||||
|
Variant2 = variant2, |
||||
|
Operator = loginId, |
||||
|
LineNo = lineNo, |
||||
|
LotNo = lotNo, |
||||
|
JigNo = jigNo |
||||
|
}; |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
private void ApplyLoginOptions() |
||||
|
{ |
||||
|
SetComboBoxOptions(MakerComboBox, _loginOptions.Makers, allowBlank: false, historyFieldName: "Maker"); |
||||
|
SetComboBoxOptions(ModelComboBox, _loginOptions.Models, allowBlank: false, historyFieldName: "Model"); |
||||
|
SetComboBoxOptions(Variant1ComboBox, _loginOptions.Variant1Values, allowBlank: true, historyFieldName: "Variant1"); |
||||
|
SetComboBoxOptions(Variant2ComboBox, _loginOptions.Variant2Values, allowBlank: true, historyFieldName: "Variant2"); |
||||
|
SetComboBoxOptions(LineNoComboBox, _loginOptions.LineValues, allowBlank: false, historyFieldName: "LineNo"); |
||||
|
SetComboBoxOptions(JigNoComboBox, _loginOptions.JigNoValues, allowBlank: false, historyFieldName: "JigNo"); |
||||
|
} |
||||
|
|
||||
|
private void SetComboBoxOptions( |
||||
|
ComboBox comboBox, |
||||
|
IReadOnlyList<string> values, |
||||
|
bool allowBlank, |
||||
|
string historyFieldName) |
||||
|
{ |
||||
|
var items = new List<LoginOptionItem>(); |
||||
|
if (allowBlank) |
||||
|
{ |
||||
|
items.Add(new LoginOptionItem("(None)", string.Empty)); |
||||
|
} |
||||
|
|
||||
|
foreach (var value in values.Where(value => !string.IsNullOrWhiteSpace(value))) |
||||
|
{ |
||||
|
if (items.Any(item => string.Equals(item.Value, value, StringComparison.OrdinalIgnoreCase))) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
items.Add(new LoginOptionItem(value, value)); |
||||
|
} |
||||
|
|
||||
|
comboBox.ItemsSource = items; |
||||
|
comboBox.DisplayMemberPath = nameof(LoginOptionItem.DisplayText); |
||||
|
|
||||
|
var rememberedValue = _loginHistoryStore.GetValues(historyFieldName).FirstOrDefault(); |
||||
|
if (!string.IsNullOrWhiteSpace(rememberedValue) && |
||||
|
TrySelectComboBoxValue(comboBox, rememberedValue)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (allowBlank) |
||||
|
{ |
||||
|
comboBox.SelectedIndex = 0; |
||||
|
} |
||||
|
else if (items.Count == 1) |
||||
|
{ |
||||
|
comboBox.SelectedIndex = 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static bool TrySelectComboBoxValue(ComboBox comboBox, string value) |
||||
|
{ |
||||
|
for (var index = 0; index < comboBox.Items.Count; index++) |
||||
|
{ |
||||
|
if (comboBox.Items[index] is LoginOptionItem item && |
||||
|
string.Equals(item.Value, value, StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
comboBox.SelectedIndex = index; |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
private void AttachSelectionDropDowns() |
||||
|
{ |
||||
|
foreach (var comboBox in new[] |
||||
|
{ |
||||
|
MakerComboBox, |
||||
|
ModelComboBox, |
||||
|
Variant1ComboBox, |
||||
|
Variant2ComboBox, |
||||
|
LineNoComboBox, |
||||
|
JigNoComboBox |
||||
|
}) |
||||
|
{ |
||||
|
comboBox.GotKeyboardFocus += (_, _) => CloseHistoryPopup(); |
||||
|
comboBox.DropDownOpened += (_, _) => CloseHistoryPopup(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void AttachLoginHistoryMenus() |
||||
|
{ |
||||
|
AttachHistoryMenu(OperatorTextBox, OperatorHistoryFieldName, PasswordBox); |
||||
|
AttachHistoryMenu(LotNoTextBox, "LotNo", JigNoComboBox); |
||||
|
} |
||||
|
|
||||
|
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 TryReadRequiredComboBox(ComboBox comboBox, string fieldName, out string value) |
||||
|
{ |
||||
|
value = ReadComboBoxValue(comboBox); |
||||
|
if (!string.IsNullOrWhiteSpace(value)) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
MessageBox.Show( |
||||
|
this, |
||||
|
$"{fieldName} value is required. Edit LoginOptions.json and select a value.", |
||||
|
"Login", |
||||
|
MessageBoxButton.OK, |
||||
|
MessageBoxImage.Warning); |
||||
|
comboBox.Focus(); |
||||
|
comboBox.IsDropDownOpen = true; |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
private static string ReadOptionalComboBox(ComboBox comboBox) |
||||
|
{ |
||||
|
return ReadComboBoxValue(comboBox); |
||||
|
} |
||||
|
|
||||
|
private static string ReadComboBoxValue(ComboBox comboBox) |
||||
|
{ |
||||
|
return comboBox.SelectedItem is LoginOptionItem item |
||||
|
? item.Value.Trim() |
||||
|
: comboBox.Text.Trim(); |
||||
|
} |
||||
|
|
||||
|
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 HousingSettingsPaths.GetIniPath("Database.ini"); |
||||
|
} |
||||
|
|
||||
|
private static string GetLoginOptionsPath() |
||||
|
{ |
||||
|
var bundledPath = Path.Combine(AppContext.BaseDirectory, "LoginOptions.json"); |
||||
|
if (File.Exists(bundledPath)) |
||||
|
{ |
||||
|
return bundledPath; |
||||
|
} |
||||
|
|
||||
|
var directory = Path.Combine( |
||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), |
||||
|
"Housing"); |
||||
|
var filePath = Path.Combine(directory, "LoginOptions.json"); |
||||
|
if (!File.Exists(filePath)) |
||||
|
{ |
||||
|
LoginSelectionOptions.CreateDefaultFile(filePath); |
||||
|
} |
||||
|
|
||||
|
return filePath; |
||||
|
} |
||||
|
|
||||
|
private static string GetLoginHistoryPath() |
||||
|
{ |
||||
|
return Path.Combine( |
||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), |
||||
|
"Housing", |
||||
|
"LoginHistory.json"); |
||||
|
} |
||||
|
|
||||
|
private sealed record LoginOptionItem(string DisplayText, string Value); |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
{ |
||||
|
"loginAccounts": [ |
||||
|
{ |
||||
|
"id": "su", |
||||
|
"password": "su" |
||||
|
} |
||||
|
], |
||||
|
"makers": [ |
||||
|
"AMO", |
||||
|
"ABC" |
||||
|
], |
||||
|
"models": [ |
||||
|
"MODEL-1" |
||||
|
], |
||||
|
"variant1Values": [], |
||||
|
"variant2Values": [], |
||||
|
"lineValues": [ |
||||
|
"LINE-1" |
||||
|
], |
||||
|
"jigNoValues": [ |
||||
|
"JIG-1" |
||||
|
] |
||||
|
} |
||||
@ -0,0 +1,708 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<Window x:Class="Housing.MainWindow" |
||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" |
||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||
|
Title="Housing Assembly HMI" |
||||
|
Width="1200" |
||||
|
Height="800" |
||||
|
MinWidth="840" |
||||
|
MinHeight="560" |
||||
|
WindowStartupLocation="CenterScreen" |
||||
|
WindowState="Maximized" |
||||
|
Background="{StaticResource HmiScreenBackgroundBrush}" |
||||
|
UseLayoutRounding="True" |
||||
|
SnapsToDevicePixels="True"> |
||||
|
<Window.Resources> |
||||
|
<SolidColorBrush x:Key="ProcessStepInactiveBrush" Color="#244253"/> |
||||
|
<SolidColorBrush x:Key="ProcessStepActiveBackgroundBrush" Color="#0D2632"/> |
||||
|
|
||||
|
<Style x:Key="LabelStyle" TargetType="TextBlock"> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiTextBrush}"/> |
||||
|
<Setter Property="FontSize" Value="18"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="Margin" Value="0,0,0,10"/> |
||||
|
<Setter Property="TextWrapping" Value="Wrap"/> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="InputBoxStyle" TargetType="TextBox"> |
||||
|
<Setter Property="Height" Value="58"/> |
||||
|
<Setter Property="MinWidth" Value="160"/> |
||||
|
<Setter Property="Background" Value="{StaticResource HmiInputBrush}"/> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiTextBrush}"/> |
||||
|
<Setter Property="BorderBrush" Value="#3F6178"/> |
||||
|
<Setter Property="BorderThickness" Value="1"/> |
||||
|
<Setter Property="FontSize" Value="24"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="CaretBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter Property="SelectionBrush" Value="{StaticResource HmiAccentBlueBrush}"/> |
||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="Padding" Value="10,0"/> |
||||
|
<Setter Property="Template"> |
||||
|
<Setter.Value> |
||||
|
<ControlTemplate TargetType="{x:Type TextBox}"> |
||||
|
<Border x:Name="FieldChrome" |
||||
|
Background="{TemplateBinding Background}" |
||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||
|
CornerRadius="3" |
||||
|
SnapsToDevicePixels="True"> |
||||
|
<Grid> |
||||
|
<Rectangle Height="2" |
||||
|
VerticalAlignment="Top" |
||||
|
Fill="{StaticResource HmiAccentBrush}" |
||||
|
Opacity="0.42"/> |
||||
|
<ScrollViewer x:Name="PART_ContentHost" |
||||
|
Margin="{TemplateBinding Padding}" |
||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" |
||||
|
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
<ControlTemplate.Triggers> |
||||
|
<Trigger Property="IsKeyboardFocused" Value="True"> |
||||
|
<Setter TargetName="FieldChrome" Property="BorderBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter TargetName="FieldChrome" Property="BorderThickness" Value="2"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsReadOnly" Value="True"> |
||||
|
<Setter TargetName="FieldChrome" Property="BorderBrush" Value="#426070"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsEnabled" Value="False"> |
||||
|
<Setter TargetName="FieldChrome" Property="Opacity" Value="0.55"/> |
||||
|
</Trigger> |
||||
|
</ControlTemplate.Triggers> |
||||
|
</ControlTemplate> |
||||
|
</Setter.Value> |
||||
|
</Setter> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="OutputBoxStyle" TargetType="TextBox"> |
||||
|
<Setter Property="MinHeight" Value="220"/> |
||||
|
<Setter Property="Background" Value="{StaticResource HmiOutputBrush}"/> |
||||
|
<Setter Property="Foreground" Value="#E9FBFF"/> |
||||
|
<Setter Property="BorderBrush" Value="#2A7B93"/> |
||||
|
<Setter Property="BorderThickness" Value="2"/> |
||||
|
<Setter Property="FontSize" Value="42"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="CaretBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter Property="SelectionBrush" Value="{StaticResource HmiAccentBlueBrush}"/> |
||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="Padding" Value="14"/> |
||||
|
<Setter Property="IsReadOnly" Value="True"/> |
||||
|
<Setter Property="Template"> |
||||
|
<Setter.Value> |
||||
|
<ControlTemplate TargetType="{x:Type TextBox}"> |
||||
|
<Border x:Name="OutputChrome" |
||||
|
Background="{TemplateBinding Background}" |
||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||
|
CornerRadius="4" |
||||
|
SnapsToDevicePixels="True"> |
||||
|
<Grid> |
||||
|
<Rectangle Height="4" |
||||
|
VerticalAlignment="Top" |
||||
|
Fill="{StaticResource HmiAccentBrush}" |
||||
|
Opacity="0.68"/> |
||||
|
<Rectangle Height="1" |
||||
|
VerticalAlignment="Bottom" |
||||
|
Fill="#6FAFC0" |
||||
|
Opacity="0.3"/> |
||||
|
<ScrollViewer x:Name="PART_ContentHost" |
||||
|
Margin="{TemplateBinding Padding}" |
||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" |
||||
|
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
<ControlTemplate.Triggers> |
||||
|
<Trigger Property="IsKeyboardFocused" Value="True"> |
||||
|
<Setter TargetName="OutputChrome" Property="BorderBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsEnabled" Value="False"> |
||||
|
<Setter TargetName="OutputChrome" Property="Opacity" Value="0.55"/> |
||||
|
</Trigger> |
||||
|
</ControlTemplate.Triggers> |
||||
|
</ControlTemplate> |
||||
|
</Setter.Value> |
||||
|
</Setter> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="RangeBoxStyle" TargetType="TextBox"> |
||||
|
<Setter Property="Width" Value="76"/> |
||||
|
<Setter Property="Height" Value="34"/> |
||||
|
<Setter Property="Background" Value="{StaticResource HmiInputBrush}"/> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiTextBrush}"/> |
||||
|
<Setter Property="BorderBrush" Value="#49687B"/> |
||||
|
<Setter Property="BorderThickness" Value="1"/> |
||||
|
<Setter Property="FontSize" Value="15"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="CaretBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter Property="SelectionBrush" Value="{StaticResource HmiAccentBlueBrush}"/> |
||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/> |
||||
|
<Setter Property="Margin" Value="6,0,0,10"/> |
||||
|
<Setter Property="Padding" Value="4,0"/> |
||||
|
<Setter Property="Template"> |
||||
|
<Setter.Value> |
||||
|
<ControlTemplate TargetType="{x:Type TextBox}"> |
||||
|
<Border x:Name="RangeChrome" |
||||
|
Background="{TemplateBinding Background}" |
||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||
|
CornerRadius="2"> |
||||
|
<ScrollViewer x:Name="PART_ContentHost" |
||||
|
Margin="{TemplateBinding Padding}" |
||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> |
||||
|
</Border> |
||||
|
<ControlTemplate.Triggers> |
||||
|
<Trigger Property="IsKeyboardFocused" Value="True"> |
||||
|
<Setter TargetName="RangeChrome" Property="BorderBrush" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsEnabled" Value="False"> |
||||
|
<Setter TargetName="RangeChrome" Property="Opacity" Value="0.55"/> |
||||
|
</Trigger> |
||||
|
</ControlTemplate.Triggers> |
||||
|
</ControlTemplate> |
||||
|
</Setter.Value> |
||||
|
</Setter> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="MeasurementRangeBoxStyle" TargetType="TextBox" BasedOn="{StaticResource RangeBoxStyle}"> |
||||
|
<Setter Property="Width" Value="Auto"/> |
||||
|
<Setter Property="MinWidth" Value="0"/> |
||||
|
<Setter Property="Margin" Value="0"/> |
||||
|
<Setter Property="FontSize" Value="14"/> |
||||
|
<Setter Property="Padding" Value="2,0"/> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="MeasurementOutputBoxStyle" TargetType="TextBox" BasedOn="{StaticResource OutputBoxStyle}"> |
||||
|
<Setter Property="MinHeight" Value="140"/> |
||||
|
<Setter Property="FontSize" Value="32"/> |
||||
|
<Setter Property="Padding" Value="8"/> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="LogoutButtonStyle" TargetType="Button"> |
||||
|
<Setter Property="Height" Value="36"/> |
||||
|
<Setter Property="MinWidth" Value="98"/> |
||||
|
<Setter Property="Padding" Value="14,0"/> |
||||
|
<Setter Property="Background" Value="#3A1A1D"/> |
||||
|
<Setter Property="Foreground" Value="White"/> |
||||
|
<Setter Property="BorderBrush" Value="{StaticResource HmiDangerBrush}"/> |
||||
|
<Setter Property="BorderThickness" Value="1"/> |
||||
|
<Setter Property="FontSize" Value="14"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="Cursor" Value="Hand"/> |
||||
|
<Setter Property="Template"> |
||||
|
<Setter.Value> |
||||
|
<ControlTemplate TargetType="{x:Type Button}"> |
||||
|
<Border x:Name="ButtonChrome" |
||||
|
Background="{TemplateBinding Background}" |
||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||
|
CornerRadius="3"> |
||||
|
<Grid> |
||||
|
<Rectangle Height="2" |
||||
|
VerticalAlignment="Top" |
||||
|
Fill="#FF8A8A" |
||||
|
Opacity="0.65"/> |
||||
|
<ContentPresenter RecognizesAccessKey="True" |
||||
|
HorizontalAlignment="Center" |
||||
|
VerticalAlignment="Center"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
<ControlTemplate.Triggers> |
||||
|
<Trigger Property="IsMouseOver" Value="True"> |
||||
|
<Setter TargetName="ButtonChrome" Property="Background" Value="#5A2428"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsPressed" Value="True"> |
||||
|
<Setter TargetName="ButtonChrome" Property="Background" Value="#1C0B0D"/> |
||||
|
</Trigger> |
||||
|
<Trigger Property="IsEnabled" Value="False"> |
||||
|
<Setter TargetName="ButtonChrome" Property="Opacity" Value="0.45"/> |
||||
|
</Trigger> |
||||
|
</ControlTemplate.Triggers> |
||||
|
</ControlTemplate> |
||||
|
</Setter.Value> |
||||
|
</Setter> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="PanelFrameStyle" TargetType="Border"> |
||||
|
<Setter Property="Background" Value="{StaticResource HmiPanelBrush}"/> |
||||
|
<Setter Property="BorderBrush" Value="{StaticResource HmiPanelBorderBrush}"/> |
||||
|
<Setter Property="BorderThickness" Value="1"/> |
||||
|
<Setter Property="Padding" Value="0"/> |
||||
|
<Setter Property="SnapsToDevicePixels" Value="True"/> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="FieldPanelStyle" TargetType="Border"> |
||||
|
<Setter Property="Background" Value="#111B24"/> |
||||
|
<Setter Property="BorderBrush" Value="#30475A"/> |
||||
|
<Setter Property="BorderThickness" Value="1"/> |
||||
|
<Setter Property="Padding" Value="18"/> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="PanelHeaderTextStyle" TargetType="TextBlock"> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiAccentBrush}"/> |
||||
|
<Setter Property="FontSize" Value="15"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="VerticalAlignment" Value="Center"/> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="SmallStatusTextStyle" TargetType="TextBlock"> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiMutedTextBrush}"/> |
||||
|
<Setter Property="FontSize" Value="12"/> |
||||
|
<Setter Property="FontWeight" Value="SemiBold"/> |
||||
|
<Setter Property="VerticalAlignment" Value="Center"/> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="ProcessStepFrameStyle" TargetType="Border"> |
||||
|
<Setter Property="Height" Value="26"/> |
||||
|
<Setter Property="MinWidth" Value="74"/> |
||||
|
<Setter Property="Background" Value="#0B161F"/> |
||||
|
<Setter Property="BorderBrush" Value="{StaticResource ProcessStepInactiveBrush}"/> |
||||
|
<Setter Property="BorderThickness" Value="1"/> |
||||
|
<Setter Property="Padding" Value="9,0"/> |
||||
|
<Setter Property="Margin" Value="0,0,6,0"/> |
||||
|
<Setter Property="VerticalAlignment" Value="Center"/> |
||||
|
</Style> |
||||
|
|
||||
|
<Style x:Key="ProcessStepTextStyle" TargetType="TextBlock"> |
||||
|
<Setter Property="Foreground" Value="{StaticResource HmiMutedTextBrush}"/> |
||||
|
<Setter Property="FontSize" Value="11"/> |
||||
|
<Setter Property="FontWeight" Value="Bold"/> |
||||
|
<Setter Property="HorizontalAlignment" Value="Center"/> |
||||
|
<Setter Property="VerticalAlignment" Value="Center"/> |
||||
|
<Setter Property="TextTrimming" Value="CharacterEllipsis"/> |
||||
|
</Style> |
||||
|
</Window.Resources> |
||||
|
|
||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto" |
||||
|
HorizontalScrollBarVisibility="Disabled" |
||||
|
Background="{StaticResource HmiScreenBackgroundBrush}"> |
||||
|
<Grid MinHeight="620" Margin="24"> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="18"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="18"/> |
||||
|
<RowDefinition Height="*"/> |
||||
|
<RowDefinition Height="18"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<Border Grid.Row="0" |
||||
|
Style="{StaticResource PanelFrameStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<Border Grid.Row="0" |
||||
|
Background="{StaticResource HmiPanelHeaderBrush}" |
||||
|
BorderBrush="{StaticResource HmiPanelInnerBorderBrush}" |
||||
|
BorderThickness="0,0,0,1" |
||||
|
Padding="22,16"> |
||||
|
<Grid> |
||||
|
<Grid.ColumnDefinitions> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
<ColumnDefinition Width="Auto"/> |
||||
|
</Grid.ColumnDefinitions> |
||||
|
|
||||
|
<StackPanel> |
||||
|
<TextBlock Text="HOUSING ASSEMBLY HMI" |
||||
|
Foreground="{StaticResource HmiTextBrush}" |
||||
|
FontSize="30" |
||||
|
FontWeight="Bold" |
||||
|
VerticalAlignment="Center" |
||||
|
TextWrapping="Wrap"/> |
||||
|
<TextBlock Text="Inspection control and sensor monitoring console" |
||||
|
Foreground="{StaticResource HmiMutedTextBrush}" |
||||
|
FontSize="13" |
||||
|
Margin="0,4,0,0"/> |
||||
|
</StackPanel> |
||||
|
|
||||
|
<StackPanel Grid.Column="1" |
||||
|
Orientation="Horizontal" |
||||
|
VerticalAlignment="Center" |
||||
|
Margin="18,0,0,0"> |
||||
|
<Border Background="#0B161F" |
||||
|
BorderBrush="#2E4658" |
||||
|
BorderThickness="1" |
||||
|
Padding="12,7" |
||||
|
Margin="0,0,14,0"> |
||||
|
<StackPanel Orientation="Horizontal"> |
||||
|
<TextBlock Text="LOGIN ID" |
||||
|
Foreground="{StaticResource HmiMutedTextBrush}" |
||||
|
FontSize="13" |
||||
|
FontWeight="SemiBold" |
||||
|
Margin="0,0,10,0" |
||||
|
VerticalAlignment="Center"/> |
||||
|
<TextBlock x:Name="OperatorTextBlock" |
||||
|
Text="-" |
||||
|
Foreground="{StaticResource HmiTextBrush}" |
||||
|
FontSize="15" |
||||
|
FontWeight="Bold" |
||||
|
Margin="0,0,14,0" |
||||
|
VerticalAlignment="Center"/> |
||||
|
<Button x:Name="LogoutButton" |
||||
|
Content="LOGOUT" |
||||
|
Style="{StaticResource LogoutButtonStyle}" |
||||
|
Visibility="Collapsed" |
||||
|
Click="LogoutButton_Click"/> |
||||
|
</StackPanel> |
||||
|
</Border> |
||||
|
|
||||
|
<Image Source="Resources/mobidigm-logo.png" |
||||
|
Width="150" |
||||
|
Height="37" |
||||
|
Stretch="Uniform" |
||||
|
RenderOptions.BitmapScalingMode="HighQuality"/> |
||||
|
</StackPanel> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Row="1" |
||||
|
Background="#081019" |
||||
|
Padding="18,8"> |
||||
|
<ScrollViewer HorizontalScrollBarVisibility="Auto" |
||||
|
VerticalScrollBarVisibility="Disabled"> |
||||
|
<StackPanel Orientation="Horizontal" |
||||
|
VerticalAlignment="Center"> |
||||
|
<TextBlock Text="PROCESS" |
||||
|
Foreground="{StaticResource HmiMutedTextBrush}" |
||||
|
FontSize="11" |
||||
|
FontWeight="Bold" |
||||
|
Margin="0,0,10,0" |
||||
|
VerticalAlignment="Center"/> |
||||
|
<TextBlock x:Name="CurrentProcessTextBlock" |
||||
|
Text="WAITING BARCODE" |
||||
|
Foreground="{StaticResource HmiAccentBrush}" |
||||
|
FontSize="11" |
||||
|
FontWeight="Bold" |
||||
|
Margin="0,0,12,0" |
||||
|
VerticalAlignment="Center" |
||||
|
TextTrimming="CharacterEllipsis"/> |
||||
|
|
||||
|
<Border x:Name="ProcessStepBarcodeBorder" |
||||
|
Style="{StaticResource ProcessStepFrameStyle}"> |
||||
|
<TextBlock Text="BARCODE" Style="{StaticResource ProcessStepTextStyle}"/> |
||||
|
</Border> |
||||
|
<TextBlock Text=">" |
||||
|
Foreground="{StaticResource HmiAccentBrush}" |
||||
|
FontSize="13" |
||||
|
FontWeight="Bold" |
||||
|
Margin="0,0,6,0" |
||||
|
VerticalAlignment="Center"/> |
||||
|
<Border x:Name="ProcessStepStartBorder" |
||||
|
Style="{StaticResource ProcessStepFrameStyle}"> |
||||
|
<TextBlock Text="START" Style="{StaticResource ProcessStepTextStyle}"/> |
||||
|
</Border> |
||||
|
<TextBlock Text=">" |
||||
|
Foreground="{StaticResource HmiAccentBrush}" |
||||
|
FontSize="13" |
||||
|
FontWeight="Bold" |
||||
|
Margin="0,0,6,0" |
||||
|
VerticalAlignment="Center"/> |
||||
|
<Border x:Name="ProcessStepBoardBorder" |
||||
|
Style="{StaticResource ProcessStepFrameStyle}"> |
||||
|
<TextBlock Text="BOARD" Style="{StaticResource ProcessStepTextStyle}"/> |
||||
|
</Border> |
||||
|
<TextBlock Text=">" |
||||
|
Foreground="{StaticResource HmiAccentBrush}" |
||||
|
FontSize="13" |
||||
|
FontWeight="Bold" |
||||
|
Margin="0,0,6,0" |
||||
|
VerticalAlignment="Center"/> |
||||
|
<Border x:Name="ProcessStepIcSnBorder" |
||||
|
Style="{StaticResource ProcessStepFrameStyle}"> |
||||
|
<TextBlock Text="IC_SN" Style="{StaticResource ProcessStepTextStyle}"/> |
||||
|
</Border> |
||||
|
<TextBlock Text=">" |
||||
|
Foreground="{StaticResource HmiAccentBrush}" |
||||
|
FontSize="13" |
||||
|
FontWeight="Bold" |
||||
|
Margin="0,0,6,0" |
||||
|
VerticalAlignment="Center"/> |
||||
|
<Border x:Name="ProcessStepMeasureBorder" |
||||
|
Style="{StaticResource ProcessStepFrameStyle}"> |
||||
|
<TextBlock Text="MEASURE" Style="{StaticResource ProcessStepTextStyle}"/> |
||||
|
</Border> |
||||
|
<TextBlock Text=">" |
||||
|
Foreground="{StaticResource HmiAccentBrush}" |
||||
|
FontSize="13" |
||||
|
FontWeight="Bold" |
||||
|
Margin="0,0,6,0" |
||||
|
VerticalAlignment="Center"/> |
||||
|
<Border x:Name="ProcessStepDbBorder" |
||||
|
Style="{StaticResource ProcessStepFrameStyle}"> |
||||
|
<TextBlock Text="DB SAVE" Style="{StaticResource ProcessStepTextStyle}"/> |
||||
|
</Border> |
||||
|
<TextBlock Text=">" |
||||
|
Foreground="{StaticResource HmiAccentBrush}" |
||||
|
FontSize="13" |
||||
|
FontWeight="Bold" |
||||
|
Margin="0,0,6,0" |
||||
|
VerticalAlignment="Center"/> |
||||
|
<Border x:Name="ProcessStepResultBorder" |
||||
|
Style="{StaticResource ProcessStepFrameStyle}" |
||||
|
Margin="0"> |
||||
|
<TextBlock Text="RESULT" Style="{StaticResource ProcessStepTextStyle}"/> |
||||
|
</Border> |
||||
|
</StackPanel> |
||||
|
</ScrollViewer> |
||||
|
</Border> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Row="2" |
||||
|
Style="{StaticResource PanelFrameStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<Border Grid.Row="0" |
||||
|
Background="#101E2A" |
||||
|
BorderBrush="#30475A" |
||||
|
BorderThickness="0,0,0,1" |
||||
|
Padding="18,11"> |
||||
|
<DockPanel> |
||||
|
<TextBlock Text="SCAN AND TRACEABILITY" |
||||
|
Style="{StaticResource PanelHeaderTextStyle}"/> |
||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal"> |
||||
|
<Ellipse Width="10" Height="10" Fill="{StaticResource HmiAccentBrush}" Margin="0,0,8,0"/> |
||||
|
<TextBlock Text="BARCODE INPUT CHANNEL" Style="{StaticResource SmallStatusTextStyle}"/> |
||||
|
</StackPanel> |
||||
|
</DockPanel> |
||||
|
</Border> |
||||
|
|
||||
|
<Grid Grid.Row="1" Margin="18"> |
||||
|
<Grid.ColumnDefinitions> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
<ColumnDefinition Width="18"/> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
</Grid.ColumnDefinitions> |
||||
|
|
||||
|
<Border Grid.Column="0" Style="{StaticResource FieldPanelStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
<TextBlock Text="PCB_BARCODE INPUT" Style="{StaticResource LabelStyle}"/> |
||||
|
<TextBox x:Name="BarcodeTextBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource InputBoxStyle}" |
||||
|
PreviewKeyDown="BarcodeTextBox_PreviewKeyDown" |
||||
|
PreviewTextInput="BarcodeTextBox_PreviewTextInput" |
||||
|
InputMethod.IsInputMethodEnabled="False" |
||||
|
LostKeyboardFocus="BarcodeTextBox_LostKeyboardFocus" |
||||
|
TextChanged="BarcodeTextBox_TextChanged"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Column="2" Style="{StaticResource FieldPanelStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
<TextBlock Text="IC_SN OUTPUT" Style="{StaticResource LabelStyle}"/> |
||||
|
<TextBox x:Name="IcSnOutputTextBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource InputBoxStyle}" |
||||
|
Background="{StaticResource HmiOutputBrush}" |
||||
|
IsReadOnly="True"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
</Grid> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Row="4" |
||||
|
Style="{StaticResource PanelFrameStyle}"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto"/> |
||||
|
<RowDefinition Height="*"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
|
||||
|
<Border Grid.Row="0" |
||||
|
Background="#101E2A" |
||||
|
BorderBrush="#30475A" |
||||
|
BorderThickness="0,0,0,1" |
||||
|
Padding="18,11"> |
||||
|
<DockPanel> |
||||
|
<TextBlock Text="MEASUREMENT MONITORING" |
||||
|
Style="{StaticResource PanelHeaderTextStyle}"/> |
||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal"> |
||||
|
<Ellipse Width="10" Height="10" Fill="{StaticResource HmiRunBrush}" Margin="0,0,8,0"/> |
||||
|
<TextBlock Text="V / mA / kΩ / RESULT" Style="{StaticResource SmallStatusTextStyle}"/> |
||||
|
</StackPanel> |
||||
|
</DockPanel> |
||||
|
</Border> |
||||
|
|
||||
|
<Grid x:Name="MeasurementPanelGrid" Grid.Row="1" Margin="18" Grid.IsSharedSizeScope="True"> |
||||
|
<Grid.ColumnDefinitions> |
||||
|
<ColumnDefinition Width="1*"/> |
||||
|
<ColumnDefinition Width="12"/> |
||||
|
<ColumnDefinition Width="1*"/> |
||||
|
<ColumnDefinition Width="12"/> |
||||
|
<ColumnDefinition Width="1*"/> |
||||
|
<ColumnDefinition Width="12"/> |
||||
|
<ColumnDefinition Width="1.1*"/> |
||||
|
</Grid.ColumnDefinitions> |
||||
|
|
||||
|
<Border Grid.Column="0" Style="{StaticResource FieldPanelStyle}" Padding="12"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto" SharedSizeGroup="MeasurementHeader"/> |
||||
|
<RowDefinition Height="*"/> |
||||
|
<RowDefinition Height="24"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
<StackPanel Margin="0,0,0,12"> |
||||
|
<TextBlock Text="V OUTPUT" |
||||
|
Style="{StaticResource LabelStyle}" |
||||
|
TextWrapping="NoWrap"/> |
||||
|
<Grid> |
||||
|
<Grid.ColumnDefinitions> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
<ColumnDefinition Width="20"/> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
</Grid.ColumnDefinitions> |
||||
|
<TextBox x:Name="VMinTextBox" Style="{StaticResource MeasurementRangeBoxStyle}" ToolTip="전압 최소값 (V)"/> |
||||
|
<TextBlock Text="~" |
||||
|
Grid.Column="1" |
||||
|
Foreground="{StaticResource HmiMutedTextBrush}" |
||||
|
FontSize="18" |
||||
|
FontWeight="Bold" |
||||
|
HorizontalAlignment="Center" |
||||
|
VerticalAlignment="Center"/> |
||||
|
<TextBox x:Name="VMaxTextBox" Grid.Column="2" Style="{StaticResource MeasurementRangeBoxStyle}" ToolTip="전압 최대값 (V)"/> |
||||
|
</Grid> |
||||
|
</StackPanel> |
||||
|
<TextBox x:Name="VOutputTextBox" |
||||
|
Grid.Row="1" |
||||
|
Text="0.00" |
||||
|
Style="{StaticResource MeasurementOutputBoxStyle}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Column="2" Style="{StaticResource FieldPanelStyle}" Padding="12"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto" SharedSizeGroup="MeasurementHeader"/> |
||||
|
<RowDefinition Height="*"/> |
||||
|
<RowDefinition Height="24"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
<StackPanel Margin="0,0,0,12"> |
||||
|
<TextBlock Text="mA OUTPUT" |
||||
|
Style="{StaticResource LabelStyle}" |
||||
|
TextWrapping="NoWrap"/> |
||||
|
<Grid> |
||||
|
<Grid.ColumnDefinitions> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
<ColumnDefinition Width="20"/> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
</Grid.ColumnDefinitions> |
||||
|
<TextBox x:Name="AMinTextBox" Style="{StaticResource MeasurementRangeBoxStyle}" ToolTip="전류 최소값 (mA)"/> |
||||
|
<TextBlock Text="~" |
||||
|
Grid.Column="1" |
||||
|
Foreground="{StaticResource HmiMutedTextBrush}" |
||||
|
FontSize="18" |
||||
|
FontWeight="Bold" |
||||
|
HorizontalAlignment="Center" |
||||
|
VerticalAlignment="Center"/> |
||||
|
<TextBox x:Name="AMaxTextBox" Grid.Column="2" Style="{StaticResource MeasurementRangeBoxStyle}" ToolTip="전류 최대값 (mA)"/> |
||||
|
</Grid> |
||||
|
</StackPanel> |
||||
|
<TextBox x:Name="AOutputTextBox" |
||||
|
Grid.Row="1" |
||||
|
Text="0.00" |
||||
|
Style="{StaticResource MeasurementOutputBoxStyle}"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Column="4" Style="{StaticResource FieldPanelStyle}" Padding="12"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto" SharedSizeGroup="MeasurementHeader"/> |
||||
|
<RowDefinition Height="*"/> |
||||
|
<RowDefinition Height="24"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
<StackPanel Margin="0,0,0,12"> |
||||
|
<TextBlock Text="kΩ OUTPUT" Style="{StaticResource LabelStyle}" TextWrapping="NoWrap"/> |
||||
|
<Grid> |
||||
|
<Grid.ColumnDefinitions> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
<ColumnDefinition Width="20"/> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
</Grid.ColumnDefinitions> |
||||
|
<TextBox x:Name="RMinTextBox" Style="{StaticResource MeasurementRangeBoxStyle}" ToolTip="저항 최소값 (kΩ). 첫 검사 전 입력하세요."/> |
||||
|
<TextBlock Grid.Column="1" Text="~" Foreground="{StaticResource HmiMutedTextBrush}" |
||||
|
FontSize="18" FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"/> |
||||
|
<TextBox x:Name="RMaxTextBox" Grid.Column="2" Style="{StaticResource MeasurementRangeBoxStyle}" ToolTip="저항 최대값 (kΩ). 첫 검사 전 입력하세요."/> |
||||
|
</Grid> |
||||
|
</StackPanel> |
||||
|
<TextBox x:Name="ROutputTextBox" Grid.Row="1" Text="0.00" |
||||
|
Style="{StaticResource MeasurementOutputBoxStyle}" ToolTip="저항 측정값 (kΩ)"/> |
||||
|
<TextBlock x:Name="RStatusTextBlock" Grid.Row="2" Text="측정 대기" |
||||
|
Style="{StaticResource SmallStatusTextStyle}" Margin="0,6,0,0"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Column="6" Style="{StaticResource FieldPanelStyle}" Padding="12"> |
||||
|
<Grid> |
||||
|
<Grid.RowDefinitions> |
||||
|
<RowDefinition Height="Auto" SharedSizeGroup="MeasurementHeader"/> |
||||
|
<RowDefinition Height="*"/> |
||||
|
<RowDefinition Height="24"/> |
||||
|
</Grid.RowDefinitions> |
||||
|
<StackPanel> |
||||
|
<TextBlock Text="RESULT" Style="{StaticResource LabelStyle}"/> |
||||
|
<TextBlock Text="V · mA · kΩ 종합 판정" Style="{StaticResource SmallStatusTextStyle}" TextWrapping="Wrap"/> |
||||
|
</StackPanel> |
||||
|
<TextBox x:Name="ResultOutputTextBox" |
||||
|
Grid.Row="1" |
||||
|
Style="{StaticResource MeasurementOutputBoxStyle}" |
||||
|
BorderBrush="{StaticResource HmiWarningBrush}" |
||||
|
FontSize="22" |
||||
|
FontWeight="SemiBold" |
||||
|
HorizontalContentAlignment="Center" |
||||
|
VerticalContentAlignment="Center" |
||||
|
TextAlignment="Center" |
||||
|
TextWrapping="Wrap" |
||||
|
AcceptsReturn="True" |
||||
|
VerticalScrollBarVisibility="Auto"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
</Grid> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
|
||||
|
<Border Grid.Row="6" |
||||
|
Background="#081019" |
||||
|
BorderBrush="#253849" |
||||
|
BorderThickness="1" |
||||
|
Padding="16,8"> |
||||
|
<Grid> |
||||
|
<Grid.ColumnDefinitions> |
||||
|
<ColumnDefinition Width="Auto"/> |
||||
|
<ColumnDefinition Width="26"/> |
||||
|
<ColumnDefinition Width="Auto"/> |
||||
|
<ColumnDefinition Width="26"/> |
||||
|
<ColumnDefinition Width="Auto"/> |
||||
|
<ColumnDefinition Width="*"/> |
||||
|
</Grid.ColumnDefinitions> |
||||
|
<TextBlock Text="EQUIPMENT UI MODE" Style="{StaticResource SmallStatusTextStyle}"/> |
||||
|
<TextBlock Grid.Column="2" Text="HOUSING LINE" Style="{StaticResource SmallStatusTextStyle}"/> |
||||
|
<TextBlock Grid.Column="4" Text="READY FOR INSPECTION" Foreground="{StaticResource HmiRunBrush}" FontSize="12" FontWeight="Bold"/> |
||||
|
</Grid> |
||||
|
</Border> |
||||
|
</Grid> |
||||
|
</ScrollViewer> |
||||
|
</Window> |
||||
File diff suppressed because it is too large
@ -0,0 +1,12 @@ |
|||||
|
namespace Housing.Models; |
||||
|
|
||||
|
public sealed class BoardMeasurementResult |
||||
|
{ |
||||
|
public string IcSn { get; set; } = string.Empty; |
||||
|
public decimal Voltage { get; set; } |
||||
|
public decimal Current { get; set; } |
||||
|
public decimal? ResistanceKilohms { get; set; } |
||||
|
public bool ResistanceOverloaded { get; set; } |
||||
|
public bool ResistanceExceedsStorageRange => ResistanceStorage.ExceedsRange(ResistanceKilohms); |
||||
|
public string RawLog { get; set; } = string.Empty; |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace Housing.Models; |
||||
|
|
||||
|
public sealed class HousingAssemblyRecord |
||||
|
{ |
||||
|
public string IcSn { get; set; } = string.Empty; |
||||
|
public string PcbBarcode { get; set; } = string.Empty; |
||||
|
public string Maker { get; set; } = string.Empty; |
||||
|
public string Model { get; set; } = string.Empty; |
||||
|
public string Variant1 { get; set; } = string.Empty; |
||||
|
public string Variant2 { get; set; } = string.Empty; |
||||
|
public string Operator { get; set; } = string.Empty; |
||||
|
public DateTime ProductionDate { get; set; } = DateTime.Now; |
||||
|
public string Line { get; set; } = string.Empty; |
||||
|
public string LotNo { get; set; } = string.Empty; |
||||
|
public string JigNo { get; set; } = string.Empty; |
||||
|
public decimal PtVol1 { get; set; } |
||||
|
public decimal PtCurrent1 { get; set; } |
||||
|
public decimal? PtResistor1 { get; set; } |
||||
|
public string Result { get; set; } = string.Empty; |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
namespace Housing.Models; |
||||
|
|
||||
|
public sealed class InspectionJudgementSettings |
||||
|
{ |
||||
|
public decimal VMin { get; set; } = 0.00m; |
||||
|
public decimal VMax { get; set; } = 9.99m; |
||||
|
public decimal AMin { get; set; } = 0.00m; |
||||
|
public decimal AMax { get; set; } = 9.99m; |
||||
|
public decimal? RMin { get; set; } |
||||
|
public decimal? RMax { get; set; } |
||||
|
|
||||
|
public bool IsResistancePass(decimal? resistanceKilohms, bool overloaded = false) |
||||
|
{ |
||||
|
return !overloaded && resistanceKilohms.HasValue && !ResistanceStorage.ExceedsRange(resistanceKilohms) && |
||||
|
RMin.HasValue && RMax.HasValue && RMin >= 0m && RMin <= RMax && |
||||
|
resistanceKilohms >= RMin && resistanceKilohms <= RMax; |
||||
|
} |
||||
|
|
||||
|
public bool IsPass(decimal voltage, decimal current, decimal? resistanceKilohms, bool resistanceOverloaded = false) |
||||
|
{ |
||||
|
return voltage >= VMin && |
||||
|
voltage <= VMax && |
||||
|
current >= AMin && |
||||
|
current <= AMax && |
||||
|
IsResistancePass(resistanceKilohms, resistanceOverloaded); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
namespace Housing.Models; |
||||
|
|
||||
|
public static class ResistanceStorage |
||||
|
{ |
||||
|
public const decimal Minimum = -999.99m; |
||||
|
public const decimal Maximum = 999.99m; |
||||
|
|
||||
|
public static bool ExceedsRange(decimal? resistanceKilohms) |
||||
|
{ |
||||
|
if (!resistanceKilohms.HasValue) return false; |
||||
|
return resistanceKilohms.Value < Minimum || resistanceKilohms.Value > Maximum; |
||||
|
} |
||||
|
|
||||
|
public static decimal? ToDatabaseValue(decimal? resistanceKilohms) |
||||
|
{ |
||||
|
return !resistanceKilohms.HasValue || ExceedsRange(resistanceKilohms) |
||||
|
? null |
||||
|
: decimal.Round(resistanceKilohms.Value, 2, MidpointRounding.AwayFromZero); |
||||
|
} |
||||
|
} |
||||
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
@ -0,0 +1,69 @@ |
|||||
|
using System.IO; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class BarcodeScannerSettings |
||||
|
{ |
||||
|
public bool Enabled { get; set; } |
||||
|
public string PortName { get; set; } = "Auto"; |
||||
|
public int BaudRate { get; set; } = 9600; |
||||
|
public int ReadTimeout { get; set; } = 500; |
||||
|
public int IdleCommitMilliseconds { get; set; } = 250; |
||||
|
public bool DtrEnable { get; set; } = true; |
||||
|
public bool RtsEnable { get; set; } = true; |
||||
|
|
||||
|
public static BarcodeScannerSettings Load(string filePath) |
||||
|
{ |
||||
|
if (!File.Exists(filePath)) |
||||
|
{ |
||||
|
throw new FileNotFoundException("Hardware.ini file was not found.", filePath); |
||||
|
} |
||||
|
|
||||
|
var values = IniFile.LoadSection(filePath, "BarcodeScanner"); |
||||
|
return new BarcodeScannerSettings |
||||
|
{ |
||||
|
Enabled = GetBool(values, "Enabled", false), |
||||
|
PortName = GetString(values, "PortName", "Auto"), |
||||
|
BaudRate = GetInt(values, "BaudRate", 9600), |
||||
|
ReadTimeout = GetInt(values, "ReadTimeout", 500), |
||||
|
IdleCommitMilliseconds = GetInt(values, "IdleCommitMilliseconds", 250), |
||||
|
DtrEnable = GetBool(values, "DtrEnable", true), |
||||
|
RtsEnable = GetBool(values, "RtsEnable", true) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public static bool IsAutoPort(string portName) |
||||
|
{ |
||||
|
return string.IsNullOrWhiteSpace(portName) || |
||||
|
string.Equals(portName.Trim(), "Auto", 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 |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,71 @@ |
|||||
|
using System.IO; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class BoardHardwareSettings |
||||
|
{ |
||||
|
public string PortName { get; set; } = "Auto"; |
||||
|
public int BaudRate { get; set; } = 115200; |
||||
|
public int ReadTimeout { get; set; } = 5000; |
||||
|
public string PreConnectCommand { get; set; } = "x00o"; |
||||
|
public string ConnectCommand { get; set; } = "x00c_001101:owt28006727ea97c7801"; |
||||
|
public string ReadIdCommand { get; set; } = "x00c_001101:ow2800326003e"; |
||||
|
public string PostReadIdCommand { get; set; } = "x00o"; |
||||
|
public string CalDefaultCommand { get; set; } = "x00c_001001:owt28006727ea97c7801"; |
||||
|
public string EndToken { get; set; } = "<end>"; |
||||
|
public bool DtrEnable { get; set; } |
||||
|
public bool RtsEnable { get; set; } |
||||
|
|
||||
|
public static BoardHardwareSettings Load(string filePath) |
||||
|
{ |
||||
|
if (!File.Exists(filePath)) |
||||
|
{ |
||||
|
throw new FileNotFoundException("Hardware.ini 파일을 찾을 수 없습니다.", filePath); |
||||
|
} |
||||
|
|
||||
|
var values = IniFile.LoadSection(filePath, "Board"); |
||||
|
return new BoardHardwareSettings |
||||
|
{ |
||||
|
PortName = GetString(values, "PortName", "Auto"), |
||||
|
BaudRate = GetInt(values, "BaudRate", 115200), |
||||
|
ReadTimeout = GetInt(values, "ReadTimeout", 5000), |
||||
|
PreConnectCommand = GetString(values, "PreConnectCommand", "x00o"), |
||||
|
ConnectCommand = GetString(values, "ConnectCommand", "x00c_001101:owt28006727ea97c7801"), |
||||
|
ReadIdCommand = GetString(values, "ReadIdCommand", "x00c_001101:ow2800326003e"), |
||||
|
PostReadIdCommand = GetString(values, "PostReadIdCommand", "x00o"), |
||||
|
CalDefaultCommand = GetString(values, "CalDefaultCommand", "x00c_001001:owt28006727ea97c7801"), |
||||
|
EndToken = GetString(values, "EndToken", "<end>"), |
||||
|
DtrEnable = GetBool(values, "DtrEnable", false), |
||||
|
RtsEnable = GetBool(values, "RtsEnable", false) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,167 @@ |
|||||
|
using System.Text; |
||||
|
using System.Text.RegularExpressions; |
||||
|
using Housing.Models; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class BoardTestService |
||||
|
{ |
||||
|
private readonly BoardHardwareSettings _boardSettings; |
||||
|
private readonly EquipmentMeasurementSettings _equipmentSettings; |
||||
|
|
||||
|
public BoardTestService(BoardHardwareSettings boardSettings, EquipmentMeasurementSettings equipmentSettings) |
||||
|
{ |
||||
|
_boardSettings = boardSettings; |
||||
|
_equipmentSettings = equipmentSettings; |
||||
|
} |
||||
|
|
||||
|
public async Task<BoardMeasurementResult> RunAsync( |
||||
|
Func<string, Task>? onIcSnReadAsync = null, |
||||
|
DateTime? measurementNotBeforeUtc = null) |
||||
|
{ |
||||
|
var log = new StringBuilder(); |
||||
|
var equipmentService = new EquipmentMeasurementService(_equipmentSettings); |
||||
|
|
||||
|
log.Append(await equipmentService.RunPreBoardCommandAsync()); |
||||
|
if (_equipmentSettings.PreBoardDelayMilliseconds > 0) |
||||
|
{ |
||||
|
log.AppendLine($"> CURRENT PRE-BOARD DELAY: {_equipmentSettings.PreBoardDelayMilliseconds} ms"); |
||||
|
await Task.Delay(_equipmentSettings.PreBoardDelayMilliseconds); |
||||
|
} |
||||
|
|
||||
|
using var boardClient = new SerialBoardClient(_boardSettings); |
||||
|
log.AppendLine($"> BOARD PORT: {boardClient.PortName} @ {_boardSettings.BaudRate}"); |
||||
|
|
||||
|
var postReadIdCommandSent = false; |
||||
|
try |
||||
|
{ |
||||
|
await SendOnlyAndLogAsync(boardClient, "PRE_CONNECT", _boardSettings.PreConnectCommand, log); |
||||
|
|
||||
|
var connectResponse = await SendAndLogAsync(boardClient, "CONNECT", _boardSettings.ConnectCommand, _boardSettings.ReadTimeout, true, log); |
||||
|
if (!ContainsSuccess(connectResponse)) |
||||
|
{ |
||||
|
throw new InvalidOperationException("시료 연결 확인 실패"); |
||||
|
} |
||||
|
|
||||
|
var idResponse = await SendAndLogAsync(boardClient, "READ_ID", _boardSettings.ReadIdCommand, _boardSettings.ReadTimeout, true, log); |
||||
|
var icSn = ExtractFirstHexLine(idResponse); |
||||
|
if (string.IsNullOrWhiteSpace(icSn)) |
||||
|
{ |
||||
|
throw new InvalidOperationException("IC_SN 읽기 실패"); |
||||
|
} |
||||
|
|
||||
|
await SendOnlyAndLogAsync(boardClient, "POST_READ_ID", _boardSettings.PostReadIdCommand, log); |
||||
|
postReadIdCommandSent = true; |
||||
|
|
||||
|
if (onIcSnReadAsync is not null) |
||||
|
{ |
||||
|
await onIcSnReadAsync(icSn); |
||||
|
} |
||||
|
|
||||
|
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(); |
||||
|
log.Append(equipmentResult.RawLog); |
||||
|
|
||||
|
return new BoardMeasurementResult |
||||
|
{ |
||||
|
IcSn = icSn, |
||||
|
Voltage = equipmentResult.Voltage, |
||||
|
Current = equipmentResult.Current, |
||||
|
ResistanceKilohms = equipmentResult.ResistanceKilohms, |
||||
|
ResistanceOverloaded = equipmentResult.ResistanceOverloaded, |
||||
|
RawLog = log.ToString() |
||||
|
}; |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
if (!postReadIdCommandSent) |
||||
|
{ |
||||
|
await SendFinalBoardOffAsync(boardClient, log); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task SendFinalBoardOffAsync(SerialBoardClient boardClient, StringBuilder log) |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(_boardSettings.PostReadIdCommand)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
await SendOnlyAndLogAsync(boardClient, "FINAL_OFF", _boardSettings.PostReadIdCommand, log); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
log.AppendLine($"> BOARD FINAL_OFF FAILED: {ex.Message}"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static async Task<string> SendAndLogAsync( |
||||
|
SerialBoardClient client, |
||||
|
string label, |
||||
|
string command, |
||||
|
int timeoutMilliseconds, |
||||
|
bool waitForEndToken, |
||||
|
StringBuilder log) |
||||
|
{ |
||||
|
log.AppendLine($"> BOARD {label}: {command}"); |
||||
|
var response = await client.SendCommandAsync(command, timeoutMilliseconds, waitForEndToken); |
||||
|
log.Append(response); |
||||
|
return response; |
||||
|
} |
||||
|
|
||||
|
private static async Task SendOnlyAndLogAsync( |
||||
|
SerialBoardClient client, |
||||
|
string label, |
||||
|
string command, |
||||
|
StringBuilder log) |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(command)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
log.AppendLine($"> BOARD {label}: {command}"); |
||||
|
await client.SendOnlyAsync(command); |
||||
|
} |
||||
|
|
||||
|
private static bool ContainsSuccess(string response) |
||||
|
{ |
||||
|
return response |
||||
|
.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries) |
||||
|
.Any(line => string.Equals(line.Trim(), "Success", StringComparison.OrdinalIgnoreCase)); |
||||
|
} |
||||
|
|
||||
|
private static string ExtractFirstHexLine(string response) |
||||
|
{ |
||||
|
foreach (var rawLine in response.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)) |
||||
|
{ |
||||
|
var line = rawLine.Trim(); |
||||
|
if (line.Equals("<end>", StringComparison.OrdinalIgnoreCase) || |
||||
|
line.Equals("Fail", StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
if (Regex.IsMatch(line, "^[0-9A-Fa-f]{8,32}$")) |
||||
|
{ |
||||
|
return line.ToUpperInvariant(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return string.Empty; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,59 @@ |
|||||
|
using System.IO; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class DatabaseSettings |
||||
|
{ |
||||
|
public string Ip { get; set; } = string.Empty; |
||||
|
public string Database { get; set; } = string.Empty; |
||||
|
public string DbId { get; set; } = string.Empty; |
||||
|
public string DbPw { get; set; } = string.Empty; |
||||
|
public bool Encrypt { get; set; } = true; |
||||
|
public bool TrustServerCertificate { get; set; } = true; |
||||
|
public int Timeout { get; set; } = 5; |
||||
|
|
||||
|
public static DatabaseSettings Load(string filePath) |
||||
|
{ |
||||
|
if (!File.Exists(filePath)) |
||||
|
{ |
||||
|
throw new FileNotFoundException("Database.ini 파일을 찾을 수 없습니다.", filePath); |
||||
|
} |
||||
|
|
||||
|
var values = IniFile.LoadSection(filePath, "Database"); |
||||
|
return new DatabaseSettings |
||||
|
{ |
||||
|
Ip = GetValue(values, "IP", "Server"), |
||||
|
Database = GetValue(values, "Database", "DB"), |
||||
|
DbId = GetValue(values, "DbId", "UserId", "ID"), |
||||
|
DbPw = GetValue(values, "DbPw", "Password", "PW"), |
||||
|
Encrypt = GetBool(values, true, "Encrypt"), |
||||
|
TrustServerCertificate = GetBool(values, true, "TrustServerCertificate"), |
||||
|
Timeout = GetInt(values, 5, "Timeout") |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private static string GetValue(Dictionary<string, string> values, params string[] keys) |
||||
|
{ |
||||
|
foreach (var key in keys) |
||||
|
{ |
||||
|
if (values.TryGetValue(key, out var value)) |
||||
|
{ |
||||
|
return value.Trim(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return string.Empty; |
||||
|
} |
||||
|
|
||||
|
private static bool GetBool(Dictionary<string, string> values, bool defaultValue, params string[] keys) |
||||
|
{ |
||||
|
var value = GetValue(values, keys); |
||||
|
return string.IsNullOrWhiteSpace(value) ? defaultValue : bool.Parse(value); |
||||
|
} |
||||
|
|
||||
|
private static int GetInt(Dictionary<string, string> values, int defaultValue, params string[] keys) |
||||
|
{ |
||||
|
var value = GetValue(values, keys); |
||||
|
return string.IsNullOrWhiteSpace(value) ? defaultValue : int.Parse(value); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,403 @@ |
|||||
|
using System.Globalization; |
||||
|
using System.Text; |
||||
|
using System.Text.RegularExpressions; |
||||
|
using Housing.Models; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class EquipmentMeasurementService |
||||
|
{ |
||||
|
private readonly EquipmentMeasurementSettings _settings; |
||||
|
|
||||
|
public EquipmentMeasurementService(EquipmentMeasurementSettings settings) |
||||
|
{ |
||||
|
_settings = settings; |
||||
|
} |
||||
|
|
||||
|
public async Task<string> RunPreBoardCommandAsync() |
||||
|
{ |
||||
|
ValidateMeasurementConfiguration(); |
||||
|
var log = new StringBuilder(); |
||||
|
if (IsDisabled(_settings.Current) || string.IsNullOrWhiteSpace(_settings.PreBoardCommand)) |
||||
|
{ |
||||
|
return string.Empty; |
||||
|
} |
||||
|
|
||||
|
using var client = await CreateClientAsync("CURRENT", _settings.Current); |
||||
|
log.AppendLine($"> CURRENT PRE-BOARD CONNECT: {DescribeClient(client, _settings.Current)}"); |
||||
|
log.AppendLine($"> CURRENT PRE-BOARD SETUP: {_settings.PreBoardCommand}"); |
||||
|
await SendCommandListAsync(client, _settings.PreBoardCommand); |
||||
|
return log.ToString(); |
||||
|
} |
||||
|
|
||||
|
public async Task<string> RunLogoutCommandAsync() |
||||
|
{ |
||||
|
var log = new StringBuilder(); |
||||
|
if (IsDisabled(_settings.Current) || string.IsNullOrWhiteSpace(_settings.LogoutCommand)) |
||||
|
{ |
||||
|
return string.Empty; |
||||
|
} |
||||
|
|
||||
|
using var client = await CreateClientAsync("CURRENT", _settings.Current); |
||||
|
log.AppendLine($"> CURRENT LOGOUT CONNECT: {DescribeClient(client, _settings.Current)}"); |
||||
|
log.AppendLine($"> CURRENT LOGOUT: {_settings.LogoutCommand}"); |
||||
|
await SendCommandListAsync(client, _settings.LogoutCommand); |
||||
|
return log.ToString(); |
||||
|
} |
||||
|
|
||||
|
public async Task<BoardMeasurementResult> ReadAsync() |
||||
|
{ |
||||
|
ValidateMeasurementConfiguration(); |
||||
|
var log = new StringBuilder(); |
||||
|
IScpiClient? currentClient = null; |
||||
|
var cleanupCompleted = false; |
||||
|
var resistanceEnabled = !IsDisabled(_settings.Resistance); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
if (!IsDisabled(_settings.Current)) |
||||
|
{ |
||||
|
currentClient = await CreateClientAsync("CURRENT", _settings.Current); |
||||
|
log.AppendLine($"> CURRENT PRE-READ CONNECT: {DescribeClient(currentClient, _settings.Current)}"); |
||||
|
if (!string.IsNullOrWhiteSpace(_settings.Current.SetupCommand)) |
||||
|
{ |
||||
|
log.AppendLine($"> CURRENT PRE-READ SETUP: {_settings.Current.SetupCommand}"); |
||||
|
await SendCommandListAsync(currentClient, _settings.Current.SetupCommand); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (_settings.SettleMilliseconds > 0) |
||||
|
{ |
||||
|
await Task.Delay(_settings.SettleMilliseconds); |
||||
|
} |
||||
|
|
||||
|
var voltage = await ReadChannelAsync("VOLTAGE", _settings.Voltage, log); |
||||
|
var current = currentClient is null |
||||
|
? await ReadChannelAsync("CURRENT", _settings.Current, log) |
||||
|
: await ReadChannelWithClientAsync("CURRENT", _settings.Current, currentClient, log, skipSetupCommand: true); |
||||
|
|
||||
|
// Resistance is measured only after the existing CH1 output cleanup.
|
||||
|
// Keep the protected CH2 supply behavior unchanged.
|
||||
|
await SendCleanupCommandAsync(currentClient, _settings.Current, log, throwOnFailure: resistanceEnabled); |
||||
|
if (resistanceEnabled && currentClient is not null) |
||||
|
{ |
||||
|
log.AppendLine($"> CURRENT OUTPUT CHECK: {_settings.ResistancePowerOffQuery}"); |
||||
|
var outputState = await currentClient.QueryAsync(_settings.ResistancePowerOffQuery); |
||||
|
log.AppendLine(outputState); |
||||
|
if (!decimal.TryParse(outputState.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var state) || state != 0m) |
||||
|
{ |
||||
|
throw new InvalidOperationException("CH1 출력 OFF를 확인하지 못해 저항 측정을 중단했습니다."); |
||||
|
} |
||||
|
} |
||||
|
cleanupCompleted = true; |
||||
|
|
||||
|
decimal? resistance = null; |
||||
|
var resistanceOverloaded = false; |
||||
|
if (resistanceEnabled) |
||||
|
{ |
||||
|
if (_settings.ResistanceSettleMilliseconds > 0) |
||||
|
{ |
||||
|
await Task.Delay(_settings.ResistanceSettleMilliseconds); |
||||
|
} |
||||
|
|
||||
|
using var resistanceClient = await CreateClientAsync("RESISTANCE", _settings.Resistance); |
||||
|
(resistance, resistanceOverloaded) = await ScpiMeasurement.ReadResistanceAsync( |
||||
|
command => QueryChannelWithClientAsync("RESISTANCE", _settings.Resistance, resistanceClient, log, readCommand: command), |
||||
|
_settings.Resistance.ReadCommand); |
||||
|
log.AppendLine(resistanceOverloaded |
||||
|
? "> RESISTANCE OVERLOAD: 999 (OL)" |
||||
|
: $"> RESISTANCE kOhm: {resistance.Value.ToString(CultureInfo.InvariantCulture)}"); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
log.AppendLine("> RESISTANCE SKIP: disabled in Hardware.ini"); |
||||
|
} |
||||
|
|
||||
|
return new BoardMeasurementResult |
||||
|
{ |
||||
|
Voltage = voltage, |
||||
|
Current = current, |
||||
|
ResistanceKilohms = resistance, |
||||
|
ResistanceOverloaded = resistanceOverloaded, |
||||
|
RawLog = log.ToString() |
||||
|
}; |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
if (!cleanupCompleted) |
||||
|
{ |
||||
|
await SendCleanupCommandAsync(currentClient, _settings.Current, log); |
||||
|
} |
||||
|
currentClient?.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static async Task SendCleanupCommandAsync(IScpiClient? client, ScpiChannelSettings channel, StringBuilder log, bool throwOnFailure = false) |
||||
|
{ |
||||
|
if (client is null || string.IsNullOrWhiteSpace(channel.CleanupCommand)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
log.AppendLine($"> CURRENT CLEANUP: {channel.CleanupCommand}"); |
||||
|
await SendCommandListAsync(client, channel.CleanupCommand); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
log.AppendLine($"> CURRENT CLEANUP FAILED: {ex.Message}"); |
||||
|
if (throwOnFailure) |
||||
|
{ |
||||
|
throw new InvalidOperationException("전원 출력 OFF에 실패해 저항 측정을 중단했습니다.", ex); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task<decimal> ReadChannelAsync( |
||||
|
string label, |
||||
|
ScpiChannelSettings channel, |
||||
|
StringBuilder log, |
||||
|
bool skipSetupCommand = false) |
||||
|
{ |
||||
|
if (IsDisabled(channel)) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"{label} 측정이 비활성화되어 검사를 시작할 수 없습니다."); |
||||
|
} |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(channel.ReadCommand)) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Hardware.ini [Equipment] {label} ReadCommand 값을 확인하세요."); |
||||
|
} |
||||
|
|
||||
|
using var client = await CreateClientAsync(label, channel); |
||||
|
return await ReadChannelWithClientAsync(label, channel, client, log, skipSetupCommand); |
||||
|
} |
||||
|
|
||||
|
private static async Task<decimal> ReadChannelWithClientAsync( |
||||
|
string label, |
||||
|
ScpiChannelSettings channel, |
||||
|
IScpiClient client, |
||||
|
StringBuilder log, |
||||
|
bool skipSetupCommand = false) |
||||
|
{ |
||||
|
var response = await QueryChannelWithClientAsync(label, channel, client, log, skipSetupCommand); |
||||
|
return ExtractFirstDecimal(response, label); |
||||
|
} |
||||
|
|
||||
|
private static async Task<string> QueryChannelWithClientAsync( |
||||
|
string label, |
||||
|
ScpiChannelSettings channel, |
||||
|
IScpiClient client, |
||||
|
StringBuilder log, |
||||
|
bool skipSetupCommand = false, |
||||
|
string? readCommand = null) |
||||
|
{ |
||||
|
readCommand ??= channel.ReadCommand; |
||||
|
if (string.IsNullOrWhiteSpace(readCommand)) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Hardware.ini [Equipment] {label} ReadCommand 값을 확인하세요."); |
||||
|
} |
||||
|
|
||||
|
log.AppendLine($"> {label} CONNECT: {DescribeClient(client, channel)}"); |
||||
|
if (!skipSetupCommand && !string.IsNullOrWhiteSpace(channel.SetupCommand)) |
||||
|
{ |
||||
|
log.AppendLine($"> {label} SETUP: {channel.SetupCommand}"); |
||||
|
await SendCommandListAsync(client, channel.SetupCommand); |
||||
|
} |
||||
|
|
||||
|
log.AppendLine($"> {label} READ: {readCommand}"); |
||||
|
var response = await client.QueryAsync(readCommand); |
||||
|
log.AppendLine(response); |
||||
|
|
||||
|
return response; |
||||
|
} |
||||
|
|
||||
|
private Task<IScpiClient> CreateClientAsync(string label, ScpiChannelSettings channel) |
||||
|
{ |
||||
|
var connection = GetConnectionType(channel); |
||||
|
return connection switch |
||||
|
{ |
||||
|
"TCP" or "LAN" or "ETHERNET" => CreateTcpClientAsync(label, channel), |
||||
|
"SERIAL" or "COM" => Task.FromResult<IScpiClient>(new SerialScpiClient(channel.PortName, channel.BaudRate, _settings.Timeout, channel.GetIdnMatches(), channel.GetTerminator())), |
||||
|
"VISA" or "USB" => CreateVisaClientAsync(channel), |
||||
|
_ => throw new InvalidOperationException($"{label} 장비 연결 방식은 Visa, Serial, Tcp 중 하나로 설정하세요.") |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private static string GetConnectionType(ScpiChannelSettings channel) |
||||
|
{ |
||||
|
if (!string.IsNullOrWhiteSpace(channel.Connection)) |
||||
|
{ |
||||
|
return channel.Connection.Trim().ToUpperInvariant(); |
||||
|
} |
||||
|
|
||||
|
if (!string.IsNullOrWhiteSpace(channel.Host)) |
||||
|
{ |
||||
|
return "TCP"; |
||||
|
} |
||||
|
|
||||
|
if (!string.IsNullOrWhiteSpace(channel.ResourceName)) |
||||
|
{ |
||||
|
return "VISA"; |
||||
|
} |
||||
|
|
||||
|
if (!string.IsNullOrWhiteSpace(channel.PortName)) |
||||
|
{ |
||||
|
return "SERIAL"; |
||||
|
} |
||||
|
|
||||
|
if (!string.IsNullOrWhiteSpace(channel.IdnMatch)) |
||||
|
{ |
||||
|
return "VISA"; |
||||
|
} |
||||
|
|
||||
|
return string.Empty; |
||||
|
} |
||||
|
|
||||
|
private async Task<IScpiClient> CreateTcpClientAsync(string label, ScpiChannelSettings channel) |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(channel.Host)) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Hardware.ini [Equipment] {label} Host 값을 확인하세요."); |
||||
|
} |
||||
|
|
||||
|
var client = await TcpScpiClient.ConnectAsync(channel.Host, channel.Port, _settings.Timeout); |
||||
|
try |
||||
|
{ |
||||
|
await ValidateTcpIdnAsync(label, channel, client); |
||||
|
return client; |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
client.Dispose(); |
||||
|
throw; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static async Task ValidateTcpIdnAsync(string label, ScpiChannelSettings channel, IScpiClient client) |
||||
|
{ |
||||
|
var idnMatches = channel.GetIdnMatches(); |
||||
|
if (idnMatches.Length == 0) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var idn = await client.QueryAsync("*IDN?"); |
||||
|
var isMatch = idnMatches.Any(token => idn.Contains(token, StringComparison.OrdinalIgnoreCase)); |
||||
|
if (!isMatch) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"{label} LAN IDN mismatch. Expected: {string.Join(", ", idnMatches)} / Actual: {idn}"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task<IScpiClient> CreateVisaClientAsync(ScpiChannelSettings channel) |
||||
|
{ |
||||
|
return await VisaScpiClient.OpenMatchingAsync(channel.ResourceName, channel.GetIdnMatches(), _settings.Timeout); |
||||
|
} |
||||
|
|
||||
|
private static string DescribeClient(IScpiClient client, ScpiChannelSettings channel) |
||||
|
{ |
||||
|
return client switch |
||||
|
{ |
||||
|
SerialScpiClient serial => $"Serial {serial.PortName} @ {channel.BaudRate}", |
||||
|
VisaScpiClient visa => $"VISA {visa.ResourceName}", |
||||
|
TcpScpiClient => $"TCP {channel.Host}:{channel.Port}", |
||||
|
_ => channel.Connection |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private static decimal ExtractFirstDecimal(string response, string label) |
||||
|
{ |
||||
|
var (value, overloaded) = ScpiMeasurement.ParseResponse(response, label); |
||||
|
if (overloaded) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"{label} 계측기 측정 범위를 초과했습니다."); |
||||
|
} |
||||
|
|
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private void ValidateMeasurementConfiguration() |
||||
|
{ |
||||
|
ValidateChannel("Voltage", _settings.Voltage); |
||||
|
ValidateChannel("Current", _settings.Current); |
||||
|
ValidateChannel("Resistance", _settings.Resistance); |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(_settings.Current.CleanupCommand)) |
||||
|
{ |
||||
|
throw new InvalidOperationException("저항 측정 전 출력을 끌 수 있도록 Hardware.ini의 CurrentCleanupCommand를 설정하세요."); |
||||
|
} |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(_settings.ResistancePowerOffQuery)) |
||||
|
{ |
||||
|
throw new InvalidOperationException("저항 측정 전 CH1 출력 OFF를 확인하도록 Hardware.ini의 ResistancePowerOffQuery를 설정하세요."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static void ValidateChannel(string name, ScpiChannelSettings channel) |
||||
|
{ |
||||
|
if (IsDisabled(channel)) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"{name} 측정이 비활성화되어 검사를 시작할 수 없습니다. Hardware.ini의 {name}Connection을 확인하세요."); |
||||
|
} |
||||
|
|
||||
|
var connection = GetConnectionType(channel); |
||||
|
if (string.IsNullOrWhiteSpace(connection)) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Hardware.ini에 {name} 장비 연결 설정이 없습니다. {name}Connection과 장비 주소를 설정하세요."); |
||||
|
} |
||||
|
|
||||
|
if (connection is "TCP" or "LAN" or "ETHERNET") |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(channel.Host) || channel.Port is < 1 or > 65535) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Hardware.ini의 {name}Host와 {name}Port를 확인하세요."); |
||||
|
} |
||||
|
} |
||||
|
else if (connection is not ("SERIAL" or "COM" or "VISA" or "USB")) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Hardware.ini의 {name}Connection은 LAN, Serial 또는 VISA로 설정하세요."); |
||||
|
} |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(channel.ReadCommand)) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Hardware.ini의 {name}ReadCommand를 설정하세요."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static async Task SendCommandListAsync(IScpiClient client, string commands) |
||||
|
{ |
||||
|
foreach (var command in SplitCommands(commands)) |
||||
|
{ |
||||
|
if (IsProtectedPowerOffCommand(command)) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
await client.SendAsync(command); |
||||
|
await Task.Delay(100); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static IEnumerable<string> SplitCommands(string commands) |
||||
|
{ |
||||
|
return commands |
||||
|
.Split(';') |
||||
|
.Select(command => command.Trim()) |
||||
|
.Where(command => !string.IsNullOrWhiteSpace(command)); |
||||
|
} |
||||
|
|
||||
|
private static bool IsProtectedPowerOffCommand(string command) |
||||
|
{ |
||||
|
return Regex.IsMatch(command, @"^\s*OUTP(?:UT)?\s+OFF\s*,?\s*\(@2\)\s*$", RegexOptions.IgnoreCase); |
||||
|
} |
||||
|
|
||||
|
private static bool IsDisabled(ScpiChannelSettings channel) |
||||
|
{ |
||||
|
var connection = channel.Connection.Trim(); |
||||
|
return string.Equals(connection, "None", StringComparison.OrdinalIgnoreCase) || |
||||
|
string.Equals(connection, "Disabled", StringComparison.OrdinalIgnoreCase) || |
||||
|
string.Equals(connection, "Off", StringComparison.OrdinalIgnoreCase); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,105 @@ |
|||||
|
using System.IO; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class EquipmentMeasurementSettings |
||||
|
{ |
||||
|
public int Timeout { get; set; } = 5000; |
||||
|
public int SettleMilliseconds { get; set; } = 300; |
||||
|
public int ResistanceSettleMilliseconds { get; set; } = 300; |
||||
|
public string ResistancePowerOffQuery { get; set; } = "OUTP? (@1)"; |
||||
|
public string PreBoardCommand { get; set; } = string.Empty; |
||||
|
public int PreBoardDelayMilliseconds { get; set; } = 0; |
||||
|
public string LogoutCommand { get; set; } = string.Empty; |
||||
|
public ScpiChannelSettings Voltage { get; set; } = new(); |
||||
|
public ScpiChannelSettings Current { get; set; } = new(); |
||||
|
public ScpiChannelSettings Resistance { get; set; } = new() { Connection = "Disabled" }; |
||||
|
|
||||
|
public static EquipmentMeasurementSettings Load(string filePath) |
||||
|
{ |
||||
|
if (!File.Exists(filePath)) |
||||
|
{ |
||||
|
throw new FileNotFoundException("Hardware.ini 파일을 찾을 수 없습니다.", filePath); |
||||
|
} |
||||
|
|
||||
|
var values = IniFile.LoadSection(filePath, "Equipment"); |
||||
|
return new EquipmentMeasurementSettings |
||||
|
{ |
||||
|
Timeout = GetInt(values, "Timeout", 5000), |
||||
|
SettleMilliseconds = GetInt(values, "SettleMilliseconds", 300), |
||||
|
ResistanceSettleMilliseconds = Math.Max(0, GetInt(values, "ResistanceSettleMilliseconds", 300)), |
||||
|
ResistancePowerOffQuery = GetString(values, "ResistancePowerOffQuery", "OUTP? (@1)"), |
||||
|
PreBoardCommand = GetString(values, "PreBoardCommand", string.Empty), |
||||
|
PreBoardDelayMilliseconds = GetInt(values, "PreBoardDelayMilliseconds", 0), |
||||
|
LogoutCommand = GetString(values, "LogoutCommand", string.Empty), |
||||
|
Voltage = LoadChannel(values, "Voltage", "MEAS:VOLT:DC?", "34460,34461,34465,34470,3446,3447"), |
||||
|
Current = LoadChannel(values, "Current", "MEAS:CURR? CH1", "E362"), |
||||
|
Resistance = LoadChannel(values, "Resistance", ScpiMeasurement.ResistanceReadCommand, string.Empty) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private static ScpiChannelSettings LoadChannel( |
||||
|
Dictionary<string, string> values, |
||||
|
string prefix, |
||||
|
string defaultReadCommand, |
||||
|
string defaultIdnMatch) |
||||
|
{ |
||||
|
return new ScpiChannelSettings |
||||
|
{ |
||||
|
Connection = GetString(values, prefix + "Connection", string.Empty), |
||||
|
ResourceName = GetString(values, prefix + "Resource", string.Empty), |
||||
|
IdnMatch = GetString(values, prefix + "IdnMatch", defaultIdnMatch), |
||||
|
Host = GetString(values, prefix + "Host", string.Empty), |
||||
|
Port = GetInt(values, prefix + "Port", 5025), |
||||
|
PortName = GetString(values, prefix + "PortName", string.Empty), |
||||
|
BaudRate = GetInt(values, prefix + "BaudRate", 115200), |
||||
|
Terminator = GetString(values, prefix + "Terminator", string.Empty), |
||||
|
SetupCommand = GetString(values, prefix + "SetupCommand", string.Empty), |
||||
|
CleanupCommand = GetString(values, prefix + "CleanupCommand", string.Empty), |
||||
|
ReadCommand = GetString(values, prefix + "ReadCommand", defaultReadCommand) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
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; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public sealed class ScpiChannelSettings |
||||
|
{ |
||||
|
public string Connection { get; set; } = string.Empty; |
||||
|
public string ResourceName { get; set; } = string.Empty; |
||||
|
public string IdnMatch { get; set; } = string.Empty; |
||||
|
public string Host { get; set; } = string.Empty; |
||||
|
public int Port { get; set; } = 5025; |
||||
|
public string PortName { get; set; } = string.Empty; |
||||
|
public int BaudRate { get; set; } = 115200; |
||||
|
public string Terminator { get; set; } = string.Empty; |
||||
|
public string SetupCommand { get; set; } = string.Empty; |
||||
|
public string CleanupCommand { get; set; } = string.Empty; |
||||
|
public string ReadCommand { get; set; } = string.Empty; |
||||
|
|
||||
|
public string[] GetIdnMatches() |
||||
|
{ |
||||
|
return IdnMatch |
||||
|
.Split(',', ';') |
||||
|
.Select(value => value.Trim()) |
||||
|
.Where(value => !string.IsNullOrWhiteSpace(value)) |
||||
|
.ToArray(); |
||||
|
} |
||||
|
|
||||
|
public string GetTerminator() |
||||
|
{ |
||||
|
return string.IsNullOrWhiteSpace(Terminator) ? "\n" : Terminator; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,350 @@ |
|||||
|
using System.Data; |
||||
|
using Housing.Models; |
||||
|
using Microsoft.Data.SqlClient; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class HousingAssemblyRepository |
||||
|
{ |
||||
|
private readonly DatabaseSettings _settings; |
||||
|
|
||||
|
public HousingAssemblyRepository(DatabaseSettings settings) |
||||
|
{ |
||||
|
_settings = settings; |
||||
|
} |
||||
|
|
||||
|
public async Task InsertHeaderAsync(HousingAssemblyRecord record) |
||||
|
{ |
||||
|
using var connection = new SqlConnection(CreateConnectionString()); |
||||
|
using var command = CreateCommand(connection); |
||||
|
|
||||
|
command.CommandText = @"
|
||||
|
INSERT INTO dbo.Housing_Assembly |
||||
|
( |
||||
|
[IC_SN], |
||||
|
[PCB_Barcode], |
||||
|
[Maker], |
||||
|
[Model], |
||||
|
[Variant_1], |
||||
|
[Variant_2], |
||||
|
[Operator], |
||||
|
[Production_Date], |
||||
|
[Line], |
||||
|
[Lot_No], |
||||
|
[Jig_No] |
||||
|
) |
||||
|
VALUES |
||||
|
( |
||||
|
@IC_SN, |
||||
|
@PCB_Barcode, |
||||
|
@Maker, |
||||
|
@Model, |
||||
|
@Variant_1, |
||||
|
@Variant_2, |
||||
|
@Operator, |
||||
|
@Production_Date, |
||||
|
@Line, |
||||
|
@Lot_No, |
||||
|
@Jig_No |
||||
|
);";
|
||||
|
|
||||
|
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); |
||||
|
AddNullableNVarChar(command, "@Variant_1", 10, record.Variant1); |
||||
|
AddNullableNVarChar(command, "@Variant_2", 10, record.Variant2); |
||||
|
AddNVarChar(command, "@Operator", 10, record.Operator); |
||||
|
command.Parameters.Add(new SqlParameter("@Production_Date", SqlDbType.DateTime2) { Value = record.ProductionDate }); |
||||
|
AddNVarChar(command, "@Line", 10, 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 UpsertHeaderAsync(HousingAssemblyRecord record) |
||||
|
{ |
||||
|
using var connection = new SqlConnection(CreateConnectionString()); |
||||
|
using var command = CreateCommand(connection); |
||||
|
|
||||
|
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, |
||||
|
[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], |
||||
|
[Production_Date], |
||||
|
[Line], |
||||
|
[Lot_No], |
||||
|
[Jig_No] |
||||
|
) |
||||
|
VALUES |
||||
|
( |
||||
|
@IC_SN, |
||||
|
@PCB_Barcode, |
||||
|
@Maker, |
||||
|
@Model, |
||||
|
@Variant_1, |
||||
|
@Variant_2, |
||||
|
@Operator, |
||||
|
@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); |
||||
|
AddNullableNVarChar(command, "@Variant_1", 10, record.Variant1); |
||||
|
AddNullableNVarChar(command, "@Variant_2", 10, record.Variant2); |
||||
|
AddNVarChar(command, "@Operator", 10, record.Operator); |
||||
|
command.Parameters.Add(new SqlParameter("@Production_Date", SqlDbType.DateTime2) { Value = record.ProductionDate }); |
||||
|
AddNVarChar(command, "@Line", 10, 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) |
||||
|
{ |
||||
|
using var connection = new SqlConnection(CreateConnectionString()); |
||||
|
using var command = CreateCommand(connection); |
||||
|
|
||||
|
command.CommandText = @"
|
||||
|
UPDATE dbo.Housing_Assembly |
||||
|
SET |
||||
|
[PT_Vol_1] = @PT_Vol_1, |
||||
|
[PT_Current_1] = @PT_Current_1, |
||||
|
[PT_Resistor_1] = @PT_Resistor_1, |
||||
|
[Result] = @Result |
||||
|
WHERE |
||||
|
[IC_SN] = @IC_SN;";
|
||||
|
|
||||
|
AddNVarChar(command, "@IC_SN", 50, record.IcSn); |
||||
|
AddDecimal(command, "@PT_Vol_1", record.PtVol1, 4, 2); |
||||
|
AddDecimal(command, "@PT_Current_1", record.PtCurrent1, 4, 2); |
||||
|
AddResistance(command, record.PtResistor1); |
||||
|
AddNVarChar(command, "@Result", 5, ResistanceStorage.ExceedsRange(record.PtResistor1) ? "NG" : record.Result); |
||||
|
|
||||
|
await connection.OpenAsync(); |
||||
|
var affectedRows = await command.ExecuteNonQueryAsync(); |
||||
|
if (affectedRows == 0) |
||||
|
{ |
||||
|
throw new InvalidOperationException("DB measurement update target was not found."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task UpsertInspectionAsync(HousingAssemblyRecord record) |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(record.Result)) |
||||
|
{ |
||||
|
throw new InvalidOperationException("DB result value is required."); |
||||
|
} |
||||
|
|
||||
|
using var connection = new SqlConnection(CreateConnectionString()); |
||||
|
using var command = CreateCommand(connection); |
||||
|
|
||||
|
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, |
||||
|
[Production_Date] = @Production_Date, |
||||
|
[Line] = @Line, |
||||
|
[Lot_No] = @Lot_No, |
||||
|
[Jig_No] = @Jig_No, |
||||
|
[PT_Vol_1] = @PT_Vol_1, |
||||
|
[PT_Current_1] = @PT_Current_1, |
||||
|
[PT_Resistor_1] = @PT_Resistor_1, |
||||
|
[Result] = @Result |
||||
|
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], |
||||
|
[Production_Date], |
||||
|
[Line], |
||||
|
[Lot_No], |
||||
|
[Jig_No], |
||||
|
[PT_Vol_1], |
||||
|
[PT_Current_1], |
||||
|
[PT_Resistor_1], |
||||
|
[Result] |
||||
|
) |
||||
|
VALUES |
||||
|
( |
||||
|
@IC_SN, |
||||
|
@PCB_Barcode, |
||||
|
@Maker, |
||||
|
@Model, |
||||
|
@Variant_1, |
||||
|
@Variant_2, |
||||
|
@Operator, |
||||
|
@Production_Date, |
||||
|
@Line, |
||||
|
@Lot_No, |
||||
|
@Jig_No, |
||||
|
@PT_Vol_1, |
||||
|
@PT_Current_1, |
||||
|
@PT_Resistor_1, |
||||
|
@Result |
||||
|
); |
||||
|
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); |
||||
|
AddNullableNVarChar(command, "@Variant_1", 10, record.Variant1); |
||||
|
AddNullableNVarChar(command, "@Variant_2", 10, record.Variant2); |
||||
|
AddNVarChar(command, "@Operator", 10, record.Operator); |
||||
|
command.Parameters.Add(new SqlParameter("@Production_Date", SqlDbType.DateTime2) { Value = record.ProductionDate }); |
||||
|
AddNVarChar(command, "@Line", 10, record.Line); |
||||
|
AddNVarChar(command, "@Lot_No", 8, record.LotNo); |
||||
|
AddNVarChar(command, "@Jig_No", 10, record.JigNo); |
||||
|
AddDecimal(command, "@PT_Vol_1", record.PtVol1, 4, 2); |
||||
|
AddDecimal(command, "@PT_Current_1", record.PtCurrent1, 4, 2); |
||||
|
AddResistance(command, record.PtResistor1); |
||||
|
AddNVarChar(command, "@Result", 5, ResistanceStorage.ExceedsRange(record.PtResistor1) ? "NG" : record.Result); |
||||
|
|
||||
|
await connection.OpenAsync(); |
||||
|
await command.ExecuteNonQueryAsync(); |
||||
|
} |
||||
|
|
||||
|
public async Task InsertAsync(HousingAssemblyRecord record) |
||||
|
{ |
||||
|
await UpsertInspectionAsync(record); |
||||
|
} |
||||
|
|
||||
|
public async Task WarmUpAsync() |
||||
|
{ |
||||
|
using var connection = new SqlConnection(CreateConnectionString()); |
||||
|
using var command = CreateCommand(connection); |
||||
|
|
||||
|
command.CommandText = "SELECT 1;"; |
||||
|
|
||||
|
await connection.OpenAsync(); |
||||
|
await command.ExecuteScalarAsync(); |
||||
|
} |
||||
|
|
||||
|
private string CreateConnectionString() |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(_settings.Ip) || |
||||
|
string.IsNullOrWhiteSpace(_settings.Database) || |
||||
|
string.IsNullOrWhiteSpace(_settings.DbId)) |
||||
|
{ |
||||
|
throw new InvalidOperationException("Database.ini의 IP, Database, DbId 값을 확인하세요."); |
||||
|
} |
||||
|
|
||||
|
var builder = new SqlConnectionStringBuilder |
||||
|
{ |
||||
|
DataSource = _settings.Ip, |
||||
|
InitialCatalog = _settings.Database, |
||||
|
UserID = _settings.DbId, |
||||
|
Password = _settings.DbPw, |
||||
|
Encrypt = _settings.Encrypt, |
||||
|
TrustServerCertificate = _settings.TrustServerCertificate, |
||||
|
ConnectTimeout = _settings.Timeout |
||||
|
}; |
||||
|
|
||||
|
return builder.ConnectionString; |
||||
|
} |
||||
|
|
||||
|
private SqlCommand CreateCommand(SqlConnection connection) |
||||
|
{ |
||||
|
var command = connection.CreateCommand(); |
||||
|
command.CommandTimeout = Math.Max(1, _settings.Timeout); |
||||
|
return command; |
||||
|
} |
||||
|
|
||||
|
private static void AddNVarChar(SqlCommand command, string name, int size, string value) |
||||
|
{ |
||||
|
command.Parameters.Add(new SqlParameter(name, SqlDbType.NVarChar, size) { Value = value }); |
||||
|
} |
||||
|
|
||||
|
private static void AddNullableNVarChar(SqlCommand command, string name, int size, string? value) |
||||
|
{ |
||||
|
command.Parameters.Add(new SqlParameter(name, SqlDbType.NVarChar, size) |
||||
|
{ |
||||
|
Value = string.IsNullOrWhiteSpace(value) ? DBNull.Value : value |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private static void AddDecimal(SqlCommand command, string name, decimal value, byte precision, byte scale) |
||||
|
{ |
||||
|
var parameter = new SqlParameter(name, SqlDbType.Decimal) |
||||
|
{ |
||||
|
Precision = precision, |
||||
|
Scale = scale, |
||||
|
Value = value |
||||
|
}; |
||||
|
|
||||
|
command.Parameters.Add(parameter); |
||||
|
} |
||||
|
|
||||
|
private static void AddResistance(SqlCommand command, decimal? resistanceKilohms) |
||||
|
{ |
||||
|
// An unrepresentable value is saved as NULL; its inspection result is NG.
|
||||
|
var rounded = ResistanceStorage.ToDatabaseValue(resistanceKilohms); |
||||
|
|
||||
|
command.Parameters.Add(new SqlParameter("@PT_Resistor_1", SqlDbType.Decimal) |
||||
|
{ |
||||
|
Precision = 5, |
||||
|
Scale = 2, |
||||
|
Value = rounded.HasValue ? rounded.Value : DBNull.Value |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,52 @@ |
|||||
|
using System.IO; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public static class HousingSettingsPaths |
||||
|
{ |
||||
|
public static string GetIniPath(string fileName) |
||||
|
{ |
||||
|
return GetIniPath( |
||||
|
fileName, |
||||
|
Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), |
||||
|
AppContext.BaseDirectory, |
||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); |
||||
|
} |
||||
|
|
||||
|
internal static string GetIniPath(string fileName, string desktopDirectory, |
||||
|
string applicationDirectory, string localApplicationData) |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(desktopDirectory)) |
||||
|
{ |
||||
|
throw new DirectoryNotFoundException("사용자 바탕화면 폴더를 찾을 수 없습니다."); |
||||
|
} |
||||
|
|
||||
|
var settingsDirectory = Path.Combine(desktopDirectory, "Housing"); |
||||
|
Directory.CreateDirectory(settingsDirectory); |
||||
|
var settingsPath = Path.Combine(settingsDirectory, fileName); |
||||
|
if (File.Exists(settingsPath)) |
||||
|
{ |
||||
|
return settingsPath; |
||||
|
} |
||||
|
|
||||
|
// Older versions saved edited judgement limits separately from the bundled INI.
|
||||
|
var legacyInspectionPath = Path.Combine(localApplicationData, "AMO Housing", fileName); |
||||
|
var sourcePath = fileName == "InspectionSettings.ini" && File.Exists(legacyInspectionPath) |
||||
|
? legacyInspectionPath |
||||
|
: Path.Combine(applicationDirectory, fileName); |
||||
|
|
||||
|
if (File.Exists(sourcePath)) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
File.Copy(sourcePath, settingsPath, overwrite: false); |
||||
|
} |
||||
|
catch (IOException ex) when ((ex.HResult & 0xFFFF) is 80 or 183 && File.Exists(settingsPath)) |
||||
|
{ |
||||
|
// Another instance may have initialized the same file first.
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return settingsPath; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public interface IScpiClient : IDisposable |
||||
|
{ |
||||
|
Task SendAsync(string command); |
||||
|
Task<string> QueryAsync(string command); |
||||
|
} |
||||
@ -0,0 +1,45 @@ |
|||||
|
using System.IO; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public static class IniFile |
||||
|
{ |
||||
|
public static Dictionary<string, string> LoadSection(string filePath, string sectionName) |
||||
|
{ |
||||
|
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
||||
|
var isTargetSection = false; |
||||
|
|
||||
|
foreach (var rawLine in File.ReadAllLines(filePath)) |
||||
|
{ |
||||
|
var line = rawLine.Trim(); |
||||
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith(';') || line.StartsWith('#')) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
if (line.StartsWith('[') && line.EndsWith(']')) |
||||
|
{ |
||||
|
var currentSection = line.Substring(1, line.Length - 2).Trim(); |
||||
|
isTargetSection = string.Equals(currentSection, sectionName, StringComparison.OrdinalIgnoreCase); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
if (!isTargetSection) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
var separatorIndex = line.IndexOf('='); |
||||
|
if (separatorIndex <= 0) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
var key = line.Substring(0, separatorIndex).Trim(); |
||||
|
var value = line.Substring(separatorIndex + 1).Trim(); |
||||
|
values[key] = value; |
||||
|
} |
||||
|
|
||||
|
return values; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,69 @@ |
|||||
|
using System.Globalization; |
||||
|
using System.IO; |
||||
|
using Housing.Models; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public static class InspectionSettingsStore |
||||
|
{ |
||||
|
public static InspectionJudgementSettings Load(string filePath) |
||||
|
{ |
||||
|
if (!File.Exists(filePath)) |
||||
|
{ |
||||
|
var defaultSettings = new InspectionJudgementSettings(); |
||||
|
Save(filePath, defaultSettings); |
||||
|
return defaultSettings; |
||||
|
} |
||||
|
|
||||
|
var values = IniFile.LoadSection(filePath, "Judgement"); |
||||
|
return new InspectionJudgementSettings |
||||
|
{ |
||||
|
VMin = GetDecimal(values, "VMin", 0.00m), |
||||
|
VMax = GetDecimal(values, "VMax", 9.99m), |
||||
|
AMin = GetDecimal(values, "AMin", 0.00m), |
||||
|
AMax = GetDecimal(values, "AMax", 9.99m), |
||||
|
RMin = GetOptionalDecimal(values, "RMin"), |
||||
|
RMax = GetOptionalDecimal(values, "RMax") |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public static void Save(string filePath, InspectionJudgementSettings settings) |
||||
|
{ |
||||
|
var lines = new[] |
||||
|
{ |
||||
|
"[Judgement]", |
||||
|
$"VMin={Format(settings.VMin)}", |
||||
|
$"VMax={Format(settings.VMax)}", |
||||
|
$"AMin={Format(settings.AMin)}", |
||||
|
$"AMax={Format(settings.AMax)}", |
||||
|
$"RMin={(settings.RMin.HasValue ? Format(settings.RMin.Value) : string.Empty)}", |
||||
|
$"RMax={(settings.RMax.HasValue ? Format(settings.RMax.Value) : string.Empty)}" |
||||
|
}; |
||||
|
|
||||
|
File.WriteAllLines(filePath, lines); |
||||
|
} |
||||
|
|
||||
|
private static decimal GetDecimal(Dictionary<string, string> values, string key, decimal defaultValue) |
||||
|
{ |
||||
|
if (!values.TryGetValue(key, out var value)) |
||||
|
{ |
||||
|
return defaultValue; |
||||
|
} |
||||
|
|
||||
|
return decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var result) |
||||
|
? result |
||||
|
: defaultValue; |
||||
|
} |
||||
|
|
||||
|
private static string Format(decimal value) |
||||
|
{ |
||||
|
return value.ToString("0.############################", CultureInfo.InvariantCulture); |
||||
|
} |
||||
|
|
||||
|
private static decimal? GetOptionalDecimal(Dictionary<string, string> values, string key) |
||||
|
{ |
||||
|
return values.TryGetValue(key, out var text) && |
||||
|
decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var value) |
||||
|
? value : null; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
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.CommandType = CommandType.StoredProcedure; |
||||
|
command.CommandText = _loginSettings.GetProcedureName(); |
||||
|
command.Parameters.Add(new SqlParameter("@Operator", SqlDbType.NVarChar, 10) { Value = loginId }); |
||||
|
command.Parameters.Add(new SqlParameter("@Password", SqlDbType.NVarChar, 50) { Value = password }); |
||||
|
command.Parameters.Add(new SqlParameter("@ProcessName", SqlDbType.NVarChar, 30) { Value = _loginSettings.ProcessName }); |
||||
|
|
||||
|
await connection.OpenAsync(); |
||||
|
var result = await command.ExecuteScalarAsync(); |
||||
|
|
||||
|
return result switch |
||||
|
{ |
||||
|
bool isValid => isValid, |
||||
|
byte value => value == 1, |
||||
|
int value => value == 1, |
||||
|
_ => result is not null && Convert.ToInt32(result) == 1 |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private string CreateConnectionString() |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(_databaseSettings.Ip) || |
||||
|
string.IsNullOrWhiteSpace(_databaseSettings.Database) || |
||||
|
string.IsNullOrWhiteSpace(_databaseSettings.DbId)) |
||||
|
{ |
||||
|
throw new InvalidOperationException("Check Database.ini IP, Database, and DbId values."); |
||||
|
} |
||||
|
|
||||
|
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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,174 @@ |
|||||
|
using System.IO; |
||||
|
using System.Text.Json; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class LoginSelectionOptions |
||||
|
{ |
||||
|
private const string DefaultContent = """
|
||||
|
{ |
||||
|
"loginAccounts": [ |
||||
|
{ |
||||
|
"id": "su", |
||||
|
"password": "su" |
||||
|
} |
||||
|
], |
||||
|
"makers": [ |
||||
|
"AMO", |
||||
|
"ABC" |
||||
|
], |
||||
|
"models": [ |
||||
|
"MODEL-1" |
||||
|
], |
||||
|
"variant1Values": [], |
||||
|
"variant2Values": [], |
||||
|
"lineValues": [ |
||||
|
"LINE-1" |
||||
|
], |
||||
|
"jigNoValues": [ |
||||
|
"JIG-1" |
||||
|
] |
||||
|
} |
||||
|
""";
|
||||
|
|
||||
|
public IReadOnlyList<string> Makers { get; init; } = Array.Empty<string>(); |
||||
|
public IReadOnlyList<string> Models { get; init; } = Array.Empty<string>(); |
||||
|
public IReadOnlyList<string> Variant1Values { get; init; } = Array.Empty<string>(); |
||||
|
public IReadOnlyList<string> Variant2Values { get; init; } = Array.Empty<string>(); |
||||
|
public IReadOnlyList<string> LineValues { get; init; } = Array.Empty<string>(); |
||||
|
public IReadOnlyList<string> JigNoValues { get; init; } = Array.Empty<string>(); |
||||
|
public IReadOnlyList<LoginAccountOption> LoginAccounts { get; init; } = Array.Empty<LoginAccountOption>(); |
||||
|
|
||||
|
public static LoginSelectionOptions Load(string filePath) |
||||
|
{ |
||||
|
EnsureFileExists(filePath); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var json = File.ReadAllText(filePath); |
||||
|
var data = JsonSerializer.Deserialize<LoginSelectionOptionsData>( |
||||
|
json, |
||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new LoginSelectionOptionsData(); |
||||
|
|
||||
|
return new LoginSelectionOptions |
||||
|
{ |
||||
|
LoginAccounts = NormalizeLoginAccounts(data.LoginAccounts), |
||||
|
Makers = NormalizeValues(data.Makers), |
||||
|
Models = NormalizeValues(data.Models), |
||||
|
Variant1Values = NormalizeValues(data.Variant1Values, includeBlank: true), |
||||
|
Variant2Values = NormalizeValues(data.Variant2Values, includeBlank: true), |
||||
|
LineValues = NormalizeValues(data.LineValues), |
||||
|
JigNoValues = NormalizeValues(data.JigNoValues) |
||||
|
}; |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
return new LoginSelectionOptions |
||||
|
{ |
||||
|
Variant1Values = new[] { string.Empty }, |
||||
|
Variant2Values = new[] { string.Empty } |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public static void CreateDefaultFile(string filePath) |
||||
|
{ |
||||
|
var directory = Path.GetDirectoryName(filePath); |
||||
|
if (!string.IsNullOrWhiteSpace(directory)) |
||||
|
{ |
||||
|
Directory.CreateDirectory(directory); |
||||
|
} |
||||
|
|
||||
|
File.WriteAllText(filePath, DefaultContent); |
||||
|
} |
||||
|
|
||||
|
private static void EnsureFileExists(string filePath) |
||||
|
{ |
||||
|
if (File.Exists(filePath)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
CreateDefaultFile(filePath); |
||||
|
} |
||||
|
|
||||
|
private static IReadOnlyList<string> NormalizeValues( |
||||
|
IReadOnlyList<string>? source, |
||||
|
bool includeBlank = false) |
||||
|
{ |
||||
|
var values = new List<string>(); |
||||
|
if (includeBlank) |
||||
|
{ |
||||
|
values.Add(string.Empty); |
||||
|
} |
||||
|
|
||||
|
if (source is null) |
||||
|
{ |
||||
|
return values; |
||||
|
} |
||||
|
|
||||
|
foreach (var rawValue in source) |
||||
|
{ |
||||
|
var value = rawValue.Trim(); |
||||
|
if (string.IsNullOrWhiteSpace(value)) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
if (!values.Any(existing => string.Equals(existing, value, StringComparison.OrdinalIgnoreCase))) |
||||
|
{ |
||||
|
values.Add(value); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return values; |
||||
|
} |
||||
|
|
||||
|
private static IReadOnlyList<LoginAccountOption> NormalizeLoginAccounts( |
||||
|
IReadOnlyList<LoginAccountOptionData>? source) |
||||
|
{ |
||||
|
if (source is null) |
||||
|
{ |
||||
|
return Array.Empty<LoginAccountOption>(); |
||||
|
} |
||||
|
|
||||
|
var accounts = new List<LoginAccountOption>(); |
||||
|
foreach (var account in source) |
||||
|
{ |
||||
|
var id = account.Id?.Trim() ?? string.Empty; |
||||
|
var password = account.Password ?? string.Empty; |
||||
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(password)) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
if (accounts.Any(existing => string.Equals(existing.Id, id, StringComparison.OrdinalIgnoreCase))) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
accounts.Add(new LoginAccountOption(id, password)); |
||||
|
} |
||||
|
|
||||
|
return accounts; |
||||
|
} |
||||
|
|
||||
|
private sealed class LoginSelectionOptionsData |
||||
|
{ |
||||
|
public List<LoginAccountOptionData>? LoginAccounts { get; set; } |
||||
|
public List<string>? Makers { get; set; } |
||||
|
public List<string>? Models { get; set; } |
||||
|
public List<string>? Variant1Values { get; set; } |
||||
|
public List<string>? Variant2Values { get; set; } |
||||
|
public List<string>? LineValues { get; set; } |
||||
|
public List<string>? JigNoValues { get; set; } |
||||
|
} |
||||
|
|
||||
|
private sealed class LoginAccountOptionData |
||||
|
{ |
||||
|
public string? Id { get; set; } |
||||
|
public string? Password { get; set; } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public sealed record LoginAccountOption(string Id, string Password); |
||||
@ -0,0 +1,82 @@ |
|||||
|
using System.IO; |
||||
|
using System.Text.RegularExpressions; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class LoginSettings |
||||
|
{ |
||||
|
private const string DefaultProcedure = "dbo.CheckOperator"; |
||||
|
private const string DefaultProcessName = "Housing"; |
||||
|
|
||||
|
public string Procedure { get; set; } = DefaultProcedure; |
||||
|
public string ProcessName { get; set; } = DefaultProcessName; |
||||
|
public bool OfflinePreview { get; set; } |
||||
|
|
||||
|
public static LoginSettings Load(string filePath) |
||||
|
{ |
||||
|
if (!File.Exists(filePath)) |
||||
|
{ |
||||
|
throw new FileNotFoundException("Database.ini file was not found.", filePath); |
||||
|
} |
||||
|
|
||||
|
var values = IniFile.LoadSection(filePath, "Login"); |
||||
|
return new LoginSettings |
||||
|
{ |
||||
|
Procedure = GetString(values, "Procedure", DefaultProcedure), |
||||
|
ProcessName = GetProcessName(values), |
||||
|
OfflinePreview = GetBool(values, "OfflinePreview", false) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public string GetProcedureName() |
||||
|
{ |
||||
|
var parts = Procedure |
||||
|
.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) |
||||
|
.ToArray(); |
||||
|
|
||||
|
if (parts.Length is 0 or > 3 || parts.Any(part => !IsSqlIdentifier(part))) |
||||
|
{ |
||||
|
throw new InvalidOperationException("Check Database.ini [Login] Procedure value."); |
||||
|
} |
||||
|
|
||||
|
return string.Join(".", parts); |
||||
|
} |
||||
|
|
||||
|
private static string GetProcessName(Dictionary<string, string> values) |
||||
|
{ |
||||
|
var processName = GetString(values, "ProcessName", DefaultProcessName); |
||||
|
if (!Regex.IsMatch(processName, @"^[A-Za-z0-9_ -]{1,30}$")) |
||||
|
{ |
||||
|
throw new InvalidOperationException("Check Database.ini [Login] ProcessName value."); |
||||
|
} |
||||
|
|
||||
|
return processName; |
||||
|
} |
||||
|
|
||||
|
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 bool IsSqlIdentifier(string value) |
||||
|
{ |
||||
|
return Regex.IsMatch(value, @"^[A-Za-z_][A-Za-z0-9_]*$"); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
using System.Globalization; |
||||
|
using System.Text.RegularExpressions; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
// Adapted from the supplied ScpiMeasurement.cs. Resistance is stored/displayed in kOhm.
|
||||
|
public static class ScpiMeasurement |
||||
|
{ |
||||
|
public const string ResistanceReadCommand = "MEAS:RES?"; |
||||
|
public const decimal OverloadValue = 999m; |
||||
|
public const double OverloadThreshold = 9.0e37; |
||||
|
|
||||
|
public static async Task<(decimal Value, bool Overloaded)> ReadResistanceAsync( |
||||
|
Func<string, Task<string>> queryAsync, string? command = null) |
||||
|
{ |
||||
|
ArgumentNullException.ThrowIfNull(queryAsync); |
||||
|
var response = await queryAsync(string.IsNullOrWhiteSpace(command) |
||||
|
? ResistanceReadCommand : command).ConfigureAwait(false); |
||||
|
return ParseResponse(response, "RESISTANCE", 0.001m); |
||||
|
} |
||||
|
|
||||
|
public static (decimal Value, bool Overloaded) ParseResponse( |
||||
|
string response, string label, decimal scale = 1m) |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(response)) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"[{label}] 계측기 응답이 비어있습니다 (응답 수신 실패)."); |
||||
|
} |
||||
|
|
||||
|
var text = response.Trim(); |
||||
|
if (text.Equals("OL", StringComparison.OrdinalIgnoreCase) || |
||||
|
text.Equals("O.L", StringComparison.OrdinalIgnoreCase) || |
||||
|
text.Equals("OVERLOAD", StringComparison.OrdinalIgnoreCase) || |
||||
|
text.Equals("OVLD", StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
return (OverloadValue, true); |
||||
|
} |
||||
|
|
||||
|
// Require a complete numeric response; an error message containing a number
|
||||
|
// must never be interpreted as a measurement. Only base-unit suffixes are
|
||||
|
// accepted so that, for example, mA cannot silently be treated as A.
|
||||
|
var unitPattern = label.ToUpperInvariant() switch |
||||
|
{ |
||||
|
"VOLTAGE" => @"(?:V|VOLT|VOLTS)", |
||||
|
"CURRENT" => @"(?:A|AMP|AMPS|AMPERE|AMPERES)", |
||||
|
"RESISTANCE" => @"(?:OHM|OHMS|Ω)", |
||||
|
_ => @"(?!)" |
||||
|
}; |
||||
|
var match = Regex.Match(text, |
||||
|
@"^(?<number>[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][-+]?\d+)?)(?:\s*" + unitPattern + @")?$", |
||||
|
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); |
||||
|
var numericText = match.Groups["number"].Value; |
||||
|
if (!match.Success || |
||||
|
!double.TryParse(numericText, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) || |
||||
|
!double.IsFinite(number)) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"[{label}] 수치 파싱 실패. 원본 응답: [{text}]"); |
||||
|
} |
||||
|
|
||||
|
// Check overload before converting to decimal or applying the Ohm -> kOhm scale.
|
||||
|
if (Math.Abs(number) >= OverloadThreshold) |
||||
|
{ |
||||
|
return (OverloadValue, true); |
||||
|
} |
||||
|
|
||||
|
if (!decimal.TryParse(numericText, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"[{label}] 수치 변환 실패. 원본 응답: [{text}]"); |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
return (checked(value * scale), false); |
||||
|
} |
||||
|
catch (OverflowException ex) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"[{label}] 단위 변환 실패. 원본 응답: [{text}]", ex); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,426 @@ |
|||||
|
using System.IO.Ports; |
||||
|
using System.Text; |
||||
|
using System.Text.RegularExpressions; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class BarcodeScanReceivedEventArgs : EventArgs |
||||
|
{ |
||||
|
public BarcodeScanReceivedEventArgs(string portName, string text, bool isConfirmedPort) |
||||
|
{ |
||||
|
PortName = portName; |
||||
|
Text = text; |
||||
|
IsConfirmedPort = isConfirmedPort; |
||||
|
} |
||||
|
|
||||
|
public string PortName { get; } |
||||
|
public string Text { get; } |
||||
|
public bool IsConfirmedPort { get; } |
||||
|
} |
||||
|
|
||||
|
public sealed class SerialBarcodeScanner : IDisposable |
||||
|
{ |
||||
|
private readonly BarcodeScannerSettings _settings; |
||||
|
private readonly HashSet<string> _excludedPortNames; |
||||
|
private readonly List<PortSession> _sessions = new(); |
||||
|
private readonly object _sync = new(); |
||||
|
private bool _disposed; |
||||
|
private string? _confirmedPortName; |
||||
|
|
||||
|
public SerialBarcodeScanner(BarcodeScannerSettings settings, IEnumerable<string> excludedPortNames) |
||||
|
{ |
||||
|
_settings = settings; |
||||
|
_excludedPortNames = excludedPortNames |
||||
|
.Where(portName => !string.IsNullOrWhiteSpace(portName)) |
||||
|
.Select(NormalizePortName) |
||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase); |
||||
|
} |
||||
|
|
||||
|
public event EventHandler<BarcodeScanReceivedEventArgs>? ScanReceived; |
||||
|
|
||||
|
public string? ConfirmedPortName |
||||
|
{ |
||||
|
get |
||||
|
{ |
||||
|
lock (_sync) |
||||
|
{ |
||||
|
return _confirmedPortName; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public void Start() |
||||
|
{ |
||||
|
ThrowIfDisposed(); |
||||
|
|
||||
|
var candidatePortNames = GetCandidatePortNames(); |
||||
|
if (candidatePortNames.Length == 0) |
||||
|
{ |
||||
|
throw new InvalidOperationException("No available COM ports for the barcode scanner."); |
||||
|
} |
||||
|
|
||||
|
var openErrors = new List<string>(); |
||||
|
foreach (var portName in candidatePortNames) |
||||
|
{ |
||||
|
PortSession? session = null; |
||||
|
try |
||||
|
{ |
||||
|
var port = CreateSerialPort(portName); |
||||
|
session = new PortSession(port, CommitBufferedScan); |
||||
|
|
||||
|
port.DataReceived += SerialPort_DataReceived; |
||||
|
lock (_sync) |
||||
|
{ |
||||
|
_sessions.Add(session); |
||||
|
} |
||||
|
|
||||
|
port.Open(); |
||||
|
port.DiscardInBuffer(); |
||||
|
port.DiscardOutBuffer(); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
if (session is not null) |
||||
|
{ |
||||
|
lock (_sync) |
||||
|
{ |
||||
|
_sessions.Remove(session); |
||||
|
} |
||||
|
|
||||
|
CloseSession(session); |
||||
|
} |
||||
|
|
||||
|
openErrors.Add($"{portName}: {ex.Message}"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
int sessionCount; |
||||
|
lock (_sync) |
||||
|
{ |
||||
|
sessionCount = _sessions.Count; |
||||
|
} |
||||
|
|
||||
|
if (sessionCount == 0) |
||||
|
{ |
||||
|
var detail = openErrors.Count == 0 ? string.Empty : $" ({string.Join("; ", openErrors)})"; |
||||
|
throw new InvalidOperationException($"Could not open a barcode scanner COM port.{detail}"); |
||||
|
} |
||||
|
|
||||
|
if (!BarcodeScannerSettings.IsAutoPort(_settings.PortName) || sessionCount == 1) |
||||
|
{ |
||||
|
lock (_sync) |
||||
|
{ |
||||
|
_confirmedPortName = _sessions[0].Port.PortName; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public void ConfirmPort(string portName) |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(portName)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
List<PortSession> sessionsToClose; |
||||
|
lock (_sync) |
||||
|
{ |
||||
|
if (_disposed) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var normalizedPortName = NormalizePortName(portName); |
||||
|
if (!string.IsNullOrWhiteSpace(_confirmedPortName) && |
||||
|
!string.Equals(_confirmedPortName, normalizedPortName, StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var confirmedSession = _sessions.FirstOrDefault(session => |
||||
|
string.Equals(session.Port.PortName, normalizedPortName, StringComparison.OrdinalIgnoreCase)); |
||||
|
if (confirmedSession is null) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
_confirmedPortName = confirmedSession.Port.PortName; |
||||
|
sessionsToClose = _sessions |
||||
|
.Where(session => !ReferenceEquals(session, confirmedSession)) |
||||
|
.ToList(); |
||||
|
|
||||
|
_sessions.RemoveAll(session => !ReferenceEquals(session, confirmedSession)); |
||||
|
} |
||||
|
|
||||
|
foreach (var session in sessionsToClose) |
||||
|
{ |
||||
|
CloseSession(session); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private SerialPort CreateSerialPort(string portName) |
||||
|
{ |
||||
|
return new SerialPort(portName, _settings.BaudRate) |
||||
|
{ |
||||
|
Encoding = Encoding.ASCII, |
||||
|
NewLine = "\r\n", |
||||
|
ReadTimeout = _settings.ReadTimeout, |
||||
|
WriteTimeout = _settings.ReadTimeout, |
||||
|
DtrEnable = _settings.DtrEnable, |
||||
|
RtsEnable = _settings.RtsEnable |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private string[] GetCandidatePortNames() |
||||
|
{ |
||||
|
if (!BarcodeScannerSettings.IsAutoPort(_settings.PortName)) |
||||
|
{ |
||||
|
return new[] { NormalizePortName(_settings.PortName) }; |
||||
|
} |
||||
|
|
||||
|
return SerialPort.GetPortNames() |
||||
|
.Select(NormalizePortName) |
||||
|
.Where(portName => !_excludedPortNames.Contains(portName)) |
||||
|
.OrderBy(GetPortNumber) |
||||
|
.ThenBy(portName => portName, StringComparer.OrdinalIgnoreCase) |
||||
|
.ToArray(); |
||||
|
} |
||||
|
|
||||
|
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) |
||||
|
{ |
||||
|
if (sender is not SerialPort port) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
PortSession? session; |
||||
|
lock (_sync) |
||||
|
{ |
||||
|
if (_disposed) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
session = _sessions.FirstOrDefault(item => ReferenceEquals(item.Port, port)); |
||||
|
} |
||||
|
|
||||
|
if (session is null) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
string text; |
||||
|
try |
||||
|
{ |
||||
|
text = port.ReadExisting(); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (string.IsNullOrEmpty(text)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var scans = AppendReceivedText(session, text); |
||||
|
foreach (var scan in scans) |
||||
|
{ |
||||
|
PublishScan(session, scan); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private List<string> AppendReceivedText(PortSession session, string text) |
||||
|
{ |
||||
|
lock (session.Sync) |
||||
|
{ |
||||
|
session.Buffer.Append(text); |
||||
|
var scans = DrainCompleteScans(session.Buffer); |
||||
|
|
||||
|
if (session.Buffer.Length > 0) |
||||
|
{ |
||||
|
var delay = Math.Max(50, _settings.IdleCommitMilliseconds); |
||||
|
session.CommitTimer.Change(delay, Timeout.Infinite); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
session.CommitTimer.Change(Timeout.Infinite, Timeout.Infinite); |
||||
|
} |
||||
|
|
||||
|
return scans; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void CommitBufferedScan(PortSession session) |
||||
|
{ |
||||
|
string scan; |
||||
|
lock (session.Sync) |
||||
|
{ |
||||
|
scan = NormalizeScanText(session.Buffer.ToString()); |
||||
|
session.Buffer.Clear(); |
||||
|
session.CommitTimer.Change(Timeout.Infinite, Timeout.Infinite); |
||||
|
} |
||||
|
|
||||
|
PublishScan(session, scan); |
||||
|
} |
||||
|
|
||||
|
private void PublishScan(PortSession session, string scan) |
||||
|
{ |
||||
|
scan = NormalizeScanText(scan); |
||||
|
if (string.IsNullOrWhiteSpace(scan)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
bool isConfirmedPort; |
||||
|
lock (_sync) |
||||
|
{ |
||||
|
if (_disposed || !_sessions.Contains(session)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
isConfirmedPort = string.Equals( |
||||
|
_confirmedPortName, |
||||
|
session.Port.PortName, |
||||
|
StringComparison.OrdinalIgnoreCase); |
||||
|
} |
||||
|
|
||||
|
ScanReceived?.Invoke(this, new BarcodeScanReceivedEventArgs(session.Port.PortName, scan, isConfirmedPort)); |
||||
|
} |
||||
|
|
||||
|
private static List<string> DrainCompleteScans(StringBuilder buffer) |
||||
|
{ |
||||
|
var scans = new List<string>(); |
||||
|
var text = buffer.ToString(); |
||||
|
var start = 0; |
||||
|
|
||||
|
for (var index = 0; index < text.Length; index++) |
||||
|
{ |
||||
|
if (text[index] != '\r' && text[index] != '\n') |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
var line = NormalizeScanText(text[start..index]); |
||||
|
if (line.Length > 0) |
||||
|
{ |
||||
|
scans.Add(line); |
||||
|
} |
||||
|
|
||||
|
if (text[index] == '\r' && index + 1 < text.Length && text[index + 1] == '\n') |
||||
|
{ |
||||
|
index++; |
||||
|
} |
||||
|
|
||||
|
start = index + 1; |
||||
|
} |
||||
|
|
||||
|
if (start > 0) |
||||
|
{ |
||||
|
buffer.Clear(); |
||||
|
if (start < text.Length) |
||||
|
{ |
||||
|
buffer.Append(text[start..]); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return scans; |
||||
|
} |
||||
|
|
||||
|
private static string NormalizeScanText(string text) |
||||
|
{ |
||||
|
if (string.IsNullOrEmpty(text)) |
||||
|
{ |
||||
|
return string.Empty; |
||||
|
} |
||||
|
|
||||
|
var builder = new StringBuilder(text.Length); |
||||
|
foreach (var character in text.Trim()) |
||||
|
{ |
||||
|
if (!char.IsControl(character)) |
||||
|
{ |
||||
|
builder.Append(character); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return builder.ToString().Trim(); |
||||
|
} |
||||
|
|
||||
|
private static string NormalizePortName(string portName) |
||||
|
{ |
||||
|
return portName.Trim().ToUpperInvariant(); |
||||
|
} |
||||
|
|
||||
|
private static int GetPortNumber(string portName) |
||||
|
{ |
||||
|
var match = Regex.Match(portName, @"\d+"); |
||||
|
return match.Success && int.TryParse(match.Value, out var number) |
||||
|
? number |
||||
|
: int.MaxValue; |
||||
|
} |
||||
|
|
||||
|
private void ThrowIfDisposed() |
||||
|
{ |
||||
|
ObjectDisposedException.ThrowIf(_disposed, this); |
||||
|
} |
||||
|
|
||||
|
private void CloseSession(PortSession session) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
session.Port.DataReceived -= SerialPort_DataReceived; |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
// Best effort during shutdown.
|
||||
|
} |
||||
|
|
||||
|
session.Dispose(); |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
List<PortSession> sessionsToClose; |
||||
|
lock (_sync) |
||||
|
{ |
||||
|
if (_disposed) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
_disposed = true; |
||||
|
sessionsToClose = _sessions.ToList(); |
||||
|
_sessions.Clear(); |
||||
|
} |
||||
|
|
||||
|
foreach (var session in sessionsToClose) |
||||
|
{ |
||||
|
CloseSession(session); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private sealed class PortSession : IDisposable |
||||
|
{ |
||||
|
private readonly Action<PortSession> _commitBufferedScan; |
||||
|
|
||||
|
public PortSession(SerialPort port, Action<PortSession> commitBufferedScan) |
||||
|
{ |
||||
|
Port = port; |
||||
|
_commitBufferedScan = commitBufferedScan; |
||||
|
CommitTimer = new Timer(_ => _commitBufferedScan(this), null, Timeout.Infinite, Timeout.Infinite); |
||||
|
} |
||||
|
|
||||
|
public SerialPort Port { get; } |
||||
|
public StringBuilder Buffer { get; } = new(); |
||||
|
public object Sync { get; } = new(); |
||||
|
public Timer CommitTimer { get; } |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
CommitTimer.Dispose(); |
||||
|
Port.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,244 @@ |
|||||
|
using System.IO.Ports; |
||||
|
using System.Text; |
||||
|
using System.Text.RegularExpressions; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class SerialBoardClient : IDisposable |
||||
|
{ |
||||
|
private const int SendOnlyDrainTimeoutMilliseconds = 1000; |
||||
|
private const int SendOnlyDrainIdleMilliseconds = 100; |
||||
|
private readonly BoardHardwareSettings _settings; |
||||
|
private readonly SerialPort _serialPort; |
||||
|
|
||||
|
public string PortName => _serialPort.PortName; |
||||
|
|
||||
|
public SerialBoardClient(BoardHardwareSettings settings) |
||||
|
{ |
||||
|
_settings = settings; |
||||
|
var portName = ResolvePortName(settings); |
||||
|
|
||||
|
_serialPort = CreateSerialPort(portName, _settings); |
||||
|
} |
||||
|
|
||||
|
public void Open() |
||||
|
{ |
||||
|
if (!_serialPort.IsOpen) |
||||
|
{ |
||||
|
_serialPort.Open(); |
||||
|
_serialPort.DiscardInBuffer(); |
||||
|
_serialPort.DiscardOutBuffer(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public Task<string> SendCommandAsync(string command, int timeoutMilliseconds, bool waitForEndToken) |
||||
|
{ |
||||
|
return Task.Run(() => SendCommand(command, timeoutMilliseconds, waitForEndToken)); |
||||
|
} |
||||
|
|
||||
|
public Task SendOnlyAsync(string command) |
||||
|
{ |
||||
|
return Task.Run(() => |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(command)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
Open(); |
||||
|
_serialPort.DiscardInBuffer(); |
||||
|
_serialPort.Write(command.Trim() + "\r\n"); |
||||
|
DrainInputBuffer(SendOnlyDrainTimeoutMilliseconds, SendOnlyDrainIdleMilliseconds); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private static SerialPort CreateSerialPort(string portName, BoardHardwareSettings settings) |
||||
|
{ |
||||
|
return new SerialPort(portName, settings.BaudRate) |
||||
|
{ |
||||
|
Encoding = Encoding.ASCII, |
||||
|
NewLine = "\r\n", |
||||
|
ReadTimeout = settings.ReadTimeout, |
||||
|
WriteTimeout = settings.ReadTimeout, |
||||
|
DtrEnable = settings.DtrEnable, |
||||
|
RtsEnable = settings.RtsEnable |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private static string ResolvePortName(BoardHardwareSettings settings) |
||||
|
{ |
||||
|
if (!IsAutoPort(settings.PortName)) |
||||
|
{ |
||||
|
return settings.PortName.Trim(); |
||||
|
} |
||||
|
|
||||
|
var portNames = GetOrderedPortNames(); |
||||
|
if (portNames.Length == 0) |
||||
|
{ |
||||
|
throw new InvalidOperationException("사용 가능한 COM 포트를 찾을 수 없습니다."); |
||||
|
} |
||||
|
|
||||
|
foreach (var portName in portNames) |
||||
|
{ |
||||
|
if (CanConnectBoard(portName, settings)) |
||||
|
{ |
||||
|
return portName; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
throw new InvalidOperationException($"보드 자동 포트 검색 실패. 확인한 포트: {string.Join(", ", portNames)}"); |
||||
|
} |
||||
|
|
||||
|
private static bool IsAutoPort(string portName) |
||||
|
{ |
||||
|
return string.IsNullOrWhiteSpace(portName) || |
||||
|
string.Equals(portName.Trim(), "Auto", StringComparison.OrdinalIgnoreCase); |
||||
|
} |
||||
|
|
||||
|
private static string[] GetOrderedPortNames() |
||||
|
{ |
||||
|
return SerialPort.GetPortNames() |
||||
|
.OrderBy(GetPortNumber) |
||||
|
.ThenBy(portName => portName, StringComparer.OrdinalIgnoreCase) |
||||
|
.ToArray(); |
||||
|
} |
||||
|
|
||||
|
private static int GetPortNumber(string portName) |
||||
|
{ |
||||
|
var match = Regex.Match(portName, @"\d+"); |
||||
|
return match.Success && int.TryParse(match.Value, out var number) |
||||
|
? number |
||||
|
: int.MaxValue; |
||||
|
} |
||||
|
|
||||
|
private static bool CanConnectBoard(string portName, BoardHardwareSettings settings) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
using var probe = CreateSerialPort(portName, settings); |
||||
|
probe.Open(); |
||||
|
probe.DiscardInBuffer(); |
||||
|
probe.DiscardOutBuffer(); |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(settings.ConnectCommand)) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
probe.Write(settings.ConnectCommand.Trim() + "\r\n"); |
||||
|
var startedAt = DateTime.UtcNow; |
||||
|
|
||||
|
while ((DateTime.UtcNow - startedAt).TotalMilliseconds < settings.ReadTimeout) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var line = probe.ReadLine(); |
||||
|
if (IsSuccessLine(line)) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
if (!string.IsNullOrWhiteSpace(settings.EndToken) && |
||||
|
line.Contains(settings.EndToken, StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
catch (TimeoutException) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
private static bool IsSuccessLine(string line) |
||||
|
{ |
||||
|
return string.Equals(line.Trim(), "Success", StringComparison.OrdinalIgnoreCase); |
||||
|
} |
||||
|
|
||||
|
private string SendCommand(string command, int timeoutMilliseconds, bool waitForEndToken) |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(command)) |
||||
|
{ |
||||
|
return string.Empty; |
||||
|
} |
||||
|
|
||||
|
Open(); |
||||
|
_serialPort.ReadTimeout = Math.Max(500, timeoutMilliseconds); |
||||
|
_serialPort.DiscardInBuffer(); |
||||
|
_serialPort.Write(command.Trim() + "\r\n"); |
||||
|
|
||||
|
var startedAt = DateTime.UtcNow; |
||||
|
var response = new StringBuilder(); |
||||
|
|
||||
|
while ((DateTime.UtcNow - startedAt).TotalMilliseconds < timeoutMilliseconds) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var line = _serialPort.ReadLine(); |
||||
|
response.AppendLine(line); |
||||
|
|
||||
|
if (!waitForEndToken) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
if (line.Contains(_settings.EndToken, StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
catch (TimeoutException) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return response.ToString(); |
||||
|
} |
||||
|
|
||||
|
private void DrainInputBuffer(int timeoutMilliseconds, int idleMilliseconds) |
||||
|
{ |
||||
|
var originalReadTimeout = _serialPort.ReadTimeout; |
||||
|
var startedAt = DateTime.UtcNow; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
_serialPort.ReadTimeout = Math.Max(50, idleMilliseconds); |
||||
|
|
||||
|
while ((DateTime.UtcNow - startedAt).TotalMilliseconds < timeoutMilliseconds) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var line = _serialPort.ReadLine(); |
||||
|
if (!string.IsNullOrWhiteSpace(_settings.EndToken) && |
||||
|
line.Contains(_settings.EndToken, StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
catch (TimeoutException) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
_serialPort.ReadTimeout = originalReadTimeout; |
||||
|
_serialPort.DiscardInBuffer(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
_serialPort.Dispose(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,210 @@ |
|||||
|
using System.IO.Ports; |
||||
|
using System.Text; |
||||
|
using System.Text.RegularExpressions; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class SerialScpiClient : IScpiClient |
||||
|
{ |
||||
|
private readonly SerialPort _serialPort; |
||||
|
private readonly string _commandTerminator; |
||||
|
|
||||
|
public string PortName => _serialPort.PortName; |
||||
|
|
||||
|
public SerialScpiClient( |
||||
|
string portName, |
||||
|
int baudRate, |
||||
|
int timeoutMilliseconds, |
||||
|
IEnumerable<string>? idnMatches = null, |
||||
|
string commandTerminator = "\n") |
||||
|
{ |
||||
|
var resolvedPortName = ResolvePortName(portName, baudRate, timeoutMilliseconds, idnMatches); |
||||
|
|
||||
|
_commandTerminator = NormalizeTerminator(commandTerminator); |
||||
|
_serialPort = CreateSerialPort(resolvedPortName, baudRate, timeoutMilliseconds); |
||||
|
_serialPort.Open(); |
||||
|
_serialPort.DiscardInBuffer(); |
||||
|
_serialPort.DiscardOutBuffer(); |
||||
|
} |
||||
|
|
||||
|
public Task SendAsync(string command) |
||||
|
{ |
||||
|
return Task.Run(() => _serialPort.Write(command.Trim() + _commandTerminator)); |
||||
|
} |
||||
|
|
||||
|
public Task<string> QueryAsync(string command) |
||||
|
{ |
||||
|
return Task.Run(() => |
||||
|
{ |
||||
|
_serialPort.DiscardInBuffer(); |
||||
|
_serialPort.Write(command.Trim() + _commandTerminator); |
||||
|
return ReadResponse(command); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private string ReadResponse(string command) |
||||
|
{ |
||||
|
var response = new List<byte>(); |
||||
|
|
||||
|
while (true) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var value = _serialPort.ReadByte(); |
||||
|
if (value < 0) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
if (value is '\r' or '\n') |
||||
|
{ |
||||
|
if (response.Count > 0) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
response.Add((byte)value); |
||||
|
} |
||||
|
catch (TimeoutException) |
||||
|
{ |
||||
|
if (response.Count > 0) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
throw new TimeoutException($"{_serialPort.PortName} did not respond to {command}."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return Encoding.ASCII.GetString(response.ToArray()).Trim(); |
||||
|
} |
||||
|
|
||||
|
private static SerialPort CreateSerialPort(string portName, int baudRate, int timeoutMilliseconds) |
||||
|
{ |
||||
|
return new SerialPort(portName, baudRate) |
||||
|
{ |
||||
|
Encoding = Encoding.ASCII, |
||||
|
NewLine = "\n", |
||||
|
ReadTimeout = timeoutMilliseconds, |
||||
|
WriteTimeout = timeoutMilliseconds, |
||||
|
DtrEnable = true, |
||||
|
RtsEnable = true |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private static string ResolvePortName(string portName, int baudRate, int timeoutMilliseconds, IEnumerable<string>? idnMatches) |
||||
|
{ |
||||
|
if (!IsAutoPort(portName)) |
||||
|
{ |
||||
|
return portName.Trim(); |
||||
|
} |
||||
|
|
||||
|
var portNames = SerialPort.GetPortNames() |
||||
|
.OrderBy(GetPortNumber) |
||||
|
.ThenBy(candidate => candidate, StringComparer.OrdinalIgnoreCase) |
||||
|
.ToArray(); |
||||
|
|
||||
|
if (portNames.Length == 0) |
||||
|
{ |
||||
|
throw new InvalidOperationException("No available SCPI COM port."); |
||||
|
} |
||||
|
|
||||
|
var tokens = NormalizeIdnMatches(idnMatches); |
||||
|
if (portNames.Length == 1) |
||||
|
{ |
||||
|
return portNames[0]; |
||||
|
} |
||||
|
|
||||
|
foreach (var candidate in portNames) |
||||
|
{ |
||||
|
if (MatchesIdn(candidate, baudRate, timeoutMilliseconds, tokens)) |
||||
|
{ |
||||
|
return candidate; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
throw new InvalidOperationException($"SCPI device auto port search failed. Checked ports: {string.Join(", ", portNames)}"); |
||||
|
} |
||||
|
|
||||
|
private static bool IsAutoPort(string portName) |
||||
|
{ |
||||
|
return string.IsNullOrWhiteSpace(portName) || |
||||
|
string.Equals(portName.Trim(), "Auto", StringComparison.OrdinalIgnoreCase); |
||||
|
} |
||||
|
|
||||
|
private static int GetPortNumber(string portName) |
||||
|
{ |
||||
|
var match = Regex.Match(portName, @"\d+"); |
||||
|
return match.Success && int.TryParse(match.Value, out var number) |
||||
|
? number |
||||
|
: int.MaxValue; |
||||
|
} |
||||
|
|
||||
|
private static string[] NormalizeIdnMatches(IEnumerable<string>? idnMatches) |
||||
|
{ |
||||
|
return idnMatches? |
||||
|
.SelectMany(value => value.Split(',', ';')) |
||||
|
.Select(value => value.Trim()) |
||||
|
.Where(value => !string.IsNullOrWhiteSpace(value)) |
||||
|
.ToArray() ?? Array.Empty<string>(); |
||||
|
} |
||||
|
|
||||
|
private static bool MatchesIdn(string portName, int baudRate, int timeoutMilliseconds, string[] idnMatches) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
using var probe = CreateSerialPort(portName, baudRate, timeoutMilliseconds); |
||||
|
probe.Open(); |
||||
|
probe.DiscardInBuffer(); |
||||
|
probe.DiscardOutBuffer(); |
||||
|
probe.Write("*IDN?\r\n"); |
||||
|
|
||||
|
var response = new List<byte>(); |
||||
|
while (true) |
||||
|
{ |
||||
|
var value = probe.ReadByte(); |
||||
|
if (value < 0 || value is '\r' or '\n') |
||||
|
{ |
||||
|
if (response.Count > 0) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
response.Add((byte)value); |
||||
|
} |
||||
|
|
||||
|
var idn = Encoding.ASCII.GetString(response.ToArray()).Trim(); |
||||
|
return idnMatches.Length == 0 || |
||||
|
idnMatches.Any(token => idn.Contains(token, StringComparison.OrdinalIgnoreCase)); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static string NormalizeTerminator(string value) |
||||
|
{ |
||||
|
return value.Trim().ToUpperInvariant() switch |
||||
|
{ |
||||
|
"CRLF" => "\r\n", |
||||
|
"CR" => "\r", |
||||
|
"LF" => "\n", |
||||
|
"\\R\\N" => "\r\n", |
||||
|
"\\R" => "\r", |
||||
|
"\\N" => "\n", |
||||
|
_ => string.IsNullOrEmpty(value) ? "\n" : value |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
_serialPort.Dispose(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,126 @@ |
|||||
|
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; |
||||
|
// Zero keeps waiting until the start signal arrives (or the wait is cancelled).
|
||||
|
public int TimeoutMilliseconds { get; set; } = 0; |
||||
|
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", 0)), |
||||
|
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 |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,92 @@ |
|||||
|
using System.Net.Sockets; |
||||
|
using System.Text; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class TcpScpiClient : IScpiClient |
||||
|
{ |
||||
|
private readonly TcpClient _tcpClient; |
||||
|
private readonly NetworkStream _stream; |
||||
|
private readonly int _timeoutMilliseconds; |
||||
|
|
||||
|
private TcpScpiClient(TcpClient tcpClient, int timeoutMilliseconds) |
||||
|
{ |
||||
|
_tcpClient = tcpClient; |
||||
|
_stream = _tcpClient.GetStream(); |
||||
|
_timeoutMilliseconds = timeoutMilliseconds; |
||||
|
} |
||||
|
|
||||
|
public static async Task<TcpScpiClient> ConnectAsync(string host, int port, int timeoutMilliseconds) |
||||
|
{ |
||||
|
var tcpClient = new TcpClient(); |
||||
|
using var cancellation = new CancellationTokenSource(timeoutMilliseconds); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
await tcpClient.ConnectAsync(host, port, cancellation.Token); |
||||
|
} |
||||
|
catch (OperationCanceledException) |
||||
|
{ |
||||
|
tcpClient.Dispose(); |
||||
|
throw new TimeoutException($"{host}:{port} 장비 연결 시간이 초과되었습니다."); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
tcpClient.Dispose(); |
||||
|
throw; |
||||
|
} |
||||
|
|
||||
|
return new TcpScpiClient(tcpClient, timeoutMilliseconds); |
||||
|
} |
||||
|
|
||||
|
public async Task SendAsync(string command) |
||||
|
{ |
||||
|
using var cancellation = new CancellationTokenSource(_timeoutMilliseconds); |
||||
|
var bytes = Encoding.ASCII.GetBytes(command.Trim() + "\n"); |
||||
|
await _stream.WriteAsync(bytes, cancellation.Token); |
||||
|
await _stream.FlushAsync(cancellation.Token); |
||||
|
} |
||||
|
|
||||
|
public async Task<string> QueryAsync(string command) |
||||
|
{ |
||||
|
await SendAsync(command); |
||||
|
|
||||
|
using var cancellation = new CancellationTokenSource(_timeoutMilliseconds); |
||||
|
var buffer = new byte[1024]; |
||||
|
var response = new StringBuilder(); |
||||
|
|
||||
|
while (true) |
||||
|
{ |
||||
|
int bytesRead; |
||||
|
try |
||||
|
{ |
||||
|
bytesRead = await _stream.ReadAsync(buffer, cancellation.Token); |
||||
|
} |
||||
|
catch (OperationCanceledException) |
||||
|
{ |
||||
|
throw new TimeoutException($"{command} 응답 시간이 초과되었습니다."); |
||||
|
} |
||||
|
|
||||
|
if (bytesRead == 0) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
var chunk = Encoding.ASCII.GetString(buffer, 0, bytesRead); |
||||
|
response.Append(chunk); |
||||
|
|
||||
|
if (chunk.Contains('\n')) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return response.ToString().Trim(); |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
_stream.Dispose(); |
||||
|
_tcpClient.Dispose(); |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,214 @@ |
|||||
|
using System.Collections; |
||||
|
|
||||
|
namespace Housing.Services; |
||||
|
|
||||
|
public sealed class VisaScpiClient : IScpiClient |
||||
|
{ |
||||
|
private readonly dynamic _io; |
||||
|
|
||||
|
private VisaScpiClient(dynamic io, string resourceName) |
||||
|
{ |
||||
|
_io = io; |
||||
|
ResourceName = resourceName; |
||||
|
} |
||||
|
|
||||
|
public string ResourceName { get; } |
||||
|
|
||||
|
public static Task<VisaScpiClient> OpenAsync(string resourceName, int timeoutMilliseconds) |
||||
|
{ |
||||
|
return Task.Run(() => Open(resourceName, timeoutMilliseconds)); |
||||
|
} |
||||
|
|
||||
|
public static Task<VisaScpiClient> OpenMatchingAsync( |
||||
|
string resourceName, |
||||
|
IEnumerable<string> idnMatches, |
||||
|
int timeoutMilliseconds) |
||||
|
{ |
||||
|
return string.IsNullOrWhiteSpace(resourceName) |
||||
|
? Task.Run(() => OpenFirstMatching(idnMatches, timeoutMilliseconds)) |
||||
|
: OpenAsync(resourceName, timeoutMilliseconds); |
||||
|
} |
||||
|
|
||||
|
private static VisaScpiClient Open(string resourceName, int timeoutMilliseconds) |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(resourceName)) |
||||
|
{ |
||||
|
throw new InvalidOperationException("장비 VISA Resource 값을 확인하세요."); |
||||
|
} |
||||
|
|
||||
|
var (resourceManagerType, formattedIoType) = GetVisaComTypes(); |
||||
|
dynamic resourceManager = Activator.CreateInstance(resourceManagerType)!; |
||||
|
return OpenResource(resourceManager, formattedIoType, resourceName.Trim(), timeoutMilliseconds); |
||||
|
} |
||||
|
|
||||
|
private static VisaScpiClient OpenFirstMatching(IEnumerable<string> idnMatches, int timeoutMilliseconds) |
||||
|
{ |
||||
|
var tokens = NormalizeIdnMatches(idnMatches); |
||||
|
var (resourceManagerType, formattedIoType) = GetVisaComTypes(); |
||||
|
dynamic resourceManager = Activator.CreateInstance(resourceManagerType)!; |
||||
|
var resourceNames = FindResourceNames(resourceManager); |
||||
|
|
||||
|
if (resourceNames.Length == 0) |
||||
|
{ |
||||
|
throw new InvalidOperationException("VISA 장비를 찾을 수 없습니다. USB/LAN 장비 연결과 VISA 드라이버 설치를 확인하세요."); |
||||
|
} |
||||
|
|
||||
|
var checkedResources = new List<string>(); |
||||
|
foreach (var resourceName in resourceNames) |
||||
|
{ |
||||
|
VisaScpiClient? client = null; |
||||
|
try |
||||
|
{ |
||||
|
client = OpenResource(resourceManager, formattedIoType, resourceName, timeoutMilliseconds); |
||||
|
var idn = client.Query("*IDN?"); |
||||
|
checkedResources.Add($"{resourceName}: {idn}"); |
||||
|
|
||||
|
if (tokens.Length == 0 || |
||||
|
tokens.Any(token => idn.Contains(token, StringComparison.OrdinalIgnoreCase))) |
||||
|
{ |
||||
|
return client; |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
checkedResources.Add($"{resourceName}: {ex.Message}"); |
||||
|
} |
||||
|
|
||||
|
client?.Dispose(); |
||||
|
} |
||||
|
|
||||
|
var tokenMessage = tokens.Length == 0 ? string.Empty : $" IDN 키워드: {string.Join(", ", tokens)}."; |
||||
|
var checkedMessage = checkedResources.Count == 0 |
||||
|
? string.Empty |
||||
|
: $" 확인 결과: {string.Join(" / ", checkedResources)}"; |
||||
|
|
||||
|
throw new InvalidOperationException($"VISA 장비 자동 검색 실패.{tokenMessage}{checkedMessage}"); |
||||
|
} |
||||
|
|
||||
|
private static (Type ResourceManagerType, Type FormattedIoType) GetVisaComTypes() |
||||
|
{ |
||||
|
var resourceManagerType = Type.GetTypeFromProgID("VISA.GlobalRM") ?? |
||||
|
Type.GetTypeFromProgID("VisaComLib.ResourceManager"); |
||||
|
var formattedIoType = Type.GetTypeFromProgID("VISA.FormattedIO488") ?? |
||||
|
Type.GetTypeFromProgID("VisaComLib.FormattedIO488"); |
||||
|
|
||||
|
if (resourceManagerType is null || formattedIoType is null) |
||||
|
{ |
||||
|
throw new InvalidOperationException("VISA 라이브러리를 찾을 수 없습니다. Keysight IO Libraries 또는 NI-VISA 설치가 필요합니다."); |
||||
|
} |
||||
|
|
||||
|
return (resourceManagerType, formattedIoType); |
||||
|
} |
||||
|
|
||||
|
private static VisaScpiClient OpenResource( |
||||
|
dynamic resourceManager, |
||||
|
Type formattedIoType, |
||||
|
string resourceName, |
||||
|
int timeoutMilliseconds) |
||||
|
{ |
||||
|
dynamic session = resourceManager.Open(resourceName); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
session.Timeout = timeoutMilliseconds; |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
// Some VISA COM sessions expose timeout through driver-specific properties only.
|
||||
|
} |
||||
|
|
||||
|
dynamic formattedIo = Activator.CreateInstance(formattedIoType)!; |
||||
|
formattedIo.IO = session; |
||||
|
return new VisaScpiClient(formattedIo, resourceName); |
||||
|
} |
||||
|
|
||||
|
private static string[] FindResourceNames(dynamic resourceManager) |
||||
|
{ |
||||
|
var resourceNames = new SortedSet<string>(StringComparer.OrdinalIgnoreCase); |
||||
|
foreach (var pattern in new[] { "?*INSTR", "USB?*INSTR", "TCPIP?*INSTR", "GPIB?*INSTR", "ASRL?*INSTR" }) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
AddResourceNames(resourceNames, resourceManager.FindRsrc(pattern)); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
// Resource discovery support differs by VISA COM implementation and backend.
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return resourceNames.ToArray(); |
||||
|
} |
||||
|
|
||||
|
private static void AddResourceNames(ISet<string> resourceNames, object? value) |
||||
|
{ |
||||
|
switch (value) |
||||
|
{ |
||||
|
case null: |
||||
|
return; |
||||
|
case string resourceName: |
||||
|
AddResourceName(resourceNames, resourceName); |
||||
|
return; |
||||
|
case IEnumerable enumerable: |
||||
|
foreach (var item in enumerable) |
||||
|
{ |
||||
|
AddResourceName(resourceNames, item?.ToString()); |
||||
|
} |
||||
|
|
||||
|
return; |
||||
|
default: |
||||
|
AddResourceName(resourceNames, value.ToString()); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static void AddResourceName(ISet<string> resourceNames, string? resourceName) |
||||
|
{ |
||||
|
if (!string.IsNullOrWhiteSpace(resourceName)) |
||||
|
{ |
||||
|
resourceNames.Add(resourceName.Trim()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static string[] NormalizeIdnMatches(IEnumerable<string> idnMatches) |
||||
|
{ |
||||
|
return idnMatches |
||||
|
.SelectMany(value => value.Split(',', ';')) |
||||
|
.Select(value => value.Trim()) |
||||
|
.Where(value => !string.IsNullOrWhiteSpace(value)) |
||||
|
.ToArray(); |
||||
|
} |
||||
|
|
||||
|
public Task SendAsync(string command) |
||||
|
{ |
||||
|
return Task.Run(() => Send(command)); |
||||
|
} |
||||
|
|
||||
|
public Task<string> QueryAsync(string command) |
||||
|
{ |
||||
|
return Task.Run(() => Query(command)); |
||||
|
} |
||||
|
|
||||
|
private void Send(string command) |
||||
|
{ |
||||
|
_io.WriteString(command.Trim() + "\n", true); |
||||
|
} |
||||
|
|
||||
|
private string Query(string command) |
||||
|
{ |
||||
|
Send(command); |
||||
|
return ((string)_io.ReadString()).Trim(); |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
_io.IO.Close(); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
// Closing is best-effort because VISA COM implementations differ by vendor.
|
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
-- Run against the database containing dbo.Housing_Assembly before deploying |
||||
|
-- the application version that saves resistance. Values are stored in kOhm. |
||||
|
-- Existing data is not changed; existing columns with a different type are |
||||
|
-- reported for manual review instead of being altered. |
||||
|
IF OBJECT_ID(N'dbo.Housing_Assembly', N'U') IS NULL |
||||
|
BEGIN |
||||
|
THROW 51003, 'dbo.Housing_Assembly was not found in the current database.', 1; |
||||
|
END; |
||||
|
|
||||
|
IF COL_LENGTH(N'dbo.Housing_Assembly', N'PT_Resistor_1') IS NULL |
||||
|
BEGIN |
||||
|
ALTER TABLE dbo.Housing_Assembly ADD [PT_Resistor_1] DECIMAL(5,2) NULL; |
||||
|
END |
||||
|
ELSE IF NOT EXISTS |
||||
|
( |
||||
|
SELECT 1 |
||||
|
FROM sys.columns AS c |
||||
|
WHERE c.object_id = OBJECT_ID(N'dbo.Housing_Assembly', N'U') |
||||
|
AND c.name = N'PT_Resistor_1' |
||||
|
AND c.system_type_id = TYPE_ID(N'decimal') |
||||
|
AND c.user_type_id = TYPE_ID(N'decimal') |
||||
|
AND c.precision = 5 |
||||
|
AND c.scale = 2 |
||||
|
AND c.is_nullable = 1 |
||||
|
) |
||||
|
BEGIN |
||||
|
THROW 51002, 'Housing_Assembly.PT_Resistor_1 must be decimal(5,2) NULL; review the existing column before migration.', 1; |
||||
|
END; |
||||
|
GO |
||||
@ -0,0 +1,241 @@ |
|||||
|
-- Run this script against the Housing database before running the app. |
||||
|
-- It creates the login tables and login procedures. |
||||
|
-- It also adds the nullable resistance measurement column to an existing Housing_Assembly table. |
||||
|
|
||||
|
IF OBJECT_ID(N'dbo.Operator_Account', N'U') IS NULL |
||||
|
BEGIN |
||||
|
CREATE TABLE dbo.Operator_Account |
||||
|
( |
||||
|
[Operator] NVARCHAR(10) NOT NULL |
||||
|
CONSTRAINT PK_Operator_Account PRIMARY KEY, |
||||
|
[Password] NVARCHAR(50) NOT NULL, |
||||
|
[CreatedAt] DATETIME2(0) NOT NULL |
||||
|
CONSTRAINT DF_Operator_Account_CreatedAt DEFAULT (SYSDATETIME()), |
||||
|
[UpdatedAt] DATETIME2(0) NULL |
||||
|
); |
||||
|
END; |
||||
|
GO |
||||
|
|
||||
|
IF OBJECT_ID(N'dbo.Housing_Assembly', N'U') IS NOT NULL |
||||
|
BEGIN |
||||
|
IF COL_LENGTH(N'dbo.Housing_Assembly', N'PT_Resistor_1') IS NULL |
||||
|
BEGIN |
||||
|
ALTER TABLE dbo.Housing_Assembly ADD [PT_Resistor_1] DECIMAL(5,2) NULL; |
||||
|
END |
||||
|
ELSE IF NOT EXISTS |
||||
|
( |
||||
|
SELECT 1 FROM sys.columns AS c |
||||
|
WHERE c.object_id = OBJECT_ID(N'dbo.Housing_Assembly', N'U') |
||||
|
AND c.name = N'PT_Resistor_1' |
||||
|
AND c.system_type_id = TYPE_ID(N'decimal') |
||||
|
AND c.user_type_id = TYPE_ID(N'decimal') |
||||
|
AND c.precision = 5 AND c.scale = 2 AND c.is_nullable = 1 |
||||
|
) |
||||
|
BEGIN |
||||
|
THROW 51002, 'Housing_Assembly.PT_Resistor_1 must be decimal(5,2) NULL; review the existing column before migration.', 1; |
||||
|
END; |
||||
|
END; |
||||
|
GO |
||||
|
|
||||
|
IF COL_LENGTH(N'dbo.Operator_Account', N'IsActive') IS NOT NULL |
||||
|
BEGIN |
||||
|
DECLARE @DefaultConstraintName SYSNAME; |
||||
|
|
||||
|
SELECT @DefaultConstraintName = dc.name |
||||
|
FROM sys.default_constraints AS dc |
||||
|
INNER JOIN sys.columns AS c |
||||
|
ON c.default_object_id = dc.object_id |
||||
|
WHERE dc.parent_object_id = OBJECT_ID(N'dbo.Operator_Account', N'U') |
||||
|
AND c.name = N'IsActive'; |
||||
|
|
||||
|
IF @DefaultConstraintName IS NOT NULL |
||||
|
BEGIN |
||||
|
EXEC(N'ALTER TABLE dbo.Operator_Account DROP CONSTRAINT [' + @DefaultConstraintName + N']'); |
||||
|
END; |
||||
|
|
||||
|
ALTER TABLE dbo.Operator_Account DROP COLUMN [IsActive]; |
||||
|
END; |
||||
|
GO |
||||
|
|
||||
|
IF OBJECT_ID(N'dbo.Operator_Process', N'U') IS NULL |
||||
|
BEGIN |
||||
|
CREATE TABLE dbo.Operator_Process |
||||
|
( |
||||
|
[Operator] NVARCHAR(10) NOT NULL, |
||||
|
[ProcessName] NVARCHAR(30) NOT NULL, |
||||
|
[CreatedAt] DATETIME2(0) NOT NULL |
||||
|
CONSTRAINT DF_Operator_Process_CreatedAt DEFAULT (SYSDATETIME()), |
||||
|
CONSTRAINT PK_Operator_Process PRIMARY KEY ([Operator], [ProcessName]), |
||||
|
CONSTRAINT FK_Operator_Process_Operator_Account FOREIGN KEY ([Operator]) |
||||
|
REFERENCES dbo.Operator_Account ([Operator]) |
||||
|
ON DELETE CASCADE |
||||
|
); |
||||
|
END; |
||||
|
GO |
||||
|
|
||||
|
IF EXISTS (SELECT 1 FROM dbo.Operator_Account WHERE [Operator] = N'su') |
||||
|
BEGIN |
||||
|
UPDATE dbo.Operator_Account |
||||
|
SET |
||||
|
[Password] = N'su', |
||||
|
[UpdatedAt] = SYSDATETIME() |
||||
|
WHERE [Operator] = N'su'; |
||||
|
END |
||||
|
ELSE |
||||
|
BEGIN |
||||
|
INSERT INTO dbo.Operator_Account |
||||
|
( |
||||
|
[Operator], |
||||
|
[Password] |
||||
|
) |
||||
|
VALUES |
||||
|
( |
||||
|
N'su', |
||||
|
N'su' |
||||
|
); |
||||
|
END; |
||||
|
GO |
||||
|
|
||||
|
DECLARE @DefaultOperatorProcesses TABLE |
||||
|
( |
||||
|
[ProcessName] NVARCHAR(30) NOT NULL PRIMARY KEY |
||||
|
); |
||||
|
|
||||
|
INSERT INTO @DefaultOperatorProcesses |
||||
|
( |
||||
|
[ProcessName] |
||||
|
) |
||||
|
VALUES |
||||
|
(N'Housing'), |
||||
|
(N'Calibration'), |
||||
|
(N'EOL'), |
||||
|
(N'Marking'); |
||||
|
|
||||
|
INSERT INTO dbo.Operator_Process |
||||
|
( |
||||
|
[Operator], |
||||
|
[ProcessName] |
||||
|
) |
||||
|
SELECT |
||||
|
N'su', |
||||
|
defaultProcess.[ProcessName] |
||||
|
FROM @DefaultOperatorProcesses AS defaultProcess |
||||
|
WHERE NOT EXISTS |
||||
|
( |
||||
|
SELECT 1 |
||||
|
FROM dbo.Operator_Process AS operatorProcess |
||||
|
WHERE operatorProcess.[Operator] = N'su' |
||||
|
AND operatorProcess.[ProcessName] = defaultProcess.[ProcessName] |
||||
|
); |
||||
|
GO |
||||
|
|
||||
|
CREATE OR ALTER PROCEDURE dbo.CheckOperator |
||||
|
@Operator NVARCHAR(10), |
||||
|
@Password NVARCHAR(50), |
||||
|
@ProcessName NVARCHAR(30) = N'Housing' |
||||
|
AS |
||||
|
BEGIN |
||||
|
SET NOCOUNT ON; |
||||
|
|
||||
|
SELECT CAST( |
||||
|
CASE |
||||
|
WHEN EXISTS |
||||
|
( |
||||
|
SELECT 1 |
||||
|
FROM dbo.Operator_Account AS account |
||||
|
INNER JOIN dbo.Operator_Process AS process |
||||
|
ON process.[Operator] = account.[Operator] |
||||
|
WHERE account.[Operator] = @Operator |
||||
|
AND account.[Password] = @Password |
||||
|
AND process.[ProcessName] = @ProcessName |
||||
|
) |
||||
|
THEN 1 |
||||
|
ELSE 0 |
||||
|
END AS BIT) AS IsValid; |
||||
|
END; |
||||
|
GO |
||||
|
|
||||
|
CREATE OR ALTER PROCEDURE dbo.SaveOperator |
||||
|
@Operator NVARCHAR(10), |
||||
|
@Password NVARCHAR(50), |
||||
|
@ProcessName NVARCHAR(30) = N'Housing' |
||||
|
AS |
||||
|
BEGIN |
||||
|
SET NOCOUNT ON; |
||||
|
SET XACT_ABORT ON; |
||||
|
|
||||
|
BEGIN TRANSACTION; |
||||
|
|
||||
|
UPDATE dbo.Operator_Account |
||||
|
SET |
||||
|
[Password] = @Password, |
||||
|
[UpdatedAt] = SYSDATETIME() |
||||
|
WHERE [Operator] = @Operator; |
||||
|
|
||||
|
IF @@ROWCOUNT = 0 |
||||
|
BEGIN |
||||
|
INSERT INTO dbo.Operator_Account |
||||
|
( |
||||
|
[Operator], |
||||
|
[Password] |
||||
|
) |
||||
|
VALUES |
||||
|
( |
||||
|
@Operator, |
||||
|
@Password |
||||
|
); |
||||
|
END; |
||||
|
|
||||
|
IF NOT EXISTS |
||||
|
( |
||||
|
SELECT 1 |
||||
|
FROM dbo.Operator_Process |
||||
|
WHERE [Operator] = @Operator |
||||
|
AND [ProcessName] = @ProcessName |
||||
|
) |
||||
|
BEGIN |
||||
|
INSERT INTO dbo.Operator_Process |
||||
|
( |
||||
|
[Operator], |
||||
|
[ProcessName] |
||||
|
) |
||||
|
VALUES |
||||
|
( |
||||
|
@Operator, |
||||
|
@ProcessName |
||||
|
); |
||||
|
END; |
||||
|
|
||||
|
COMMIT TRANSACTION; |
||||
|
END; |
||||
|
GO |
||||
|
|
||||
|
IF OBJECT_ID(N'dbo.Housing_Assembly', N'U') IS NOT NULL |
||||
|
AND COL_LENGTH(N'dbo.Housing_Assembly', N'IC_SN') IS NOT NULL |
||||
|
AND NOT EXISTS |
||||
|
( |
||||
|
SELECT 1 |
||||
|
FROM sys.indexes |
||||
|
WHERE object_id = OBJECT_ID(N'dbo.Housing_Assembly', N'U') |
||||
|
AND name = N'UX_Housing_Assembly_IC_SN' |
||||
|
) |
||||
|
BEGIN |
||||
|
IF EXISTS |
||||
|
( |
||||
|
SELECT 1 |
||||
|
FROM dbo.Housing_Assembly |
||||
|
WHERE [IC_SN] IS NOT NULL |
||||
|
AND [IC_SN] <> N'' |
||||
|
GROUP BY [IC_SN] |
||||
|
HAVING COUNT(*) > 1 |
||||
|
) |
||||
|
BEGIN |
||||
|
THROW 51001, 'Cannot create UX_Housing_Assembly_IC_SN because duplicate IC_SN values exist.', 1; |
||||
|
END; |
||||
|
|
||||
|
CREATE UNIQUE INDEX UX_Housing_Assembly_IC_SN |
||||
|
ON dbo.Housing_Assembly ([IC_SN]) |
||||
|
WHERE [IC_SN] IS NOT NULL |
||||
|
AND [IC_SN] <> N''; |
||||
|
END; |
||||
|
GO |
||||
@ -0,0 +1,12 @@ |
|||||
|
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" |
||||
|
xmlns:bal="http://wixtoolset.org/schemas/v4/wxs/bal"> |
||||
|
<Bundle Name="Housing Setup" Manufacturer="Housing" Version="$(var.ProductVersion)" |
||||
|
UpgradeCode="{C9E5EB95-4364-4CCC-9A54-5B310AF6DE4A}"> |
||||
|
<BootstrapperApplication> |
||||
|
<bal:WixStandardBootstrapperApplication Theme="hyperlinkLicense" LicenseUrl="" SuppressOptionsUI="yes" /> |
||||
|
</BootstrapperApplication> |
||||
|
<Chain> |
||||
|
<MsiPackage SourceFile="$(var.MsiFile)" /> |
||||
|
</Chain> |
||||
|
</Bundle> |
||||
|
</Wix> |
||||
@ -0,0 +1,137 @@ |
|||||
|
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> |
||||
|
<Package Name="Housing" Manufacturer="Housing" Version="$(var.ProductVersion)" |
||||
|
UpgradeCode="{FD4F1ED8-73C1-42EF-8A8D-1C0F424D8CC3}" Scope="perUser"> |
||||
|
<MajorUpgrade DowngradeErrorMessage="A newer version of Housing is already installed." /> |
||||
|
<MediaTemplate EmbedCab="yes" /> |
||||
|
<Icon Id="HousingIcon.exe" SourceFile="$(var.PublishDir)\Housing.exe" /> |
||||
|
|
||||
|
<!-- AppSearch runs before costing: keep existing desktop settings, otherwise |
||||
|
copy the previous installation's settings instead of installing defaults. --> |
||||
|
<Property Id="DESKTOP_HARDWARE"> |
||||
|
<DirectorySearch Id="FindDesktopHardwareDirectory" Path="[DesktopFolder]Housing" Depth="0"> |
||||
|
<FileSearch Id="FindDesktopHardware" Name="Hardware.ini" /> |
||||
|
</DirectorySearch> |
||||
|
</Property> |
||||
|
<Property Id="LEGACY_HARDWARE"> |
||||
|
<DirectorySearch Id="FindLegacyHardwareDirectory" Path="[LocalAppDataFolder]Housing" Depth="0"> |
||||
|
<FileSearch Id="FindLegacyHardware" Name="Hardware.ini" /> |
||||
|
</DirectorySearch> |
||||
|
</Property> |
||||
|
<Property Id="DESKTOP_DATABASE"> |
||||
|
<DirectorySearch Id="FindDesktopDatabaseDirectory" Path="[DesktopFolder]Housing" Depth="0"> |
||||
|
<FileSearch Id="FindDesktopDatabase" Name="Database.ini" /> |
||||
|
</DirectorySearch> |
||||
|
</Property> |
||||
|
<Property Id="LEGACY_DATABASE"> |
||||
|
<DirectorySearch Id="FindLegacyDatabaseDirectory" Path="[LocalAppDataFolder]Housing" Depth="0"> |
||||
|
<FileSearch Id="FindLegacyDatabase" Name="Database.ini" /> |
||||
|
</DirectorySearch> |
||||
|
</Property> |
||||
|
<Property Id="DESKTOP_INSPECTIONSETTINGS"> |
||||
|
<DirectorySearch Id="FindDesktopInspectionSettingsDirectory" Path="[DesktopFolder]Housing" Depth="0"> |
||||
|
<FileSearch Id="FindDesktopInspectionSettings" Name="InspectionSettings.ini" /> |
||||
|
</DirectorySearch> |
||||
|
</Property> |
||||
|
<Property Id="LEGACY_INSPECTIONSETTINGS"> |
||||
|
<DirectorySearch Id="FindLegacyInspectionSettingsDirectory" Path="[LocalAppDataFolder]Housing" Depth="0"> |
||||
|
<FileSearch Id="FindLegacyInspectionSettings" Name="InspectionSettings.ini" /> |
||||
|
</DirectorySearch> |
||||
|
</Property> |
||||
|
<Property Id="LEGACY_SAVED_INSPECTION"> |
||||
|
<DirectorySearch Id="FindSavedInspectionDirectory" Path="[LocalAppDataFolder]AMO Housing" Depth="0"> |
||||
|
<FileSearch Id="FindSavedInspection" Name="InspectionSettings.ini" /> |
||||
|
</DirectorySearch> |
||||
|
</Property> |
||||
|
<SetProperty Id="LEGACY_INSPECTIONSETTINGS" Value="[LEGACY_SAVED_INSPECTION]" |
||||
|
After="AppSearch" Sequence="both" Condition="LEGACY_SAVED_INSPECTION" /> |
||||
|
|
||||
|
<StandardDirectory Id="LocalAppDataFolder"> |
||||
|
<Directory Id="INSTALLFOLDER" Name="Housing"> |
||||
|
<Component Id="AppExecutable" Guid="*"> |
||||
|
<File Id="HousingExe" Source="$(var.PublishDir)\Housing.exe" KeyPath="yes" /> |
||||
|
</Component> |
||||
|
<Component Id="LoginOptions" Guid="*" Permanent="yes" NeverOverwrite="yes"> |
||||
|
<File Source="$(var.PublishDir)\LoginOptions.json" KeyPath="yes" /> |
||||
|
</Component> |
||||
|
</Directory> |
||||
|
</StandardDirectory> |
||||
|
|
||||
|
<StandardDirectory Id="DesktopFolder"> |
||||
|
<Component Id="DesktopShortcut" Guid="*"> |
||||
|
<Shortcut Id="HousingDesktopShortcut" Name="Housing" Target="[INSTALLFOLDER]Housing.exe" |
||||
|
WorkingDirectory="INSTALLFOLDER" Icon="HousingIcon.exe" IconIndex="0" /> |
||||
|
<RegistryValue Root="HKCU" Key="Software\Housing\Installer" Name="DesktopShortcutInstalled" |
||||
|
Type="integer" Value="1" KeyPath="yes" /> |
||||
|
</Component> |
||||
|
<Directory Id="DESKTOPSETTINGSFOLDER" Name="Housing"> |
||||
|
<!-- Settings survive upgrade, repair and uninstall. --> |
||||
|
<Component Id="DesktopHardwareSettings" Guid="*" Permanent="yes" NeverOverwrite="yes" |
||||
|
Condition="NOT LEGACY_HARDWARE"> |
||||
|
<File Id="DesktopHardwareIni" Source="$(var.PublishDir)\Hardware.ini" KeyPath="yes" /> |
||||
|
</Component> |
||||
|
<Component Id="MigrateHardwareSettings" Guid="*" Permanent="yes" Transitive="yes" |
||||
|
Condition="LEGACY_HARDWARE AND NOT DESKTOP_HARDWARE"> |
||||
|
<CreateFolder /> |
||||
|
<CopyFile Id="CopyLegacyHardware" SourceProperty="LEGACY_HARDWARE" |
||||
|
DestinationDirectory="DESKTOPSETTINGSFOLDER" DestinationName="Hardware.ini" /> |
||||
|
<RegistryValue Root="HKCU" Key="Software\Housing\Installer" Name="MigratedHardware" |
||||
|
Type="integer" Value="1" KeyPath="yes" /> |
||||
|
</Component> |
||||
|
<Component Id="DesktopDatabaseSettings" Guid="*" Permanent="yes" NeverOverwrite="yes" |
||||
|
Condition="NOT LEGACY_DATABASE"> |
||||
|
<File Id="DesktopDatabaseIni" Source="$(var.PublishDir)\Database.ini" KeyPath="yes" /> |
||||
|
</Component> |
||||
|
<Component Id="MigrateDatabaseSettings" Guid="*" Permanent="yes" Transitive="yes" |
||||
|
Condition="LEGACY_DATABASE AND NOT DESKTOP_DATABASE"> |
||||
|
<CreateFolder /> |
||||
|
<CopyFile Id="CopyLegacyDatabase" SourceProperty="LEGACY_DATABASE" |
||||
|
DestinationDirectory="DESKTOPSETTINGSFOLDER" DestinationName="Database.ini" /> |
||||
|
<RegistryValue Root="HKCU" Key="Software\Housing\Installer" Name="MigratedDatabase" |
||||
|
Type="integer" Value="1" KeyPath="yes" /> |
||||
|
</Component> |
||||
|
<Component Id="DesktopInspectionSettingsSettings" Guid="*" Permanent="yes" NeverOverwrite="yes" |
||||
|
Condition="NOT LEGACY_INSPECTIONSETTINGS"> |
||||
|
<File Id="DesktopInspectionSettingsIni" Source="$(var.PublishDir)\InspectionSettings.ini" KeyPath="yes" /> |
||||
|
</Component> |
||||
|
<Component Id="MigrateInspectionSettingsSettings" Guid="*" Permanent="yes" Transitive="yes" |
||||
|
Condition="LEGACY_INSPECTIONSETTINGS AND NOT DESKTOP_INSPECTIONSETTINGS"> |
||||
|
<CreateFolder /> |
||||
|
<CopyFile Id="CopyLegacyInspectionSettings" SourceProperty="LEGACY_INSPECTIONSETTINGS" |
||||
|
DestinationDirectory="DESKTOPSETTINGSFOLDER" DestinationName="InspectionSettings.ini" /> |
||||
|
<RegistryValue Root="HKCU" Key="Software\Housing\Installer" Name="MigratedInspectionSettings" |
||||
|
Type="integer" Value="1" KeyPath="yes" /> |
||||
|
</Component> |
||||
|
</Directory> |
||||
|
</StandardDirectory> |
||||
|
|
||||
|
<StandardDirectory Id="ProgramMenuFolder"> |
||||
|
<Directory Id="HousingProgramMenu" Name="Housing"> |
||||
|
<Component Id="StartMenuShortcut" Guid="*"> |
||||
|
<Shortcut Id="HousingShortcut" Name="Housing" Target="[INSTALLFOLDER]Housing.exe" |
||||
|
WorkingDirectory="INSTALLFOLDER" Icon="HousingIcon.exe" IconIndex="0" /> |
||||
|
<RemoveFolder Id="RemoveHousingProgramMenu" On="uninstall" /> |
||||
|
<RegistryValue Root="HKCU" Key="Software\Housing\Installer" Name="ShortcutInstalled" |
||||
|
Type="integer" Value="1" KeyPath="yes" /> |
||||
|
</Component> |
||||
|
</Directory> |
||||
|
</StandardDirectory> |
||||
|
|
||||
|
<Feature Id="Main" Title="Housing" Level="1"> |
||||
|
<Files Directory="INSTALLFOLDER" Include="$(var.PublishDir)\**"> |
||||
|
<Exclude Files="$(var.PublishDir)\Housing.exe" /> |
||||
|
<Exclude Files="$(var.PublishDir)\Hardware.ini" /> |
||||
|
<Exclude Files="$(var.PublishDir)\Database.ini" /> |
||||
|
<Exclude Files="$(var.PublishDir)\InspectionSettings.ini" /> |
||||
|
<Exclude Files="$(var.PublishDir)\LoginOptions.json" /> |
||||
|
</Files> |
||||
|
<ComponentRef Id="AppExecutable" /> |
||||
|
<?foreach Setting in Hardware;Database;InspectionSettings ?> |
||||
|
<ComponentRef Id="Desktop$(var.Setting)Settings" /> |
||||
|
<ComponentRef Id="Migrate$(var.Setting)Settings" /> |
||||
|
<?endforeach ?> |
||||
|
<ComponentRef Id="LoginOptions" /> |
||||
|
<ComponentRef Id="StartMenuShortcut" /> |
||||
|
<ComponentRef Id="DesktopShortcut" /> |
||||
|
</Feature> |
||||
|
</Package> |
||||
|
</Wix> |
||||
@ -0,0 +1,33 @@ |
|||||
|
# Housing installer |
||||
|
|
||||
|
Publish the app as a self-contained Windows x64 application to `obj/setup-staging/app` before running `powershell -File installer/build.ps1`. The build requires WiX 6.0.2 at `obj/installer-tools/wix.exe` and WixToolset.Bal.wixext 6.0.2 in the local WiX extension cache. The default version is `1.0.3`, which can upgrade the previously distributed `1.0.0` through `1.0.2` WiX packages. For every subsequent release, set `-ProductVersion` to a higher three-part version than the installed release. Reusing a version does not produce a major upgrade; Windows Installer compares the first three version fields. |
||||
|
|
||||
|
The unsigned HousingSetup.exe contains Housing.msi, which installs for the current user under `%LOCALAPPDATA%\Housing` and adds Housing shortcuts to the desktop and Start menu. It does not require a separate .NET runtime. |
||||
|
|
||||
|
The retained final release is `installer/output/HousingSetup-1.0.3.exe` (built October 28, 2026). This standalone bundle includes the MSI; no separate MSI file is needed for installation. Build outputs, downloaded build tools, and older installers have been removed from the working folder. |
||||
|
|
||||
|
Installation creates a `Housing` folder on the current user's Windows desktop (including a redirected/OneDrive desktop) and puts `Hardware.ini`, `Database.ini`, and `InspectionSettings.ini` there. Both the main window and login window read these desktop settings; edited judgement limits are saved to the same `InspectionSettings.ini`. Restart the app after manually editing INI files. |
||||
|
|
||||
|
Existing desktop INI files are preserved. When a desktop INI is missing, setup copies its counterpart from the previous `%LOCALAPPDATA%\Housing` installation if available, otherwise installs the bundled default. For inspection limits, the previously saved `%LOCALAPPDATA%\AMO Housing\InspectionSettings.ini` takes priority over the old installation's bundled file. Migration copies files without deleting the originals. INI files survive upgrade, repair and uninstall. `LoginOptions.json` remains in the installation directory and is also preserved. Update settings manually when a new release introduces required keys. |
||||
|
|
||||
|
Starting with 1.0.3, the default `Hardware.ini` uses `[StartSignal] TimeoutMilliseconds=0`: after a valid barcode and judgement limits, the app waits indefinitely for the equipment start signal. The screen identifies this as a wait without a time limit. The existing inactive-before-active check remains enabled, so an already-held button must be released and pressed again. NI driver/connection errors still stop the inspection. Existing installations keep their INI values; to enable indefinite waiting there, change `TimeoutMilliseconds=30000` to `TimeoutMilliseconds=0` in the desktop `Housing\Hardware.ini` before the next inspection. Positive values still enable a finite timeout in milliseconds. |
||||
|
|
||||
|
Build and check from the repository root: |
||||
|
|
||||
|
After a cleanup or fresh checkout, restore the pinned installer tools first: |
||||
|
|
||||
|
```powershell |
||||
|
dotnet tool install --tool-path obj/installer-tools wix --version 6.0.2 |
||||
|
./obj/installer-tools/wix.exe extension add WixToolset.Bal.wixext/6.0.2 |
||||
|
``` |
||||
|
|
||||
|
```powershell |
||||
|
dotnet run --project tools/SettingsPathChecks/SettingsPathChecks.csproj -- obj/settings-path-checks |
||||
|
dotnet publish Housing.csproj -c Release -r win-x64 --self-contained true -o obj/setup-staging/app -p:Version=1.0.3 |
||||
|
powershell -File installer/build.ps1 -OutputDir installer/output |
||||
|
powershell -File tools/SettingsPathChecks/Check-Installer.ps1 |
||||
|
``` |
||||
|
|
||||
|
The checks use isolated fixtures under `obj`, including read-only MSI inspection and AppSearch/condition evaluation. They do not install the product or change the user's desktop/settings. |
||||
|
|
||||
|
The WiX installer has its own upgrade identity and does not automatically remove previous installers created by the legacy Setup projects. Installation, upgrade, and database and hardware connectivity must be tested on a representative target computer. The package is not code-signed. The package includes the current Database.ini, which can contain database credentials; distribute it only to authorized recipients. It includes add-resistance-column.sql but does not execute it against the database. |
||||
@ -0,0 +1,27 @@ |
|||||
|
param( |
||||
|
[string]$ProductVersion = '1.0.3', |
||||
|
[string]$PublishDir = (Join-Path $PSScriptRoot '..\obj\setup-staging\app'), |
||||
|
[string]$OutputDir = (Join-Path $PSScriptRoot '..\obj\setup-staging\installer') |
||||
|
) |
||||
|
|
||||
|
$ErrorActionPreference = 'Stop' |
||||
|
$wix = Join-Path $PSScriptRoot '..\obj\installer-tools\wix.exe' |
||||
|
if (-not (Test-Path -LiteralPath $wix)) { throw "WiX CLI not found: $wix" } |
||||
|
$PublishDir = (Resolve-Path -LiteralPath $PublishDir).Path |
||||
|
$migration = Join-Path $PSScriptRoot '..\add-resistance-column.sql' |
||||
|
if (-not (Test-Path -LiteralPath $migration -PathType Leaf)) { throw "Missing resistance migration: $migration" } |
||||
|
Copy-Item -LiteralPath $migration -Destination (Join-Path $PublishDir 'add-resistance-column.sql') -Force |
||||
|
foreach ($file in @('Housing.exe', 'Housing.dll', 'Housing.runtimeconfig.json', 'hostfxr.dll', 'coreclr.dll', 'PresentationFramework.dll', 'Hardware.ini', 'Database.ini', 'InspectionSettings.ini', 'LoginOptions.json', 'add-resistance-column.sql')) { |
||||
|
if (-not (Test-Path -LiteralPath (Join-Path $PublishDir $file) -PathType Leaf)) { throw "Missing publish file: $file" } |
||||
|
} |
||||
|
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null |
||||
|
$output = Join-Path (Resolve-Path -LiteralPath $OutputDir).Path 'Housing.msi' |
||||
|
& $wix build (Join-Path $PSScriptRoot 'Housing.wxs') -arch x64 -d "PublishDir=$PublishDir" -d "ProductVersion=$ProductVersion" -o $output |
||||
|
if ($LASTEXITCODE -ne 0) { throw "WiX build failed (exit $LASTEXITCODE)." } |
||||
|
Write-Host "Unsigned installer: $output" |
||||
|
$bundle = Join-Path (Resolve-Path -LiteralPath $OutputDir).Path 'HousingSetup.exe' |
||||
|
$bal = Join-Path $PSScriptRoot '..\.wix\extensions\WixToolset.Bal.wixext\6.0.2\wixext6\WixToolset.BootstrapperApplications.wixext.dll' |
||||
|
if (-not (Test-Path -LiteralPath $bal)) { throw "WiX bootstrapper extension missing: $bal. MSI remains available at $output." } |
||||
|
& $wix build (Join-Path $PSScriptRoot 'Bundle.wxs') -arch x64 -ext $bal -d "MsiFile=$output" -d "ProductVersion=$ProductVersion" -o $bundle |
||||
|
if ($LASTEXITCODE -ne 0) { throw "WiX bundle build failed (exit $LASTEXITCODE). MSI remains available at $output." } |
||||
|
Write-Host "Unsigned installer: $bundle" |
||||
@ -0,0 +1,12 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
<PropertyGroup> |
||||
|
<OutputType>Exe</OutputType> |
||||
|
<TargetFramework>net9.0-windows</TargetFramework> |
||||
|
<UseWPF>true</UseWPF> |
||||
|
<Nullable>enable</Nullable> |
||||
|
<ImplicitUsings>enable</ImplicitUsings> |
||||
|
</PropertyGroup> |
||||
|
<ItemGroup> |
||||
|
<ProjectReference Include="..\..\Housing.csproj" /> |
||||
|
</ItemGroup> |
||||
|
</Project> |
||||
@ -0,0 +1,374 @@ |
|||||
|
using System.Collections.Concurrent; |
||||
|
using System.Data; |
||||
|
using System.Globalization; |
||||
|
using System.IO; |
||||
|
using System.Net; |
||||
|
using System.Net.Sockets; |
||||
|
using System.Reflection; |
||||
|
using System.Windows; |
||||
|
using System.Windows.Controls; |
||||
|
using System.Windows.Media; |
||||
|
using System.Windows.Media.Imaging; |
||||
|
using Housing; |
||||
|
using Housing.Models; |
||||
|
using Housing.Services; |
||||
|
using Microsoft.Data.SqlClient; |
||||
|
|
||||
|
// Offline checks only: no real instruments, login, scanner, or database are started.
|
||||
|
internal static class Program |
||||
|
{ |
||||
|
private static int _checks; |
||||
|
|
||||
|
[STAThread] |
||||
|
private static int Main(string[] args) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var output = Path.GetFullPath(args.Length > 0 ? args[0] : Path.Combine(AppContext.BaseDirectory, "checks")); |
||||
|
Directory.CreateDirectory(output); |
||||
|
CheckParserAsync().GetAwaiter().GetResult(); |
||||
|
CheckJudgementAndSettings(output); |
||||
|
CheckDatabaseParameters(); |
||||
|
CheckEquipmentAsync().GetAwaiter().GetResult(); |
||||
|
RenderAndCheckUi(output); |
||||
|
Console.WriteLine($"PASS: {_checks} checks. UI previews: {output}"); |
||||
|
return 0; |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
Console.Error.WriteLine(ex); |
||||
|
return 1; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static void Check(bool condition, string description) |
||||
|
{ |
||||
|
if (!condition) throw new InvalidOperationException("FAIL: " + description); |
||||
|
_checks++; |
||||
|
} |
||||
|
|
||||
|
private static async Task ExpectFailureAsync(Func<Task> action, string description) |
||||
|
{ |
||||
|
try { await action(); } |
||||
|
catch (Exception ex) when (ex is InvalidOperationException or TimeoutException) |
||||
|
{ |
||||
|
_checks++; |
||||
|
return; |
||||
|
} |
||||
|
throw new InvalidOperationException("FAIL: " + description); |
||||
|
} |
||||
|
|
||||
|
private static async Task CheckParserAsync() |
||||
|
{ |
||||
|
foreach (var (raw, expected) in new[] { ("+4.700000E+03", 4.7m), (".5", 0.0005m), ("0", 0m), ("999000", 999m) }) |
||||
|
{ |
||||
|
var result = await ScpiMeasurement.ReadResistanceAsync(command => |
||||
|
{ |
||||
|
Check(command == "MEAS:RES?", "default resistance command"); |
||||
|
return Task.FromResult(raw); |
||||
|
}); |
||||
|
Check(result.Value == expected && !result.Overloaded, "Ohm to kOhm: " + raw); |
||||
|
} |
||||
|
foreach (var raw in new[] { "OL", "o.l", "OVERLOAD", "OVLD", "+9.9E37", "-9.0E37" }) |
||||
|
{ |
||||
|
var result = await ScpiMeasurement.ReadResistanceAsync(_ => Task.FromResult(raw)); |
||||
|
Check(result == (999m, true), "unscaled overload marker: " + raw); |
||||
|
} |
||||
|
foreach (var raw in new[] { "", "bad response", "NaN", "Infinity", "1e999", "ERROR 100", "-113,\"Undefined header\"", "abc4700", "4.7 kOhm" }) |
||||
|
await ExpectFailureAsync(async () => { await ScpiMeasurement.ReadResistanceAsync(_ => Task.FromResult(raw)); }, "invalid response must fail"); |
||||
|
Check((await ScpiMeasurement.ReadResistanceAsync(_ => Task.FromResult("4700 OHM"))).Value == 4.7m, "base-unit resistance response"); |
||||
|
Check(ScpiMeasurement.ParseResponse("5.02 V", "VOLTAGE").Value == 5.02m, "base-unit voltage response"); |
||||
|
Check(ScpiMeasurement.ParseResponse(".1184 A", "CURRENT").Value == 0.1184m, "base-unit current response"); |
||||
|
await ExpectFailureAsync(() => Task.FromResult(ScpiMeasurement.ParseResponse("0.12 mA", "CURRENT")), "mA must not be interpreted as A"); |
||||
|
await ExpectFailureAsync(async () => { await ScpiMeasurement.ReadResistanceAsync(_ => throw new TimeoutException()); }, "communication failure is not overload"); |
||||
|
await ScpiMeasurement.ReadResistanceAsync(command => |
||||
|
{ |
||||
|
Check(command == "READ?", "custom resistance command"); |
||||
|
return Task.FromResult("4700"); |
||||
|
}, "READ?"); |
||||
|
} |
||||
|
|
||||
|
private static InspectionJudgementSettings Criteria() => new() |
||||
|
{ |
||||
|
VMin = 4.5m, VMax = 5.5m, AMin = 0.08m, AMax = 0.15m, RMin = 4m, RMax = 5m |
||||
|
}; |
||||
|
|
||||
|
private static void CheckJudgementAndSettings(string output) |
||||
|
{ |
||||
|
var criteria = Criteria(); |
||||
|
Check(criteria.IsPass(5m, 0.12m, 4.7m), "all three measurements pass"); |
||||
|
Check(criteria.IsResistancePass(4m) && criteria.IsResistancePass(5m), "inclusive resistance bounds"); |
||||
|
Check(!criteria.IsPass(5m, 0.12m, 5.001m), "resistance changes overall result to NG"); |
||||
|
criteria.RMax = 4.70m; |
||||
|
Check(!criteria.IsResistancePass(4.704m), "raw resistance above a 4.70 kOhm limit remains NG even when it displays as 4.70"); |
||||
|
criteria.RMax = 5m; |
||||
|
Check(!criteria.IsResistancePass(null), "unmeasured resistance cannot pass"); |
||||
|
criteria.RMax = 1000m; |
||||
|
Check(criteria.IsResistancePass(999m) && !criteria.IsResistancePass(999m, true), "real 999 kOhm differs from overload"); |
||||
|
Check(!criteria.IsResistancePass(999.995m) && ResistanceStorage.ExceedsRange(999.995m), "database overflow is NG even within user limits"); |
||||
|
Check(!ResistanceStorage.ExceedsRange(999.99m) && ResistanceStorage.ToDatabaseValue(999.99m) == 999.99m, "database range boundary"); |
||||
|
Check(ResistanceStorage.ExceedsRange(999.994m) && ResistanceStorage.ExceedsRange(-999.991m), "raw values outside database range are rejected"); |
||||
|
Check(ResistanceStorage.ToDatabaseValue(999.995m) is null && ResistanceStorage.ToDatabaseValue(null) is null, "overflow and missing values become SQL NULL"); |
||||
|
criteria.RMin = 10m; |
||||
|
criteria.RMax = 1m; |
||||
|
Check(!criteria.IsResistancePass(4.7m), "invalid bounds cannot pass"); |
||||
|
|
||||
|
var path = Path.Combine(output, "settings.ini"); |
||||
|
File.WriteAllText(path, "[Judgement]\nVMin=4.5\nVMax=5.5\nAMin=0.08\nAMax=0.15\n"); |
||||
|
var legacy = InspectionSettingsStore.Load(path); |
||||
|
Check(legacy.RMin is null && legacy.RMax is null && !legacy.IsResistancePass(4.7m), "legacy settings require resistance limits"); |
||||
|
InspectionSettingsStore.Save(path, Criteria()); |
||||
|
var saved = InspectionSettingsStore.Load(path); |
||||
|
Check(saved.RMin == 4m && saved.RMax == 5m && saved.AMax == 0.15m, "settings round trip preserves units"); |
||||
|
var precise = Criteria(); |
||||
|
precise.RMin = 4.0000000000000000000000000001m; |
||||
|
precise.RMax = 4.9999999999999999999999999999m; |
||||
|
InspectionSettingsStore.Save(path, precise); |
||||
|
var reloaded = InspectionSettingsStore.Load(path); |
||||
|
Check(reloaded.RMin == precise.RMin && reloaded.RMax == precise.RMax, "resistance limits preserve all decimal precision"); |
||||
|
|
||||
|
var hardwarePath = Path.Combine(AppContext.BaseDirectory, "Hardware.ini"); |
||||
|
var hardware = EquipmentMeasurementSettings.Load(hardwarePath); |
||||
|
Check(hardware.Resistance.Host == "192.168.10.34" && hardware.Voltage.Host == "192.168.10.13", "separate configured instrument addresses"); |
||||
|
Check(hardware.Resistance.ReadCommand == "MEAS:RES?" && hardware.Resistance.Port == 5025, "editable resistance command and port"); |
||||
|
} |
||||
|
|
||||
|
private static async Task CheckEquipmentAsync() |
||||
|
{ |
||||
|
foreach (var invalid in new[] { "VoltageDisabled", "CurrentDisabled", "ResistanceDisabled", "ResistanceMissing", "ResistanceHostMissing", "CleanupMissing" }) |
||||
|
{ |
||||
|
var commands = new ConcurrentQueue<string>(); |
||||
|
using var voltage = new FakeInstrument("V", commands, _ => "5.02"); |
||||
|
using var current = new FakeInstrument("A", commands, _ => "0"); |
||||
|
using var resistance = new FakeInstrument("R", commands, _ => "4700"); |
||||
|
var settings = new EquipmentMeasurementSettings |
||||
|
{ |
||||
|
PreBoardCommand = "OUTP ON, (@2)", |
||||
|
Voltage = voltage.Channel("MEAS:VOLT:DC?"), |
||||
|
Current = current.Channel("MEAS:CURR? (@1)"), |
||||
|
Resistance = resistance.Channel("MEAS:RES?") |
||||
|
}; |
||||
|
settings.Current.CleanupCommand = "OUTP OFF, (@1)"; |
||||
|
switch (invalid) |
||||
|
{ |
||||
|
case "VoltageDisabled": settings.Voltage.Connection = "Disabled"; break; |
||||
|
case "CurrentDisabled": settings.Current.Connection = "Disabled"; break; |
||||
|
case "ResistanceDisabled": settings.Resistance.Connection = "Disabled"; break; |
||||
|
case "ResistanceMissing": settings.Resistance.Connection = ""; settings.Resistance.Host = ""; break; |
||||
|
case "ResistanceHostMissing": settings.Resistance.Host = ""; break; |
||||
|
case "CleanupMissing": settings.Current.CleanupCommand = ""; break; |
||||
|
} |
||||
|
var service = new EquipmentMeasurementService(settings); |
||||
|
await ExpectFailureAsync(async () => { await service.RunPreBoardCommandAsync(); }, invalid + " pre-board configuration must fail"); |
||||
|
await ExpectFailureAsync(async () => { await service.ReadAsync(); }, invalid + " measurement configuration must fail"); |
||||
|
Check(commands.IsEmpty, invalid + " must not issue instrument commands"); |
||||
|
} |
||||
|
|
||||
|
foreach (var raw in new[] { "4700", "OL", "999000", "bad response", "TIMEOUT", "POWER_STILL_ON" }) |
||||
|
{ |
||||
|
var commands = new ConcurrentQueue<string>(); |
||||
|
using var voltage = new FakeInstrument("V", commands, command => command == "MEAS:VOLT:DC?" ? "5.02" : null); |
||||
|
using var current = new FakeInstrument("A", commands, command => command switch |
||||
|
{ |
||||
|
"MEAS:CURR? (@1)" => "0.1184", |
||||
|
"OUTP? (@1)" => raw == "POWER_STILL_ON" ? "1" : "0", |
||||
|
_ => null |
||||
|
}); |
||||
|
using var resistance = new FakeInstrument("R", commands, command => command == "MEAS:RES?" |
||||
|
? raw == "TIMEOUT" ? null : raw : null); |
||||
|
var settings = new EquipmentMeasurementSettings |
||||
|
{ |
||||
|
Timeout = 1000, SettleMilliseconds = 0, ResistanceSettleMilliseconds = 0, |
||||
|
Voltage = voltage.Channel("MEAS:VOLT:DC?"), |
||||
|
Current = current.Channel("MEAS:CURR? (@1)"), |
||||
|
Resistance = resistance.Channel("MEAS:RES?") |
||||
|
}; |
||||
|
settings.Current.SetupCommand = "OUTP ON, (@1)"; |
||||
|
settings.Current.CleanupCommand = "OUTP OFF, (@1);OUTP OFF, (@2)"; |
||||
|
var service = new EquipmentMeasurementService(settings); |
||||
|
if (raw is "bad response" or "TIMEOUT" or "POWER_STILL_ON") |
||||
|
{ |
||||
|
await ExpectFailureAsync(async () => { await service.ReadAsync(); }, "equipment error propagates: " + raw); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
var measured = await service.ReadAsync(); |
||||
|
Check(measured.Voltage == 5.02m && measured.Current == 0.1184m, "existing voltage/current units retained"); |
||||
|
Check(measured.ResistanceKilohms == (raw == "4700" ? 4.7m : 999m) && measured.ResistanceOverloaded == (raw == "OL"), "separate resistance response propagated"); |
||||
|
Check(measured.RawLog.Contains("CURRENT CLEANUP") && measured.RawLog.Contains("RESISTANCE"), "measurement log includes cleanup and resistance"); |
||||
|
} |
||||
|
var sequence = commands.ToArray(); |
||||
|
Check(!sequence.Any(c => c.Contains("OFF, (@2)")), "protected CH2 is not switched off"); |
||||
|
var rIndex = Array.IndexOf(sequence, "R:MEAS:RES?"); |
||||
|
if (raw == "POWER_STILL_ON") |
||||
|
Check(rIndex < 0, "resistance query blocked while output is on"); |
||||
|
else |
||||
|
Check(Array.IndexOf(sequence, "V:MEAS:VOLT:DC?") < Array.IndexOf(sequence, "A:MEAS:CURR? (@1)") && |
||||
|
Array.IndexOf(sequence, "A:MEAS:CURR? (@1)") < Array.IndexOf(sequence, "A:OUTP OFF, (@1)") && |
||||
|
Array.IndexOf(sequence, "A:OUTP OFF, (@1)") < Array.IndexOf(sequence, "A:OUTP? (@1)") && |
||||
|
Array.IndexOf(sequence, "A:OUTP? (@1)") < rIndex, "V -> current -> OFF -> confirm -> resistance"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static void CheckDatabaseParameters() |
||||
|
{ |
||||
|
var addResistance = typeof(HousingAssemblyRepository).GetMethod("AddResistance", BindingFlags.NonPublic | BindingFlags.Static)!; |
||||
|
foreach (var (input, expected) in new (decimal? Input, object Expected)[] |
||||
|
{ |
||||
|
(null, DBNull.Value), (0m, 0m), (4.704m, 4.70m), (4.705m, 4.71m), |
||||
|
(-4.705m, -4.71m), (999m, 999m), (999.99m, 999.99m), (999.994m, DBNull.Value), (999.995m, DBNull.Value), (1000m, DBNull.Value) |
||||
|
}) |
||||
|
{ |
||||
|
using var command = new SqlCommand(); |
||||
|
addResistance.Invoke(null, new object?[] { command, input }); |
||||
|
var parameter = command.Parameters["@PT_Resistor_1"]; |
||||
|
Check(parameter.SqlDbType == SqlDbType.Decimal && parameter.Precision == 5 && parameter.Scale == 2, |
||||
|
"resistance SQL parameter has decimal(5,2) type"); |
||||
|
Check(Equals(parameter.Value, expected), "resistance SQL parameter preserves NULL or rounded kOhm value"); |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
private static void RenderAndCheckUi(string output) |
||||
|
{ |
||||
|
var app = new App(); |
||||
|
app.InitializeComponent(); |
||||
|
foreach (var width in new[] { 1200, 840 }) |
||||
|
{ |
||||
|
var window = new MainWindow { WindowState = WindowState.Normal, Width = width, Height = 800 }; |
||||
|
Check(Find<TextBox>(window, "ROutputTextBox").Text == "0.00" && |
||||
|
Find<TextBlock>(window, "RStatusTextBlock").Text == "측정 대기", "resistance default is zero and marked unmeasured"); |
||||
|
var createRecord = typeof(MainWindow).GetMethod("CreateInspectionRecord", BindingFlags.NonPublic | BindingFlags.Instance)!; |
||||
|
var record = (HousingAssemblyRecord)createRecord.Invoke(window, new object[] |
||||
|
{ |
||||
|
new BoardMeasurementResult { IcSn = "TEST-IC", ResistanceKilohms = 4.705m }, "OK" |
||||
|
})!; |
||||
|
Check(record.PtResistor1 == 4.705m, "measured resistance reaches database record in kOhm"); |
||||
|
var missingRecord = (HousingAssemblyRecord)createRecord.Invoke(window, new object[] |
||||
|
{ |
||||
|
new BoardMeasurementResult { IcSn = "TEST-IC", ResistanceKilohms = null }, "NG" |
||||
|
})!; |
||||
|
Check(missingRecord.PtResistor1 is null, "unmeasured resistance remains NULL in database record"); |
||||
|
var overflowRecord = (HousingAssemblyRecord)createRecord.Invoke(window, new object[] |
||||
|
{ |
||||
|
new BoardMeasurementResult { IcSn = "TEST-IC", ResistanceKilohms = 1000m }, "NG" |
||||
|
})!; |
||||
|
Check(overflowRecord.PtResistor1 is null && overflowRecord.Result == "NG", "out-of-range resistance saves NULL and NG"); |
||||
|
typeof(MainWindow).GetField("_inspectionSettings", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(window, Criteria()); |
||||
|
Invoke(window, "ApplyInspectionSettingsToTextBoxes"); |
||||
|
Find<TextBox>(window, "VOutputTextBox").Text = "5.02"; |
||||
|
Find<TextBox>(window, "AOutputTextBox").Text = "118.40"; |
||||
|
Invoke(window, "ApplyMeasurementOutputState", Find<TextBox>(window, "VOutputTextBox"), true); |
||||
|
Invoke(window, "ApplyMeasurementOutputState", Find<TextBox>(window, "AOutputTextBox"), true); |
||||
|
Invoke(window, "ShowResistanceMeasurement", new BoardMeasurementResult { ResistanceKilohms = 4.7m }); |
||||
|
Invoke(window, "ShowInspectionResult", true); |
||||
|
Invoke(window, "SetProcessStatus", "COMPLETE OK", "HmiRunBrush"); |
||||
|
var content = (FrameworkElement)window.Content; |
||||
|
content.Width = width; |
||||
|
content.Height = 800; |
||||
|
content.Measure(new Size(width, 800)); |
||||
|
content.Arrange(new Rect(0, 0, width, 800)); |
||||
|
content.UpdateLayout(); |
||||
|
var outputs = new[] { "VOutputTextBox", "AOutputTextBox", "ROutputTextBox", "ResultOutputTextBox" }.Select(n => Find<TextBox>(window, n)).ToArray(); |
||||
|
var positions = outputs.Select(o => o.TranslatePoint(new Point(0, 0), content)).ToArray(); |
||||
|
Check(outputs.All(o => o.ActualWidth > 100), "readable output widths at " + width); |
||||
|
Check(positions.All(p => Math.Abs(p.Y - positions[0].Y) < 1), "four outputs share baseline at " + width); |
||||
|
Check(Enumerable.Range(0, 3).All(i => positions[i].X + outputs[i].ActualWidth < positions[i + 1].X), "no overlap at " + width); |
||||
|
Check(Find<TextBox>(window, "RMinTextBox").Text == "4.00" && Find<TextBox>(window, "RMaxTextBox").Text == "5.00", "resistance range visible with two decimal places"); |
||||
|
var preciseLimit = typeof(MainWindow).GetMethod("FormatResistanceLimit", BindingFlags.NonPublic | BindingFlags.Static)!; |
||||
|
Check((string)preciseLimit.Invoke(null, new object?[] { 4.705m })! == "4.705", "existing precise resistance limits are not silently rounded"); |
||||
|
Check(Find<TextBox>(window, "ROutputTextBox").Text == "4.70", "normal resistance UI uses two decimal places"); |
||||
|
Invoke(window, "ShowResistanceMeasurement", new BoardMeasurementResult { ResistanceKilohms = 4.705m }); |
||||
|
Check(Find<TextBox>(window, "ROutputTextBox").Text == "4.71" && |
||||
|
(string)Find<TextBox>(window, "ROutputTextBox").ToolTip == "4.705 kΩ", |
||||
|
"display uses database rounding while tooltip retains raw measurement"); |
||||
|
Invoke(window, "ShowResistanceMeasurement", new BoardMeasurementResult { ResistanceKilohms = 4.7m }); |
||||
|
SaveImage(content, Path.Combine(output, $"resistance-{width}.png"), width, 800); |
||||
|
Invoke(window, "ShowResistanceMeasurement", new BoardMeasurementResult { ResistanceKilohms = 999m, ResistanceOverloaded = true }); |
||||
|
Invoke(window, "ShowInspectionResult", false); |
||||
|
Invoke(window, "SetProcessStatus", "COMPLETE NG", "HmiDangerBrush"); |
||||
|
content.UpdateLayout(); |
||||
|
Check(Find<TextBox>(window, "ROutputTextBox").Text == "999.00" && Find<TextBlock>(window, "RStatusTextBlock").Text.Contains("OL"), "overload remains distinguishable in UI"); |
||||
|
SaveImage(content, Path.Combine(output, $"resistance-overload-{width}.png"), width, 800); |
||||
|
Invoke(window, "ShowResistanceMeasurement", new BoardMeasurementResult { ResistanceKilohms = 1000m }); |
||||
|
Check(Find<TextBox>(window, "ROutputTextBox").Text == "1000.00" && |
||||
|
Find<TextBlock>(window, "RStatusTextBlock").Text.Contains("DB 저장 범위 초과"), "database range overflow is visible with the measured value"); |
||||
|
Invoke(window, "ResetMeasurementOutputs"); |
||||
|
Check(Find<TextBox>(window, "ROutputTextBox").Text == "0.00" && |
||||
|
Find<TextBlock>(window, "RStatusTextBlock").Text == "측정 대기", "new measurement resets resistance without claiming a measurement"); |
||||
|
Check(Find<TextBox>(window, "VOutputTextBox").Text == "0.00" && Find<TextBox>(window, "AOutputTextBox").Text == "0.00", "new inspection clears old voltage and current"); |
||||
|
window.Close(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static T Find<T>(MainWindow window, string name) => (T)window.FindName(name); |
||||
|
private static void Invoke(MainWindow window, string name, params object[] arguments) => |
||||
|
typeof(MainWindow).GetMethod(name, BindingFlags.NonPublic | BindingFlags.Instance)!.Invoke(window, arguments); |
||||
|
|
||||
|
private static void SaveImage(FrameworkElement content, string path, int width, int height) |
||||
|
{ |
||||
|
var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); |
||||
|
bitmap.Render(content); |
||||
|
var encoder = new PngBitmapEncoder(); |
||||
|
encoder.Frames.Add(BitmapFrame.Create(bitmap)); |
||||
|
using var stream = File.Create(path); |
||||
|
encoder.Save(stream); |
||||
|
} |
||||
|
|
||||
|
private sealed class FakeInstrument : IDisposable |
||||
|
{ |
||||
|
private readonly TcpListener _listener = new(IPAddress.Loopback, 0); |
||||
|
private readonly CancellationTokenSource _stop = new(); |
||||
|
private readonly string _name; |
||||
|
private readonly ConcurrentQueue<string> _commands; |
||||
|
private readonly Func<string, string?> _respond; |
||||
|
public FakeInstrument(string name, ConcurrentQueue<string> commands, Func<string, string?> respond) |
||||
|
{ |
||||
|
(_name, _commands, _respond) = (name, commands, respond); |
||||
|
_listener.Start(); |
||||
|
_ = AcceptAsync(); |
||||
|
} |
||||
|
|
||||
|
public ScpiChannelSettings Channel(string readCommand) => new() |
||||
|
{ |
||||
|
Connection = "TCP", Host = "127.0.0.1", Port = ((IPEndPoint)_listener.LocalEndpoint).Port, ReadCommand = readCommand |
||||
|
}; |
||||
|
|
||||
|
private async Task AcceptAsync() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
while (!_stop.IsCancellationRequested) |
||||
|
{ |
||||
|
var client = await _listener.AcceptTcpClientAsync(_stop.Token); |
||||
|
_ = RespondAsync(client); |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) { } |
||||
|
} |
||||
|
|
||||
|
private async Task RespondAsync(TcpClient client) |
||||
|
{ |
||||
|
using (client) |
||||
|
using (var reader = new StreamReader(client.GetStream())) |
||||
|
using (var writer = new StreamWriter(client.GetStream()) { AutoFlush = true }) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
while (await reader.ReadLineAsync(_stop.Token) is { } command) |
||||
|
{ |
||||
|
_commands.Enqueue(_name + ":" + command); |
||||
|
if (_respond(command) is { } response) await writer.WriteLineAsync(response); |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) when (ex is IOException or OperationCanceledException or ObjectDisposedException) { } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
_stop.Cancel(); |
||||
|
_listener.Stop(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,117 @@ |
|||||
|
param( |
||||
|
[string]$MsiPath = (Join-Path $PSScriptRoot '..\..\installer\output\Housing.msi'), |
||||
|
[string]$FixtureRoot = (Join-Path $PSScriptRoot '..\..\obj\installer-checks') |
||||
|
) |
||||
|
|
||||
|
$ErrorActionPreference = 'Stop' |
||||
|
$MsiPath = (Resolve-Path -LiteralPath $MsiPath).Path |
||||
|
$fixture = Join-Path ([IO.Path]::GetFullPath($FixtureRoot)) ([Guid]::NewGuid().ToString('N')) |
||||
|
$desktop = Join-Path $fixture 'OneDrive\Desktop' |
||||
|
$localData = Join-Path $fixture 'LocalAppData' |
||||
|
New-Item -ItemType Directory -Force -Path $desktop, $localData | Out-Null |
||||
|
$script:checks = 0 |
||||
|
|
||||
|
function Assert-Check($Condition, [string]$Message) { |
||||
|
if (-not $Condition) { throw "FAIL: $Message" } |
||||
|
$script:checks++ |
||||
|
} |
||||
|
|
||||
|
# Open the database read-only. No InstallInitialize/InstallExecute/InstallFinalize actions run. |
||||
|
$installer = New-Object -ComObject WindowsInstaller.Installer |
||||
|
$database = $installer.OpenDatabase($MsiPath, 0) |
||||
|
function Read-Rows([string]$Query, [int]$Columns) { |
||||
|
$view = $database.OpenView($Query) |
||||
|
[void]$view.Execute() |
||||
|
while ($record = $view.Fetch()) { |
||||
|
$values = @(for ($column = 1; $column -le $Columns; $column++) { $record.StringData($column) }) |
||||
|
,$values |
||||
|
} |
||||
|
[void]$view.Close() |
||||
|
} |
||||
|
|
||||
|
$folder = @(Read-Rows "SELECT Directory_Parent, DefaultDir FROM Directory WHERE Directory = 'DESKTOPSETTINGSFOLDER'" 2) |
||||
|
Assert-Check ($folder.Count -eq 1 -and $folder[0][0] -eq 'DesktopFolder' -and $folder[0][1] -eq 'Housing') 'desktop Housing folder' |
||||
|
$shortcut = @(Read-Rows "SELECT Directory_, Target, WkDir, Icon_ FROM Shortcut WHERE Shortcut = 'HousingDesktopShortcut'" 4) |
||||
|
Assert-Check ($shortcut.Count -eq 1 -and $shortcut[0][0] -eq 'DesktopFolder' -and |
||||
|
$shortcut[0][1] -eq '[INSTALLFOLDER]Housing.exe' -and $shortcut[0][2] -eq 'INSTALLFOLDER' -and |
||||
|
$shortcut[0][3] -eq 'HousingIcon.exe') 'desktop shortcut target, working directory and icon' |
||||
|
|
||||
|
$names = @('Hardware', 'Database', 'InspectionSettings') |
||||
|
$defaultConditions = @{} |
||||
|
$migrationConditions = @{} |
||||
|
foreach ($name in $names) { |
||||
|
$component = @(Read-Rows "SELECT Directory_, Attributes, Condition FROM Component WHERE Component = 'Desktop${name}Settings'" 3) |
||||
|
Assert-Check ($component.Count -eq 1 -and $component[0][0] -eq 'DESKTOPSETTINGSFOLDER') "$name destination" |
||||
|
$attributes = [int]$component[0][1] |
||||
|
Assert-Check (($attributes -band 16) -and ($attributes -band 128)) "$name Permanent and NeverOverwrite" |
||||
|
$defaultConditions[$name] = $component[0][2] |
||||
|
$migration = @(Read-Rows "SELECT Condition FROM Component WHERE Component = 'Migrate${name}Settings'" 1) |
||||
|
$migrationConditions[$name] = $migration[0][0] |
||||
|
$copy = @(Read-Rows "SELECT SourceFolder, DestFolder, Options FROM MoveFile WHERE FileKey = 'CopyLegacy$name'" 3) |
||||
|
Assert-Check ($copy.Count -eq 1 -and $copy[0][0] -eq ('LEGACY_' + $name.ToUpperInvariant()) -and |
||||
|
$copy[0][1] -eq 'DESKTOPSETTINGSFOLDER' -and $copy[0][2] -eq '0') "$name migration copies without deleting source" |
||||
|
} |
||||
|
|
||||
|
Add-Type @' |
||||
|
using System; |
||||
|
using System.Text; |
||||
|
using System.Runtime.InteropServices; |
||||
|
public static class SettingsMsiCheck { |
||||
|
[DllImport("msi.dll", CharSet = CharSet.Unicode)] public static extern uint MsiOpenPackageEx(string path, uint options, out uint handle); |
||||
|
[DllImport("msi.dll", CharSet = CharSet.Unicode)] public static extern uint MsiSetProperty(uint handle, string name, string value); |
||||
|
[DllImport("msi.dll", CharSet = CharSet.Unicode)] public static extern uint MsiGetProperty(uint handle, string name, StringBuilder value, ref uint size); |
||||
|
[DllImport("msi.dll", CharSet = CharSet.Unicode)] public static extern uint MsiDoAction(uint handle, string action); |
||||
|
[DllImport("msi.dll", CharSet = CharSet.Unicode)] public static extern int MsiEvaluateCondition(uint handle, string condition); |
||||
|
[DllImport("msi.dll")] public static extern uint MsiCloseHandle(uint handle); |
||||
|
[DllImport("msi.dll")] public static extern uint MsiSetInternalUI(uint level, IntPtr owner); |
||||
|
} |
||||
|
'@ |
||||
|
|
||||
|
function Get-MsiProperty([uint32]$Handle, [string]$Name) { |
||||
|
$value = New-Object Text.StringBuilder 4096 |
||||
|
[uint32]$size = $value.Capacity |
||||
|
$result = [SettingsMsiCheck]::MsiGetProperty($Handle, $Name, $value, [ref]$size) |
||||
|
if ($result -ne 0) { throw "MsiGetProperty failed: $result" } |
||||
|
$value.ToString() |
||||
|
} |
||||
|
|
||||
|
$previousUi = [SettingsMsiCheck]::MsiSetInternalUI(2, [IntPtr]::Zero) |
||||
|
try { |
||||
|
foreach ($scenario in @('fresh', 'legacy', 'existing-desktop')) { |
||||
|
if ($scenario -eq 'legacy') { |
||||
|
New-Item -ItemType Directory -Force -Path (Join-Path $localData 'Housing'), (Join-Path $localData 'AMO Housing') | Out-Null |
||||
|
foreach ($name in $names) { Set-Content -LiteralPath (Join-Path $localData "Housing\$name.ini") -Value 'old settings' } |
||||
|
Set-Content -LiteralPath (Join-Path $localData 'AMO Housing\InspectionSettings.ini') -Value 'saved limits' |
||||
|
} |
||||
|
if ($scenario -eq 'existing-desktop') { |
||||
|
New-Item -ItemType Directory -Force -Path (Join-Path $desktop 'Housing') | Out-Null |
||||
|
foreach ($name in $names) { Set-Content -LiteralPath (Join-Path $desktop "Housing\$name.ini") -Value 'desktop settings' } |
||||
|
} |
||||
|
[uint32]$handle = 0 |
||||
|
$result = [SettingsMsiCheck]::MsiOpenPackageEx($MsiPath, 1, [ref]$handle) |
||||
|
if ($result -ne 0) { throw "MsiOpenPackageEx failed: $result" } |
||||
|
try { |
||||
|
foreach ($entry in @{ DesktopFolder = $desktop + '\'; LocalAppDataFolder = $localData + '\' }.GetEnumerator()) { |
||||
|
$result = [SettingsMsiCheck]::MsiSetProperty($handle, $entry.Key, $entry.Value) |
||||
|
if ($result -ne 0) { throw "MsiSetProperty failed: $result" } |
||||
|
} |
||||
|
$result = [SettingsMsiCheck]::MsiDoAction($handle, 'AppSearch') |
||||
|
if ($result -ne 0) { throw "AppSearch failed: $result" } |
||||
|
if (Get-MsiProperty $handle 'LEGACY_SAVED_INSPECTION') { |
||||
|
$result = [SettingsMsiCheck]::MsiDoAction($handle, 'SetLEGACY_INSPECTIONSETTINGS') |
||||
|
if ($result -ne 0) { throw "Inspection migration selection failed: $result" } |
||||
|
Assert-Check ((Get-MsiProperty $handle 'LEGACY_INSPECTIONSETTINGS') -eq |
||||
|
(Join-Path $localData 'AMO Housing\InspectionSettings.ini')) "$scenario saved limits precedence" |
||||
|
} |
||||
|
foreach ($name in $names) { |
||||
|
Assert-Check ([SettingsMsiCheck]::MsiEvaluateCondition($handle, $defaultConditions[$name]) -eq |
||||
|
[int]($scenario -eq 'fresh')) "$scenario $name default selection" |
||||
|
Assert-Check ([SettingsMsiCheck]::MsiEvaluateCondition($handle, $migrationConditions[$name]) -eq |
||||
|
[int]($scenario -eq 'legacy')) "$scenario $name migration selection" |
||||
|
} |
||||
|
} |
||||
|
finally { [void][SettingsMsiCheck]::MsiCloseHandle($handle) } |
||||
|
} |
||||
|
} |
||||
|
finally { [void][SettingsMsiCheck]::MsiSetInternalUI($previousUi, [IntPtr]::Zero) } |
||||
|
Write-Host "PASS: $script:checks installer checks; no product was installed." |
||||
@ -0,0 +1,56 @@ |
|||||
|
using Housing.Services; |
||||
|
|
||||
|
// All files stay in an isolated fixture; never read or alter the real user's settings.
|
||||
|
var root = Path.Combine(Path.GetFullPath(args[0]), Guid.NewGuid().ToString("N")); |
||||
|
var desktop = Path.Combine(root, "OneDrive", "바탕 화면"); |
||||
|
var app = Path.Combine(root, "LocalAppData", "Housing"); |
||||
|
var localData = Path.Combine(root, "LocalAppData"); |
||||
|
Directory.CreateDirectory(app); |
||||
|
var checks = 0; |
||||
|
|
||||
|
void Check(bool condition, string description) |
||||
|
{ |
||||
|
if (!condition) throw new InvalidOperationException("FAIL: " + description); |
||||
|
checks++; |
||||
|
} |
||||
|
|
||||
|
string Resolve(string name) => HousingSettingsPaths.GetIniPath(name, desktop, app, localData); |
||||
|
|
||||
|
foreach (var name in new[] { "Database.ini", "Hardware.ini", "InspectionSettings.ini" }) |
||||
|
{ |
||||
|
File.WriteAllText(Path.Combine(app, name), "bundled " + name); |
||||
|
var path = Resolve(name); |
||||
|
Check(path == Path.Combine(desktop, "Housing", name), "redirected desktop path: " + name); |
||||
|
Check(File.ReadAllText(path) == "bundled " + name, "first-run initialization: " + name); |
||||
|
File.WriteAllText(path, "user settings " + name); |
||||
|
File.WriteAllText(Path.Combine(app, name), "new release defaults"); |
||||
|
Check(File.ReadAllText(Resolve(name)) == "user settings " + name, "existing settings survive: " + name); |
||||
|
Check(File.Exists(Path.Combine(app, name)), "migration leaves source intact: " + name); |
||||
|
} |
||||
|
|
||||
|
var saved = Path.Combine(localData, "AMO Housing"); |
||||
|
Directory.CreateDirectory(saved); |
||||
|
File.WriteAllText(Path.Combine(saved, "InspectionSettings.ini"), "previously saved limits"); |
||||
|
File.Delete(Path.Combine(desktop, "Housing", "InspectionSettings.ini")); |
||||
|
Check(File.ReadAllText(Resolve("InspectionSettings.ini")) == "previously saved limits", |
||||
|
"legacy user limits take priority over bundled limits"); |
||||
|
File.WriteAllText(Path.Combine(desktop, "Housing", "InspectionSettings.ini"), "desktop limits"); |
||||
|
Check(File.ReadAllText(Resolve("InspectionSettings.ini")) == "desktop limits", |
||||
|
"desktop limits take priority over legacy user limits"); |
||||
|
|
||||
|
File.Delete(Path.Combine(app, "Database.ini")); |
||||
|
Check(File.ReadAllText(Resolve("Database.ini")) == "user settings Database.ini", |
||||
|
"installed app uses desktop even without bundled INI"); |
||||
|
File.Delete(Path.Combine(desktop, "Housing", "Database.ini")); |
||||
|
Check(!File.Exists(Resolve("Database.ini")), "missing required INI is not replaced with an empty file"); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
HousingSettingsPaths.GetIniPath("Hardware.ini", "", app, localData); |
||||
|
throw new InvalidOperationException("FAIL: missing desktop should fail"); |
||||
|
} |
||||
|
catch (DirectoryNotFoundException) |
||||
|
{ |
||||
|
checks++; |
||||
|
} |
||||
|
Console.WriteLine($"PASS: {checks} settings path checks."); |
||||
@ -0,0 +1,11 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
<PropertyGroup> |
||||
|
<OutputType>Exe</OutputType> |
||||
|
<TargetFramework>net9.0</TargetFramework> |
||||
|
<Nullable>enable</Nullable> |
||||
|
<ImplicitUsings>enable</ImplicitUsings> |
||||
|
</PropertyGroup> |
||||
|
<ItemGroup> |
||||
|
<Compile Include="..\..\Services\HousingSettingsPaths.cs" Link="HousingSettingsPaths.cs" /> |
||||
|
</ItemGroup> |
||||
|
</Project> |
||||
Loading…
Reference in new issue