213 lines
8.1 KiB
C#
213 lines
8.1 KiB
C#
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) =>
|
||
throw new NotSupportedException("UDP5080 当前仅接入实时测量,未启用未验证的输出开关 SCPI 命令。");
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|