SSPC-Tester/SSPCTester.Devices/Drivers/Real/Udp5080.cs

236 lines
8.9 KiB
C#
Raw Normal View History

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;
/// <summary>Real UDP5080-100 power supply driver over SCPI TCP socket.</summary>
public sealed class Udp5080 : IPowerSupply
{
private const int MaxResponseBytes = 1024 * 1024;
private readonly PowerSupplyOptions _options;
private readonly ILogger<Udp5080> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private TcpClient? _client;
private NetworkStream? _stream;
private bool _isConnected;
public Udp5080(IOptions<DeviceOptions> options, ILogger<Udp5080> 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<bool>? ConnectionChanged;
public async Task<bool> 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 连接失败:{_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) =>
throw new NotSupportedException("UDP5080 当前仅接入实时测量,未启用未验证的电压设置 SCPI 命令。");
public Task SetCurrentLimitAsync(double amps, CancellationToken ct = default) =>
throw new NotSupportedException("UDP5080 当前仅接入实时测量,未启用未验证的电流限制 SCPI 命令。");
public Task OutputAsync(bool on, CancellationToken ct = default) =>
SendAsync(on ? ":OUTput ON" : ":OUTput OFF", ct);
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<string> QueryIdentityAsync(CancellationToken ct = default) => QueryAsync(_options.IdnCommand, ct);
public async Task<string> 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 查询失败:{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}");
var value = double.Parse(match.Value, CultureInfo.InvariantCulture);
if (!double.IsFinite(value))
throw new FormatException($"UDP5080 响应数值无效:{response}");
return 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<string> 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 尚未连接。");
return _stream;
}
private void SetConnected(bool connected)
{
if (_isConnected == connected) return;
_isConnected = connected;
ConnectionChanged?.Invoke(this, connected);
}
}