diff --git a/Log/modbus-log.txt b/Log/modbus-log.txt new file mode 100644 index 0000000..bc50a09 --- /dev/null +++ b/Log/modbus-log.txt @@ -0,0 +1,4 @@ +2026-07-12 10:04:51.673 | RX | 05 03 30 00 00 00 17 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 FE B7 00 1D 00 00 00 00 00 00 00 00 00 14 FF E9 00 00 00 00 00 00 00 14 33 12 | CRC OK | 150ms +2026-07-12 10:04:51.522 | TX | 05 03 00 00 00 18 44 44 | CRC OK | 0ms +2026-07-12 10:02:53.259 | RX | 04 03 30 03 E3 03 E5 03 E5 03 E4 03 E9 03 E3 03 E7 03 E6 03 E4 03 E6 03 E6 03 E3 03 E5 03 E7 03 E5 03 E3 03 E5 03 E5 03 E3 03 E5 03 E4 03 E3 03 E6 00 00 A9 D0 | CRC OK | 139ms +2026-07-12 10:02:53.117 | TX | 04 03 00 00 00 18 45 95 | CRC OK | 0ms diff --git a/SSPCTester.Devices/Config/DeviceOptions.cs b/SSPCTester.Devices/Config/DeviceOptions.cs index 6aa36c7..8c267f2 100644 --- a/SSPCTester.Devices/Config/DeviceOptions.cs +++ b/SSPCTester.Devices/Config/DeviceOptions.cs @@ -57,6 +57,10 @@ public sealed class PowerSupplyOptions public string IdnCommand { get; set; } = "*IDN?"; public string VoltageCommand { get; set; } = "MEASure:VOLTage?"; public string CurrentCommand { get; set; } = "MEASure:CURRent?"; + public string SetVoltageCommand { get; set; } = "VOLT {0}"; + public string SetCurrentLimitCommand { get; set; } = "CURR {0}"; + public string OutputStateCommand { get; set; } = "OUTP?"; + public string ErrorCommand { get; set; } = "SYST:ERR?"; } public enum LoadQueryMode @@ -78,6 +82,11 @@ public sealed class LoadOptions public int Channel { get; set; } = 1; public bool UseFetch { get; set; } = true; public LoadQueryMode QueryMode { get; set; } = LoadQueryMode.Combined; + public string SetModeCommand { get; set; } = ""; + public string SetValueCommand { get; set; } = "{0} {1}"; + public string ResetCommand { get; set; } = "*RST"; + public string InputStateCommand { get; set; } = "INP?"; + public string ErrorCommand { get; set; } = "SYST:ERR?"; } public sealed class DaqOptions diff --git a/SSPCTester.Devices/Drivers/ConfigurableDevices.cs b/SSPCTester.Devices/Drivers/ConfigurableDevices.cs index 1319a45..9b8027a 100644 --- a/SSPCTester.Devices/Drivers/ConfigurableDevices.cs +++ b/SSPCTester.Devices/Drivers/ConfigurableDevices.cs @@ -61,8 +61,10 @@ public sealed class ConfigurablePowerSupply : IPowerSupply public Task SetVoltageAsync(double volts, CancellationToken ct = default) => Current.SetVoltageAsync(volts, ct); public Task SetCurrentLimitAsync(double amps, CancellationToken ct = default) => Current.SetCurrentLimitAsync(amps, ct); public Task OutputAsync(bool on, CancellationToken ct = default) => Current.OutputAsync(on, ct); + public Task GetOutputStateAsync(CancellationToken ct = default) => Current.GetOutputStateAsync(ct); public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => Current.ReadAsync(ct); public Task QueryIdentityAsync(CancellationToken ct = default) => Current.QueryIdentityAsync(ct); + public Task QueryErrorAsync(CancellationToken ct = default) => Current.QueryErrorAsync(ct); private void Forward(object? sender, bool connected) => ConnectionChanged?.Invoke(this, connected); private static async Task SafeDisconnect(IDeviceConnection device) { try { await device.DisconnectAsync(); } catch (NotImplementedException) { } } } @@ -86,9 +88,12 @@ public sealed class ConfigurableLoad : ILoad public Task SetModeAsync(LoadMode mode, CancellationToken ct = default) => Current.SetModeAsync(mode, ct); public Task SetValueAsync(double value, CancellationToken ct = default) => Current.SetValueAsync(value, ct); public Task InputAsync(bool on, CancellationToken ct = default) => Current.InputAsync(on, ct); + public Task GetInputStateAsync(CancellationToken ct = default) => Current.GetInputStateAsync(ct); + public Task ResetAsync(CancellationToken ct = default) => Current.ResetAsync(ct); public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => Current.ReadAsync(ct); public Task ReadPowerAsync(CancellationToken ct = default) => Current.ReadPowerAsync(ct); public Task QueryIdentityAsync(CancellationToken ct = default) => Current.QueryIdentityAsync(ct); + public Task QueryErrorAsync(CancellationToken ct = default) => Current.QueryErrorAsync(ct); private void Forward(object? sender, bool connected) => ConnectionChanged?.Invoke(this, connected); private static async Task SafeDisconnect(IDeviceConnection device) { try { await device.DisconnectAsync(); } catch (NotImplementedException) { } } } diff --git a/SSPCTester.Devices/Drivers/Mock/MockLoad.cs b/SSPCTester.Devices/Drivers/Mock/MockLoad.cs index 0cb75f2..62b3d2f 100644 --- a/SSPCTester.Devices/Drivers/Mock/MockLoad.cs +++ b/SSPCTester.Devices/Drivers/Mock/MockLoad.cs @@ -36,6 +36,17 @@ public sealed class MockLoad : MockDeviceBase, ILoad _input = on; } + public Task GetInputStateAsync(CancellationToken ct = default) => + Task.FromResult(_input); + + public async Task ResetAsync(CancellationToken ct = default) + { + await Task.Delay(10, ct).ConfigureAwait(false); + _mode = LoadMode.CC; + _value = 0; + _input = false; + } + public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) { if (!_input) return Task.FromResult((0.0, 0.0)); @@ -57,4 +68,7 @@ public sealed class MockLoad : MockDeviceBase, ILoad public Task QueryIdentityAsync(CancellationToken ct = default) => Task.FromResult("ITECH,IT8702P,MOCK,1.0"); + + public Task QueryErrorAsync(CancellationToken ct = default) => + Task.FromResult("0,\"No error\""); } diff --git a/SSPCTester.Devices/Drivers/Mock/MockPowerSupply.cs b/SSPCTester.Devices/Drivers/Mock/MockPowerSupply.cs index 2617df2..801b786 100644 --- a/SSPCTester.Devices/Drivers/Mock/MockPowerSupply.cs +++ b/SSPCTester.Devices/Drivers/Mock/MockPowerSupply.cs @@ -36,6 +36,9 @@ public sealed class MockPowerSupply : MockDeviceBase, IPowerSupply _output = on; } + public Task GetOutputStateAsync(CancellationToken ct = default) => + Task.FromResult(_output); + public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) { if (!_output) return Task.FromResult((0.0, 0.0)); @@ -47,4 +50,7 @@ public sealed class MockPowerSupply : MockDeviceBase, IPowerSupply public Task QueryIdentityAsync(CancellationToken ct = default) => Task.FromResult("UNI-T,UDP5080-100,MOCK,1.0"); + + public Task QueryErrorAsync(CancellationToken ct = default) => + Task.FromResult("0,\"No error\""); } diff --git a/SSPCTester.Devices/Drivers/Real/It87xxLoad.cs b/SSPCTester.Devices/Drivers/Real/It87xxLoad.cs index fe60709..60bfc34 100644 --- a/SSPCTester.Devices/Drivers/Real/It87xxLoad.cs +++ b/SSPCTester.Devices/Drivers/Real/It87xxLoad.cs @@ -18,6 +18,7 @@ public sealed class It87xxLoad : ILoad private TcpClient? _client; private NetworkStream? _stream; private bool _isConnected; + private LoadMode _mode = LoadMode.CC; public It87xxLoad(IOptions options, ILogger logger) { @@ -77,15 +78,42 @@ public sealed class It87xxLoad : ILoad 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 async Task SetModeAsync(LoadMode mode, CancellationToken ct = default) + { + _mode = mode; + if (string.IsNullOrWhiteSpace(_options.SetModeCommand)) + { + if (mode == LoadMode.CC) + return; - 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."); + 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 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); @@ -121,6 +149,8 @@ public sealed class It87xxLoad : ILoad public Task QueryIdentityAsync(CancellationToken ct = default) => QueryAsync("*IDN?", ct); + public Task QueryErrorAsync(CancellationToken ct = default) => QueryAsync(_options.ErrorCommand, ct); + public async Task QueryAsync(string command, CancellationToken ct = default) { await _gate.WaitAsync(ct).ConfigureAwait(false); @@ -175,6 +205,40 @@ public sealed class It87xxLoad : ILoad 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); diff --git a/SSPCTester.Devices/Drivers/Real/RealDrivers.cs b/SSPCTester.Devices/Drivers/Real/RealDrivers.cs index 0c9e148..b42f276 100644 --- a/SSPCTester.Devices/Drivers/Real/RealDrivers.cs +++ b/SSPCTester.Devices/Drivers/Real/RealDrivers.cs @@ -13,8 +13,10 @@ public sealed class Udp5080Placeholder : IPowerSupply public Task SetVoltageAsync(double volts, CancellationToken ct = default) => throw new NotImplementedException(); public Task SetCurrentLimitAsync(double amps, CancellationToken ct = default) => throw new NotImplementedException(); public Task OutputAsync(bool on, CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetOutputStateAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task QueryIdentityAsync(CancellationToken ct = default) => throw new NotImplementedException(); + public Task QueryErrorAsync(CancellationToken ct = default) => throw new NotImplementedException(); private void Raise(bool s) => ConnectionChanged?.Invoke(this, s); } diff --git a/SSPCTester.Devices/Drivers/Real/Udp5080.cs b/SSPCTester.Devices/Drivers/Real/Udp5080.cs index 07e4ca8..8ab625c 100644 --- a/SSPCTester.Devices/Drivers/Real/Udp5080.cs +++ b/SSPCTester.Devices/Drivers/Real/Udp5080.cs @@ -61,7 +61,7 @@ public sealed class Udp5080 : IPowerSupply _client = null; client.Dispose(); SetConnected(false); - throw new InvalidOperationException($"UDP5080 连接失败:{_options.Host}:{_options.Port},{ex.Message}", ex); + throw new InvalidOperationException($"UDP5080 connection failed: {_options.Host}:{_options.Port}, {ex.Message}", ex); } } @@ -75,15 +75,24 @@ public sealed class Udp5080 : IPowerSupply return Task.CompletedTask; } - public Task SetVoltageAsync(double volts, CancellationToken ct = default) => - throw new NotSupportedException("UDP5080 当前仅接入实时测量,未启用未验证的电压设置 SCPI 命令。"); + public Task SetVoltageAsync(double volts, CancellationToken ct = default) + { + ValidateSetpoint(volts, nameof(volts)); + return SendAsync(FormatCommand(_options.SetVoltageCommand, volts), ct); + } - public Task SetCurrentLimitAsync(double amps, CancellationToken ct = default) => - throw new NotSupportedException("UDP5080 当前仅接入实时测量,未启用未验证的电流限制 SCPI 命令。"); + public Task SetCurrentLimitAsync(double amps, CancellationToken ct = default) + { + ValidateSetpoint(amps, nameof(amps)); + return SendAsync(FormatCommand(_options.SetCurrentLimitCommand, amps), ct); + } public Task OutputAsync(bool on, CancellationToken ct = default) => SendAsync(on ? ":OUTput ON" : ":OUTput OFF", ct); + public async Task GetOutputStateAsync(CancellationToken ct = default) => + ParseSwitchState(await QueryAsync(_options.OutputStateCommand, ct).ConfigureAwait(false)); + public async Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) { try @@ -101,6 +110,8 @@ public sealed class Udp5080 : IPowerSupply public Task QueryIdentityAsync(CancellationToken ct = default) => QueryAsync(_options.IdnCommand, ct); + public Task QueryErrorAsync(CancellationToken ct = default) => QueryAsync(_options.ErrorCommand, ct); + public async Task QueryAsync(string command, CancellationToken ct = default) { await _gate.WaitAsync(ct).ConfigureAwait(false); @@ -112,7 +123,7 @@ public sealed class Udp5080 : IPowerSupply catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException) { SetConnected(false); - throw new InvalidOperationException($"UDP5080 查询失败:{command},{ex.Message}", ex); + throw new InvalidOperationException($"UDP5080 query failed: {command}, {ex.Message}", ex); } finally { @@ -149,14 +160,37 @@ public sealed class Udp5080 : IPowerSupply response.Trim(), @"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"); if (!match.Success) - throw new FormatException($"UDP5080 响应中没有数值:{response}"); + throw new FormatException($"UDP5080 response does not contain a number: {response}"); var value = double.Parse(match.Value, CultureInfo.InvariantCulture); if (!double.IsFinite(value)) - throw new FormatException($"UDP5080 响应数值无效:{response}"); + throw new FormatException($"UDP5080 response number is not finite: {response}"); return value; } + 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($"UDP5080 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, double value) + { + string commandTemplate = string.IsNullOrWhiteSpace(template) ? "{0}" : template.Trim(); + return string.Format(CultureInfo.InvariantCulture, commandTemplate, value); + } + private TimeSpan Timeout => TimeSpan.FromSeconds(_options.TimeoutSeconds > 0 ? _options.TimeoutSeconds : 2); private string Terminator => string.IsNullOrEmpty(_options.Terminator) ? "\n" : _options.Terminator; @@ -222,7 +256,7 @@ public sealed class Udp5080 : IPowerSupply private NetworkStream RequireStream() { if (!IsConnected || _stream is null) - throw new InvalidOperationException("UDP5080 尚未连接。"); + throw new InvalidOperationException("UDP5080 is not connected."); return _stream; } diff --git a/SSPCTester.Devices/Interfaces/ILoad.cs b/SSPCTester.Devices/Interfaces/ILoad.cs index 569ad42..df12036 100644 --- a/SSPCTester.Devices/Interfaces/ILoad.cs +++ b/SSPCTester.Devices/Interfaces/ILoad.cs @@ -31,10 +31,17 @@ public interface ILoad : IDeviceConnection /// 开启 / 关闭负载输入。 Task InputAsync(bool on, CancellationToken ct = default); + /// 从设备查询当前输入开关状态。 + Task GetInputStateAsync(CancellationToken ct = default); + + Task ResetAsync(CancellationToken ct = default); + /// 读取实际电压、电流。 Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default); Task ReadPowerAsync(CancellationToken ct = default); Task QueryIdentityAsync(CancellationToken ct = default); + + Task QueryErrorAsync(CancellationToken ct = default); } diff --git a/SSPCTester.Devices/Interfaces/IPowerSupply.cs b/SSPCTester.Devices/Interfaces/IPowerSupply.cs index 378f2d4..c716c9c 100644 --- a/SSPCTester.Devices/Interfaces/IPowerSupply.cs +++ b/SSPCTester.Devices/Interfaces/IPowerSupply.cs @@ -14,9 +14,15 @@ public interface IPowerSupply : IDeviceConnection /// 开启 / 关闭输出。 Task OutputAsync(bool on, CancellationToken ct = default); + /// 从设备查询当前输出开关状态。 + Task GetOutputStateAsync(CancellationToken ct = default); + /// 读取当前实际电压、电流。 Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default); /// Query instrument identity for connection diagnostics. Task QueryIdentityAsync(CancellationToken ct = default); + + /// Query instrument error queue/status text. + Task QueryErrorAsync(CancellationToken ct = default); } diff --git a/SSPCTester.Tests/InterfacePowerTests.cs b/SSPCTester.Tests/InterfacePowerTests.cs index 1000c6c..781f163 100644 --- a/SSPCTester.Tests/InterfacePowerTests.cs +++ b/SSPCTester.Tests/InterfacePowerTests.cs @@ -138,11 +138,13 @@ public sealed class InterfacePowerTests public Task DisconnectAsync() => Task.CompletedTask; public Task SetModeAsync(LoadMode mode, CancellationToken ct = default) => throw new InvalidOperationException(); public Task SetValueAsync(double value, CancellationToken ct = default) => throw new InvalidOperationException(); + public Task ResetAsync(CancellationToken ct = default) => throw new InvalidOperationException(); public Task InputAsync(bool on, CancellationToken ct = default) { if (on) InputOnCount++; return Task.CompletedTask; } + public Task GetInputStateAsync(CancellationToken ct = default) => Task.FromResult(true); public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => Task.FromResult((28.0, 1.0)); public Task ReadPowerAsync(CancellationToken ct = default) { @@ -150,6 +152,7 @@ public sealed class InterfacePowerTests return Task.FromResult(new PowerReading(28.0, 1.0, 27.5)); } public Task QueryIdentityAsync(CancellationToken ct = default) => Task.FromResult("it8702p"); + public Task QueryErrorAsync(CancellationToken ct = default) => Task.FromResult("0,\"No error\""); } private sealed class FixedPowerSupply : IPowerSupply @@ -167,8 +170,10 @@ public sealed class InterfacePowerTests if (on) OutputOnCount++; return Task.CompletedTask; } + public Task GetOutputStateAsync(CancellationToken ct = default) => Task.FromResult(true); public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => Task.FromResult((30.0, 1.0)); public Task QueryIdentityAsync(CancellationToken ct = default) => Task.FromResult("fixed"); + public Task QueryErrorAsync(CancellationToken ct = default) => Task.FromResult("0,\"No error\""); } private sealed class ForbiddenPowerSupply : IPowerSupply @@ -181,8 +186,10 @@ public sealed class InterfacePowerTests public Task SetVoltageAsync(double volts, CancellationToken ct = default) => throw new InvalidOperationException(); public Task SetCurrentLimitAsync(double amps, CancellationToken ct = default) => throw new InvalidOperationException(); public Task OutputAsync(bool on, CancellationToken ct = default) => throw new InvalidOperationException(); + public Task GetOutputStateAsync(CancellationToken ct = default) => throw new InvalidOperationException(); public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => throw new InvalidOperationException(); public Task QueryIdentityAsync(CancellationToken ct = default) => throw new InvalidOperationException(); + public Task QueryErrorAsync(CancellationToken ct = default) => throw new InvalidOperationException(); } private sealed class ForbiddenLoad : ILoad @@ -194,10 +201,13 @@ public sealed class InterfacePowerTests public Task DisconnectAsync() => Task.CompletedTask; public Task SetModeAsync(LoadMode mode, CancellationToken ct = default) => throw new InvalidOperationException(); public Task SetValueAsync(double value, CancellationToken ct = default) => throw new InvalidOperationException(); + public Task ResetAsync(CancellationToken ct = default) => throw new InvalidOperationException(); public Task InputAsync(bool on, CancellationToken ct = default) => throw new InvalidOperationException(); + public Task GetInputStateAsync(CancellationToken ct = default) => throw new InvalidOperationException(); public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => throw new InvalidOperationException(); public Task ReadPowerAsync(CancellationToken ct = default) => throw new InvalidOperationException(); public Task QueryIdentityAsync(CancellationToken ct = default) => throw new InvalidOperationException(); + public Task QueryErrorAsync(CancellationToken ct = default) => throw new InvalidOperationException(); } } #pragma warning restore CS0067 diff --git a/SSPCTester.Tests/It87xxLoadTests.cs b/SSPCTester.Tests/It87xxLoadTests.cs index 6d62b9f..3e05935 100644 --- a/SSPCTester.Tests/It87xxLoadTests.cs +++ b/SSPCTester.Tests/It87xxLoadTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SSPCTester.Devices.Config; using SSPCTester.Devices.Drivers.Real; +using SSPCTester.Devices.Interfaces; namespace SSPCTester.Tests; @@ -13,10 +14,12 @@ public sealed class It87xxLoadTests [Fact] public async Task CombinedQuery_ReadsVoltageCurrentAndPower() { + var sync = new object(); var commands = new List(); await using var server = await FakeScpiServer.StartAsync(command => { - commands.Add(command); + lock (sync) + commands.Add(command); return command switch { "*IDN?" => "ITECH,IT8702P,SN,1.0", @@ -65,6 +68,71 @@ public sealed class It87xxLoadTests Assert.Equal(3.0, reading.Watts, 6); } + [Fact] + public async Task ControlCommands_SetCurrentAndReset() + { + var sync = new object(); + var commands = new List(); + await using var server = await FakeScpiServer.StartAsync(command => + { + lock (sync) + commands.Add(command); + return command switch + { + "*IDN?" => "ITECH,IT8702P,SN,1.0", + "SYST:REM" => FakeScpiServer.NoResponse, + "CHAN 1" => FakeScpiServer.NoResponse, + "CURR 1.25" => FakeScpiServer.NoResponse, + "INP?" => "0", + "*RST" => FakeScpiServer.NoResponse, + "SYST:LOC" => FakeScpiServer.NoResponse, + _ => "0" + }; + }); + var driver = CreateDriver(server.Port, LoadQueryMode.Combined); + + Assert.True(await driver.ConnectAsync()); + await driver.SetModeAsync(LoadMode.CC); + await driver.SetValueAsync(1.25); + Assert.False(await driver.GetInputStateAsync()); + await driver.ResetAsync(); + await driver.DisconnectAsync(); + await WaitForCommandAsync("SYST:LOC"); + + string[] sentCommands; + lock (sync) + sentCommands = commands.ToArray(); + + Assert.Contains("CURR 1.25", sentCommands); + Assert.Contains("INP?", sentCommands); + Assert.Contains("*RST", sentCommands); + Assert.Equal(2, sentCommands.Count(x => x == "SYST:REM")); + Assert.Equal(2, sentCommands.Count(x => x == "CHAN 1")); + Assert.DoesNotContain("MODE CC", sentCommands); + + async Task WaitForCommandAsync(string expected) + { + for (int i = 0; i < 20; i++) + { + lock (sync) + { + if (commands.Contains(expected)) + return; + } + + await Task.Delay(10); + } + } + } + + [Theory] + [InlineData("ON", true)] + [InlineData("1", true)] + [InlineData("OFF", false)] + [InlineData("0", false)] + public void ParseSwitchState_AcceptsScpiValues(string response, bool expected) => + Assert.Equal(expected, It87xxLoad.ParseSwitchState(response)); + [Fact] public async Task QueryFailure_MarksDisconnected() { @@ -141,19 +209,32 @@ public sealed class It87xxLoadTests using var client = await _listener.AcceptTcpClientAsync(_cts.Token); await using var stream = client.GetStream(); var buffer = new byte[1024]; + var pending = ""; while (!_cts.IsCancellationRequested) { int read = await stream.ReadAsync(buffer, _cts.Token); if (read <= 0) break; - string command = Encoding.ASCII.GetString(buffer, 0, read).Trim(); - string response = _handler(command); - if (response == CloseConnection) break; - if (response == NoResponse) continue; + pending += Encoding.ASCII.GetString(buffer, 0, read); + while (true) + { + int newline = pending.IndexOf('\n'); + if (newline < 0) + break; - byte[] payload = Encoding.ASCII.GetBytes(response + "\n"); - await stream.WriteAsync(payload, _cts.Token); - await stream.FlushAsync(_cts.Token); + string command = pending[..newline].Trim(); + pending = pending[(newline + 1)..]; + if (string.IsNullOrEmpty(command)) + continue; + + string response = _handler(command); + if (response == CloseConnection) return; + if (response == NoResponse) continue; + + byte[] payload = Encoding.ASCII.GetBytes(response + "\n"); + await stream.WriteAsync(payload, _cts.Token); + await stream.FlushAsync(_cts.Token); + } } } } diff --git a/SSPCTester.Tests/Udp5080Tests.cs b/SSPCTester.Tests/Udp5080Tests.cs index a9e708b..4408dba 100644 --- a/SSPCTester.Tests/Udp5080Tests.cs +++ b/SSPCTester.Tests/Udp5080Tests.cs @@ -46,6 +46,64 @@ public sealed class Udp5080Tests Assert.True(driver.IsConnected); } + [Fact] + public async Task TcpDriver_SendsVoltageAndCurrentLimitCommands() + { + var sync = new object(); + var commands = new List(); + await using var server = await FakeScpiServer.StartAsync(command => + { + lock (sync) + commands.Add(command); + return command switch + { + "*IDN?" => "UNI-T,UDP5080-100,AWPK225300032,1.03.0513", + "VOLT 12.345" => FakeScpiServer.NoResponse, + "CURR 1.25" => FakeScpiServer.NoResponse, + "OUTP?" => "1", + _ => "0" + }; + }); + var driver = CreateDriver(server.Port); + + Assert.True(await driver.ConnectAsync()); + await driver.SetVoltageAsync(12.345); + await driver.SetCurrentLimitAsync(1.25); + Assert.True(await driver.GetOutputStateAsync()); + await WaitForCommandAsync("CURR 1.25"); + + string[] sentCommands; + lock (sync) + sentCommands = commands.ToArray(); + + Assert.Contains("VOLT 12.345", sentCommands); + Assert.Contains("CURR 1.25", sentCommands); + Assert.Contains("OUTP?", sentCommands); + + async Task WaitForCommandAsync(string expected) + { + for (int i = 0; i < 20; i++) + { + lock (sync) + { + if (commands.Contains(expected)) + return; + } + + await Task.Delay(10); + } + } + } + + [Theory] + [InlineData("ON", true)] + [InlineData("1", true)] + [InlineData("OFF", false)] + [InlineData("0", false)] + public void ParseSwitchState_AcceptsScpiValues(string response, bool expected) => + Assert.Equal(expected, Udp5080.ParseSwitchState(response)); + + [Fact] public async Task TcpDriver_MarksDisconnectedWhenRemoteCloses() { @@ -92,6 +150,7 @@ public sealed class Udp5080Tests private sealed class FakeScpiServer : IAsyncDisposable { + public const string NoResponse = "__NO_RESPONSE__"; private readonly TcpListener _listener; private readonly Func _handler; private readonly CancellationTokenSource _cts = new(); @@ -129,18 +188,32 @@ public sealed class Udp5080Tests using var client = await _listener.AcceptTcpClientAsync(_cts.Token); await using var stream = client.GetStream(); var buffer = new byte[1024]; + var pending = ""; while (!_cts.IsCancellationRequested) { int read = await stream.ReadAsync(buffer, _cts.Token); if (read <= 0) break; - string command = Encoding.ASCII.GetString(buffer, 0, read).Trim(); - string? response = _handler(command); - if (response is null) break; + pending += Encoding.ASCII.GetString(buffer, 0, read); + while (true) + { + int newline = pending.IndexOf('\n'); + if (newline < 0) + break; - byte[] payload = Encoding.ASCII.GetBytes(response + "\n"); - await stream.WriteAsync(payload, _cts.Token); - await stream.FlushAsync(_cts.Token); + string command = pending[..newline].Trim(); + pending = pending[(newline + 1)..]; + if (string.IsNullOrEmpty(command)) + continue; + + string? response = _handler(command); + if (response is null) return; + if (response == NoResponse) continue; + + byte[] payload = Encoding.ASCII.GetBytes(response + "\n"); + await stream.WriteAsync(payload, _cts.Token); + await stream.FlushAsync(_cts.Token); + } } } } diff --git a/SSPCTester.UI/App.xaml.cs b/SSPCTester.UI/App.xaml.cs index 4ccf699..83a2f78 100644 --- a/SSPCTester.UI/App.xaml.cs +++ b/SSPCTester.UI/App.xaml.cs @@ -130,9 +130,16 @@ public partial class App : Application } var storageOpts = cfg.GetSection(StorageOptions.SectionName).Get() ?? new StorageOptions(); var reportOpts = cfg.GetSection(ReportOptions.SectionName).Get() ?? new ReportOptions(); + // Preset collections must be loaded as complete arrays. ConfigurationBinder + // merges array indexes, which brings deleted default presets back on restart. + var consoleOpts = UserSettingsStore.LoadConsoleOptions() + ?? cfg.GetSection(ConsoleOptions.SectionName).Get() + ?? new ConsoleOptions(); + EnsureConsoleOptions(consoleOpts); services.AddSingleton>(Options.Create(devOpts)); services.AddSingleton>(Options.Create(storageOpts)); services.AddSingleton>(Options.Create(reportOpts)); + services.AddSingleton>(Options.Create(consoleOpts)); services.AddSingleton(); services.AddSingleton(); @@ -173,6 +180,7 @@ public partial class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -233,6 +241,9 @@ public partial class App : Application ["Devices:PowerSupply:IdnCommand"] = "*IDN?", ["Devices:PowerSupply:VoltageCommand"] = "MEASure:VOLTage?", ["Devices:PowerSupply:CurrentCommand"] = "MEASure:CURRent?", + ["Devices:PowerSupply:SetVoltageCommand"] = "VOLT {0}", + ["Devices:PowerSupply:SetCurrentLimitCommand"] = "CURR {0}", + ["Devices:PowerSupply:ErrorCommand"] = "SYST:ERR?", ["Devices:Load:PortName"] = "COM3", ["Devices:Load:UseMock"] = "false", ["Devices:Load:AutoConnect"] = "true", @@ -244,6 +255,10 @@ public partial class App : Application ["Devices:Load:Channel"] = "1", ["Devices:Load:UseFetch"] = "true", ["Devices:Load:QueryMode"] = "Combined", + ["Devices:Load:SetModeCommand"] = "", + ["Devices:Load:SetValueCommand"] = "{0} {1}", + ["Devices:Load:ResetCommand"] = "*RST", + ["Devices:Load:ErrorCommand"] = "SYST:ERR?", ["Devices:Daq:LogicalDeviceId"] = "0", ["Devices:Daq:UseMock"] = "false", ["Devices:Daq:AutoConnect"] = "true", @@ -302,6 +317,14 @@ public partial class App : Application options.Channels = DaqChannelOptionsNormalizer.Normalize(options.Channels); } + private static void EnsureConsoleOptions(ConsoleOptions options) + { + if (options.PowerPresets == null) + options.PowerPresets = ConsoleOptions.DefaultPowerPresets(); + if (options.LoadPresets == null) + options.LoadPresets = ConsoleOptions.DefaultLoadPresets(); + } + private static void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { FatalCrash(e.Exception); diff --git a/SSPCTester.UI/Resources/DataTemplates.xaml b/SSPCTester.UI/Resources/DataTemplates.xaml index 4541e96..1030495 100644 --- a/SSPCTester.UI/Resources/DataTemplates.xaml +++ b/SSPCTester.UI/Resources/DataTemplates.xaml @@ -18,6 +18,9 @@ + + + diff --git a/SSPCTester.UI/Services/AppOptions.cs b/SSPCTester.UI/Services/AppOptions.cs index edce9b6..5e21ec2 100644 --- a/SSPCTester.UI/Services/AppOptions.cs +++ b/SSPCTester.UI/Services/AppOptions.cs @@ -13,3 +13,37 @@ public sealed class ReportOptions public string SavePath { get; set; } = ""; public string Format { get; set; } = "Pdf"; } + +public sealed class ConsoleOptions +{ + public const string SectionName = "Console"; + public List PowerPresets { get; set; } = DefaultPowerPresets(); + public List LoadPresets { get; set; } = DefaultLoadPresets(); + + public static List DefaultPowerPresets() => + [ + new() { Name = "3V / 1A 基础环境", Voltage = 3.0, CurrentLimit = 1.0 }, + new() { Name = "5V / 1A 调试环境", Voltage = 5.0, CurrentLimit = 1.0 }, + new() { Name = "28V / 2A 标准环境", Voltage = 28.0, CurrentLimit = 2.0 } + ]; + + public static List DefaultLoadPresets() => + [ + new() { Name = "1A 基础拉载", Current = 1.0 }, + new() { Name = "0.5A 低负载", Current = 0.5 }, + new() { Name = "2A 调试负载", Current = 2.0 } + ]; +} + +public sealed class PowerPreset +{ + public string Name { get; set; } = ""; + public double Voltage { get; set; } + public double CurrentLimit { get; set; } +} + +public sealed class LoadPreset +{ + public string Name { get; set; } = ""; + public double Current { get; set; } +} diff --git a/SSPCTester.UI/Services/SafetyMonitorService.cs b/SSPCTester.UI/Services/SafetyMonitorService.cs index 76cf6bd..2f339f7 100644 --- a/SSPCTester.UI/Services/SafetyMonitorService.cs +++ b/SSPCTester.UI/Services/SafetyMonitorService.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; +using System.IO; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SSPCTester.Devices.Config; @@ -15,11 +17,17 @@ public sealed class SafetyMonitorService : IDisposable private readonly UiLogSink _log; private readonly ILogger _logger; private readonly SemaphoreSlim _gate = new(1, 1); + private readonly string _traceLogPath; + private readonly object _traceLock = new(); private Timer? _timer; private bool _handlingEmergency; private bool _softwareEmergencyActive; private bool _hardwareEmergencyLatched; private bool _disposed; + private long _pollSequence; + private long _pollGateBusyCount; + private string? _lastStatusSnapshot; + private string? _lastPollSnapshot; public SafetyMonitorService( ISafetyDio safetyDio, @@ -37,6 +45,14 @@ public sealed class SafetyMonitorService : IDisposable _options = options.Value; _log = log; _logger = logger; + + string logDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "SSPCTester", + "Logs"); + Directory.CreateDirectory(logDir); + _traceLogPath = Path.Combine(logDir, $"safety-trace-{DateTime.Now:yyyyMMdd-HHmmss}.log"); + Trace($"Safety trace started. UseMock={_options.Pci2312.UseMock}, AutoConnect={_options.Pci2312.AutoConnect}, Enabled={_options.Pci2312.Enabled}, PollIntervalMs={_options.Pci2312.PollIntervalMs}"); } public bool IsEmergencyActive { get; private set; } @@ -50,6 +66,7 @@ public sealed class SafetyMonitorService : IDisposable if (_timer is not null) return; var options = _options.Pci2312; + Trace($"Start() called. Enabled={options.Enabled}, AutoConnect={options.AutoConnect}, DeviceId={options.DeviceId}, DI={options.EmergencyInputChannel}, DO={options.AlarmOutputChannel}, PollIntervalMs={options.PollIntervalMs}"); if (!options.Enabled) { SetStatus(false, false, "设备故障", "PCI2312 安全监控未启用"); @@ -59,10 +76,13 @@ public sealed class SafetyMonitorService : IDisposable int interval = Math.Clamp(options.PollIntervalMs, 50, 10000); _timer = new Timer(OnTimer, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(interval)); SetStatus(false, false, "设备正常", "正在监控 PCI2312 急停输入"); + _log.Add(UiLogLevel.Info, $"安全观测日志已启用:{_traceLogPath}"); } public async Task TriggerSoftwareEmergencyAsync(CancellationToken ct = default) { + var sw = Stopwatch.StartNew(); + Trace("TriggerSoftwareEmergencyAsync enter."); await _gate.WaitAsync(ct).ConfigureAwait(false); try { @@ -77,6 +97,7 @@ public sealed class SafetyMonitorService : IDisposable await TryShutdownPowerAsync().ConfigureAwait(false); await TryShutdownLoadAsync().ConfigureAwait(false); SetStatus(true, true, "软件急停已触发", "报警灯已接通,SSPC 通道已全部关断,电源输出和负载输入已发送关闭指令"); + Trace($"TriggerSoftwareEmergencyAsync success in {sw.ElapsedMilliseconds}ms."); } catch (Exception ex) { @@ -85,6 +106,7 @@ public sealed class SafetyMonitorService : IDisposable _logger.LogError(ex, "Software emergency shutdown failed."); _log.Add(UiLogLevel.Error, $"软件急停执行异常:{ex.Message}"); SetStatus(true, IsAlarmOutputOn, "设备故障", ex.Message); + Trace($"TriggerSoftwareEmergencyAsync failed in {sw.ElapsedMilliseconds}ms. {ex.GetType().Name}: {ex.Message}"); throw; } finally @@ -108,12 +130,14 @@ public sealed class SafetyMonitorService : IDisposable private async Task ResetCoreAsync(CancellationToken ct) { + Trace("ResetCoreAsync enter."); var options = _options.Pci2312; await EnsureSafetyDioConnectedAsync(ct).ConfigureAwait(false); if (await IsEmergencyInputActiveAsync(options, ct).ConfigureAwait(false)) { const string message = "硬件急停按钮仍处于触发状态,无法执行状态重置。请先旋转或释放设备上的急停按钮,确认急停输入复位后再重试。"; SetStatus(true, IsAlarmOutputOn, "急停已触发", message); + Trace("ResetCoreAsync blocked: emergency input still active."); return new SafetyResetResult(false, message); } @@ -122,6 +146,7 @@ public sealed class SafetyMonitorService : IDisposable _hardwareEmergencyLatched = false; _handlingEmergency = false; SetStatus(false, false, "设备正常", "急停状态已重置,报警灯已关闭,正在监控 PCI2312 输入"); + Trace("ResetCoreAsync success."); return new SafetyResetResult(true, "急停状态已重置。"); } @@ -133,31 +158,52 @@ public sealed class SafetyMonitorService : IDisposable private async Task PollAsync() { - if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return; + if (!await _gate.WaitAsync(0).ConfigureAwait(false)) + { + long skipped = Interlocked.Increment(ref _pollGateBusyCount); + if (skipped % 20 == 0) + Trace($"PollAsync skipped by gate contention. skipped={skipped}"); + return; + } try { + long seq = Interlocked.Increment(ref _pollSequence); var options = _options.Pci2312; if (!options.Enabled) return; if (options.AutoConnect && !_safetyDio.IsConnected) + { + Trace($"Poll#{seq}: attempting auto connect for safety DIO."); await _safetyDio.ConnectAsync().ConfigureAwait(false); + } bool active = await IsEmergencyInputActiveAsync(options, CancellationToken.None).ConfigureAwait(false); + string snapshot = $"poll={seq},active={active},handling={_handlingEmergency},soft={_softwareEmergencyActive},latched={_hardwareEmergencyLatched},emergency={IsEmergencyActive},alarm={IsAlarmOutputOn},dioConnected={_safetyDio.IsConnected}"; + if (!string.Equals(_lastPollSnapshot, snapshot, StringComparison.Ordinal)) + { + _lastPollSnapshot = snapshot; + Trace($"Poll snapshot: {snapshot}"); + } + if (active) { _hardwareEmergencyLatched = true; + Trace($"Poll#{seq}: branch=ACTIVE -> HandleEmergencyAsync"); await HandleEmergencyAsync().ConfigureAwait(false); } else if (_hardwareEmergencyLatched) { + Trace($"Poll#{seq}: branch=HardwareLatchedClear -> ClearHardwareEmergencyAsync"); await ClearHardwareEmergencyAsync(options).ConfigureAwait(false); } else if (_softwareEmergencyActive) { + Trace($"Poll#{seq}: branch=SoftwareActiveWaitReset"); SetStatus(true, IsAlarmOutputOn, "软件急停已触发", "等待状态重置,电源和负载不会自动恢复"); } else if (IsEmergencyActive || IsAlarmOutputOn) { + Trace($"Poll#{seq}: branch=CleanupByStatus -> ClearHardwareEmergencyAsync"); await ClearHardwareEmergencyAsync(options).ConfigureAwait(false); } else @@ -169,6 +215,7 @@ public sealed class SafetyMonitorService : IDisposable { _logger.LogWarning(ex, "Safety monitor poll failed."); SetStatus(IsEmergencyActive, IsAlarmOutputOn, "设备故障", ex.Message); + Trace($"PollAsync failed. {ex.GetType().Name}: {ex.Message}"); } finally { @@ -178,18 +225,37 @@ public sealed class SafetyMonitorService : IDisposable private async Task HandleEmergencyAsync() { - if (_handlingEmergency) return; + if (_handlingEmergency) + { + Trace("HandleEmergencyAsync skipped because _handlingEmergency=true."); + return; + } + + var sw = Stopwatch.StartNew(); _handlingEmergency = true; SetStatus(true, IsAlarmOutputOn, "急停已触发", "正在执行安全关断"); _log.Add(UiLogLevel.Error, "检测到急停输入接通,开始执行报警灯、电源、负载关断。"); + Trace("HandleEmergencyAsync begin."); try { + var step = Stopwatch.StartNew(); await SetAlarmOutputAsync(true, CancellationToken.None).ConfigureAwait(false); + Trace($"HandleEmergencyAsync step SetAlarmOutputAsync OK in {step.ElapsedMilliseconds}ms."); + + step.Restart(); await TryShutdownSspcAsync().ConfigureAwait(false); + Trace($"HandleEmergencyAsync step TryShutdownSspcAsync done in {step.ElapsedMilliseconds}ms."); + + step.Restart(); await TryShutdownPowerAsync().ConfigureAwait(false); + Trace($"HandleEmergencyAsync step TryShutdownPowerAsync done in {step.ElapsedMilliseconds}ms."); + + step.Restart(); await TryShutdownLoadAsync().ConfigureAwait(false); + Trace($"HandleEmergencyAsync step TryShutdownLoadAsync done in {step.ElapsedMilliseconds}ms."); SetStatus(true, true, "急停已触发", "报警灯已接通,SSPC 通道已全部关断,电源输出和负载输入已发送关闭指令"); + Trace($"HandleEmergencyAsync success in {sw.ElapsedMilliseconds}ms."); } catch (Exception ex) { @@ -197,11 +263,14 @@ public sealed class SafetyMonitorService : IDisposable _log.Add(UiLogLevel.Error, $"急停关断执行异常:{ex.Message}"); SetStatus(true, IsAlarmOutputOn, "设备故障", ex.Message); _handlingEmergency = false; + Trace($"HandleEmergencyAsync failed in {sw.ElapsedMilliseconds}ms. {ex.GetType().Name}: {ex.Message}"); } } private async Task ClearHardwareEmergencyAsync(Pci2312Options options) { + var sw = Stopwatch.StartNew(); + Trace("ClearHardwareEmergencyAsync begin."); if (IsAlarmOutputOn) await SetAlarmOutputAsync(false, CancellationToken.None).ConfigureAwait(false); @@ -210,6 +279,7 @@ public sealed class SafetyMonitorService : IDisposable _hardwareEmergencyLatched = false; SetStatus(false, false, "设备正常", $"DI{options.EmergencyInputChannel} 已复位,硬件急停已解除,报警灯已关闭,设备输出不会自动恢复"); _log.Add(UiLogLevel.Info, "硬件急停输入已复位,软件急停状态和报警灯已自动解除。"); + Trace($"ClearHardwareEmergencyAsync success in {sw.ElapsedMilliseconds}ms."); } private async Task TryShutdownSspcAsync() @@ -217,7 +287,10 @@ public sealed class SafetyMonitorService : IDisposable try { if (!_sspc.IsConnected && _options.Sspc.AutoConnect) + { + Trace("TryShutdownSspcAsync: auto-connecting SSPC."); await _sspc.ConnectAsync().ConfigureAwait(false); + } if (_sspc.IsConnected) { for (int ch = 1; ch <= _sspc.ChannelCount; ch++) @@ -236,6 +309,7 @@ public sealed class SafetyMonitorService : IDisposable { _logger.LogWarning(ex, "SSPC 批量关断失败。"); _log.Add(UiLogLevel.Error, $"SSPC 通道批量关断失败:{ex.Message}"); + Trace($"TryShutdownSspcAsync failed. {ex.GetType().Name}: {ex.Message}"); } } @@ -244,7 +318,10 @@ public sealed class SafetyMonitorService : IDisposable try { if (!_power.IsConnected && _options.PowerSupply.AutoConnect) + { + Trace("TryShutdownPowerAsync: auto-connecting power supply."); await _power.ConnectAsync().ConfigureAwait(false); + } if (_power.IsConnected) await _power.OutputAsync(false).ConfigureAwait(false); _log.Add(UiLogLevel.Warning, "UDP5080 电源输出已发送关闭指令。"); @@ -253,6 +330,7 @@ public sealed class SafetyMonitorService : IDisposable { _logger.LogWarning(ex, "Power shutdown failed."); _log.Add(UiLogLevel.Error, $"UDP5080 电源关闭失败:{ex.Message}"); + Trace($"TryShutdownPowerAsync failed. {ex.GetType().Name}: {ex.Message}"); } } @@ -261,7 +339,10 @@ public sealed class SafetyMonitorService : IDisposable try { if (!_load.IsConnected && _options.Load.AutoConnect) + { + Trace("TryShutdownLoadAsync: auto-connecting load."); await _load.ConnectAsync().ConfigureAwait(false); + } if (_load.IsConnected) await _load.InputAsync(false).ConfigureAwait(false); _log.Add(UiLogLevel.Warning, "IT8702P 负载输入已发送关闭指令。"); @@ -270,6 +351,7 @@ public sealed class SafetyMonitorService : IDisposable { _logger.LogWarning(ex, "Load shutdown failed."); _log.Add(UiLogLevel.Error, $"IT8702P 负载关闭失败:{ex.Message}"); + Trace($"TryShutdownLoadAsync failed. {ex.GetType().Name}: {ex.Message}"); } } @@ -294,11 +376,21 @@ public sealed class SafetyMonitorService : IDisposable throw new InvalidOperationException("PCI2312 安全输入输出未启用,无法控制报警灯。"); if (!_safetyDio.IsConnected) + { + Trace("EnsureSafetyDioConnectedAsync: connecting safety DIO."); await _safetyDio.ConnectAsync(ct).ConfigureAwait(false); + } } private void SetStatus(bool emergencyActive, bool alarmOutputOn, string statusText, string detailText) { + string snapshot = $"emergency={emergencyActive},alarm={alarmOutputOn},status={statusText},detail={detailText}"; + if (!string.Equals(_lastStatusSnapshot, snapshot, StringComparison.Ordinal)) + { + _lastStatusSnapshot = snapshot; + Trace($"SetStatus changed: {snapshot}"); + } + IsEmergencyActive = emergencyActive; IsAlarmOutputOn = alarmOutputOn; StatusText = statusText; @@ -308,10 +400,27 @@ public sealed class SafetyMonitorService : IDisposable public void Dispose() { + Trace("Dispose() called. Safety monitor disposed."); _disposed = true; _timer?.Dispose(); _gate.Dispose(); } + + private void Trace(string message) + { + string line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [T{Environment.CurrentManagedThreadId}] {message}{Environment.NewLine}"; + try + { + lock (_traceLock) + { + File.AppendAllText(_traceLogPath, line); + } + } + catch + { + // Temporary diagnostic trace should never break runtime behavior. + } + } } public sealed record SafetyResetResult(bool Success, string Message); diff --git a/SSPCTester.UI/Services/UserSettingsStore.cs b/SSPCTester.UI/Services/UserSettingsStore.cs index d05052d..0680361 100644 --- a/SSPCTester.UI/Services/UserSettingsStore.cs +++ b/SSPCTester.UI/Services/UserSettingsStore.cs @@ -17,23 +17,64 @@ public sealed class UserSettingsStore : IDisposable private readonly DeviceOptions _devices; private readonly StorageOptions _storage; private readonly ReportOptions _report; + private readonly ConsoleOptions _console; private readonly SemaphoreSlim _writeLock = new(1, 1); public UserSettingsStore( IOptions devices, IOptions storage, - IOptions report) + IOptions report, + IOptions console) { _devices = devices.Value; _storage = storage.Value; _report = report.Value; + _console = console.Value; } public static string SettingsPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SSPCTester", "settings.json"); + /// + /// Loads console presets directly from the user settings JSON so preset arrays + /// replace the defaults as a whole. IConfiguration merges arrays by index and + /// cannot represent a deleted item or an explicitly empty preset list. + /// + public static ConsoleOptions? LoadConsoleOptions() + { + try + { + string path = SettingsPath; + if (!File.Exists(path)) return null; + + using var document = JsonDocument.Parse(File.ReadAllText(path)); + if (!document.RootElement.TryGetProperty(ConsoleOptions.SectionName, out var consoleElement)) + return null; + + var console = consoleElement.Deserialize(JsonOptions); + if (console is null) return null; + + EnsureConsoleDefaults(console); + return console; + } + catch + { + return null; + } + } + public async Task SaveAsync() + { + await SaveAsync(_devices, _storage, _report, _console).ConfigureAwait(false); + } + + public async Task SaveAsync(DeviceOptions devices, StorageOptions storage, ReportOptions report) + { + await SaveAsync(devices, storage, report, _console).ConfigureAwait(false); + } + + public async Task SaveAsync(DeviceOptions devices, StorageOptions storage, ReportOptions report, ConsoleOptions console) { await _writeLock.WaitAsync().ConfigureAwait(false); try @@ -41,21 +82,58 @@ public sealed class UserSettingsStore : IDisposable string path = SettingsPath; Directory.CreateDirectory(Path.GetDirectoryName(path)!); string temp = path + ".tmp"; - _devices.Daq.Channels = DaqChannelOptionsNormalizer.Normalize(_devices.Daq.Channels); + var snapshot = CreateSnapshot(devices, storage, report, console); var model = new { - Devices = _devices, - Storage = _storage, - Report = _report + Devices = snapshot.Devices, + Storage = snapshot.Storage, + Report = snapshot.Report, + Console = snapshot.Console }; await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(model, JsonOptions)).ConfigureAwait(false); File.Move(temp, path, true); - WriteDaqSaveTrace(path); + WriteDaqSaveTrace(path, snapshot.Devices); } finally { _writeLock.Release(); } } - private void WriteDaqSaveTrace(string settingsPath) + public SettingsSnapshot? LoadSnapshot() + { + try + { + string path = SettingsPath; + if (!File.Exists(path)) return null; + var json = File.ReadAllText(path); + var payload = JsonSerializer.Deserialize(json, JsonOptions); + if (payload is null) return null; + return CreateSnapshot(payload.Devices, payload.Storage, payload.Report, payload.Console); + } + catch + { + return null; + } + } + + private static SettingsSnapshot CreateSnapshot(DeviceOptions devices, StorageOptions storage, ReportOptions report, ConsoleOptions console) + { + var copiedDevices = JsonSerializer.Deserialize(JsonSerializer.Serialize(devices, JsonOptions)) ?? new DeviceOptions(); + var copiedStorage = JsonSerializer.Deserialize(JsonSerializer.Serialize(storage, JsonOptions)) ?? new StorageOptions(); + var copiedReport = JsonSerializer.Deserialize(JsonSerializer.Serialize(report, JsonOptions)) ?? new ReportOptions(); + var copiedConsole = JsonSerializer.Deserialize(JsonSerializer.Serialize(console, JsonOptions)) ?? new ConsoleOptions(); + copiedDevices.Daq.Channels = DaqChannelOptionsNormalizer.Normalize(copiedDevices.Daq.Channels); + EnsureConsoleDefaults(copiedConsole); + return new SettingsSnapshot(copiedDevices, copiedStorage, copiedReport, copiedConsole); + } + + private static void EnsureConsoleDefaults(ConsoleOptions console) + { + if (console.PowerPresets == null) + console.PowerPresets = ConsoleOptions.DefaultPowerPresets(); + if (console.LoadPresets == null) + console.LoadPresets = ConsoleOptions.DefaultLoadPresets(); + } + + private void WriteDaqSaveTrace(string settingsPath, DeviceOptions devices) { try { @@ -63,15 +141,25 @@ public sealed class UserSettingsStore : IDisposable Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SSPCTester", "Logs"); Directory.CreateDirectory(logDir); - string channels = string.Join(", ", _devices.Daq.Channels + string channels = string.Join(", ", devices.Daq.Channels .OrderBy(x => x.PhysicalChannel) .Select(x => $"CH{x.PhysicalChannel}:{x.Role}/{x.IsCalibrated}")); File.AppendAllText( Path.Combine(logDir, "settings-save.log"), - $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {settingsPath} Pre={_devices.Daq.PreTriggerMs:R} Post={_devices.Daq.PostTriggerMs:R} Channels=[{channels}]{Environment.NewLine}"); + $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {settingsPath} Pre={devices.Daq.PreTriggerMs:R} Post={devices.Daq.PostTriggerMs:R} Channels=[{channels}]{Environment.NewLine}"); } catch { } } public void Dispose() => _writeLock.Dispose(); -} \ No newline at end of file +} + +public sealed record SettingsSnapshot(DeviceOptions Devices, StorageOptions Storage, ReportOptions Report, ConsoleOptions Console); + +internal sealed class SettingsPayload +{ + public DeviceOptions Devices { get; set; } = new(); + public StorageOptions Storage { get; set; } = new(); + public ReportOptions Report { get; set; } = new(); + public ConsoleOptions Console { get; set; } = new(); +} diff --git a/SSPCTester.UI/ViewModels/ExperimentConsoleViewModel.cs b/SSPCTester.UI/ViewModels/ExperimentConsoleViewModel.cs new file mode 100644 index 0000000..4a31b11 --- /dev/null +++ b/SSPCTester.UI/ViewModels/ExperimentConsoleViewModel.cs @@ -0,0 +1,679 @@ +using System.Collections.ObjectModel; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Options; +using SSPCTester.Devices.Interfaces; +using SSPCTester.UI.Services; + +namespace SSPCTester.UI.ViewModels; + +public partial class ExperimentConsoleViewModel : ObservableObject +{ + private readonly IPowerSupply _power; + private readonly ISspc _sspc; + private readonly ILoad _load; + private readonly ConsoleOptions _options; + private readonly UserSettingsStore _settingsStore; + private readonly UiLogSink _log; + private readonly object _pollLock = new(); + private CancellationTokenSource? _pollCts; + private Task? _pollTask; + + [ObservableProperty] private bool _busy; + [ObservableProperty] private double _powerVoltageSetpoint = 3.0; + [ObservableProperty] private double _powerCurrentLimit = 1.0; + [ObservableProperty] private PowerPreset? _selectedPowerPreset; + [ObservableProperty] private double _powerVoltage = double.NaN; + [ObservableProperty] private double _powerCurrent = double.NaN; + [ObservableProperty] private double _powerWatts = double.NaN; + [ObservableProperty] private bool? _isPowerOutputOn; + [ObservableProperty] private string _powerOutputStateText = "未知"; + [ObservableProperty] private string _powerModeText = "未知"; + [ObservableProperty] private string _powerProtectionStateText = "未连接"; + + [ObservableProperty] private double _loadCurrentSetpoint = 1.0; + [ObservableProperty] private LoadPreset? _selectedLoadPreset; + [ObservableProperty] private double _loadVoltage = double.NaN; + [ObservableProperty] private double _loadCurrent = double.NaN; + [ObservableProperty] private double _loadWatts = double.NaN; + [ObservableProperty] private bool? _isLoadInputOn; + [ObservableProperty] private string _loadInputStateText = "未知"; + [ObservableProperty] private string _loadProtectionStateText = "未连接"; + + public ObservableCollection PowerPresets { get; } = new(); + public ObservableCollection LoadPresets { get; } = new(); + public ObservableCollection Channels { get; } = new(); + + public ExperimentConsoleViewModel( + IPowerSupply power, + ISspc sspc, + ILoad load, + IOptions options, + UserSettingsStore settingsStore, + UiLogSink log) + { + _power = power; + _sspc = sspc; + _load = load; + _options = options.Value; + _settingsStore = settingsStore; + _log = log; + + foreach (var preset in _options.PowerPresets) + PowerPresets.Add(Clone(preset)); + foreach (var preset in _options.LoadPresets) + LoadPresets.Add(Clone(preset)); + for (int i = 1; i <= _sspc.ChannelCount; i++) + Channels.Add(new ExperimentConsoleChannelRow(i)); + + SelectedPowerPreset = PowerPresets.FirstOrDefault(); + SelectedLoadPreset = LoadPresets.FirstOrDefault(); + + _power.ConnectionChanged += (_, _) => RaiseConnectionStatus(); + _sspc.ConnectionChanged += (_, _) => RaiseConnectionStatus(); + _load.ConnectionChanged += (_, _) => RaiseConnectionStatus(); + } + + public string PowerConnectionStatus => _power.IsConnected ? "已连接" : "未连接"; + public string RelayConnectionStatus => _sspc.IsConnected ? "已连接" : "未连接"; + public string LoadConnectionStatus => _load.IsConnected ? "已连接" : "未连接"; + public bool IsPowerConnected => _power.IsConnected; + public bool IsRelayConnected => _sspc.IsConnected; + public bool IsLoadConnected => _load.IsConnected; + public bool CanTurnPowerOutputOn => IsPowerOutputOn != true; + public bool CanTurnPowerOutputOff => IsPowerOutputOn != false; + public bool CanTurnLoadInputOn => IsLoadInputOn != true; + public bool CanTurnLoadInputOff => IsLoadInputOn != false; + + partial void OnIsPowerOutputOnChanged(bool? value) + { + OnPropertyChanged(nameof(CanTurnPowerOutputOn)); + OnPropertyChanged(nameof(CanTurnPowerOutputOff)); + } + + partial void OnIsLoadInputOnChanged(bool? value) + { + OnPropertyChanged(nameof(CanTurnLoadInputOn)); + OnPropertyChanged(nameof(CanTurnLoadInputOff)); + } + + partial void OnSelectedPowerPresetChanged(PowerPreset? value) + { + DeletePowerPresetCommand.NotifyCanExecuteChanged(); + if (value == null) return; + PowerVoltageSetpoint = value.Voltage; + PowerCurrentLimit = value.CurrentLimit; + } + + partial void OnSelectedLoadPresetChanged(LoadPreset? value) + { + DeleteLoadPresetCommand.NotifyCanExecuteChanged(); + if (value == null) return; + LoadCurrentSetpoint = value.Current; + } + + public void SetActive(bool active) + { + if (active) + StartPolling(); + else + StopPolling(); + } + + [RelayCommand] + private async Task ApplyPower() + { + if (!ValidateNonNegative(PowerVoltageSetpoint, "电源电压设定") || + !ValidateNonNegative(PowerCurrentLimit, "电源电流限值")) + return; + + await RunDeviceActionAsync("应用电源设置", async ct => + { + if (!await EnsureConnectedAsync(_power, "电源", ct)) return; + await _power.SetVoltageAsync(PowerVoltageSetpoint, ct); + await _power.SetCurrentLimitAsync(PowerCurrentLimit, ct); + _log.Add(UiLogLevel.Success, $"电源设置已应用:V={PowerVoltageSetpoint:F3}V,限流={PowerCurrentLimit:F3}A。"); + await RefreshPowerAsync(ct); + }); + } + + [RelayCommand] + private async Task SavePowerPreset() + { + if (!ValidateNonNegative(PowerVoltageSetpoint, "电源电压设定") || + !ValidateNonNegative(PowerCurrentLimit, "电源电流限值")) + return; + + string name = $"{PowerVoltageSetpoint:F3}V / {PowerCurrentLimit:F3}A"; + var existing = PowerPresets.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + if (existing == null) + { + existing = new PowerPreset { Name = name }; + PowerPresets.Add(existing); + } + + existing.Voltage = PowerVoltageSetpoint; + existing.CurrentLimit = PowerCurrentLimit; + SelectedPowerPreset = existing; + await PersistPresetsAsync(); + _log.Add(UiLogLevel.Success, $"电源预设已保存:{existing.Name}。"); + } + + private bool CanDeletePowerPreset() => SelectedPowerPreset != null; + + [RelayCommand(CanExecute = nameof(CanDeletePowerPreset))] + private async Task DeletePowerPreset() + { + var preset = SelectedPowerPreset; + if (preset == null) return; + if (!Confirm($"确认删除电源预设:{preset.Name}?")) return; + + int index = PowerPresets.IndexOf(preset); + PowerPresets.Remove(preset); + SelectedPowerPreset = PowerPresets.Count == 0 + ? null + : PowerPresets[Math.Clamp(index, 0, PowerPresets.Count - 1)]; + await PersistPresetsAsync(); + _log.Add(UiLogLevel.Warning, $"电源预设已删除:{preset.Name}。"); + } + + [RelayCommand] + private async Task PowerOutputOn() => await SetPowerOutputAsync(true); + + [RelayCommand] + private async Task PowerOutputOff() => await SetPowerOutputAsync(false); + + [RelayCommand] + private async Task PowerSafeReset() + { + if (!Confirm("确认执行电源安全复位?")) return; + + await RunDeviceActionAsync("电源安全复位", async ct => + { + if (!await EnsureConnectedAsync(_power, "电源", ct)) return; + await _power.OutputAsync(false, ct); + await _power.SetVoltageAsync(0, ct); + await _power.SetCurrentLimitAsync(0, ct); + PowerVoltageSetpoint = 0; + PowerCurrentLimit = 0; + SelectedPowerPreset = null; + _log.Add(UiLogLevel.Warning, "电源已安全复位:输出关闭,电压和电流限值设为 0。"); + await RefreshPowerAsync(ct); + }); + } + + [RelayCommand] + private async Task ApplyLoad() + { + if (!ValidateNonNegative(LoadCurrentSetpoint, "负载电流")) return; + + await RunDeviceActionAsync("应用负载设置", async ct => + { + if (!await EnsureConnectedAsync(_load, "负载", ct)) return; + await _load.SetModeAsync(LoadMode.CC, ct); + await _load.SetValueAsync(LoadCurrentSetpoint, ct); + _log.Add(UiLogLevel.Success, $"负载设置已应用:CC {LoadCurrentSetpoint:F3}A。"); + await RefreshLoadAsync(ct); + }); + } + + [RelayCommand] + private async Task SaveLoadPreset() + { + if (!ValidateNonNegative(LoadCurrentSetpoint, "负载电流")) return; + + string name = $"{LoadCurrentSetpoint:F3}A"; + var existing = LoadPresets.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + if (existing == null) + { + existing = new LoadPreset { Name = name }; + LoadPresets.Add(existing); + } + + existing.Current = LoadCurrentSetpoint; + SelectedLoadPreset = existing; + await PersistPresetsAsync(); + _log.Add(UiLogLevel.Success, $"负载预设已保存:{existing.Name}。"); + } + + private bool CanDeleteLoadPreset() => SelectedLoadPreset != null; + + [RelayCommand(CanExecute = nameof(CanDeleteLoadPreset))] + private async Task DeleteLoadPreset() + { + var preset = SelectedLoadPreset; + if (preset == null) return; + if (!Confirm($"确认删除负载预设:{preset.Name}?")) return; + + int index = LoadPresets.IndexOf(preset); + LoadPresets.Remove(preset); + SelectedLoadPreset = LoadPresets.Count == 0 + ? null + : LoadPresets[Math.Clamp(index, 0, LoadPresets.Count - 1)]; + await PersistPresetsAsync(); + _log.Add(UiLogLevel.Warning, $"负载预设已删除:{preset.Name}。"); + } + + [RelayCommand] + private async Task LoadInputOn() => await SetLoadInputAsync(true); + + [RelayCommand] + private async Task LoadInputOff() => await SetLoadInputAsync(false); + + [RelayCommand] + private async Task LoadSafeReset() + { + if (!Confirm("确认执行负载安全复位?")) return; + + await RunDeviceActionAsync("负载安全复位", async ct => + { + if (!await EnsureConnectedAsync(_load, "负载", ct)) return; + await _load.InputAsync(false, ct); + await _load.ResetAsync(ct); + await _load.SetModeAsync(LoadMode.CC, ct); + await _load.SetValueAsync(0, ct); + await _load.InputAsync(false, ct); + LoadCurrentSetpoint = 0; + SelectedLoadPreset = null; + _log.Add(UiLogLevel.Warning, "负载已安全复位:输入关闭,CC 电流设为 0。"); + await RefreshLoadAsync(ct); + }); + } + + [RelayCommand] + private async Task RelayAllOn() + { + if (!Confirm("确认全开 24 路通道?")) return; + + await RunDeviceActionAsync("继电器全开", async ct => + { + if (!await EnsureConnectedAsync(_sspc, "继电器", ct)) return; + foreach (var row in Channels) + await _sspc.TurnOnAsync(row.Channel, ct); + _log.Add(UiLogLevel.Warning, "继电器 24 路通道已全开。"); + await RefreshRelayAsync(ct); + }); + } + + [RelayCommand] + private async Task RelayAllOff() + { + if (!Confirm("确认全关 24 路通道?")) return; + + await RunDeviceActionAsync("继电器全关", async ct => + { + if (!await EnsureConnectedAsync(_sspc, "继电器", ct)) return; + foreach (var row in Channels) + await _sspc.TurnOffAsync(row.Channel, ct); + _log.Add(UiLogLevel.Warning, "继电器 24 路通道已全关。"); + await RefreshRelayAsync(ct); + }); + } + + [RelayCommand] + private async Task ReadRelayAll() + { + await RunDeviceActionAsync("读取继电器", async ct => + { + if (!await EnsureConnectedAsync(_sspc, "继电器", ct)) return; + await RefreshRelayAsync(ct); + }); + } + + [RelayCommand] + private async Task TurnRelayOn(ExperimentConsoleChannelRow? row) + { + if (row == null) return; + await RunDeviceActionAsync($"CH{row.Channel:00} 开", async ct => + { + if (!await EnsureConnectedAsync(_sspc, "继电器", ct)) return; + await _sspc.TurnOnAsync(row.Channel, ct); + await RefreshRelayChannelAsync(row, ct); + }); + } + + [RelayCommand] + private async Task TurnRelayOff(ExperimentConsoleChannelRow? row) + { + if (row == null) return; + await RunDeviceActionAsync($"CH{row.Channel:00} 关", async ct => + { + if (!await EnsureConnectedAsync(_sspc, "继电器", ct)) return; + await _sspc.TurnOffAsync(row.Channel, ct); + await RefreshRelayChannelAsync(row, ct); + }); + } + + private async Task SetPowerOutputAsync(bool on) + { + await RunDeviceActionAsync(on ? "电源输出开" : "电源输出关", async ct => + { + if (!await EnsureConnectedAsync(_power, "电源", ct)) return; + await _power.OutputAsync(on, ct); + _log.Add(UiLogLevel.Success, on ? "电源输出已开启。" : "电源输出已关闭。"); + await RefreshPowerAsync(ct); + }); + } + + private async Task SetLoadInputAsync(bool on) + { + await RunDeviceActionAsync(on ? "负载输入开" : "负载输入关", async ct => + { + if (!await EnsureConnectedAsync(_load, "负载", ct)) return; + await _load.InputAsync(on, ct); + _log.Add(UiLogLevel.Success, on ? "负载输入已开启。" : "负载输入已关闭。"); + await RefreshLoadAsync(ct); + }); + } + + private void StartPolling() + { + lock (_pollLock) + { + if (_pollTask is { IsCompleted: false }) return; + _pollCts = new CancellationTokenSource(); + _pollTask = PollAsync(_pollCts.Token); + } + } + + private void StopPolling() + { + Task? taskToObserve; + CancellationTokenSource? ctsToDispose; + lock (_pollLock) + { + taskToObserve = _pollTask; + ctsToDispose = _pollCts; + _pollTask = null; + _pollCts = null; + ctsToDispose?.Cancel(); + } + + if (taskToObserve != null) + _ = taskToObserve.ContinueWith(_ => ctsToDispose?.Dispose(), TaskScheduler.Default); + else + ctsToDispose?.Dispose(); + } + + private async Task PollAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + await RefreshAllConnectedAsync(ct).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + } + + private async Task RefreshAllConnectedAsync(CancellationToken ct) + { + if (_power.IsConnected) + await RefreshPowerAsync(ct).ConfigureAwait(false); + else + await RunOnUiAsync(() => + { + IsPowerOutputOn = null; + PowerOutputStateText = "未连接"; + PowerProtectionStateText = "未连接"; + }).ConfigureAwait(false); + if (_load.IsConnected) + await RefreshLoadAsync(ct).ConfigureAwait(false); + else + await RunOnUiAsync(() => + { + IsLoadInputOn = null; + LoadInputStateText = "未连接"; + LoadProtectionStateText = "未连接"; + }).ConfigureAwait(false); + if (_sspc.IsConnected) + await RefreshRelayAsync(ct).ConfigureAwait(false); + } + + private async Task RefreshPowerAsync(CancellationToken ct) + { + try + { + var (volts, amps) = await _power.ReadAsync(ct).ConfigureAwait(false); + bool outputOn = await _power.GetOutputStateAsync(ct).ConfigureAwait(false); + string errorState = await QueryErrorStateAsync(_power, ct).ConfigureAwait(false); + await RunOnUiAsync(() => + { + PowerVoltage = volts; + PowerCurrent = Math.Abs(amps); + PowerWatts = Math.Abs(volts * amps); + PowerModeText = InferPowerMode(Math.Abs(amps)); + IsPowerOutputOn = outputOn; + PowerOutputStateText = outputOn ? "ON" : "OFF"; + PowerProtectionStateText = errorState; + }).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) + { + await RunOnUiAsync(() => + { + IsPowerOutputOn = null; + PowerOutputStateText = _power.IsConnected ? "未知" : "未连接"; + PowerProtectionStateText = _power.IsConnected ? "通讯异常" : "未连接"; + }).ConfigureAwait(false); + _log.Add(UiLogLevel.Error, $"电源读数刷新失败:{ex.Message}"); + } + } + + private async Task RefreshLoadAsync(CancellationToken ct) + { + try + { + var reading = await _load.ReadPowerAsync(ct).ConfigureAwait(false); + bool inputOn = await _load.GetInputStateAsync(ct).ConfigureAwait(false); + string errorState = await QueryErrorStateAsync(_load, ct).ConfigureAwait(false); + await RunOnUiAsync(() => + { + LoadVoltage = reading.Volts; + LoadCurrent = Math.Abs(reading.Amps); + LoadWatts = Math.Abs(reading.Watts); + IsLoadInputOn = inputOn; + LoadInputStateText = inputOn ? "ON" : "OFF"; + LoadProtectionStateText = errorState; + }).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) + { + await RunOnUiAsync(() => + { + IsLoadInputOn = null; + LoadInputStateText = _load.IsConnected ? "未知" : "未连接"; + LoadProtectionStateText = _load.IsConnected ? "通讯异常" : "未连接"; + }).ConfigureAwait(false); + _log.Add(UiLogLevel.Error, $"负载读数刷新失败:{ex.Message}"); + } + } + + private async Task RefreshRelayAsync(CancellationToken ct) + { + try + { + var measurements = await _sspc.ReadMeasurementsAsync(ct).ConfigureAwait(false); + await RunOnUiAsync(() => + { + foreach (var measurement in measurements) + { + var row = Channels.FirstOrDefault(x => x.Channel == measurement.PhysicalSlot); + row?.Apply(measurement.Voltage, Math.Abs(measurement.Current)); + } + }).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) + { + _log.Add(UiLogLevel.Error, $"继电器读数刷新失败:{ex.Message}"); + } + } + + private async Task RefreshRelayChannelAsync(ExperimentConsoleChannelRow row, CancellationToken ct) + { + try + { + var measurement = await _sspc.ReadMeasurementAsync(row.Channel, ct).ConfigureAwait(false); + await RunOnUiAsync(() => row.Apply(measurement.Voltage, Math.Abs(measurement.Current))).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) + { + _log.Add(UiLogLevel.Error, $"CH{row.Channel:00} 读数刷新失败:{ex.Message}"); + } + } + + private async Task RunDeviceActionAsync(string actionName, Func action) + { + if (Busy) return; + Busy = true; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + try + { + await action(cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _log.Add(UiLogLevel.Error, $"{actionName} 超时或已取消。"); + } + catch (Exception ex) + { + _log.Add(UiLogLevel.Error, $"{actionName} 失败:{ex.Message}"); + } + finally + { + await RunOnUiAsync(() => + { + Busy = false; + RaiseConnectionStatus(); + }).ConfigureAwait(false); + } + } + + private async Task EnsureConnectedAsync(IDeviceConnection device, string name, CancellationToken ct) + { + if (device.IsConnected) return true; + + bool connected = await device.ConnectAsync(ct).ConfigureAwait(false); + await RunOnUiAsync(RaiseConnectionStatus).ConfigureAwait(false); + if (connected && device.IsConnected) return true; + + _log.Add(UiLogLevel.Warning, $"{name} 未连接,操作未执行。"); + return false; + } + + private string InferPowerMode(double amps) + { + if (!IsValidNonNegative(PowerCurrentLimit) || PowerCurrentLimit <= 0 || double.IsNaN(amps)) + return "未知"; + return amps >= PowerCurrentLimit * 0.95 ? "CC" : "CV"; + } + + private static async Task QueryErrorStateAsync(IPowerSupply power, CancellationToken ct) + { + if (!power.IsConnected) return "未连接"; + return FormatErrorState(await power.QueryErrorAsync(ct).ConfigureAwait(false)); + } + + private static async Task QueryErrorStateAsync(ILoad load, CancellationToken ct) + { + if (!load.IsConnected) return "未连接"; + return FormatErrorState(await load.QueryErrorAsync(ct).ConfigureAwait(false)); + } + + private static string FormatErrorState(string? response) + { + string text = (response ?? "").Trim(); + if (string.IsNullOrWhiteSpace(text)) + return "正常"; + + string lower = text.ToLowerInvariant(); + string firstPart = text.Split(',', 2)[0].Trim().Trim('"'); + bool zeroCode = int.TryParse(firstPart.TrimStart('+'), out int code) && code == 0; + if (zeroCode || lower.Contains("no error") || lower.Contains("noerror")) + return "正常"; + + return text.Length > 64 ? $"异常:{text[..64]}..." : $"异常:{text}"; + } + + private bool ValidateNonNegative(double value, string name) + { + if (IsValidNonNegative(value)) return true; + _log.Add(UiLogLevel.Error, $"{name} 必须是有限非负数。"); + return false; + } + + private static bool IsValidNonNegative(double value) => + double.IsFinite(value) && value >= 0; + + private async Task PersistPresetsAsync() + { + _options.PowerPresets = PowerPresets.Select(Clone).ToList(); + _options.LoadPresets = LoadPresets.Select(Clone).ToList(); + await _settingsStore.SaveAsync().ConfigureAwait(false); + } + + private static PowerPreset Clone(PowerPreset source) => new() + { + Name = source.Name, + Voltage = source.Voltage, + CurrentLimit = source.CurrentLimit + }; + + private static LoadPreset Clone(LoadPreset source) => new() + { + Name = source.Name, + Current = source.Current + }; + + private static bool Confirm(string message) => + MessageBox.Show(message, "确认操作", MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.Yes; + + private void RaiseConnectionStatus() + { + OnPropertyChanged(nameof(PowerConnectionStatus)); + OnPropertyChanged(nameof(RelayConnectionStatus)); + OnPropertyChanged(nameof(LoadConnectionStatus)); + OnPropertyChanged(nameof(IsPowerConnected)); + OnPropertyChanged(nameof(IsRelayConnected)); + OnPropertyChanged(nameof(IsLoadConnected)); + } + + private static Task RunOnUiAsync(Action action) + { + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher == null || dispatcher.CheckAccess()) + { + action(); + return Task.CompletedTask; + } + + return dispatcher.InvokeAsync(action).Task; + } +} + +public partial class ExperimentConsoleChannelRow : ObservableObject +{ + public ExperimentConsoleChannelRow(int channel) + { + Channel = channel; + } + + public int Channel { get; } + public string ChannelText => $"CH{Channel:00}"; + + [ObservableProperty] private double _voltage = double.NaN; + [ObservableProperty] private double _current = double.NaN; + + public void Apply(double voltage, double current) + { + Voltage = voltage; + Current = current; + } +} diff --git a/SSPCTester.UI/ViewModels/MainViewModel.cs b/SSPCTester.UI/ViewModels/MainViewModel.cs index c266b72..3ae2e0e 100644 --- a/SSPCTester.UI/ViewModels/MainViewModel.cs +++ b/SSPCTester.UI/ViewModels/MainViewModel.cs @@ -163,7 +163,10 @@ public partial class MainViewModel : ObservableObject { SafetyStatusText = _safetyMonitor.StatusText; SafetyDetailText = _safetyMonitor.DetailText; - AlarmLampActive = _safetyMonitor.IsAlarmOutputOn; + // Enter the alarm UI state as soon as an emergency is detected. The + // physical alarm output and the sequential device shutdown can take + // time, so waiting for the DO state makes the UI appear unresponsive. + AlarmLampActive = _safetyMonitor.IsEmergencyActive || _safetyMonitor.IsAlarmOutputOn; EmergencyButtonText = AlarmLampActive ? "状态重置" : "报警急停"; }); } diff --git a/SSPCTester.UI/ViewModels/PowerTestViewModel.cs b/SSPCTester.UI/ViewModels/PowerTestViewModel.cs index ec4ed92..a788de5 100644 --- a/SSPCTester.UI/ViewModels/PowerTestViewModel.cs +++ b/SSPCTester.UI/ViewModels/PowerTestViewModel.cs @@ -11,10 +11,10 @@ namespace SSPCTester.UI.ViewModels; /// 功率损耗页 VM。 public partial class PowerTestViewModel : ObservableObject { - private readonly ISspc _sspc; private readonly IPowerSupply _power; private readonly ILoad _load; private readonly UiLogSink _log; + private bool _syncingSelection; public ObservableCollection Rows { get; } = new(); public ObservableCollection Channels { get; } = new(); @@ -29,7 +29,6 @@ public partial class PowerTestViewModel : ObservableObject public PowerTestViewModel(ISspc sspc, IPowerSupply power, ILoad load, UiLogSink log) { - _sspc = sspc; _power = power; _load = load; _log = log; @@ -49,48 +48,34 @@ public partial class PowerTestViewModel : ObservableObject p.PowerResults.Add(new PowerLossRow { Channel = i }); } foreach (var r in p.PowerResults) Rows.Add(r); - Current = Rows.FirstOrDefault(); + SyncCurrentFromSelectedChannel(); } - [RelayCommand] - private async Task TurnOnCurrentChannel() + partial void OnSelectedChannelChanged(int value) { - Busy = true; - int ch = SelectedChannel; + if (_syncingSelection) return; + SyncCurrentFromSelectedChannel(); + } + + partial void OnCurrentChanged(PowerLossRow? value) + { + if (_syncingSelection || value == null) return; + if (SelectedChannel != value.Channel) + SelectedChannel = value.Channel; + } + + private void SyncCurrentFromSelectedChannel() + { + _syncingSelection = true; try { - if (!_sspc.IsConnected) await _sspc.ConnectAsync(); - await _sspc.TurnOnAsync(ch); - _log.Add(UiLogLevel.Info, $"CH{ch} 已开启。"); - } - catch (Exception ex) - { - _log.Add(UiLogLevel.Error, $"CH{ch} 开启失败:{ex.Message}"); + Current = Rows.FirstOrDefault(x => x.Channel == SelectedChannel) + ?? Project?.PowerResults.FirstOrDefault(x => x.Channel == SelectedChannel) + ?? Rows.FirstOrDefault(); } finally { - Busy = false; - } - } - - [RelayCommand] - private async Task TurnOffCurrentChannel() - { - Busy = true; - int ch = SelectedChannel; - try - { - if (!_sspc.IsConnected) await _sspc.ConnectAsync(); - await _sspc.TurnOffAsync(ch); - _log.Add(UiLogLevel.Info, $"CH{ch} 已关断。"); - } - catch (Exception ex) - { - _log.Add(UiLogLevel.Error, $"CH{ch} 关断失败:{ex.Message}"); - } - finally - { - Busy = false; + _syncingSelection = false; } } @@ -194,7 +179,7 @@ public partial class PowerTestViewModel : ObservableObject row.Status = TestStatus.Untested; } - Current = Rows.FirstOrDefault(); + SyncCurrentFromSelectedChannel(); _log.Add(UiLogLevel.Info, "功率损耗结果已清空。"); } diff --git a/SSPCTester.UI/ViewModels/SettingsViewModel.Manual.cs b/SSPCTester.UI/ViewModels/SettingsViewModel.Manual.cs index 046023d..4e5633c 100644 --- a/SSPCTester.UI/ViewModels/SettingsViewModel.Manual.cs +++ b/SSPCTester.UI/ViewModels/SettingsViewModel.Manual.cs @@ -51,9 +51,10 @@ public partial class SettingsViewModel : ObservableObject _settingsStore = settingsStore; _projectStore = projectStore; - Devices = CloneDeviceOptions(_runtimeDevices); - Storage = CloneStorageOptions(_runtimeStorage); - Report = CloneReportOptions(_runtimeReport); + var persisted = _settingsStore.LoadSnapshot(); + Devices = CloneDeviceOptions(persisted?.Devices ?? _runtimeDevices); + Storage = CloneStorageOptions(persisted?.Storage ?? _runtimeStorage); + Report = CloneReportOptions(persisted?.Report ?? _runtimeReport); Devices.Daq.Channels = DaqChannelOptionsNormalizer.Normalize(Devices.Daq.Channels); LoadDaqChannelEditors(); @@ -173,11 +174,27 @@ public partial class SettingsViewModel : ObservableObject { ApplyDaqChannelEditorsToDraft(); Devices.Daq.Channels = DaqChannelOptionsNormalizer.Normalize(Devices.Daq.Channels); - ApplyToRuntime(Devices, _runtimeDevices); - ApplyToRuntime(Storage, _runtimeStorage); - ApplyToRuntime(Report, _runtimeReport); + await _settingsStore.SaveAsync(Devices, Storage, Report); + + var persisted = _settingsStore.LoadSnapshot(); + if (persisted is not null) + { + ApplyToRuntime(persisted.Devices, _runtimeDevices); + ApplyToRuntime(persisted.Storage, _runtimeStorage); + ApplyToRuntime(persisted.Report, _runtimeReport); + ApplyToRuntime(persisted.Devices, Devices); + ApplyToRuntime(persisted.Storage, Storage); + ApplyToRuntime(persisted.Report, Report); + } + else + { + ApplyToRuntime(Devices, _runtimeDevices); + ApplyToRuntime(Storage, _runtimeStorage); + ApplyToRuntime(Report, _runtimeReport); + } + + LoadDaqChannelEditors(); ApplyProjectsRootToStore(); - await _settingsStore.SaveAsync(); OnPropertyChanged(nameof(HasUnsavedChanges)); OnPropertyChanged(nameof(DaqValidationMessage)); if (logSaved) @@ -585,7 +602,10 @@ public partial class SettingsViewModel : ObservableObject Terminator = source.Terminator, IdnCommand = source.IdnCommand, VoltageCommand = source.VoltageCommand, - CurrentCommand = source.CurrentCommand + CurrentCommand = source.CurrentCommand, + SetVoltageCommand = source.SetVoltageCommand, + SetCurrentLimitCommand = source.SetCurrentLimitCommand, + ErrorCommand = source.ErrorCommand }; private static LoadOptions CloneLoadOptions(LoadOptions source) => new() @@ -600,7 +620,11 @@ public partial class SettingsViewModel : ObservableObject Terminator = source.Terminator, Channel = source.Channel, UseFetch = source.UseFetch, - QueryMode = source.QueryMode + QueryMode = source.QueryMode, + SetModeCommand = source.SetModeCommand, + SetValueCommand = source.SetValueCommand, + ResetCommand = source.ResetCommand, + ErrorCommand = source.ErrorCommand }; private static DaqOptions CloneDaqOptions(DaqOptions source) => new() @@ -692,6 +716,9 @@ public partial class SettingsViewModel : ObservableObject target.IdnCommand = source.IdnCommand; target.VoltageCommand = source.VoltageCommand; target.CurrentCommand = source.CurrentCommand; + target.SetVoltageCommand = source.SetVoltageCommand; + target.SetCurrentLimitCommand = source.SetCurrentLimitCommand; + target.ErrorCommand = source.ErrorCommand; } private static void ApplyToRuntime(LoadOptions source, LoadOptions target) @@ -707,6 +734,10 @@ public partial class SettingsViewModel : ObservableObject target.Channel = source.Channel; target.UseFetch = source.UseFetch; target.QueryMode = source.QueryMode; + target.SetModeCommand = source.SetModeCommand; + target.SetValueCommand = source.SetValueCommand; + target.ResetCommand = source.ResetCommand; + target.ErrorCommand = source.ErrorCommand; } private static void ApplyToRuntime(DaqOptions source, DaqOptions target) diff --git a/SSPCTester.UI/ViewModels/TestHostViewModel.cs b/SSPCTester.UI/ViewModels/TestHostViewModel.cs index bdfad59..339c645 100644 --- a/SSPCTester.UI/ViewModels/TestHostViewModel.cs +++ b/SSPCTester.UI/ViewModels/TestHostViewModel.cs @@ -8,17 +8,29 @@ public partial class TestHostViewModel : ObservableObject { [ObservableProperty] private Project? _project; [ObservableProperty] private TestSessionViewModel? _session; + [ObservableProperty] private int _selectedTabIndex; + private bool _isViewActive; + public ExperimentConsoleViewModel Console { get; } public BasicTestViewModel Basic { get; } public CurveTestViewModel Curve { get; } public PowerTestViewModel Power { get; } public ProtectViewModel Protect { get; } - public TestHostViewModel(BasicTestViewModel basic, CurveTestViewModel curve, PowerTestViewModel power, ProtectViewModel protect) + public TestHostViewModel(ExperimentConsoleViewModel console, BasicTestViewModel basic, CurveTestViewModel curve, PowerTestViewModel power, ProtectViewModel protect) { + Console = console; Basic = basic; Curve = curve; Power = power; Protect = protect; } + partial void OnSelectedTabIndexChanged(int value) => UpdateConsoleActive(); + + public void SetViewActive(bool active) + { + _isViewActive = active; + UpdateConsoleActive(); + } + public void SetProject(Project p) { Project = p; @@ -34,4 +46,9 @@ public partial class TestHostViewModel : ObservableObject Curve.AttachSession(s); Protect.AttachSession(s); } + + private void UpdateConsoleActive() + { + Console.SetActive(_isViewActive && SelectedTabIndex == 0); + } } diff --git a/SSPCTester.UI/Views/ExperimentConsoleView.xaml b/SSPCTester.UI/Views/ExperimentConsoleView.xaml new file mode 100644 index 0000000..1598064 --- /dev/null +++ b/SSPCTester.UI/Views/ExperimentConsoleView.xaml @@ -0,0 +1,419 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +