添加控制台并验证全部测试项

This commit is contained in:
zhaotielin 2026-07-12 12:07:41 +08:00
parent b8a92220f9
commit 96bdd42528
31 changed files with 1895 additions and 100 deletions

4
Log/modbus-log.txt Normal file
View File

@ -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

View File

@ -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

View File

@ -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<bool> GetOutputStateAsync(CancellationToken ct = default) => Current.GetOutputStateAsync(ct);
public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => Current.ReadAsync(ct);
public Task<string> QueryIdentityAsync(CancellationToken ct = default) => Current.QueryIdentityAsync(ct);
public Task<string> 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<bool> 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<PowerReading> ReadPowerAsync(CancellationToken ct = default) => Current.ReadPowerAsync(ct);
public Task<string> QueryIdentityAsync(CancellationToken ct = default) => Current.QueryIdentityAsync(ct);
public Task<string> 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) { } }
}

View File

@ -36,6 +36,17 @@ public sealed class MockLoad : MockDeviceBase, ILoad
_input = on;
}
public Task<bool> 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<string> QueryIdentityAsync(CancellationToken ct = default) =>
Task.FromResult("ITECH,IT8702P,MOCK,1.0");
public Task<string> QueryErrorAsync(CancellationToken ct = default) =>
Task.FromResult("0,\"No error\"");
}

View File

@ -36,6 +36,9 @@ public sealed class MockPowerSupply : MockDeviceBase, IPowerSupply
_output = on;
}
public Task<bool> 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<string> QueryIdentityAsync(CancellationToken ct = default) =>
Task.FromResult("UNI-T,UDP5080-100,MOCK,1.0");
public Task<string> QueryErrorAsync(CancellationToken ct = default) =>
Task.FromResult("0,\"No error\"");
}

View File

@ -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<DeviceOptions> options, ILogger<It87xxLoad> 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<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);
@ -121,6 +149,8 @@ public sealed class It87xxLoad : ILoad
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);
@ -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);

View File

@ -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<bool> GetOutputStateAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<string> QueryIdentityAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<string> QueryErrorAsync(CancellationToken ct = default) => throw new NotImplementedException();
private void Raise(bool s) => ConnectionChanged?.Invoke(this, s);
}

View File

@ -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<bool> 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<string> QueryIdentityAsync(CancellationToken ct = default) => QueryAsync(_options.IdnCommand, 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);
@ -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;
}

View File

@ -31,10 +31,17 @@ public interface ILoad : IDeviceConnection
/// <summary>开启 / 关闭负载输入。</summary>
Task InputAsync(bool on, CancellationToken ct = default);
/// <summary>从设备查询当前输入开关状态。</summary>
Task<bool> GetInputStateAsync(CancellationToken ct = default);
Task ResetAsync(CancellationToken ct = default);
/// <summary>读取实际电压、电流。</summary>
Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default);
Task<PowerReading> ReadPowerAsync(CancellationToken ct = default);
Task<string> QueryIdentityAsync(CancellationToken ct = default);
Task<string> QueryErrorAsync(CancellationToken ct = default);
}

View File

@ -14,9 +14,15 @@ public interface IPowerSupply : IDeviceConnection
/// <summary>开启 / 关闭输出。</summary>
Task OutputAsync(bool on, CancellationToken ct = default);
/// <summary>从设备查询当前输出开关状态。</summary>
Task<bool> GetOutputStateAsync(CancellationToken ct = default);
/// <summary>读取当前实际电压、电流。</summary>
Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default);
/// <summary>Query instrument identity for connection diagnostics.</summary>
Task<string> QueryIdentityAsync(CancellationToken ct = default);
/// <summary>Query instrument error queue/status text.</summary>
Task<string> QueryErrorAsync(CancellationToken ct = default);
}

View File

@ -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<bool> 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<PowerReading> 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<string> QueryIdentityAsync(CancellationToken ct = default) => Task.FromResult("it8702p");
public Task<string> 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<bool> 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<string> QueryIdentityAsync(CancellationToken ct = default) => Task.FromResult("fixed");
public Task<string> 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<bool> GetOutputStateAsync(CancellationToken ct = default) => throw new InvalidOperationException();
public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => throw new InvalidOperationException();
public Task<string> QueryIdentityAsync(CancellationToken ct = default) => throw new InvalidOperationException();
public Task<string> 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<bool> GetInputStateAsync(CancellationToken ct = default) => throw new InvalidOperationException();
public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => throw new InvalidOperationException();
public Task<PowerReading> ReadPowerAsync(CancellationToken ct = default) => throw new InvalidOperationException();
public Task<string> QueryIdentityAsync(CancellationToken ct = default) => throw new InvalidOperationException();
public Task<string> QueryErrorAsync(CancellationToken ct = default) => throw new InvalidOperationException();
}
}
#pragma warning restore CS0067

View File

@ -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,9 +14,11 @@ public sealed class It87xxLoadTests
[Fact]
public async Task CombinedQuery_ReadsVoltageCurrentAndPower()
{
var sync = new object();
var commands = new List<string>();
await using var server = await FakeScpiServer.StartAsync(command =>
{
lock (sync)
commands.Add(command);
return command switch
{
@ -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<string>();
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,14 +209,26 @@ 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();
pending += Encoding.ASCII.GetString(buffer, 0, read);
while (true)
{
int newline = pending.IndexOf('\n');
if (newline < 0)
break;
string command = pending[..newline].Trim();
pending = pending[(newline + 1)..];
if (string.IsNullOrEmpty(command))
continue;
string response = _handler(command);
if (response == CloseConnection) break;
if (response == CloseConnection) return;
if (response == NoResponse) continue;
byte[] payload = Encoding.ASCII.GetBytes(response + "\n");
@ -158,4 +238,5 @@ public sealed class It87xxLoadTests
}
}
}
}
}

View File

@ -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<string>();
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<string, string?> _handler;
private readonly CancellationTokenSource _cts = new();
@ -129,14 +188,27 @@ 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();
pending += Encoding.ASCII.GetString(buffer, 0, read);
while (true)
{
int newline = pending.IndexOf('\n');
if (newline < 0)
break;
string command = pending[..newline].Trim();
pending = pending[(newline + 1)..];
if (string.IsNullOrEmpty(command))
continue;
string? response = _handler(command);
if (response is null) break;
if (response is null) return;
if (response == NoResponse) continue;
byte[] payload = Encoding.ASCII.GetBytes(response + "\n");
await stream.WriteAsync(payload, _cts.Token);
@ -145,4 +217,5 @@ public sealed class Udp5080Tests
}
}
}
}
}

View File

@ -130,9 +130,16 @@ public partial class App : Application
}
var storageOpts = cfg.GetSection(StorageOptions.SectionName).Get<StorageOptions>() ?? new StorageOptions();
var reportOpts = cfg.GetSection(ReportOptions.SectionName).Get<ReportOptions>() ?? 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<ConsoleOptions>()
?? new ConsoleOptions();
EnsureConsoleOptions(consoleOpts);
services.AddSingleton<IOptions<DeviceOptions>>(Options.Create(devOpts));
services.AddSingleton<IOptions<StorageOptions>>(Options.Create(storageOpts));
services.AddSingleton<IOptions<ReportOptions>>(Options.Create(reportOpts));
services.AddSingleton<IOptions<ConsoleOptions>>(Options.Create(consoleOpts));
services.AddSingleton<MockSspc>();
services.AddSingleton<SspcModbus>();
@ -173,6 +180,7 @@ public partial class App : Application
services.AddTransient<HomeViewModel>();
services.AddTransient<ProjectInfoViewModel>();
services.AddTransient<TestHostViewModel>();
services.AddTransient<ExperimentConsoleViewModel>();
services.AddTransient<BasicTestViewModel>();
services.AddTransient<CurveTestViewModel>();
services.AddTransient<PowerTestViewModel>();
@ -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);

View File

@ -18,6 +18,9 @@
<DataTemplate DataType="{x:Type vm:ReportViewModel}">
<v:ReportView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:ExperimentConsoleViewModel}">
<v:ExperimentConsoleView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:BasicTestViewModel}">
<v:BasicTestView />
</DataTemplate>

View File

@ -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<PowerPreset> PowerPresets { get; set; } = DefaultPowerPresets();
public List<LoadPreset> LoadPresets { get; set; } = DefaultLoadPresets();
public static List<PowerPreset> 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<LoadPreset> 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; }
}

View File

@ -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<SafetyMonitorService> _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<SafetyResetResult> 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);

View File

@ -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<DeviceOptions> devices,
IOptions<StorageOptions> storage,
IOptions<ReportOptions> report)
IOptions<ReportOptions> report,
IOptions<ConsoleOptions> 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");
/// <summary>
/// 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.
/// </summary>
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<ConsoleOptions>(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<SettingsPayload>(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<DeviceOptions>(JsonSerializer.Serialize(devices, JsonOptions)) ?? new DeviceOptions();
var copiedStorage = JsonSerializer.Deserialize<StorageOptions>(JsonSerializer.Serialize(storage, JsonOptions)) ?? new StorageOptions();
var copiedReport = JsonSerializer.Deserialize<ReportOptions>(JsonSerializer.Serialize(report, JsonOptions)) ?? new ReportOptions();
var copiedConsole = JsonSerializer.Deserialize<ConsoleOptions>(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();
}
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();
}

View File

@ -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<PowerPreset> PowerPresets { get; } = new();
public ObservableCollection<LoadPreset> LoadPresets { get; } = new();
public ObservableCollection<ExperimentConsoleChannelRow> Channels { get; } = new();
public ExperimentConsoleViewModel(
IPowerSupply power,
ISspc sspc,
ILoad load,
IOptions<ConsoleOptions> 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<CancellationToken, Task> 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<bool> 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<string> QueryErrorStateAsync(IPowerSupply power, CancellationToken ct)
{
if (!power.IsConnected) return "未连接";
return FormatErrorState(await power.QueryErrorAsync(ct).ConfigureAwait(false));
}
private static async Task<string> 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;
}
}

View File

@ -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 ? "状态重置" : "报警急停";
});
}

View File

@ -11,10 +11,10 @@ namespace SSPCTester.UI.ViewModels;
/// <summary>功率损耗页 VM。</summary>
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<PowerLossRow> Rows { get; } = new();
public ObservableCollection<int> 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, "功率损耗结果已清空。");
}

View File

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

View File

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

View File

@ -0,0 +1,419 @@
<UserControl x:Class="SSPCTester.UI.Views.ExperimentConsoleView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:SSPCTester.UI.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:ExperimentConsoleViewModel}">
<UserControl.Resources>
<Style x:Key="ConsoleSection" TargetType="Border">
<Setter Property="Background" Value="{StaticResource BgSurfaceAltBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{StaticResource RadiusMd}" />
<Setter Property="Padding" Value="14" />
<Setter Property="Margin" Value="0,0,0,12" />
</Style>
<Style x:Key="MetricCard" TargetType="Border">
<Setter Property="Background" Value="{StaticResource BgSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{StaticResource RadiusMd}" />
<Setter Property="Padding" Value="10" />
<Setter Property="MinHeight" Value="68" />
</Style>
<Style x:Key="MetricLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}" />
<Setter Property="FontSize" Value="{StaticResource FsSmall}" />
</Style>
<Style x:Key="MetricValue" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource FontMono}" />
<Setter Property="FontSize" Value="20" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,7,0,0" />
</Style>
</UserControl.Resources>
<Grid Margin="20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="1.28*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- 电源 -->
<Border Style="{StaticResource Card}" Padding="16" Margin="0,0,16,0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Margin="0,0,0,14">
<TextBlock Text="电源 UDP5080-100" FontSize="{StaticResource FsH2}" FontWeight="SemiBold" />
<Border HorizontalAlignment="Right"
Style="{StaticResource StatusBadge}"
Background="{Binding IsPowerConnected, Converter={StaticResource StatusToSoftBrush}}">
<TextBlock Text="{Binding PowerConnectionStatus}"
Foreground="{Binding IsPowerConnected, Converter={StaticResource StatusToBrush}}"
FontSize="{StaticResource FsSmall}"
FontWeight="SemiBold" />
</Border>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<Border Style="{StaticResource ConsoleSection}">
<StackPanel>
<TextBlock Style="{StaticResource CardTitle}" Text="参数设置" FontSize="{StaticResource FsBody}" />
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="88" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="34" />
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource FieldLabel}" Text="电压设定" />
<TextBox Grid.Column="1" Text="{Binding PowerVoltageSetpoint, UpdateSourceTrigger=PropertyChanged}" FontFamily="{StaticResource FontMono}" />
<TextBlock Grid.Column="2" Text="V" VerticalAlignment="Center" HorizontalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}" />
</Grid>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="88" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="34" />
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource FieldLabel}" Text="电流限值" />
<TextBox Grid.Column="1" Text="{Binding PowerCurrentLimit, UpdateSourceTrigger=PropertyChanged}" FontFamily="{StaticResource FontMono}" />
<TextBlock Grid.Column="2" Text="A" VerticalAlignment="Center" HorizontalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}" />
</Grid>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="88" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource FieldLabel}" Text="预设方案" />
<ComboBox Grid.Column="1"
ItemsSource="{Binding PowerPresets}"
SelectedItem="{Binding SelectedPowerPreset}"
DisplayMemberPath="Name" />
</Grid>
<UniformGrid Columns="3">
<Button Style="{StaticResource PrimaryButton}" Content="应用设置" Margin="0,0,6,0" Command="{Binding ApplyPowerCommand}" />
<Button Content="保存" Margin="6,0" Command="{Binding SavePowerPresetCommand}" />
<Button Content="删除" Margin="6,0,0,0" Command="{Binding DeletePowerPresetCommand}" />
</UniformGrid>
</StackPanel>
</Border>
<Border Style="{StaticResource ConsoleSection}">
<StackPanel>
<TextBlock Style="{StaticResource CardTitle}" Text="输出控制" FontSize="{StaticResource FsBody}" />
<UniformGrid Columns="3">
<Button Style="{StaticResource ToggleOnButton}" Content="输出开" Margin="0,0,6,0" Command="{Binding PowerOutputOnCommand}" IsEnabled="{Binding CanTurnPowerOutputOn}" />
<Button Style="{StaticResource ToggleOffButton}" Content="输出关" Margin="6,0" Command="{Binding PowerOutputOffCommand}" IsEnabled="{Binding CanTurnPowerOutputOff}" />
<Button Style="{StaticResource DangerButton}" Content="安全复位" Margin="6,0,0,0" Command="{Binding PowerSafeResetCommand}" />
</UniformGrid>
</StackPanel>
</Border>
<Border Style="{StaticResource ConsoleSection}" Margin="0">
<StackPanel>
<TextBlock Style="{StaticResource CardTitle}" Text="实时读数" FontSize="{StaticResource FsBody}" />
<UniformGrid Columns="2" Margin="0,0,0,10">
<Border Style="{StaticResource MetricCard}" Margin="0,0,5,5">
<StackPanel>
<TextBlock Style="{StaticResource MetricLabel}" Text="实际电压" />
<TextBlock Style="{StaticResource MetricValue}">
<Run Text="{Binding PowerVoltage, Converter={StaticResource NumberOrDash}, ConverterParameter=F3}" />
<Run Text=" V" FontSize="{StaticResource FsSmall}" Foreground="{StaticResource TextSecondaryBrush}" />
</TextBlock>
</StackPanel>
</Border>
<Border Style="{StaticResource MetricCard}" Margin="5,0,0,5">
<StackPanel>
<TextBlock Style="{StaticResource MetricLabel}" Text="实际电流" />
<TextBlock Style="{StaticResource MetricValue}">
<Run Text="{Binding PowerCurrent, Converter={StaticResource NumberOrDash}, ConverterParameter=F3}" />
<Run Text=" A" FontSize="{StaticResource FsSmall}" Foreground="{StaticResource TextSecondaryBrush}" />
</TextBlock>
</StackPanel>
</Border>
<Border Style="{StaticResource MetricCard}" Margin="0,5,5,0">
<StackPanel>
<TextBlock Style="{StaticResource MetricLabel}" Text="实际功率" />
<TextBlock Style="{StaticResource MetricValue}">
<Run Text="{Binding PowerWatts, Converter={StaticResource NumberOrDash}, ConverterParameter=F3}" />
<Run Text=" W" FontSize="{StaticResource FsSmall}" Foreground="{StaticResource TextSecondaryBrush}" />
</TextBlock>
</StackPanel>
</Border>
<Border Style="{StaticResource MetricCard}" Margin="5,5,0,0">
<StackPanel>
<TextBlock Style="{StaticResource MetricLabel}" Text="工作模式" />
<TextBlock Style="{StaticResource MetricValue}" Text="{Binding PowerModeText}" />
</StackPanel>
</Border>
</UniformGrid>
<Grid Margin="0,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="输出状态" Foreground="{StaticResource TextSecondaryBrush}" />
<Border Grid.Column="1" Style="{StaticResource StatusBadge}" Background="{StaticResource NeutralSoftBrush}">
<TextBlock Text="{Binding PowerOutputStateText}" FontWeight="SemiBold" FontSize="{StaticResource FsSmall}" />
</Border>
</Grid>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="异常状态" Foreground="{StaticResource TextSecondaryBrush}" />
<Border Grid.Column="1" Style="{StaticResource StatusBadge}" Background="{StaticResource NeutralSoftBrush}">
<TextBlock Text="{Binding PowerProtectionStateText}" FontWeight="SemiBold" FontSize="{StaticResource FsSmall}" />
</Border>
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
<!-- 继电器 -->
<Border Grid.Column="1" Style="{StaticResource Card}" Padding="16" Margin="0,0,16,0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Margin="0,0,0,14">
<TextBlock Text="继电器 / 24路通道" FontSize="{StaticResource FsH2}" FontWeight="SemiBold" />
<Border HorizontalAlignment="Right"
Style="{StaticResource StatusBadge}"
Background="{Binding IsRelayConnected, Converter={StaticResource StatusToSoftBrush}}">
<TextBlock Text="{Binding RelayConnectionStatus}"
Foreground="{Binding IsRelayConnected, Converter={StaticResource StatusToBrush}}"
FontSize="{StaticResource FsSmall}"
FontWeight="SemiBold" />
</Border>
</Grid>
<Border Grid.Row="1" Style="{StaticResource ConsoleSection}">
<StackPanel>
<TextBlock Style="{StaticResource CardTitle}" Text="批量控制" FontSize="{StaticResource FsBody}" />
<UniformGrid Columns="3">
<Button Style="{StaticResource ToggleOnButton}" Content="全开" Margin="0,0,6,0" Command="{Binding RelayAllOnCommand}" />
<Button Style="{StaticResource ToggleOffButton}" Content="全关" Margin="6,0" Command="{Binding RelayAllOffCommand}" />
<Button Style="{StaticResource PrimaryButton}" Content="读取全部" Margin="6,0,0,0" Command="{Binding ReadRelayAllCommand}" />
</UniformGrid>
</StackPanel>
</Border>
<Grid Grid.Row="2" MinHeight="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Text="通道控制与读数" FontWeight="SemiBold" Margin="0,0,0,8" />
<DataGrid Grid.Row="1" ItemsSource="{Binding Channels}" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="通道" Binding="{Binding ChannelText}" Width="76">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="电压" Width="*">
<DataGridTextColumn.Binding>
<Binding Path="Voltage" Converter="{StaticResource NumberOrDash}" ConverterParameter="F3" StringFormat="{}{0} V" />
</DataGridTextColumn.Binding>
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock" BasedOn="{StaticResource MonoText}" />
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="电流" Width="*">
<DataGridTextColumn.Binding>
<Binding Path="Current" Converter="{StaticResource NumberOrDash}" ConverterParameter="F3" StringFormat="{}{0} A" />
</DataGridTextColumn.Binding>
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock" BasedOn="{StaticResource MonoText}" />
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTemplateColumn Header="开" Width="64">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Style="{StaticResource ToggleOnButton}"
Content="开"
Command="{Binding DataContext.TurnRelayOnCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="关" Width="64">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Style="{StaticResource ToggleOffButton}"
Content="关"
Command="{Binding DataContext.TurnRelayOffCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Grid>
</Border>
<!-- 负载 -->
<Border Grid.Column="2" Style="{StaticResource Card}" Padding="16">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Margin="0,0,0,14">
<TextBlock Text="负载 IT8702P" FontSize="{StaticResource FsH2}" FontWeight="SemiBold" />
<Border HorizontalAlignment="Right"
Style="{StaticResource StatusBadge}"
Background="{Binding IsLoadConnected, Converter={StaticResource StatusToSoftBrush}}">
<TextBlock Text="{Binding LoadConnectionStatus}"
Foreground="{Binding IsLoadConnected, Converter={StaticResource StatusToBrush}}"
FontSize="{StaticResource FsSmall}"
FontWeight="SemiBold" />
</Border>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<Border Style="{StaticResource ConsoleSection}">
<StackPanel>
<TextBlock Style="{StaticResource CardTitle}" Text="参数设置" FontSize="{StaticResource FsBody}" />
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="88" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource FieldLabel}" Text="工作模式" />
<Border Grid.Column="1" Background="{StaticResource PrimarySoftBrush}" CornerRadius="{StaticResource RadiusMd}" Height="{StaticResource ControlHeight}">
<TextBlock Text="CC 恒流" VerticalAlignment="Center" Margin="10,0" Foreground="{StaticResource PrimaryBrush}" FontWeight="SemiBold" />
</Border>
</Grid>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="88" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="34" />
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource FieldLabel}" Text="负载电流" />
<TextBox Grid.Column="1" Text="{Binding LoadCurrentSetpoint, UpdateSourceTrigger=PropertyChanged}" FontFamily="{StaticResource FontMono}" />
<TextBlock Grid.Column="2" Text="A" VerticalAlignment="Center" HorizontalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}" />
</Grid>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="88" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource FieldLabel}" Text="预设方案" />
<ComboBox Grid.Column="1"
ItemsSource="{Binding LoadPresets}"
SelectedItem="{Binding SelectedLoadPreset}"
DisplayMemberPath="Name" />
</Grid>
<UniformGrid Columns="3">
<Button Style="{StaticResource PrimaryButton}" Content="应用设置" Margin="0,0,6,0" Command="{Binding ApplyLoadCommand}" />
<Button Content="保存" Margin="6,0" Command="{Binding SaveLoadPresetCommand}" />
<Button Content="删除" Margin="6,0,0,0" Command="{Binding DeleteLoadPresetCommand}" />
</UniformGrid>
</StackPanel>
</Border>
<Border Style="{StaticResource ConsoleSection}">
<StackPanel>
<TextBlock Style="{StaticResource CardTitle}" Text="输入控制" FontSize="{StaticResource FsBody}" />
<UniformGrid Columns="3">
<Button Style="{StaticResource ToggleOnButton}" Content="输入开" Margin="0,0,6,0" Command="{Binding LoadInputOnCommand}" IsEnabled="{Binding CanTurnLoadInputOn}" />
<Button Style="{StaticResource ToggleOffButton}" Content="输入关" Margin="6,0" Command="{Binding LoadInputOffCommand}" IsEnabled="{Binding CanTurnLoadInputOff}" />
<Button Style="{StaticResource DangerButton}" Content="安全复位" Margin="6,0,0,0" Command="{Binding LoadSafeResetCommand}" />
</UniformGrid>
</StackPanel>
</Border>
<Border Style="{StaticResource ConsoleSection}" Margin="0">
<StackPanel>
<TextBlock Style="{StaticResource CardTitle}" Text="实时读数" FontSize="{StaticResource FsBody}" />
<UniformGrid Columns="2" Margin="0,0,0,10">
<Border Style="{StaticResource MetricCard}" Margin="0,0,5,5">
<StackPanel>
<TextBlock Style="{StaticResource MetricLabel}" Text="实际电压" />
<TextBlock Style="{StaticResource MetricValue}">
<Run Text="{Binding LoadVoltage, Converter={StaticResource NumberOrDash}, ConverterParameter=F3}" />
<Run Text=" V" FontSize="{StaticResource FsSmall}" Foreground="{StaticResource TextSecondaryBrush}" />
</TextBlock>
</StackPanel>
</Border>
<Border Style="{StaticResource MetricCard}" Margin="5,0,0,5">
<StackPanel>
<TextBlock Style="{StaticResource MetricLabel}" Text="实际电流" />
<TextBlock Style="{StaticResource MetricValue}">
<Run Text="{Binding LoadCurrent, Converter={StaticResource NumberOrDash}, ConverterParameter=F3}" />
<Run Text=" A" FontSize="{StaticResource FsSmall}" Foreground="{StaticResource TextSecondaryBrush}" />
</TextBlock>
</StackPanel>
</Border>
<Border Style="{StaticResource MetricCard}" Margin="0,5,5,0">
<StackPanel>
<TextBlock Style="{StaticResource MetricLabel}" Text="实际功率" />
<TextBlock Style="{StaticResource MetricValue}">
<Run Text="{Binding LoadWatts, Converter={StaticResource NumberOrDash}, ConverterParameter=F3}" />
<Run Text=" W" FontSize="{StaticResource FsSmall}" Foreground="{StaticResource TextSecondaryBrush}" />
</TextBlock>
</StackPanel>
</Border>
<Border Style="{StaticResource MetricCard}" Margin="5,5,0,0">
<StackPanel>
<TextBlock Style="{StaticResource MetricLabel}" Text="工作模式" />
<TextBlock Style="{StaticResource MetricValue}" Text="CC" />
</StackPanel>
</Border>
</UniformGrid>
<Grid Margin="0,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="输入状态" Foreground="{StaticResource TextSecondaryBrush}" />
<Border Grid.Column="1" Style="{StaticResource StatusBadge}" Background="{StaticResource NeutralSoftBrush}">
<TextBlock Text="{Binding LoadInputStateText}" FontWeight="SemiBold" FontSize="{StaticResource FsSmall}" />
</Border>
</Grid>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="异常状态" Foreground="{StaticResource TextSecondaryBrush}" />
<Border Grid.Column="1" Style="{StaticResource StatusBadge}" Background="{StaticResource NeutralSoftBrush}">
<TextBlock Text="{Binding LoadProtectionStateText}" FontWeight="SemiBold" FontSize="{StaticResource FsSmall}" />
</Border>
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</Grid>
</UserControl>

View File

@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace SSPCTester.UI.Views;
public partial class ExperimentConsoleView : UserControl
{
public ExperimentConsoleView() { InitializeComponent(); }
}

View File

@ -30,14 +30,6 @@
<TextBox Text="{Binding SamplesPerChannel}" Margin="0,0,0,12" FontFamily="{StaticResource FontMono}" />
<TextBlock Style="{StaticResource FieldLabel}" Text="采集间隔 (ms)" />
<TextBox Text="{Binding IntervalMs}" Margin="0,0,0,12" FontFamily="{StaticResource FontMono}" />
<UniformGrid Columns="2" Margin="0,0,0,0">
<Button Content="开启当前通道" Margin="0,0,6,0"
Command="{Binding TurnOnCurrentChannelCommand}"
IsEnabled="{Binding CanOperate}" />
<Button Content="关断当前通道" Margin="6,0,0,0"
Command="{Binding TurnOffCurrentChannelCommand}"
IsEnabled="{Binding CanOperate}" />
</UniformGrid>
<Button Style="{StaticResource PrimaryButton}" Content="测量当前通道" Margin="0,10,0,0"
Command="{Binding MeasureCurrentCommand}"
IsEnabled="{Binding CanOperate}" />
@ -189,7 +181,8 @@
Command="{Binding ClearCommand}"
IsEnabled="{Binding CanOperate}" />
</Grid>
<DataGrid ItemsSource="{Binding Rows}">
<DataGrid ItemsSource="{Binding Rows}"
SelectedItem="{Binding Current, Mode=TwoWay}">
<DataGrid.Columns>
<DataGridTextColumn Header="通道" Width="60">
<DataGridTextColumn.Binding>

View File

@ -325,7 +325,7 @@
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button Content="刷新串口" Command="{Binding RefreshPortsCommand}" Margin="0,0,8,0" />
<Button Content="重新连接" Command="{Binding ReconnectAllCommand}" Margin="0,0,8,0" />
<Button Style="{StaticResource PrimaryButton}" Content="保存设置" Command="{Binding SaveSettingsCommand}" />
<Button Style="{StaticResource PrimaryButton}" Content="保存设置" Command="{Binding SaveSettingsCommand}" Click="SaveSettingsClicked" />
</StackPanel>
</Grid>
</Grid>

View File

@ -1,6 +1,9 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
namespace SSPCTester.UI.Views;
@ -8,6 +11,14 @@ public partial class SettingsManualView : UserControl
{
public SettingsManualView() => InitializeComponent();
private void SaveSettingsClicked(object sender, RoutedEventArgs e)
{
if (!IsLoaded) return;
CommitAllBindings();
if (DataContext is ViewModels.SettingsViewModel vm)
vm.NotifyDraftChanged();
}
private void DraftChanged(object sender, RoutedEventArgs e)
{
if (!IsLoaded) return;
@ -33,4 +44,54 @@ public partial class SettingsManualView : UserControl
toggleButton.GetBindingExpression(ToggleButton.IsCheckedProperty)?.UpdateSource();
}
}
private void CommitAllBindings()
{
// Ensure the currently focused editor pushes its latest value first.
if (Keyboard.FocusedElement is FrameworkElement focused)
UpdateFrameworkElementBindings(focused);
foreach (var element in EnumerateVisuals(this).OfType<FrameworkElement>())
UpdateFrameworkElementBindings(element);
}
private static IEnumerable<DependencyObject> EnumerateVisuals(DependencyObject root)
{
int count = VisualTreeHelper.GetChildrenCount(root);
for (int i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(root, i);
yield return child;
foreach (var nested in EnumerateVisuals(child))
yield return nested;
}
}
private static void UpdateFrameworkElementBindings(FrameworkElement element)
{
switch (element)
{
case TextBox textBox:
textBox.GetBindingExpression(TextBox.TextProperty)?.UpdateSource();
break;
case ComboBox comboBox:
comboBox.GetBindingExpression(Selector.SelectedValueProperty)?.UpdateSource();
comboBox.GetBindingExpression(Selector.SelectedItemProperty)?.UpdateSource();
comboBox.GetBindingExpression(ComboBox.TextProperty)?.UpdateSource();
break;
case ToggleButton toggleButton:
toggleButton.GetBindingExpression(ToggleButton.IsCheckedProperty)?.UpdateSource();
break;
default:
if (element is Selector selector)
{
selector.GetBindingExpression(Selector.SelectedValueProperty)?.UpdateSource();
selector.GetBindingExpression(Selector.SelectedItemProperty)?.UpdateSource();
}
break;
}
if (element.BindingGroup != null)
element.BindingGroup.UpdateSources();
}
}

View File

@ -4,11 +4,16 @@
xmlns:vm="clr-namespace:SSPCTester.UI.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Loaded="OnLoaded"
Unloaded="OnUnloaded"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:TestHostViewModel}">
<Border Background="{StaticResource BgSurfaceBrush}" BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,0,0,1">
<Grid>
<TabControl x:Name="Tabs">
<TabControl x:Name="Tabs" SelectedIndex="{Binding SelectedTabIndex, Mode=TwoWay}">
<TabItem Header="控制台">
<ContentControl Content="{Binding Console}" />
</TabItem>
<TabItem Header="基础测试">
<ContentControl Content="{Binding Basic}" />
</TabItem>

View File

@ -1,8 +1,21 @@
using System.Windows.Controls;
using SSPCTester.UI.ViewModels;
namespace SSPCTester.UI.Views;
public partial class TestHostView : UserControl
{
public TestHostView() { InitializeComponent(); }
private void OnLoaded(object sender, System.Windows.RoutedEventArgs e)
{
if (DataContext is TestHostViewModel vm)
vm.SetViewActive(true);
}
private void OnUnloaded(object sender, System.Windows.RoutedEventArgs e)
{
if (DataContext is TestHostViewModel vm)
vm.SetViewActive(false);
}
}

View File

@ -29,7 +29,11 @@
"Terminator": "\n",
"IdnCommand": "*IDN?",
"VoltageCommand": "MEASure:VOLTage?",
"CurrentCommand": "MEASure:CURRent?"
"CurrentCommand": "MEASure:CURRent?",
"SetVoltageCommand": "VOLT {0}",
"SetCurrentLimitCommand": "CURR {0}",
"OutputStateCommand": "OUTP?",
"ErrorCommand": "SYST:ERR?"
},
"Load": {
"UseMock": false,
@ -42,7 +46,12 @@
"Terminator": "\n",
"Channel": 1,
"UseFetch": true,
"QueryMode": "Combined"
"QueryMode": "Combined",
"SetModeCommand": "",
"SetValueCommand": "{0} {1}",
"ResetCommand": "*RST",
"InputStateCommand": "INP?",
"ErrorCommand": "SYST:ERR?"
},
"Daq": {
"UseMock": false,