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 _logger; private readonly SemaphoreSlim _gate = new(1, 1); private TcpClient? _client; private NetworkStream? _stream; private bool _isConnected; public It87xxLoad(IOptions options, ILogger 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? 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 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 Task SetModeAsync(LoadMode mode, CancellationToken ct = default) => throw new NotSupportedException("IT8702P load control commands are not enabled; this driver only reads V/I/P."); public Task SetValueAsync(double value, CancellationToken ct = default) => throw new NotSupportedException("IT8702P load control commands are not enabled; this driver only reads V/I/P."); public Task InputAsync(bool on, CancellationToken ct = default) => throw new NotSupportedException("IT8702P load input commands are not enabled; this driver only reads V/I/P."); 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 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 QueryIdentityAsync(CancellationToken ct = default) => QueryAsync("*IDN?", ct); public async Task 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); 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 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); } }