287 lines
11 KiB
C#
287 lines
11 KiB
C#
using System.Globalization;
|
|
using System.IO;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using SSPCTester.Devices.Config;
|
|
using SSPCTester.Devices.Interfaces;
|
|
|
|
namespace SSPCTester.Devices.Drivers.Real;
|
|
|
|
public sealed class It87xxLoad : ILoad
|
|
{
|
|
private const int MaxResponseBytes = 1024 * 1024;
|
|
private readonly LoadOptions _options;
|
|
private readonly ILogger<It87xxLoad> _logger;
|
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
|
private TcpClient? _client;
|
|
private NetworkStream? _stream;
|
|
private bool _isConnected;
|
|
private LoadMode _mode = LoadMode.CC;
|
|
|
|
public It87xxLoad(IOptions<DeviceOptions> options, ILogger<It87xxLoad> logger)
|
|
{
|
|
_options = options.Value.Load;
|
|
_logger = logger;
|
|
}
|
|
|
|
public bool IsConnected => _isConnected && _client?.Connected == true && _stream is not null;
|
|
public string DisplayName => "IT8702P electronic load";
|
|
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 client.ConnectAsync(_options.Host, _options.Port, cts.Token).ConfigureAwait(false);
|
|
_client = client;
|
|
_stream = client.GetStream();
|
|
SetConnected(true);
|
|
|
|
string idn = await QueryIdentityAsync(ct).ConfigureAwait(false);
|
|
if (!idn.Contains("IT87", StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException($"Unexpected IT8702P identity: {idn}");
|
|
|
|
await SendAsync("SYST:REM", ct).ConfigureAwait(false);
|
|
await SendAsync($"CHAN {Math.Clamp(_options.Channel, 1, 8)}", ct).ConfigureAwait(false);
|
|
_logger.LogInformation("IT8702P connected to {Host}:{Port}, channel {Channel}.", _options.Host, _options.Port, _options.Channel);
|
|
return true;
|
|
}
|
|
catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException or InvalidOperationException)
|
|
{
|
|
client.Dispose();
|
|
_stream = null;
|
|
_client = null;
|
|
SetConnected(false);
|
|
throw new InvalidOperationException($"IT8702P connection failed: {_options.Host}:{_options.Port}, {ex.Message}", ex);
|
|
}
|
|
}
|
|
|
|
public async Task DisconnectAsync()
|
|
{
|
|
if (IsConnected)
|
|
{
|
|
try { await SendAsync("SYST:LOC", CancellationToken.None).ConfigureAwait(false); }
|
|
catch { }
|
|
}
|
|
|
|
_stream?.Dispose();
|
|
_client?.Dispose();
|
|
_stream = null;
|
|
_client = null;
|
|
SetConnected(false);
|
|
}
|
|
|
|
public async Task SetModeAsync(LoadMode mode, CancellationToken ct = default)
|
|
{
|
|
_mode = mode;
|
|
if (string.IsNullOrWhiteSpace(_options.SetModeCommand))
|
|
{
|
|
if (mode == LoadMode.CC)
|
|
return;
|
|
|
|
throw new NotSupportedException($"IT8702P mode {mode} is not configured.");
|
|
}
|
|
|
|
await SendAsync(FormatCommand(_options.SetModeCommand, ModeToken(mode)), ct).ConfigureAwait(false);
|
|
}
|
|
|
|
public Task SetValueAsync(double value, CancellationToken ct = default)
|
|
{
|
|
ValidateSetpoint(value, nameof(value));
|
|
return SendAsync(FormatCommand(_options.SetValueCommand, ValueCommand(_mode), value), ct);
|
|
}
|
|
|
|
public Task InputAsync(bool on, CancellationToken ct = default) =>
|
|
SendAsync(on ? "INP ON" : "INP OFF", ct);
|
|
|
|
public async Task<bool> GetInputStateAsync(CancellationToken ct = default) =>
|
|
ParseSwitchState(await QueryAsync(_options.InputStateCommand, ct).ConfigureAwait(false));
|
|
|
|
public async Task ResetAsync(CancellationToken ct = default)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(_options.ResetCommand))
|
|
await SendAsync(_options.ResetCommand, ct).ConfigureAwait(false);
|
|
|
|
await SendAsync("SYST:REM", ct).ConfigureAwait(false);
|
|
await SendAsync($"CHAN {Math.Clamp(_options.Channel, 1, 8)}", ct).ConfigureAwait(false);
|
|
_mode = LoadMode.CC;
|
|
}
|
|
|
|
public async Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default)
|
|
{
|
|
var reading = await ReadPowerAsync(ct).ConfigureAwait(false);
|
|
return (reading.Volts, reading.Amps);
|
|
}
|
|
|
|
public async Task<PowerReading> ReadPowerAsync(CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
string prefix = _options.UseFetch ? "FETC" : "MEAS";
|
|
if (_options.QueryMode == LoadQueryMode.Combined)
|
|
{
|
|
string response = await QueryAsync($"{prefix}:VOLT?;:{prefix}:CURR?;:{prefix}:POW?", ct).ConfigureAwait(false);
|
|
var parts = response.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length < 3)
|
|
throw new FormatException($"IT8702P combined response has {parts.Length} parts: {response}");
|
|
|
|
return CreateReading(parts[0], parts[1], parts[2]);
|
|
}
|
|
|
|
string volts = await QueryAsync($"{prefix}:VOLT?", ct).ConfigureAwait(false);
|
|
string amps = await QueryAsync($"{prefix}:CURR?", ct).ConfigureAwait(false);
|
|
string watts = await QueryAsync($"{prefix}:POW?", ct).ConfigureAwait(false);
|
|
return CreateReading(volts, amps, watts);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
SetConnected(false);
|
|
throw new InvalidOperationException($"IT8702P response parse failed: {ex.Message}", ex);
|
|
}
|
|
}
|
|
|
|
public Task<string> QueryIdentityAsync(CancellationToken ct = default) => QueryAsync("*IDN?", ct);
|
|
|
|
public Task<string> QueryErrorAsync(CancellationToken ct = default) => QueryAsync(_options.ErrorCommand, ct);
|
|
|
|
public async Task<string> QueryAsync(string command, CancellationToken ct = default)
|
|
{
|
|
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
var stream = RequireStream();
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(Timeout);
|
|
await WriteLineAsync(stream, command, cts.Token).ConfigureAwait(false);
|
|
return await ReadLineAsync(stream, cts.Token).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException)
|
|
{
|
|
SetConnected(false);
|
|
throw new InvalidOperationException($"IT8702P 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);
|
|
await WriteLineAsync(stream, command, cts.Token).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException)
|
|
{
|
|
SetConnected(false);
|
|
throw new InvalidOperationException($"IT8702P command failed: {command}, {ex.Message}", ex);
|
|
}
|
|
finally
|
|
{
|
|
_gate.Release();
|
|
}
|
|
}
|
|
|
|
private static PowerReading CreateReading(string volts, string amps, string watts)
|
|
{
|
|
var reading = new PowerReading(ParseNumber(volts), Math.Abs(ParseNumber(amps)), ParseNumber(watts));
|
|
if (!double.IsFinite(reading.Volts) || !double.IsFinite(reading.Amps) || !double.IsFinite(reading.Watts))
|
|
throw new FormatException("IT8702P response contains a non-finite value.");
|
|
return reading;
|
|
}
|
|
|
|
private static double ParseNumber(string value) =>
|
|
double.Parse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture);
|
|
|
|
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($"IT8702P 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, params object[] args)
|
|
{
|
|
string commandTemplate = string.IsNullOrWhiteSpace(template) ? "{0} {1}" : template.Trim();
|
|
return string.Format(CultureInfo.InvariantCulture, commandTemplate, args);
|
|
}
|
|
|
|
private static string ModeToken(LoadMode mode) => mode.ToString().ToUpperInvariant();
|
|
|
|
private static string ValueCommand(LoadMode mode) => mode switch
|
|
{
|
|
LoadMode.CC => "CURR",
|
|
LoadMode.CV => "VOLT",
|
|
LoadMode.CR => "RES",
|
|
LoadMode.CP => "POW",
|
|
_ => "CURR"
|
|
};
|
|
|
|
private async Task WriteLineAsync(NetworkStream stream, string command, CancellationToken ct)
|
|
{
|
|
var payload = Encoding.ASCII.GetBytes(command.TrimEnd('\r', '\n') + Terminator);
|
|
await stream.WriteAsync(payload, ct).ConfigureAwait(false);
|
|
await stream.FlushAsync(ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task<string> ReadLineAsync(NetworkStream stream, CancellationToken ct)
|
|
{
|
|
var buffer = new byte[256];
|
|
using var memory = new MemoryStream();
|
|
while (memory.Length < MaxResponseBytes)
|
|
{
|
|
int read = await stream.ReadAsync(buffer, ct).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 TimeSpan Timeout => TimeSpan.FromSeconds(_options.TimeoutSeconds > 0 ? _options.TimeoutSeconds : 3);
|
|
private string Terminator => string.IsNullOrEmpty(_options.Terminator) ? "\n" : _options.Terminator;
|
|
|
|
private NetworkStream RequireStream()
|
|
{
|
|
if (!IsConnected || _stream is null)
|
|
throw new InvalidOperationException("IT8702P is not connected.");
|
|
return _stream;
|
|
}
|
|
|
|
private void SetConnected(bool connected)
|
|
{
|
|
if (_isConnected == connected) return;
|
|
_isConnected = connected;
|
|
ConnectionChanged?.Invoke(this, connected);
|
|
}
|
|
}
|