diff --git a/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs b/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs index 80076d4..fdff365 100644 --- a/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs +++ b/ConeCalorimeter/Services/ITcpDeviceConnectionService.cs @@ -16,6 +16,8 @@ public interface ITcpDeviceConnectionService : IAsyncDisposable bool TryReadFloat(ushort registerAddress, out double value); + bool TryReadFloatValues(ushort registerAddress, out ModbusFloatReadResult result); + bool TryReadInt16(ushort registerAddress, out int value); bool TryWriteInt16(ushort registerAddress, short value); diff --git a/ConeCalorimeter/Services/ModbusFloatReadResult.cs b/ConeCalorimeter/Services/ModbusFloatReadResult.cs new file mode 100644 index 0000000..f310c4b --- /dev/null +++ b/ConeCalorimeter/Services/ModbusFloatReadResult.cs @@ -0,0 +1,45 @@ +namespace ConeCalorimeter.Services; + +public enum ModbusFloatByteOrder +{ + Abcd, + Cdab, + Badc, + Dcba +} + +public readonly record struct ModbusFloatReadResult(byte A, byte B, byte C, byte D) +{ + public string RawHex => $"{A:X2} {B:X2} {C:X2} {D:X2}"; + + public double Abcd => Decode(A, B, C, D); + + public double Cdab => Decode(C, D, A, B); + + public double Badc => Decode(B, A, D, C); + + public double Dcba => Decode(D, C, B, A); + + public double GetValue(ModbusFloatByteOrder byteOrder) + { + return byteOrder switch + { + ModbusFloatByteOrder.Abcd => Abcd, + ModbusFloatByteOrder.Cdab => Cdab, + ModbusFloatByteOrder.Badc => Badc, + ModbusFloatByteOrder.Dcba => Dcba, + _ => double.NaN + }; + } + + private static double Decode(byte b0, byte b1, byte b2, byte b3) + { + var rawValue = unchecked((int)( + ((uint)b0 << 24) + | ((uint)b1 << 16) + | ((uint)b2 << 8) + | b3)); + + return BitConverter.Int32BitsToSingle(rawValue); + } +} diff --git a/ConeCalorimeter/Services/ModbusRealtimeDataService.cs b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs index 244f6e4..aa4c207 100644 --- a/ConeCalorimeter/Services/ModbusRealtimeDataService.cs +++ b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using ConeCalorimeter.Models; namespace ConeCalorimeter.Services; @@ -23,8 +24,16 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService private const ushort IgnitionSecondsRegister = 1014; private const ushort TestSecondsRegister = 1015; private const ushort M3FlameMonitorBit = 3; + private static readonly ModbusFloatByteOrder[] AlternateFloatByteOrders = + [ + ModbusFloatByteOrder.Cdab, + ModbusFloatByteOrder.Badc, + ModbusFloatByteOrder.Dcba + ]; private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService; + private readonly HashSet _loggedFloatDiagnostics = []; + private readonly HashSet _loggedInvalidFloatDiagnostics = []; public ModbusRealtimeDataService(ITcpDeviceConnectionService tcpDeviceConnectionService) { @@ -34,24 +43,24 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService public RealtimeSnapshot GetCurrentSnapshot(TimeSpan elapsed) { return new RealtimeSnapshot( - OrificeFlow: ReadFloatOrEmpty(OrificeFlowRegister), - OrificePressure: ReadFloatOrEmpty(OrificePressureRegister), - OrificeTemperature: ReadFloatOrEmpty(OrificeTemperatureRegister), - ConeTemperature: ReadFloatOrEmpty(ConeTemperatureRegister), - SampleTemperature: ReadFloatOrEmpty(SampleTemperatureRegister), - Irradiance: ReadFloatOrEmpty(IrradianceRegister), + OrificeFlow: ReadRangedFloatOrEmpty("OrificeFlow", OrificeFlowRegister, 0, 10), + OrificePressure: ReadRangedFloatOrEmpty("OrificePressure", OrificePressureRegister, -5000, 5000), + OrificeTemperature: ReadRangedFloatOrEmpty("OrificeTemperature", OrificeTemperatureRegister, 200, 700), + ConeTemperature: ReadRangedFloatOrEmpty("ConeTemperature", ConeTemperatureRegister, 0, 1200), + SampleTemperature: ReadRangedFloatOrEmpty("SampleTemperature", SampleTemperatureRegister, -50, 1200), + Irradiance: ReadRangedFloatOrEmpty("Irradiance", IrradianceRegister, 0, 100), FlameDetected: ReadCoilOrFalse(M3FlameMonitorBit), - Oxygen: ReadFloatOrEmpty(OxygenRegister), - CarbonDioxide: ReadFloatOrEmpty(CarbonDioxideRegister), - CarbonMonoxide: ReadFloatOrEmpty(CarbonMonoxideRegister), - HeatReleaseRate: ReadFloatOrEmpty(HeatReleaseRateRegister), - PeakHeatReleaseRate: ReadFloatOrEmpty(PeakHeatReleaseRateRegister), - CFactor: ReadFloatOrEmpty(CFactorRegister), - Qa180: ReadFloatOrEmpty(Qa180Register), - Qa300: ReadFloatOrEmpty(Qa300Register), + Oxygen: ReadRangedFloatOrEmpty("O2", OxygenRegister, 0, 30), + CarbonDioxide: ReadRangedFloatOrEmpty("CO2", CarbonDioxideRegister, 0, 20), + CarbonMonoxide: ReadRangedFloatOrEmpty("CO", CarbonMonoxideRegister, 0, 10), + HeatReleaseRate: ReadRangedFloatOrEmpty("HeatReleaseRate", HeatReleaseRateRegister, 0, 5000), + PeakHeatReleaseRate: ReadRangedFloatOrEmpty("PeakHeatReleaseRate", PeakHeatReleaseRateRegister, 0, 5000), + CFactor: ReadRangedFloatOrEmpty("CFactor", CFactorRegister, 0, 1000), + Qa180: ReadRangedFloatOrEmpty("Qa180", Qa180Register, 0, 1000), + Qa300: ReadRangedFloatOrEmpty("Qa300", Qa300Register, 0, 1000), TotalHeatRelease: double.NaN, - SmokeProduction: ReadFloatOrEmpty(SmokeProductionRegister), - CurrentMass: ReadFloatOrEmpty(CurrentMassRegister), + SmokeProduction: ReadRangedFloatOrEmpty("SmokeProduction", SmokeProductionRegister, 0, 100), + CurrentMass: ReadRangedFloatOrEmpty("CurrentMass", CurrentMassRegister, 0, 100000), InitialMass: double.NaN, MassLoss: double.NaN, MassLossRate: double.NaN, @@ -60,11 +69,93 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService TotalSmoke: double.NaN); } - private double ReadFloatOrEmpty(ushort registerAddress) + private double ReadRangedFloatOrEmpty(string label, ushort registerAddress, double minimum, double maximum) { - return _tcpDeviceConnectionService.TryReadFloat(registerAddress, out var value) - ? value - : double.NaN; + if (!_tcpDeviceConnectionService.TryReadFloatValues(registerAddress, out var result)) + { + return double.NaN; + } + + if (!TrySelectRangedFloat(result, minimum, maximum, out var byteOrder, out var value, out var matchCount)) + { + LogFloatDiagnostic(label, registerAddress, result, null, matchCount); + return double.NaN; + } + + LogFloatDiagnostic(label, registerAddress, result, byteOrder, matchCount); + return NormalizeRealtimeValue(value); + } + + private static bool TrySelectRangedFloat( + ModbusFloatReadResult result, + double minimum, + double maximum, + out ModbusFloatByteOrder byteOrder, + out double value, + out int matchCount) + { + var alternateMatches = AlternateFloatByteOrders + .Where(candidate => IsInRange(result.GetValue(candidate), minimum, maximum)) + .ToArray(); + var isAbcdInRange = IsInRange(result.Abcd, minimum, maximum); + + matchCount = alternateMatches.Length + (isAbcdInRange ? 1 : 0); + + if (isAbcdInRange) + { + byteOrder = ModbusFloatByteOrder.Abcd; + value = result.Abcd; + return true; + } + + if (alternateMatches.Length != 1) + { + byteOrder = default; + value = double.NaN; + return false; + } + + byteOrder = alternateMatches[0]; + value = result.GetValue(byteOrder); + return true; + } + + private void LogFloatDiagnostic( + string label, + ushort registerAddress, + ModbusFloatReadResult result, + ModbusFloatByteOrder? selectedByteOrder, + int matchCount) + { + if (selectedByteOrder is null) + { + if (!_loggedInvalidFloatDiagnostics.Add(registerAddress)) + { + return; + } + } + else if (!_loggedFloatDiagnostics.Add(registerAddress)) + { + return; + } + + var selectedText = selectedByteOrder?.ToString() ?? "none"; + + Debug.WriteLine( + $"Realtime float {label} register {registerAddress} raw [{result.RawHex}], " + + $"ABCD={result.Abcd:G9}, CDAB={result.Cdab:G9}, " + + $"BADC={result.Badc:G9}, DCBA={result.Dcba:G9}, " + + $"selected={selectedText}, matches={matchCount}."); + } + + private static bool IsInRange(double value, double minimum, double maximum) + { + return double.IsFinite(value) && value >= minimum && value <= maximum; + } + + private static double NormalizeRealtimeValue(double value) + { + return Math.Abs(value) < 0.005 ? 0 : value; } private int ReadInt16OrEmpty(ushort registerAddress) diff --git a/ConeCalorimeter/Services/TcpDeviceConnectionService.cs b/ConeCalorimeter/Services/TcpDeviceConnectionService.cs index 56b5c36..d146486 100644 --- a/ConeCalorimeter/Services/TcpDeviceConnectionService.cs +++ b/ConeCalorimeter/Services/TcpDeviceConnectionService.cs @@ -146,6 +146,33 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService } } + public bool TryReadFloatValues(ushort registerAddress, out ModbusFloatReadResult result) + { + result = default; + + lock (_syncRoot) + { + if (_client is null || !IsTcpClientConnected(_client)) + { + CloseCurrentClientCore(); + return false; + } + + try + { + result = ReadFloatValues(_client, registerAddress); + return true; + } + catch (Exception ex) when (ex is IOException or SocketException or InvalidDataException or ObjectDisposedException) + { + Debug.WriteLine($"TCP device register {registerAddress} float values read failed: {ex.Message}"); + SetConnectionState(false, $"读取寄存器 {registerAddress} 失败:{ex.Message}"); + CloseCurrentClientCore(); + return false; + } + } + } + public bool TryReadInt16(ushort registerAddress, out int value) { value = 0; @@ -393,6 +420,11 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService } private double ReadFloat(TcpClient client, ushort registerAddress) + { + return ReadFloatValues(client, registerAddress).Abcd; + } + + private ModbusFloatReadResult ReadFloatValues(TcpClient client, ushort registerAddress) { var pdu = ReadHoldingRegisters(client, registerAddress, 2); @@ -401,8 +433,7 @@ public sealed class TcpDeviceConnectionService : ITcpDeviceConnectionService throw new InvalidDataException("Invalid Modbus TCP float response."); } - var rawValue = BinaryPrimitives.ReadInt32BigEndian(pdu[2..6]); - return BitConverter.Int32BitsToSingle(rawValue); + return new ModbusFloatReadResult(pdu[2], pdu[3], pdu[4], pdu[5]); } private int ReadInt16(TcpClient client, ushort registerAddress) diff --git a/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs b/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs index cbb4016..ee81d78 100644 --- a/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs +++ b/ConeCalorimeter/ViewModels/CValueCalibrationViewModel.cs @@ -14,6 +14,9 @@ public sealed class CValueCalibrationViewModel : PageViewModel private const ushort PressureDifferenceRegister = 284; private const ushort OxygenRegister = 286; private const ushort CValueRegister = 308; + private const string BaselineCollectionAction = "基线采集"; + private const string CalibrationStartAction = "标定开始"; + private static readonly TimeSpan PulseDelay = TimeSpan.FromMilliseconds(300); private readonly Action _closeAction; private readonly Action _helpAction; @@ -117,6 +120,13 @@ public sealed class CValueCalibrationViewModel : PageViewModel } LastAction = action; + + if (IsPulseAction(action)) + { + _ = PulseActionCoilAsync(action); + return; + } + WriteActionCoil(action); } @@ -151,6 +161,27 @@ public sealed class CValueCalibrationViewModel : PageViewModel } } + private async Task PulseActionCoilAsync(string action) + { + if (!TryGetActionCoil(action, out var coilAddress)) + { + return; + } + + if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true)) + { + Debug.WriteLine($"C value calibration action '{action}' pulse start failed."); + return; + } + + await Task.Delay(PulseDelay); + + if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, false)) + { + Debug.WriteLine($"C value calibration action '{action}' pulse reset failed."); + } + } + private static bool TryGetActionCoil(string action, out ushort coilAddress) { switch (action) @@ -167,10 +198,10 @@ public sealed class CValueCalibrationViewModel : PageViewModel case "取样泵": coilAddress = 50; return true; - case "基线采集": + case BaselineCollectionAction: coilAddress = 60; return true; - case "标定开始": + case CalibrationStartAction: coilAddress = 70; return true; default: @@ -178,4 +209,9 @@ public sealed class CValueCalibrationViewModel : PageViewModel return false; } } + + private static bool IsPulseAction(string action) + { + return action is BaselineCollectionAction or CalibrationStartAction; + } } diff --git a/ConeCalorimeter/ViewModels/TestPageViewModel.cs b/ConeCalorimeter/ViewModels/TestPageViewModel.cs index 596cb77..b8a2d69 100644 --- a/ConeCalorimeter/ViewModels/TestPageViewModel.cs +++ b/ConeCalorimeter/ViewModels/TestPageViewModel.cs @@ -245,6 +245,34 @@ public sealed class TestPageViewModel : PageViewModel } } + public bool StartMomentaryDeviceAction(string? action) + { + return TryWriteMomentaryDeviceAction(action, true); + } + + public bool StopMomentaryDeviceAction(string? action) + { + return TryWriteMomentaryDeviceAction(action, false); + } + + private bool TryWriteMomentaryDeviceAction(string? action, bool isRunning) + { + if (string.IsNullOrWhiteSpace(action) + || !TryGetMomentaryDeviceActionCoil(action, out var coilAddress)) + { + return false; + } + + LastAction = isRunning ? action : $"停止:{action}"; + + if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, isRunning)) + { + Debug.WriteLine($"Momentary device action '{action}' write failed."); + } + + return true; + } + private void ExecuteDeviceAction(string? action) { if (string.IsNullOrWhiteSpace(action)) @@ -252,6 +280,11 @@ public sealed class TestPageViewModel : PageViewModel return; } + if (IsMomentaryDeviceAction(action)) + { + return; + } + LastAction = action; if (action == "测试开始") @@ -292,18 +325,6 @@ public sealed class TestPageViewModel : PageViewModel 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; @@ -328,4 +349,31 @@ public sealed class TestPageViewModel : PageViewModel return false; } } + + private static bool IsMomentaryDeviceAction(string action) + { + return TryGetMomentaryDeviceActionCoil(action, out _); + } + + private static bool TryGetMomentaryDeviceActionCoil(string action, out ushort coilAddress) + { + switch (action) + { + case "称重台升": + coilAddress = 93; + return true; + case "称重台降": + coilAddress = 94; + return true; + case "辐射锥升": + coilAddress = 83; + return true; + case "辐射锥降": + coilAddress = 84; + return true; + default: + coilAddress = 0; + return false; + } + } } diff --git a/ConeCalorimeter/Views/TestPageView.xaml b/ConeCalorimeter/Views/TestPageView.xaml index c3eb62d..29df73c 100644 --- a/ConeCalorimeter/Views/TestPageView.xaml +++ b/ConeCalorimeter/Views/TestPageView.xaml @@ -216,6 +216,15 @@ CommandParameter="{Binding Label}" Margin="5,3" MinHeight="40" + PreviewMouseLeftButtonDown="DeviceActionButton_PreviewMouseLeftButtonDown" + PreviewMouseLeftButtonUp="DeviceActionButton_PreviewMouseLeftButtonUp" + MouseLeave="DeviceActionButton_MouseLeave" + LostMouseCapture="DeviceActionButton_LostMouseCapture" + LostKeyboardFocus="DeviceActionButton_LostKeyboardFocus" + TouchDown="DeviceActionButton_TouchDown" + TouchUp="DeviceActionButton_TouchUp" + TouchLeave="DeviceActionButton_TouchLeave" + LostTouchCapture="DeviceActionButton_LostTouchCapture" Style="{StaticResource InstrumentButtonStyle}" /> diff --git a/ConeCalorimeter/Views/TestPageView.xaml.cs b/ConeCalorimeter/Views/TestPageView.xaml.cs index 82462de..bc404d1 100644 --- a/ConeCalorimeter/Views/TestPageView.xaml.cs +++ b/ConeCalorimeter/Views/TestPageView.xaml.cs @@ -1,11 +1,159 @@ using System.Windows.Controls; +using System.Windows.Input; +using ConeCalorimeter.ViewModels; namespace ConeCalorimeter.Views; public partial class TestPageView : UserControl { + private readonly HashSet _runningMomentaryActions = []; + public TestPageView() { InitializeComponent(); } + + private void DeviceActionButton_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (StartMomentaryAction(sender)) + { + if (sender is Button button) + { + button.CaptureMouse(); + } + + e.Handled = true; + } + } + + private void DeviceActionButton_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (StopMomentaryAction(sender)) + { + ReleaseInputCapture(sender); + e.Handled = true; + } + } + + private void DeviceActionButton_MouseLeave(object sender, MouseEventArgs e) + { + if (StopMomentaryAction(sender)) + { + ReleaseInputCapture(sender); + } + } + + private void DeviceActionButton_LostMouseCapture(object sender, MouseEventArgs e) + { + StopMomentaryAction(sender); + } + + private void DeviceActionButton_LostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) + { + if (StopMomentaryAction(sender)) + { + ReleaseInputCapture(sender); + } + } + + private void DeviceActionButton_TouchDown(object sender, TouchEventArgs e) + { + if (StartMomentaryAction(sender)) + { + if (sender is Button button) + { + button.CaptureTouch(e.TouchDevice); + } + + e.Handled = true; + } + } + + private void DeviceActionButton_TouchUp(object sender, TouchEventArgs e) + { + if (StopMomentaryAction(sender)) + { + ReleaseInputCapture(sender); + e.Handled = true; + } + } + + private void DeviceActionButton_TouchLeave(object sender, TouchEventArgs e) + { + if (StopMomentaryAction(sender)) + { + ReleaseInputCapture(sender); + } + } + + private void DeviceActionButton_LostTouchCapture(object sender, TouchEventArgs e) + { + StopMomentaryAction(sender); + } + + private bool StartMomentaryAction(object sender) + { + if (!TryGetDeviceAction(sender, out var label, out var viewModel)) + { + return false; + } + + if (_runningMomentaryActions.Contains(label)) + { + return true; + } + + if (!viewModel.StartMomentaryDeviceAction(label)) + { + return false; + } + + _runningMomentaryActions.Add(label); + return true; + } + + private bool StopMomentaryAction(object sender) + { + if (!TryGetDeviceAction(sender, out var label, out var viewModel) + || !_runningMomentaryActions.Remove(label)) + { + return false; + } + + return viewModel.StopMomentaryDeviceAction(label); + } + + private bool TryGetDeviceAction( + object sender, + out string label, + out TestPageViewModel viewModel) + { + label = string.Empty; + viewModel = null!; + + if (sender is not Button { DataContext: DeviceActionViewModel action } + || DataContext is not TestPageViewModel testPageViewModel) + { + return false; + } + + label = action.Label; + viewModel = testPageViewModel; + return true; + } + + private static void ReleaseInputCapture(object sender) + { + if (sender is not Button button) + { + return; + } + + if (button.IsMouseCaptured) + { + button.ReleaseMouseCapture(); + } + + button.ReleaseAllTouchCaptures(); + } }