更新20260521

This commit is contained in:
GukSang.Jin
2026-05-21 15:13:17 +08:00
parent 5322294ed5
commit 44c86765fa
8 changed files with 446 additions and 36 deletions

View File

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

View File

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

View File

@@ -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<ushort> _loggedFloatDiagnostics = [];
private readonly HashSet<ushort> _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)

View File

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

View File

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

View File

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

View File

@@ -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}" />
</DataTemplate>
</ItemsControl.ItemTemplate>

View File

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