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 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 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 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 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 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 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 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(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 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 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 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); } }