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

236 lines
8.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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