diff --git a/DentistryHandpieces/MainWindowViewModel.cs b/DentistryHandpieces/MainWindowViewModel.cs index 1258a91..96fe30f 100644 --- a/DentistryHandpieces/MainWindowViewModel.cs +++ b/DentistryHandpieces/MainWindowViewModel.cs @@ -103,6 +103,7 @@ public sealed class MainWindowViewModel : ObservableObject private const double MinimumTorqueChangeThreshold = 0.05; private const string TorqueUnit = "mN.m"; private static readonly TimeSpan RealtimeRefreshInterval = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan RealtimeDataFreshnessTimeout = TimeSpan.FromSeconds(3); private static readonly TimeSpan SnapshotInterval = TimeSpan.FromSeconds(5); private static readonly TimeSpan NoLoadCaptureDuration = TimeSpan.FromSeconds(3); private static readonly TimeSpan SpeedTorqueStartConfirmationTimeout = TimeSpan.FromSeconds(5); @@ -220,6 +221,7 @@ public sealed class MainWindowViewModel : ObservableObject private bool _isReadingParameterConfig; private bool _hasLoadedParameterConfigFromPlc; private bool _lastRealtimeReadFailed; + private DateTime _lastSuccessfulRealtimeReadAt = DateTime.MinValue; private bool _isAutoStoppingDisplacement; private bool _isAutoStoppingSpeedTorque; private bool _isApplyingParameterConfigToInputs; @@ -752,11 +754,10 @@ public sealed class MainWindowViewModel : ObservableObject try { PlcConnectionConfig config = _parameterConfig.ToPlcConnectionConfig(); - Task> registerReadTask = - _plcRegisterService.ReadFloatValuesAsync(config, RealtimeRegisterAddresses); - Task> coilReadTask = - _plcCoilService.ReadCoilValuesAsync(config, RealtimeCoilAddresses); - IReadOnlyDictionary values = await registerReadTask; + IReadOnlyDictionary values = + await _plcRegisterService.ReadFloatValuesAsync(config, RealtimeRegisterAddresses); + IReadOnlyDictionary coilValues = + await _plcCoilService.ReadCoilValuesAsync(config, RealtimeCoilAddresses); double dialIndicator = ReadFloatValue(values, DialIndicatorRegister, "千分表显示"); double axialForce = ReadFloatValue(values, AxialForceDisplayRegister, "轴向力显示"); @@ -788,12 +789,12 @@ public sealed class MainWindowViewModel : ObservableObject UpdateSpeedTorqueDisplay(); UpdateNoLoadSpeedDisplay(); - IReadOnlyDictionary coilValues = await coilReadTask; ApplyResetCoilValues(coilValues); ApplyAxialForceModeState(ReadCoilValue(coilValues, AxialForceModeCoil)); bool isVentValveOpen = ReadCoilValue(coilValues, VentValveCoil); VentValveButtonText = isVentValveOpen ? "关闭气阀" : "通气阀"; + _lastSuccessfulRealtimeReadAt = DateTime.Now; CaptureRealtimeSample(dialIndicator, coilValues); FinalizeNoLoadSpeedRunIfDue(); QueueSnapshotIfDue(); @@ -837,7 +838,7 @@ public sealed class MainWindowViewModel : ObservableObject { if (!_lastRealtimeReadFailed) { - StatusText = $"实时数据读取失败:{OperatorMessageFormatter.FromException(ex)}"; + StatusText = $"实时数据读取失败,当前读数为上次有效值:{OperatorMessageFormatter.FromException(ex)}"; _lastRealtimeReadFailed = true; Log.Warning(ex, "PLC实时数据读取失败,后续相同故障将等待恢复后再记录"); } @@ -2433,6 +2434,12 @@ public sealed class MainWindowViewModel : ObservableObject return; } + if (!TryGetRealtimeSpeed(out _)) + { + StatusText = "实时数据未更新,已阻止空载转速记录。"; + return; + } + try { await _plcCoilService.WriteCoilAsync( @@ -3089,13 +3096,13 @@ public sealed class MainWindowViewModel : ObservableObject private bool TryGetDialValue(out double value) { value = _dialIndicator; - return true; + return HasFreshRealtimeData() && double.IsFinite(value); } private bool TryGetAxialForceValue(out double value) { value = _axialForce; - return true; + return HasFreshRealtimeData() && double.IsFinite(value); } private void UpdateRealtimeSpeedFromInput() @@ -3244,13 +3251,19 @@ public sealed class MainWindowViewModel : ObservableObject private bool TryGetRealtimeSpeed(out double value) { value = _realtimeSpeed; - return true; + return HasFreshRealtimeData() && double.IsFinite(value); } private bool TryGetRealtimeTorque(out double value) { value = _realtimeTorque; - return true; + return HasFreshRealtimeData() && double.IsFinite(value); + } + + private bool HasFreshRealtimeData() + { + return _lastSuccessfulRealtimeReadAt != DateTime.MinValue + && DateTime.Now - _lastSuccessfulRealtimeReadAt <= RealtimeDataFreshnessTimeout; } private void ApplyResetCoilValues(IReadOnlyDictionary coilValues) @@ -3483,7 +3496,14 @@ public sealed class MainWindowViewModel : ObservableObject private int GetTorqueSampleLimit() { - return MaxTorqueSampleCount; + if (_parameterConfig.TorqueHoldTime <= 0) + { + return MaxTorqueSampleCount; + } + + int requiredSamples = (int)Math.Ceiling( + _parameterConfig.TorqueHoldTime / RealtimeRefreshInterval.TotalSeconds) + 2; + return Math.Max(MaxTorqueSampleCount, requiredSamples); } private void CaptureRealtimeSample(double dialIndicator, IReadOnlyDictionary coilValues) diff --git a/DentistryHandpieces/ModbusTcpPlcCoilService.cs b/DentistryHandpieces/ModbusTcpPlcCoilService.cs index b53285c..d9de3c3 100644 --- a/DentistryHandpieces/ModbusTcpPlcCoilService.cs +++ b/DentistryHandpieces/ModbusTcpPlcCoilService.cs @@ -34,6 +34,7 @@ public interface IPlcRegisterService public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterService { private readonly object _transactionLock = new(); + private readonly SemaphoreSlim _transportLock = new(1, 1); private ushort _transactionId; public async Task PulseCoilAsync(PlcConnectionConfig config, ushort coilAddress, CancellationToken cancellationToken = default) @@ -94,10 +95,16 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi } IReadOnlyList groups = CreateRegisterReadGroups(registerAddresses); - Task[] readTasks = groups - .Select(group => ReadHoldingRegistersAsync(config, group.StartAddress, group.RegisterCount, cancellationToken)) - .ToArray(); - ushort[][] groupRegisters = await Task.WhenAll(readTasks); + var groupRegisters = new ushort[groups.Count][]; + for (int groupIndex = 0; groupIndex < groups.Count; groupIndex++) + { + RegisterReadGroup group = groups[groupIndex]; + groupRegisters[groupIndex] = await ReadHoldingRegistersAsync( + config, + group.StartAddress, + group.RegisterCount, + cancellationToken); + } var values = new Dictionary(); for (int groupIndex = 0; groupIndex < groups.Count; groupIndex++) @@ -199,183 +206,235 @@ public sealed class ModbusTcpPlcCoilService : IPlcCoilService, IPlcRegisterServi private async Task WriteSingleCoilAsync(PlcConnectionConfig config, ushort coilAddress, bool value, CancellationToken cancellationToken) { - using var client = new TcpClient(); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); - - await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); - await using NetworkStream stream = client.GetStream(); - - ushort transactionId = NextTransactionId(); - byte[] request = BuildWriteSingleCoilRequest(transactionId, config.UnitId, coilAddress, value); - await stream.WriteAsync(request, timeoutCts.Token); - - byte[] response = new byte[12]; - await ReadExactlyAsync(stream, response, timeoutCts.Token); - - if (response[0] != request[0] || response[1] != request[1]) + await _transportLock.WaitAsync(cancellationToken); + try { - throw new InvalidOperationException("PLC 响应事务号不匹配。"); + using var client = new TcpClient(); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); + + await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); + await using NetworkStream stream = client.GetStream(); + + ushort transactionId = NextTransactionId(); + byte[] request = BuildWriteSingleCoilRequest(transactionId, config.UnitId, coilAddress, value); + await stream.WriteAsync(request, timeoutCts.Token); + + byte[] response = new byte[12]; + await ReadExactlyAsync(stream, response, timeoutCts.Token); + ValidateFixedResponseHeader(response, request); + + if (response[7] == 0x85) + { + throw new InvalidOperationException($"PLC 写线圈异常,功能码 0x05,异常码 0x{response[8]:X2}。"); + } + + if (response[7] != 0x05) + { + throw new InvalidOperationException($"PLC 响应功能码无效:0x{response[7]:X2}。"); + } + + if (!response.AsSpan(8, 4).SequenceEqual(request.AsSpan(8, 4))) + { + throw new InvalidOperationException("PLC 写线圈响应地址或值不匹配。"); + } } - - if (response[7] == 0x85) + finally { - throw new InvalidOperationException($"PLC 写线圈异常,功能码 0x05,异常码 0x{response[8]:X2}。"); - } - - if (response[7] != 0x05) - { - throw new InvalidOperationException($"PLC 响应功能码无效:0x{response[7]:X2}。"); + _transportLock.Release(); } } private async Task ReadHoldingRegistersAsync(PlcConnectionConfig config, ushort startAddress, ushort numberOfPoints, CancellationToken cancellationToken) { - using var client = new TcpClient(); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); - - await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); - await using NetworkStream stream = client.GetStream(); - - ushort transactionId = NextTransactionId(); - byte[] request = BuildReadHoldingRegistersRequest(transactionId, config.UnitId, startAddress, numberOfPoints); - await stream.WriteAsync(request, timeoutCts.Token); - - byte[] header = new byte[7]; - await ReadExactlyAsync(stream, header, timeoutCts.Token); - int remainingLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2)) - 1; - if (remainingLength <= 0) + await _transportLock.WaitAsync(cancellationToken); + try { - throw new InvalidOperationException("PLC 响应长度无效。"); + using var client = new TcpClient(); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); + + await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); + await using NetworkStream stream = client.GetStream(); + + ushort transactionId = NextTransactionId(); + byte[] request = BuildReadHoldingRegistersRequest(transactionId, config.UnitId, startAddress, numberOfPoints); + await stream.WriteAsync(request, timeoutCts.Token); + + byte[] header = new byte[7]; + await ReadExactlyAsync(stream, header, timeoutCts.Token); + int remainingLength = ValidateReadResponseHeader(header, request); + + byte[] pdu = new byte[remainingLength]; + await ReadExactlyAsync(stream, pdu, timeoutCts.Token); + + if (pdu[0] == 0x83) + { + byte exceptionCode = pdu.Length > 1 ? pdu[1] : (byte)0; + throw new InvalidOperationException($"PLC 读寄存器异常,功能码 0x03,异常码 0x{exceptionCode:X2}。"); + } + + if (pdu[0] != 0x03) + { + throw new InvalidOperationException($"PLC 响应功能码无效:0x{pdu[0]:X2}。"); + } + + int byteCount = pdu[1]; + if (byteCount != numberOfPoints * 2 || pdu.Length < 2 + byteCount) + { + throw new InvalidOperationException("PLC 读寄存器响应字节数无效。"); + } + + ushort[] registers = new ushort[numberOfPoints]; + for (int i = 0; i < registers.Length; i++) + { + registers[i] = BinaryPrimitives.ReadUInt16BigEndian(pdu.AsSpan(2 + i * 2, 2)); + } + + return registers; } - - byte[] pdu = new byte[remainingLength]; - await ReadExactlyAsync(stream, pdu, timeoutCts.Token); - - if (header[0] != request[0] || header[1] != request[1]) + finally { - throw new InvalidOperationException("PLC 响应事务号不匹配。"); + _transportLock.Release(); } - - if (pdu[0] == 0x83) - { - byte exceptionCode = pdu.Length > 1 ? pdu[1] : (byte)0; - throw new InvalidOperationException($"PLC 读寄存器异常,功能码 0x03,异常码 0x{exceptionCode:X2}。"); - } - - if (pdu[0] != 0x03) - { - throw new InvalidOperationException($"PLC 响应功能码无效:0x{pdu[0]:X2}。"); - } - - int byteCount = pdu[1]; - if (byteCount != numberOfPoints * 2 || pdu.Length < 2 + byteCount) - { - throw new InvalidOperationException("PLC 读寄存器响应字节数无效。"); - } - - ushort[] registers = new ushort[numberOfPoints]; - for (int i = 0; i < registers.Length; i++) - { - registers[i] = BinaryPrimitives.ReadUInt16BigEndian(pdu.AsSpan(2 + i * 2, 2)); - } - - return registers; } private async Task ReadCoilsAsync(PlcConnectionConfig config, ushort startAddress, ushort numberOfPoints, CancellationToken cancellationToken) { - using var client = new TcpClient(); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); + await _transportLock.WaitAsync(cancellationToken); + try + { + using var client = new TcpClient(); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); - await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); - await using NetworkStream stream = client.GetStream(); + await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); + await using NetworkStream stream = client.GetStream(); - ushort transactionId = NextTransactionId(); - byte[] request = BuildReadCoilsRequest(transactionId, config.UnitId, startAddress, numberOfPoints); - await stream.WriteAsync(request, timeoutCts.Token); + ushort transactionId = NextTransactionId(); + byte[] request = BuildReadCoilsRequest(transactionId, config.UnitId, startAddress, numberOfPoints); + await stream.WriteAsync(request, timeoutCts.Token); - byte[] header = new byte[7]; - await ReadExactlyAsync(stream, header, timeoutCts.Token); + byte[] header = new byte[7]; + await ReadExactlyAsync(stream, header, timeoutCts.Token); + int remainingLength = ValidateReadResponseHeader(header, request); + + byte[] pdu = new byte[remainingLength]; + await ReadExactlyAsync(stream, pdu, timeoutCts.Token); + + if (pdu[0] == 0x81) + { + byte exceptionCode = pdu.Length > 1 ? pdu[1] : (byte)0; + throw new InvalidOperationException($"PLC 读线圈异常,功能码 0x01,异常码 0x{exceptionCode:X2}。"); + } + + if (pdu[0] != 0x01) + { + throw new InvalidOperationException($"PLC 响应功能码无效:0x{pdu[0]:X2}。"); + } + + int expectedByteCount = (numberOfPoints + 7) / 8; + int byteCount = pdu[1]; + if (byteCount != expectedByteCount || pdu.Length < 2 + byteCount) + { + throw new InvalidOperationException("PLC 读线圈响应字节数无效。"); + } + + bool[] coils = new bool[numberOfPoints]; + for (int i = 0; i < coils.Length; i++) + { + coils[i] = (pdu[2 + i / 8] & (1 << (i % 8))) != 0; + } + + return coils; + } + finally + { + _transportLock.Release(); + } + } + + private async Task WriteMultipleRegistersAsync(PlcConnectionConfig config, ushort startAddress, ushort[] values, CancellationToken cancellationToken) + { + await _transportLock.WaitAsync(cancellationToken); + try + { + using var client = new TcpClient(); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); + + await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); + await using NetworkStream stream = client.GetStream(); + + ushort transactionId = NextTransactionId(); + byte[] request = BuildWriteMultipleRegistersRequest(transactionId, config.UnitId, startAddress, values); + await stream.WriteAsync(request, timeoutCts.Token); + + byte[] response = new byte[12]; + await ReadExactlyAsync(stream, response, timeoutCts.Token); + ValidateFixedResponseHeader(response, request); + + if (response[7] == 0x90) + { + throw new InvalidOperationException($"PLC 写寄存器异常,功能码 0x10,异常码 0x{response[8]:X2}。"); + } + + if (response[7] != 0x10) + { + throw new InvalidOperationException($"PLC 响应功能码无效:0x{response[7]:X2}。"); + } + + ushort responseStart = BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(8, 2)); + ushort responseCount = BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(10, 2)); + if (responseStart != startAddress || responseCount != values.Length) + { + throw new InvalidOperationException("PLC 写寄存器响应地址或数量不匹配。"); + } + } + finally + { + _transportLock.Release(); + } + } + + private static int ValidateReadResponseHeader(byte[] header, byte[] request) + { + ValidateMbapHeader(header, request); int remainingLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2)) - 1; if (remainingLength <= 0) { throw new InvalidOperationException("PLC 响应长度无效。"); } - byte[] pdu = new byte[remainingLength]; - await ReadExactlyAsync(stream, pdu, timeoutCts.Token); - - if (header[0] != request[0] || header[1] != request[1]) - { - throw new InvalidOperationException("PLC 响应事务号不匹配。"); - } - - if (pdu[0] == 0x81) - { - byte exceptionCode = pdu.Length > 1 ? pdu[1] : (byte)0; - throw new InvalidOperationException($"PLC 读线圈异常,功能码 0x01,异常码 0x{exceptionCode:X2}。"); - } - - if (pdu[0] != 0x01) - { - throw new InvalidOperationException($"PLC 响应功能码无效:0x{pdu[0]:X2}。"); - } - - int expectedByteCount = (numberOfPoints + 7) / 8; - int byteCount = pdu[1]; - if (byteCount != expectedByteCount || pdu.Length < 2 + byteCount) - { - throw new InvalidOperationException("PLC 读线圈响应字节数无效。"); - } - - bool[] coils = new bool[numberOfPoints]; - for (int i = 0; i < coils.Length; i++) - { - coils[i] = (pdu[2 + i / 8] & (1 << (i % 8))) != 0; - } - - return coils; + return remainingLength; } - private async Task WriteMultipleRegistersAsync(PlcConnectionConfig config, ushort startAddress, ushort[] values, CancellationToken cancellationToken) + private static void ValidateFixedResponseHeader(byte[] response, byte[] request) { - using var client = new TcpClient(); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(config.TimeoutMilliseconds)); + ValidateMbapHeader(response, request); + if (BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(4, 2)) != 6) + { + throw new InvalidOperationException("PLC 响应长度无效。"); + } + } - await client.ConnectAsync(config.IpAddress, config.Port, timeoutCts.Token); - await using NetworkStream stream = client.GetStream(); - - ushort transactionId = NextTransactionId(); - byte[] request = BuildWriteMultipleRegistersRequest(transactionId, config.UnitId, startAddress, values); - await stream.WriteAsync(request, timeoutCts.Token); - - byte[] response = new byte[12]; - await ReadExactlyAsync(stream, response, timeoutCts.Token); - - if (response[0] != request[0] || response[1] != request[1]) + private static void ValidateMbapHeader(ReadOnlySpan response, ReadOnlySpan request) + { + if (response.Length < 7 + || response[0] != request[0] + || response[1] != request[1]) { throw new InvalidOperationException("PLC 响应事务号不匹配。"); } - if (response[7] == 0x90) + if (response[2] != 0 || response[3] != 0) { - throw new InvalidOperationException($"PLC 写寄存器异常,功能码 0x10,异常码 0x{response[8]:X2}。"); + throw new InvalidOperationException("PLC 响应协议标识无效。"); } - if (response[7] != 0x10) + if (response[6] != request[6]) { - throw new InvalidOperationException($"PLC 响应功能码无效:0x{response[7]:X2}。"); - } - - ushort responseStart = BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(8, 2)); - ushort responseCount = BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(10, 2)); - if (responseStart != startAddress || responseCount != values.Length) - { - throw new InvalidOperationException("PLC 写寄存器响应地址或数量不匹配。"); + throw new InvalidOperationException("PLC 响应站号不匹配。"); } }