From 8d485ddc76e01b9cbf8cbd2e7c72303b010a5962 Mon Sep 17 00:00:00 2001 From: "GukSang.Jin" Date: Tue, 5 May 2026 14:55:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=82=B9=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ConeCalorimeter/MainWindow.xaml.cs | 4 +- ConeCalorimeter/Models/RealtimeDataRecord.cs | 2 + ConeCalorimeter/Models/RealtimeSnapshot.cs | 1 + .../Services/DemoRealtimeDataService.cs | 38 --- .../Services/ITcpDeviceConnectionService.cs | 10 + .../Services/ModbusRealtimeDataService.cs | 74 +++++ .../Services/TcpDeviceConnectionService.cs | 292 +++++++++++++++++- .../ViewModels/CValueCalibrationViewModel.cs | 85 ++++- .../ConeRadiationSettingsViewModel.cs | 112 ++++++- ConeCalorimeter/ViewModels/MainViewModel.cs | 13 +- .../ViewModels/MetricDisplayViewModel.cs | 12 + .../ViewModels/RealtimeDataRowViewModel.cs | 4 +- .../SmokeDensitySettingsViewModel.cs | 44 ++- .../ViewModels/TestPageViewModel.cs | 83 ++++- .../Views/ConeRadiationSettingsView.xaml | 43 ++- 15 files changed, 722 insertions(+), 95 deletions(-) delete mode 100644 ConeCalorimeter/Services/DemoRealtimeDataService.cs create mode 100644 ConeCalorimeter/Services/ModbusRealtimeDataService.cs diff --git a/ConeCalorimeter/MainWindow.xaml.cs b/ConeCalorimeter/MainWindow.xaml.cs index 4a953b2..836fd7f 100644 --- a/ConeCalorimeter/MainWindow.xaml.cs +++ b/ConeCalorimeter/MainWindow.xaml.cs @@ -15,9 +15,11 @@ namespace ConeCalorimeter _tcpDeviceConnectionService = new TcpDeviceConnectionService(); _ = _tcpDeviceConnectionService.StartAsync(); - var experimentDataService = new ExperimentDataService(new DemoRealtimeDataService()); + var experimentDataService = new ExperimentDataService( + new ModbusRealtimeDataService(_tcpDeviceConnectionService)); DataContext = new MainViewModel( experimentDataService, + _tcpDeviceConnectionService, new NpoiReportExportService(), new HelpDialogService()); } diff --git a/ConeCalorimeter/Models/RealtimeDataRecord.cs b/ConeCalorimeter/Models/RealtimeDataRecord.cs index abc6570..7086158 100644 --- a/ConeCalorimeter/Models/RealtimeDataRecord.cs +++ b/ConeCalorimeter/Models/RealtimeDataRecord.cs @@ -6,6 +6,7 @@ public sealed record RealtimeDataRecord( double OrificePressure, double OrificeTemperature, double ConeTemperature, + double SampleTemperature, double Irradiance, bool FlameDetected, double Oxygen, @@ -30,6 +31,7 @@ public sealed record RealtimeDataRecord( snapshot.OrificePressure, snapshot.OrificeTemperature, snapshot.ConeTemperature, + snapshot.SampleTemperature, snapshot.Irradiance, snapshot.FlameDetected, snapshot.Oxygen, diff --git a/ConeCalorimeter/Models/RealtimeSnapshot.cs b/ConeCalorimeter/Models/RealtimeSnapshot.cs index 0f3751e..e5031bf 100644 --- a/ConeCalorimeter/Models/RealtimeSnapshot.cs +++ b/ConeCalorimeter/Models/RealtimeSnapshot.cs @@ -5,6 +5,7 @@ public sealed record RealtimeSnapshot( double OrificePressure, double OrificeTemperature, double ConeTemperature, + double SampleTemperature, double Irradiance, bool FlameDetected, double Oxygen, diff --git a/ConeCalorimeter/Services/DemoRealtimeDataService.cs b/ConeCalorimeter/Services/DemoRealtimeDataService.cs deleted file mode 100644 index 4ac53dd..0000000 --- a/ConeCalorimeter/Services/DemoRealtimeDataService.cs +++ /dev/null @@ -1,38 +0,0 @@ -using ConeCalorimeter.Models; - -namespace ConeCalorimeter.Services; - -public sealed class DemoRealtimeDataService : IRealtimeDataService -{ - private readonly Random _random = new(20260504); - - public RealtimeSnapshot GetCurrentSnapshot(TimeSpan elapsed) - { - var seconds = elapsed.TotalSeconds; - var heatBase = 28 + Math.Sin(seconds / 8) * 11 + Math.Sin(seconds / 2.7) * 4; - var heatReleaseRate = Math.Max(0, heatBase + _random.NextDouble() * 2.5); - var totalHeat = Math.Min(150, seconds * 0.42 + Math.Sin(seconds / 10) * 2); - var totalSmoke = Math.Min(150, seconds * 0.34 + Math.Cos(seconds / 9) * 1.8); - - return new RealtimeSnapshot( - OrificeFlow: 2.35 + Math.Sin(seconds / 11) * 0.08, - OrificePressure: 18.4 + Math.Cos(seconds / 7) * 0.35, - OrificeTemperature: 296.2 + Math.Sin(seconds / 15) * 0.5, - ConeTemperature: 751 + Math.Sin(seconds / 13) * 4, - Irradiance: 50.0 + Math.Cos(seconds / 18) * 0.25, - FlameDetected: seconds % 24 > 5, - Oxygen: 20.95 - Math.Min(2.4, seconds * 0.006), - CarbonDioxide: 0.04 + Math.Min(7.5, seconds * 0.018), - CarbonMonoxide: 0.01 + Math.Min(1.3, seconds * 0.004), - HeatReleaseRate: heatReleaseRate, - Qa180: Math.Min(120, seconds * 0.21), - Qa300: Math.Min(180, seconds * 0.18), - TotalHeatRelease: totalHeat, - SmokeProduction: 0.3 + Math.Min(45, seconds * 0.09), - CurrentMass: Math.Max(0, 35.0 - seconds * 0.015), - MassLoss: Math.Min(35.0, seconds * 0.015), - IgnitionSeconds: (int)Math.Min(seconds, 999), - TestSeconds: (int)Math.Min(seconds, 9999), - TotalSmoke: totalSmoke); - } -} diff --git a/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs b/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs index 8b4bae0..15ec3fa 100644 --- a/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs +++ b/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs @@ -7,4 +7,14 @@ public interface ITcpDeviceConnectionService : IAsyncDisposable Task StartAsync(); Task StopAsync(); + + bool TryReadFloat(ushort registerAddress, out double value); + + bool TryReadInt16(ushort registerAddress, out int value); + + bool TryWriteInt16(ushort registerAddress, short value); + + bool TryReadCoil(ushort coilAddress, out bool value); + + bool TryWriteCoil(ushort coilAddress, bool value); } diff --git a/ConeCalorimeter/Services/ModbusRealtimeDataService.cs b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs new file mode 100644 index 0000000..0dbadcb --- /dev/null +++ b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs @@ -0,0 +1,74 @@ +using ConeCalorimeter.Models; + +namespace ConeCalorimeter.Services; + +public sealed class ModbusRealtimeDataService : IRealtimeDataService +{ + private const ushort OxygenRegister = 10; + private const ushort OrificeFlowRegister = 14; + private const ushort OrificePressureRegister = 16; + private const ushort CarbonMonoxideRegister = 18; + private const ushort CarbonDioxideRegister = 20; + private const ushort ConeTemperatureRegister = 26; + private const ushort OrificeTemperatureRegister = 30; + private const ushort SampleTemperatureRegister = 36; + private const ushort HeatReleaseRateRegister = 354; + private const ushort Qa180Register = 366; + private const ushort Qa300Register = 370; + private const ushort TotalHeatReleaseRegister = 372; + private const ushort SmokeProductionRegister = 390; + private const ushort IgnitionSecondsRegister = 1014; + private const ushort TestSecondsRegister = 1015; + private const ushort FlameDetectedCoil = 3; + + private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService; + + public ModbusRealtimeDataService(ITcpDeviceConnectionService tcpDeviceConnectionService) + { + _tcpDeviceConnectionService = tcpDeviceConnectionService; + } + + public RealtimeSnapshot GetCurrentSnapshot(TimeSpan elapsed) + { + return new RealtimeSnapshot( + OrificeFlow: ReadFloatOrEmpty(OrificeFlowRegister), + OrificePressure: ReadFloatOrEmpty(OrificePressureRegister), + OrificeTemperature: ReadFloatOrEmpty(OrificeTemperatureRegister), + ConeTemperature: ReadFloatOrEmpty(ConeTemperatureRegister), + SampleTemperature: ReadFloatOrEmpty(SampleTemperatureRegister), + Irradiance: double.NaN, + FlameDetected: ReadCoilOrFalse(FlameDetectedCoil), + Oxygen: ReadFloatOrEmpty(OxygenRegister), + CarbonDioxide: ReadFloatOrEmpty(CarbonDioxideRegister), + CarbonMonoxide: ReadFloatOrEmpty(CarbonMonoxideRegister), + HeatReleaseRate: ReadFloatOrEmpty(HeatReleaseRateRegister), + Qa180: ReadFloatOrEmpty(Qa180Register), + Qa300: ReadFloatOrEmpty(Qa300Register), + TotalHeatRelease: ReadFloatOrEmpty(TotalHeatReleaseRegister), + SmokeProduction: ReadFloatOrEmpty(SmokeProductionRegister), + CurrentMass: double.NaN, + MassLoss: double.NaN, + IgnitionSeconds: ReadInt16OrEmpty(IgnitionSecondsRegister), + TestSeconds: ReadInt16OrEmpty(TestSecondsRegister), + TotalSmoke: ReadFloatOrEmpty(SmokeProductionRegister)); + } + + private double ReadFloatOrEmpty(ushort registerAddress) + { + return _tcpDeviceConnectionService.TryReadFloat(registerAddress, out var value) + ? value + : double.NaN; + } + + private int ReadInt16OrEmpty(ushort registerAddress) + { + return _tcpDeviceConnectionService.TryReadInt16(registerAddress, out var value) + ? value + : -1; + } + + private bool ReadCoilOrFalse(ushort coilAddress) + { + return _tcpDeviceConnectionService.TryReadCoil(coilAddress, out var value) && value; + } +} diff --git a/ConeCalorimeter/Services/TcpDeviceConnectionService.cs b/ConeCalorimeter/Services/TcpDeviceConnectionService.cs index 617ef5a..2809ee5 100644 --- a/ConeCalorimeter/Services/TcpDeviceConnectionService.cs +++ b/ConeCalorimeter/Services/TcpDeviceConnectionService.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Buffers.Binary; +using System.IO; using System.Net.Sockets; namespace ConeCalorimeter.Services; @@ -7,13 +9,20 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService { private const string Host = "192.168.1.10"; private const int Port = 502; + private const byte UnitId = 1; + private const byte ReadCoilsFunction = 0x01; + private const byte ReadHoldingRegistersFunction = 0x03; + private const byte WriteSingleCoilFunction = 0x05; + private const byte WriteSingleRegisterFunction = 0x06; private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(3); private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan ReadWriteTimeout = TimeSpan.FromSeconds(2); private readonly object _syncRoot = new(); private CancellationTokenSource? _connectionLoopCancellation; private Task? _connectionLoopTask; private TcpClient? _client; + private ushort _transactionId; private bool _isConnected; public bool IsConnected @@ -90,6 +99,132 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService await StopAsync(); } + public bool TryReadFloat(ushort registerAddress, out double value) + { + value = double.NaN; + + lock (_syncRoot) + { + if (_client is null || !IsTcpClientConnected(_client)) + { + CloseCurrentClientCore(); + return false; + } + + try + { + value = ReadFloat(_client, registerAddress); + return true; + } + catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) + { + Debug.WriteLine($"TCP device register {registerAddress} read failed: {ex.Message}"); + CloseCurrentClientCore(); + return false; + } + } + } + + public bool TryReadInt16(ushort registerAddress, out int value) + { + value = 0; + + lock (_syncRoot) + { + if (_client is null || !IsTcpClientConnected(_client)) + { + CloseCurrentClientCore(); + return false; + } + + try + { + value = ReadInt16(_client, registerAddress); + return true; + } + catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) + { + Debug.WriteLine($"TCP device register {registerAddress} read failed: {ex.Message}"); + CloseCurrentClientCore(); + return false; + } + } + } + + public bool TryWriteInt16(ushort registerAddress, short value) + { + lock (_syncRoot) + { + if (_client is null || !IsTcpClientConnected(_client)) + { + CloseCurrentClientCore(); + return false; + } + + try + { + WriteInt16(_client, registerAddress, value); + return true; + } + catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) + { + Debug.WriteLine($"TCP device register {registerAddress} write failed: {ex.Message}"); + CloseCurrentClientCore(); + return false; + } + } + } + + public bool TryReadCoil(ushort coilAddress, out bool value) + { + value = false; + + lock (_syncRoot) + { + if (_client is null || !IsTcpClientConnected(_client)) + { + CloseCurrentClientCore(); + return false; + } + + try + { + value = ReadCoil(_client, coilAddress); + return true; + } + catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) + { + Debug.WriteLine($"TCP device coil {coilAddress} read failed: {ex.Message}"); + CloseCurrentClientCore(); + return false; + } + } + } + + public bool TryWriteCoil(ushort coilAddress, bool value) + { + lock (_syncRoot) + { + if (_client is null || !IsTcpClientConnected(_client)) + { + CloseCurrentClientCore(); + return false; + } + + try + { + WriteCoil(_client, coilAddress, value); + return true; + } + catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) + { + Debug.WriteLine($"TCP device coil {coilAddress} write failed: {ex.Message}"); + CloseCurrentClientCore(); + return false; + } + } + } + private async Task RunConnectionLoopAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) @@ -138,9 +273,12 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService } await connectTask; + client.ReceiveTimeout = (int)ReadWriteTimeout.TotalMilliseconds; + client.SendTimeout = (int)ReadWriteTimeout.TotalMilliseconds; lock (_syncRoot) { + _client?.Dispose(); _client = client; _isConnected = true; } @@ -187,15 +325,159 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService private void CloseCurrentClient() { - TcpClient? client; - lock (_syncRoot) { - client = _client; - _client = null; - _isConnected = false; + CloseCurrentClientCore(); } + } + private void CloseCurrentClientCore() + { + var client = _client; + _client = null; + _isConnected = false; client?.Dispose(); } + + private double ReadFloat(TcpClient client, ushort registerAddress) + { + var pdu = ReadHoldingRegisters(client, registerAddress, 2); + + if (pdu.Length != 6 || pdu[1] != 4) + { + throw new InvalidDataException("Invalid Modbus TCP float response."); + } + + var rawValue = BinaryPrimitives.ReadInt32BigEndian(pdu[2..6]); + return BitConverter.Int32BitsToSingle(rawValue); + } + + private int ReadInt16(TcpClient client, ushort registerAddress) + { + var pdu = ReadHoldingRegisters(client, registerAddress, 1); + + if (pdu.Length != 4 || pdu[1] != 2) + { + throw new InvalidDataException("Invalid Modbus TCP int16 response."); + } + + return BinaryPrimitives.ReadInt16BigEndian(pdu[2..4]); + } + + private void WriteInt16(TcpClient client, ushort registerAddress, short value) + { + Span payload = stackalloc byte[4]; + BinaryPrimitives.WriteUInt16BigEndian(payload[0..2], registerAddress); + BinaryPrimitives.WriteInt16BigEndian(payload[2..4], value); + + var pdu = SendModbusRequest(client, WriteSingleRegisterFunction, payload); + + if (pdu.Length != 5 + || BinaryPrimitives.ReadUInt16BigEndian(pdu[1..3]) != registerAddress + || BinaryPrimitives.ReadInt16BigEndian(pdu[3..5]) != value) + { + throw new InvalidDataException("Invalid Modbus TCP register write response."); + } + } + + private bool ReadCoil(TcpClient client, ushort coilAddress) + { + Span payload = stackalloc byte[4]; + BinaryPrimitives.WriteUInt16BigEndian(payload[0..2], coilAddress); + BinaryPrimitives.WriteUInt16BigEndian(payload[2..4], 1); + + var pdu = SendModbusRequest(client, ReadCoilsFunction, payload); + + if (pdu.Length != 3 || pdu[1] != 1) + { + throw new InvalidDataException("Invalid Modbus TCP coil response."); + } + + return (pdu[2] & 0x01) == 0x01; + } + + private void WriteCoil(TcpClient client, ushort coilAddress, bool value) + { + Span payload = stackalloc byte[4]; + BinaryPrimitives.WriteUInt16BigEndian(payload[0..2], coilAddress); + BinaryPrimitives.WriteUInt16BigEndian(payload[2..4], value ? (ushort)0xFF00 : (ushort)0x0000); + + var pdu = SendModbusRequest(client, WriteSingleCoilFunction, payload); + + if (pdu.Length != 5 + || BinaryPrimitives.ReadUInt16BigEndian(pdu[1..3]) != coilAddress + || BinaryPrimitives.ReadUInt16BigEndian(pdu[3..5]) != (value ? (ushort)0xFF00 : (ushort)0x0000)) + { + throw new InvalidDataException("Invalid Modbus TCP coil write response."); + } + } + + private byte[] ReadHoldingRegisters(TcpClient client, ushort registerAddress, ushort registerCount) + { + Span payload = stackalloc byte[4]; + BinaryPrimitives.WriteUInt16BigEndian(payload[0..2], registerAddress); + BinaryPrimitives.WriteUInt16BigEndian(payload[2..4], registerCount); + + return SendModbusRequest(client, ReadHoldingRegistersFunction, payload); + } + + private byte[] SendModbusRequest(TcpClient client, byte functionCode, ReadOnlySpan payload) + { + var transactionId = ++_transactionId; + var pduLength = 1 + payload.Length; + var request = new byte[7 + pduLength]; + + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(0, 2), transactionId); + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(2, 2), 0); + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(4, 2), (ushort)(1 + pduLength)); + request[6] = UnitId; + request[7] = functionCode; + payload.CopyTo(request.AsSpan(8)); + + var stream = client.GetStream(); + stream.Write(request); + + Span header = stackalloc byte[7]; + ReadExactly(stream, header); + + var responseTransactionId = BinaryPrimitives.ReadUInt16BigEndian(header[0..2]); + var protocolId = BinaryPrimitives.ReadUInt16BigEndian(header[2..4]); + var length = BinaryPrimitives.ReadUInt16BigEndian(header[4..6]); + var unitId = header[6]; + + if (responseTransactionId != transactionId || protocolId != 0 || unitId != UnitId || length < 2) + { + throw new InvalidDataException("Invalid Modbus TCP response header."); + } + + var pdu = new byte[length - 1]; + ReadExactly(stream, pdu); + + if (pdu[0] == (functionCode | 0x80)) + { + throw new InvalidDataException($"Modbus exception code {pdu[1]}."); + } + + if (pdu[0] != functionCode) + { + throw new InvalidDataException("Unexpected Modbus TCP function code."); + } + + return pdu; + } + + private static void ReadExactly(NetworkStream stream, Span buffer) + { + var totalRead = 0; + while (totalRead < buffer.Length) + { + var read = stream.Read(buffer[totalRead..]); + if (read == 0) + { + throw new IOException("TCP device closed the connection."); + } + + totalRead += read; + } + } } diff --git a/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs b/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs index 19ab3bd..cbb4016 100644 --- a/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs +++ b/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs @@ -1,13 +1,24 @@ using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using ConeCalorimeter.Services; namespace ConeCalorimeter.ViewModels; public sealed class CValueCalibrationViewModel : PageViewModel { + private const ushort TemperatureRegister = 282; + private const ushort PressureDifferenceRegister = 284; + private const ushort OxygenRegister = 286; + private const ushort CValueRegister = 308; + private readonly Action _closeAction; private readonly Action _helpAction; + private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService; + private readonly DispatcherTimer _refreshTimer; private string _baselineOxygenText = ""; private string _temperatureText = ""; private string _pressureDifferenceText = ""; @@ -15,10 +26,14 @@ public sealed class CValueCalibrationViewModel : PageViewModel private string _cValueText = ""; private string _lastAction = "待机"; - public CValueCalibrationViewModel(Action closeAction, Action helpAction) : base("C值标定") + public CValueCalibrationViewModel( + Action closeAction, + Action helpAction, + ITcpDeviceConnectionService tcpDeviceConnectionService) : base("C值标定") { _closeAction = closeAction; _helpAction = helpAction; + _tcpDeviceConnectionService = tcpDeviceConnectionService; CloseCommand = new RelayCommand(_closeAction); HelpCommand = new RelayCommand(_helpAction); ActionCommand = new RelayCommand(ExecuteAction); @@ -36,6 +51,14 @@ public sealed class CValueCalibrationViewModel : PageViewModel new CValueCalibrationActionViewModel("标定开始", ActionCommand), new CValueCalibrationActionViewModel("基线采集", ActionCommand) ]; + + RefreshDeviceValues(); + _refreshTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(1) + }; + _refreshTimer.Tick += (_, _) => RefreshDeviceValues(); + _refreshTimer.Start(); } public ObservableCollection TopActions { get; } @@ -94,19 +117,65 @@ public sealed class CValueCalibrationViewModel : PageViewModel } LastAction = action; + WriteActionCoil(action); + } - if (action == "基线采集") + private void RefreshDeviceValues() + { + var oxygenText = ReadFloatText(OxygenRegister); + + BaselineOxygenText = oxygenText; + TemperatureText = ReadFloatText(TemperatureRegister); + PressureDifferenceText = ReadFloatText(PressureDifferenceRegister); + CalibrationOxygenText = oxygenText; + CValueText = ReadFloatText(CValueRegister); + } + + private string ReadFloatText(ushort registerAddress) + { + return _tcpDeviceConnectionService.TryReadFloat(registerAddress, out var value) + ? value.ToString("0.00", CultureInfo.InvariantCulture) + : string.Empty; + } + + private void WriteActionCoil(string action) + { + if (!TryGetActionCoil(action, out var coilAddress)) { - BaselineOxygenText = "20.95"; return; } - if (action == "标定开始") + if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true)) { - TemperatureText = "298.15"; - PressureDifferenceText = "12.40"; - CalibrationOxygenText = "20.10"; - CValueText = "0.045"; + Debug.WriteLine($"C value calibration action '{action}' write failed."); + } + } + + private static bool TryGetActionCoil(string action, out ushort coilAddress) + { + switch (action) + { + case "甲烷阀": + coilAddress = 55; + return true; + case "风机": + coilAddress = 54; + return true; + case "点火器": + coilAddress = 53; + return true; + case "取样泵": + coilAddress = 50; + return true; + case "基线采集": + coilAddress = 60; + return true; + case "标定开始": + coilAddress = 70; + return true; + default: + coilAddress = 0; + return false; } } } diff --git a/ConeCalorimeter/ViewModels/ConeRadiationSettingsViewModel.cs b/ConeCalorimeter/ViewModels/ConeRadiationSettingsViewModel.cs index fb4f788..03ed003 100644 --- a/ConeCalorimeter/ViewModels/ConeRadiationSettingsViewModel.cs +++ b/ConeCalorimeter/ViewModels/ConeRadiationSettingsViewModel.cs @@ -1,6 +1,10 @@ using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using ConeCalorimeter.Services; using OxyPlot; using OxyPlot.Axes; using OxyPlot.Legends; @@ -10,21 +14,32 @@ namespace ConeCalorimeter.ViewModels; public sealed class ConeRadiationSettingsViewModel : PageViewModel { + private const ushort TargetTemperatureRegister = 400; + private const ushort CurrentHeatFluxRegister = 410; + private const ushort SlopeRegister = 420; + private const ushort InterceptRegister = 422; + private readonly Action _closeAction; private readonly Action _helpAction; + private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService; + private readonly DispatcherTimer _refreshTimer; private string _currentTemperatureText = ""; private string _currentHeatFluxText = ""; - private string _targetTemperatureText = "355"; + private string _targetTemperatureText = ""; private string _heatTransferText = ""; private string _slopeText = ""; private string _interceptText = ""; private string _lastAction = "待机"; private bool _alarmActive; - public ConeRadiationSettingsViewModel(Action closeAction, Action helpAction) : base("辐射锥设置") + public ConeRadiationSettingsViewModel( + Action closeAction, + Action helpAction, + ITcpDeviceConnectionService tcpDeviceConnectionService) : base("辐射锥设置") { _closeAction = closeAction; _helpAction = helpAction; + _tcpDeviceConnectionService = tcpDeviceConnectionService; CloseCommand = new RelayCommand(_closeAction); HelpCommand = new RelayCommand(_helpAction); ActionCommand = new RelayCommand(ExecuteAction); @@ -40,6 +55,14 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel ]; HeatFluxPlot = CreatePlotModel(); + + RefreshDeviceValues(); + _refreshTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(1) + }; + _refreshTimer.Tick += (_, _) => RefreshDeviceValues(); + _refreshTimer.Start(); } public ObservableCollection CalibrationActions { get; } @@ -158,18 +181,89 @@ public sealed class ConeRadiationSettingsViewModel : PageViewModel if (action == "开始升温") { - CurrentTemperatureText = TargetTemperatureText; - CurrentHeatFluxText = "25.00"; - HeatTransferText = "0.42"; - SlopeText = "0.08"; - InterceptText = "1.20"; + WriteTargetTemperature(); + WriteActionCoil(action); return; } - if (action.EndsWith("标定", StringComparison.Ordinal)) + WriteActionCoil(action); + } + + private void RefreshDeviceValues() + { + CurrentHeatFluxText = ReadFloatText(CurrentHeatFluxRegister); + SlopeText = ReadFloatText(SlopeRegister); + InterceptText = ReadFloatText(InterceptRegister); + } + + private string ReadFloatText(ushort registerAddress) + { + return _tcpDeviceConnectionService.TryReadFloat(registerAddress, out var value) + ? value.ToString("0.00", CultureInfo.InvariantCulture) + : string.Empty; + } + + private void WriteTargetTemperature() + { + if (!short.TryParse(TargetTemperatureText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) { - CurrentHeatFluxText = action.Replace("KW标定", ".00", StringComparison.Ordinal); + Debug.WriteLine($"Invalid cone radiation target temperature: {TargetTemperatureText}"); return; } + + if (!_tcpDeviceConnectionService.TryWriteInt16(TargetTemperatureRegister, value)) + { + Debug.WriteLine("Cone radiation target temperature write failed."); + } + } + + private void WriteActionCoil(string action) + { + if (!TryGetActionCoil(action, out var coilAddress)) + { + return; + } + + if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true)) + { + Debug.WriteLine($"Cone radiation action '{action}' write failed."); + } + } + + private static bool TryGetActionCoil(string action, out ushort coilAddress) + { + switch (action) + { + case "10KW标定": + coilAddress = 130; + return true; + case "25KW标定": + coilAddress = 131; + return true; + case "35KW标定": + coilAddress = 132; + return true; + case "50KW标定": + coilAddress = 133; + return true; + case "65KW标定": + coilAddress = 134; + return true; + case "75KW标定": + coilAddress = 135; + return true; + case "循环水": + coilAddress = 49; + return true; + case "开始升温": + coilAddress = 100; + return true; + case "停止升温": + coilAddress = 101; + return true; + default: + coilAddress = 0; + return false; + } } } diff --git a/ConeCalorimeter/ViewModels/MainViewModel.cs b/ConeCalorimeter/ViewModels/MainViewModel.cs index 977660b..7944b73 100644 --- a/ConeCalorimeter/ViewModels/MainViewModel.cs +++ b/ConeCalorimeter/ViewModels/MainViewModel.cs @@ -12,11 +12,12 @@ public sealed class MainViewModel : ObservableObject public MainViewModel( IExperimentDataService experimentDataService, + ITcpDeviceConnectionService tcpDeviceConnectionService, IReportExportService reportExportService, IHelpDialogService helpDialogService) { _helpDialogService = helpDialogService; - var testPage = new TestPageViewModel(experimentDataService); + var testPage = new TestPageViewModel(experimentDataService, tcpDeviceConnectionService); var reportPage = new ReportPageViewModel(experimentDataService, reportExportService); NavigationItems = []; @@ -25,13 +26,17 @@ public sealed class MainViewModel : ObservableObject "C值标定", new CValueCalibrationViewModel( () => SelectPage(testItem), - ShowCValueCalibrationHelp)); + ShowCValueCalibrationHelp, + tcpDeviceConnectionService)); var coneRadiationItem = new NavigationItemViewModel( "辐射锥设置", - new ConeRadiationSettingsViewModel(() => SelectPage(testItem), ShowConeRadiationHelp)); + new ConeRadiationSettingsViewModel( + () => SelectPage(testItem), + ShowConeRadiationHelp, + tcpDeviceConnectionService)); var smokeDensityItem = new NavigationItemViewModel( "烟密度设置", - new SmokeDensitySettingsViewModel(() => SelectPage(testItem))); + new SmokeDensitySettingsViewModel(() => SelectPage(testItem), tcpDeviceConnectionService)); var realtimeDataItem = new NavigationItemViewModel( "实时数据", new RealtimeDataViewModel(experimentDataService, () => SelectPage(testItem))); diff --git a/ConeCalorimeter/ViewModels/MetricDisplayViewModel.cs b/ConeCalorimeter/ViewModels/MetricDisplayViewModel.cs index 0531b25..c53b283 100644 --- a/ConeCalorimeter/ViewModels/MetricDisplayViewModel.cs +++ b/ConeCalorimeter/ViewModels/MetricDisplayViewModel.cs @@ -24,11 +24,23 @@ public sealed class MetricDisplayViewModel : ObservableObject public void SetValue(double value, string format = "0.00") { + if (!double.IsFinite(value)) + { + ValueText = string.Empty; + return; + } + ValueText = value.ToString(format); } public void SetValue(int value) { + if (value < 0) + { + ValueText = string.Empty; + return; + } + ValueText = value.ToString(); } } diff --git a/ConeCalorimeter/ViewModels/RealtimeDataRowViewModel.cs b/ConeCalorimeter/ViewModels/RealtimeDataRowViewModel.cs index fc4f628..9a22bd5 100644 --- a/ConeCalorimeter/ViewModels/RealtimeDataRowViewModel.cs +++ b/ConeCalorimeter/ViewModels/RealtimeDataRowViewModel.cs @@ -11,7 +11,7 @@ public sealed class RealtimeDataRowViewModel public RealtimeDataRowViewModel(RealtimeDataRecord record) { - TimeText = record.TestSeconds.ToString(CultureInfo.InvariantCulture); + TimeText = record.TestSeconds < 0 ? string.Empty : record.TestSeconds.ToString(CultureInfo.InvariantCulture); OxygenText = Format(record.Oxygen); CarbonDioxideText = Format(record.CarbonDioxide); CarbonMonoxideText = Format(record.CarbonMonoxide); @@ -25,7 +25,7 @@ public sealed class RealtimeDataRowViewModel HeatReleaseText = Format(record.HeatReleaseRate); EhcText = Format(record.EffectiveHeatOfCombustion); MassLossText = Format(record.MassLoss); - SampleTemperatureText = Format(record.ConeTemperature, "0.0"); + SampleTemperatureText = Format(record.SampleTemperature, "0.0"); } public string TimeText { get; init; } = string.Empty; diff --git a/ConeCalorimeter/ViewModels/SmokeDensitySettingsViewModel.cs b/ConeCalorimeter/ViewModels/SmokeDensitySettingsViewModel.cs index bcfff84..b126a69 100644 --- a/ConeCalorimeter/ViewModels/SmokeDensitySettingsViewModel.cs +++ b/ConeCalorimeter/ViewModels/SmokeDensitySettingsViewModel.cs @@ -1,18 +1,24 @@ using System.Collections.ObjectModel; +using System.Diagnostics; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using ConeCalorimeter.Services; namespace ConeCalorimeter.ViewModels; public sealed class SmokeDensitySettingsViewModel : PageViewModel { private readonly Action _closeAction; + private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService; private string _absorbanceText = ""; private string _lastCalibration = "待校准"; - public SmokeDensitySettingsViewModel(Action closeAction) : base("烟密度设置") + public SmokeDensitySettingsViewModel( + Action closeAction, + ITcpDeviceConnectionService tcpDeviceConnectionService) : base("烟密度设置") { _closeAction = closeAction; + _tcpDeviceConnectionService = tcpDeviceConnectionService; CloseCommand = new RelayCommand(_closeAction); CalibrationCommand = new RelayCommand(Calibrate); @@ -52,6 +58,40 @@ public sealed class SmokeDensitySettingsViewModel : PageViewModel } LastCalibration = label; - AbsorbanceText = label.StartsWith("100%", StringComparison.Ordinal) ? "100.00" : "0.00"; + + if (!TryGetCalibrationCoil(label, out var coilAddress)) + { + return; + } + + if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true)) + { + Debug.WriteLine($"Smoke density calibration '{label}' write failed."); + } + } + + private static bool TryGetCalibrationCoil(string label, out ushort coilAddress) + { + switch (label) + { + case "0%校准": + coilAddress = 28; + return true; + case "25%校准": + coilAddress = 26; + return true; + case "50%校准": + coilAddress = 24; + return true; + case "75%校准": + coilAddress = 22; + return true; + case "100%校准": + coilAddress = 20; + return true; + default: + coilAddress = 0; + return false; + } } } diff --git a/ConeCalorimeter/ViewModels/TestPageViewModel.cs b/ConeCalorimeter/ViewModels/TestPageViewModel.cs index 9663b39..f646add 100644 --- a/ConeCalorimeter/ViewModels/TestPageViewModel.cs +++ b/ConeCalorimeter/ViewModels/TestPageViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Diagnostics; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ConeCalorimeter.Models; @@ -13,15 +14,19 @@ namespace ConeCalorimeter.ViewModels; public sealed class TestPageViewModel : PageViewModel { private readonly IExperimentDataService _experimentDataService; + private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService; private readonly LineSeries _heatReleaseSeries; private readonly LineSeries _totalHeatSeries; private readonly LineSeries _totalSmokeSeries; private bool _flameDetected; private string _lastAction = "待机"; - public TestPageViewModel(IExperimentDataService experimentDataService) : base("测试界面") + public TestPageViewModel( + IExperimentDataService experimentDataService, + ITcpDeviceConnectionService tcpDeviceConnectionService) : base("测试界面") { _experimentDataService = experimentDataService; + _tcpDeviceConnectionService = tcpDeviceConnectionService; TopMetrics = [ @@ -42,8 +47,8 @@ public sealed class TestPageViewModel : PageViewModel HeatMetrics = [ new MetricDisplayViewModel("热释放速率", "kW/m²"), - new MetricDisplayViewModel("qa(180)", "MJ/m²"), - new MetricDisplayViewModel("qa(300)", "MJ/m²"), + new MetricDisplayViewModel("热释放速率180", "MJ/m²"), + new MetricDisplayViewModel("热释放速率300", "MJ/m²"), new MetricDisplayViewModel("放热总量", "MJ/m²"), new MetricDisplayViewModel("产烟量", "m³"), new MetricDisplayViewModel("当前质量", "g"), @@ -63,12 +68,12 @@ public sealed class TestPageViewModel : PageViewModel new DeviceActionViewModel("辐射锥降", ExecuteDeviceActionCommand), new DeviceActionViewModel("称重台升", ExecuteDeviceActionCommand), new DeviceActionViewModel("称重台降", ExecuteDeviceActionCommand), - new DeviceActionViewModel("复位", ExecuteDeviceActionCommand), - new DeviceActionViewModel("停止", ExecuteDeviceActionCommand), - new DeviceActionViewModel("测试", ExecuteDeviceActionCommand), + new DeviceActionViewModel("测试开始", ExecuteDeviceActionCommand), + new DeviceActionViewModel("测试结束", ExecuteDeviceActionCommand), new DeviceActionViewModel("风机开", ExecuteDeviceActionCommand), new DeviceActionViewModel("风机关", ExecuteDeviceActionCommand), - new DeviceActionViewModel("点火开", ExecuteDeviceActionCommand) + new DeviceActionViewModel("点火关", ExecuteDeviceActionCommand), + new DeviceActionViewModel("复位", ExecuteDeviceActionCommand) ]; HeatReleasePlot = CreatePlotModel(out _heatReleaseSeries, out _totalHeatSeries, out _totalSmokeSeries); @@ -227,6 +232,11 @@ public sealed class TestPageViewModel : PageViewModel private static void AppendPoint(LineSeries series, double x, double y) { + if (!double.IsFinite(x) || x < 0 || !double.IsFinite(y)) + { + return; + } + series.Points.Add(new DataPoint(x, y)); if (series.Points.Count > 600) @@ -237,9 +247,64 @@ public sealed class TestPageViewModel : PageViewModel private void ExecuteDeviceAction(string? action) { - if (!string.IsNullOrWhiteSpace(action)) + if (string.IsNullOrWhiteSpace(action)) { - LastAction = action; + return; + } + + LastAction = action; + + if (!TryGetDeviceActionCoil(action, out var coilAddress, out var value)) + { + return; + } + + if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, value)) + { + Debug.WriteLine($"Device action '{action}' write failed."); + } + } + + private static bool TryGetDeviceActionCoil(string action, out ushort coilAddress, out bool value) + { + value = true; + + switch (action) + { + case "称重台升": + coilAddress = 93; + return true; + case "称重台降": + coilAddress = 94; + return true; + case "辐射锥升": + coilAddress = 83; + return true; + case "辐射锥降": + coilAddress = 84; + return true; + case "复位": + coilAddress = 88; + return true; + case "测试开始": + coilAddress = 65; + return true; + case "测试结束": + coilAddress = 67; + return true; + case "点火关": + coilAddress = 53; + return true; + case "风机开": + coilAddress = 54; + return true; + case "风机关": + coilAddress = 54; + value = false; + return true; + default: + coilAddress = 0; + return false; } } } diff --git a/ConeCalorimeter/Views/ConeRadiationSettingsView.xaml b/ConeCalorimeter/Views/ConeRadiationSettingsView.xaml index 25575c6..178cfd4 100644 --- a/ConeCalorimeter/Views/ConeRadiationSettingsView.xaml +++ b/ConeCalorimeter/Views/ConeRadiationSettingsView.xaml @@ -238,15 +238,24 @@ -