You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
404 lines
15 KiB
404 lines
15 KiB
|
4 days ago
|
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);
|
||
|
|
}
|
||
|
|
}
|