using System.Globalization; using System.IO; using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text; using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SSPCTester.Devices.Config; using SSPCTester.Devices.Interfaces; namespace SSPCTester.Devices.Drivers.Real; /// Real UDP5080-100 power supply driver over SCPI TCP socket. public sealed class Udp5080 : IPowerSupply { private const int MaxResponseBytes = 1024 * 1024; private readonly PowerSupplyOptions _options; private readonly ILogger _logger; private readonly SemaphoreSlim _gate = new(1, 1); private TcpClient? _client; private NetworkStream? _stream; private bool _isConnected; public Udp5080(IOptions options, ILogger logger) { _options = options.Value.PowerSupply; _logger = logger; } public bool IsConnected => _isConnected && _client?.Connected == true && _stream is not null; public string DisplayName => "电源 UDP5080"; public event EventHandler? ConnectionChanged; public async Task ConnectAsync(CancellationToken ct = default) { await DisconnectAsync().ConfigureAwait(false); var client = new TcpClient { NoDelay = true }; try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(Timeout); await EnsureHostReachableAsync(cts.Token).ConfigureAwait(false); await client.ConnectAsync(_options.Host, _options.Port, cts.Token).ConfigureAwait(false); _client = client; _stream = client.GetStream(); string idn = await QueryRawAsync(_stream, _options.IdnCommand, ct).ConfigureAwait(false); if (!idn.Contains("UDP5080", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"Unexpected UDP5080 identity: {idn}"); SetConnected(true); _logger.LogInformation("UDP5080 connected to {Host}:{Port}. Identity: {Identity}", _options.Host, _options.Port, idn); return true; } catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException or InvalidOperationException) { _stream = null; _client = null; client.Dispose(); SetConnected(false); throw new InvalidOperationException($"UDP5080 connection failed: {_options.Host}:{_options.Port}, {ex.Message}", ex); } } public Task DisconnectAsync() { _stream?.Dispose(); _client?.Dispose(); _stream = null; _client = null; SetConnected(false); return Task.CompletedTask; } public Task SetVoltageAsync(double volts, CancellationToken ct = default) { ValidateSetpoint(volts, nameof(volts)); return SendAsync(FormatCommand(_options.SetVoltageCommand, volts), ct); } public Task SetCurrentLimitAsync(double amps, CancellationToken ct = default) { ValidateSetpoint(amps, nameof(amps)); return SendAsync(FormatCommand(_options.SetCurrentLimitCommand, amps), ct); } public Task OutputAsync(bool on, CancellationToken ct = default) => SendAsync(on ? ":OUTput ON" : ":OUTput OFF", ct); public async Task GetOutputStateAsync(CancellationToken ct = default) => ParseSwitchState(await QueryAsync(_options.OutputStateCommand, ct).ConfigureAwait(false)); public async Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) { try { double volts = ParseNumber(await QueryAsync(_options.VoltageCommand, ct).ConfigureAwait(false)); double amps = ParseNumber(await QueryAsync(_options.CurrentCommand, ct).ConfigureAwait(false)); return (volts, amps); } catch (FormatException ex) { SetConnected(false); throw new InvalidOperationException($"UDP5080 response parse failed: {ex.Message}", ex); } } public Task QueryIdentityAsync(CancellationToken ct = default) => QueryAsync(_options.IdnCommand, ct); public Task QueryErrorAsync(CancellationToken ct = default) => QueryAsync(_options.ErrorCommand, ct); public async Task QueryAsync(string command, CancellationToken ct = default) { await _gate.WaitAsync(ct).ConfigureAwait(false); try { var stream = RequireStream(); return await QueryRawAsync(stream, command, ct).ConfigureAwait(false); } catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException) { SetConnected(false); throw new InvalidOperationException($"UDP5080 query failed: {command}, {ex.Message}", ex); } finally { _gate.Release(); } } private async Task SendAsync(string command, CancellationToken ct = default) { await _gate.WaitAsync(ct).ConfigureAwait(false); try { var stream = RequireStream(); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(Timeout); var payload = Encoding.ASCII.GetBytes(command.TrimEnd('\r', '\n') + Terminator); await stream.WriteAsync(payload, cts.Token).ConfigureAwait(false); await stream.FlushAsync(cts.Token).ConfigureAwait(false); } catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException) { SetConnected(false); throw new InvalidOperationException($"UDP5080 command failed: {command}, {ex.Message}", ex); } finally { _gate.Release(); } } public static double ParseNumber(string response) { var match = Regex.Match( response.Trim(), @"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"); if (!match.Success) throw new FormatException($"UDP5080 response does not contain a number: {response}"); var value = double.Parse(match.Value, CultureInfo.InvariantCulture); if (!double.IsFinite(value)) throw new FormatException($"UDP5080 response number is not finite: {response}"); return value; } public static bool ParseSwitchState(string response) { string value = response.Trim().Trim('"'); if (value.Equals("ON", StringComparison.OrdinalIgnoreCase) || value == "1") return true; if (value.Equals("OFF", StringComparison.OrdinalIgnoreCase) || value == "0") return false; throw new FormatException($"UDP5080 response is not a switch state: {response}"); } private static void ValidateSetpoint(double value, string name) { if (!double.IsFinite(value) || value < 0) throw new ArgumentOutOfRangeException(name, value, "Setpoint must be a finite non-negative number."); } private static string FormatCommand(string template, double value) { string commandTemplate = string.IsNullOrWhiteSpace(template) ? "{0}" : template.Trim(); return string.Format(CultureInfo.InvariantCulture, commandTemplate, value); } private TimeSpan Timeout => TimeSpan.FromSeconds(_options.TimeoutSeconds > 0 ? _options.TimeoutSeconds : 2); private string Terminator => string.IsNullOrEmpty(_options.Terminator) ? "\n" : _options.Terminator; private async Task EnsureHostReachableAsync(CancellationToken ct) { if (!_options.RequirePingBeforeConnect || IsLoopbackHost(_options.Host)) return; int timeoutMs = Math.Clamp((int)Math.Round(Timeout.TotalMilliseconds), 500, 10_000); try { using var ping = new Ping(); var reply = await ping.SendPingAsync(_options.Host, timeoutMs).WaitAsync(ct).ConfigureAwait(false); if (reply.Status != IPStatus.Success) throw new IOException($"UDP5080 host ping failed: {_options.Host}, status={reply.Status}"); } catch (PingException ex) { throw new IOException($"UDP5080 host ping failed: {_options.Host}, {ex.Message}", ex); } catch (TimeoutException ex) { throw new IOException($"UDP5080 host ping timed out: {_options.Host}", ex); } } private static bool IsLoopbackHost(string host) { if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) return true; return IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address); } private async Task QueryRawAsync(NetworkStream stream, string command, CancellationToken ct) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(Timeout); var payload = Encoding.ASCII.GetBytes(command.TrimEnd('\r', '\n') + Terminator); await stream.WriteAsync(payload, cts.Token).ConfigureAwait(false); await stream.FlushAsync(cts.Token).ConfigureAwait(false); var buffer = new byte[4096]; using var memory = new MemoryStream(); while (memory.Length < MaxResponseBytes) { int read = await stream.ReadAsync(buffer, cts.Token).ConfigureAwait(false); if (read <= 0) throw new IOException("Device closed the connection."); int newline = Array.IndexOf(buffer, (byte)'\n', 0, read); memory.Write(buffer, 0, newline >= 0 ? newline : read); if (newline >= 0) break; } if (memory.Length >= MaxResponseBytes) throw new IOException("Device response exceeded 1 MB."); return Encoding.ASCII.GetString(memory.ToArray()).Trim(); } private NetworkStream RequireStream() { if (!IsConnected || _stream is null) throw new InvalidOperationException("UDP5080 is not connected."); return _stream; } private void SetConnected(bool connected) { if (_isConnected == connected) return; _isConnected = connected; ConnectionChanged?.Invoke(this, connected); } }