更新
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,22 @@ public partial class MainWindow : Window
|
|||||||
InspectionItemsGrid.Focus();
|
InspectionItemsGrid.Focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OpenEngineeringRegistersButton_OnClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MainViewModel viewModel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.EngineeringRegisterPanelVisible = true;
|
||||||
|
if (viewModel.RefreshEngineeringRegistersCommand.CanExecute(null))
|
||||||
|
{
|
||||||
|
viewModel.RefreshEngineeringRegistersCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
EngineeringRegistersTab.IsSelected = true;
|
||||||
|
}
|
||||||
|
|
||||||
private void ConfigureTrendBindings()
|
private void ConfigureTrendBindings()
|
||||||
{
|
{
|
||||||
var converter = (IMultiValueConverter)Resources["TrendPointCollectionConverter"];
|
var converter = (IMultiValueConverter)Resources["TrendPointCollectionConverter"];
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace Cardiopulmonarybypasssystems.Models;
|
namespace Cardiopulmonarybypasssystems.Models;
|
||||||
@@ -56,6 +58,9 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private double rs485RawOffset;
|
private double rs485RawOffset;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool rs485CalibrationConfirmed;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private double rs485MinFlowLpm;
|
private double rs485MinFlowLpm;
|
||||||
|
|
||||||
@@ -98,14 +103,26 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private double confirmedSetpointFlowValue;
|
private double confirmedSetpointFlowValue;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool? pendingRs485RunningState;
|
||||||
|
|
||||||
public string StartAddressDisplay => $"M{StartAddress}";
|
public string StartAddressDisplay => $"M{StartAddress}";
|
||||||
public string FlowAddressDisplay => FlowAddress.HasValue ? $"D{FlowAddress.Value}" : "-";
|
public string FlowAddressDisplay => FlowAddress.HasValue ? $"D{FlowAddress.Value}" : "-";
|
||||||
public bool HasFlowTelemetry => FlowAddress.HasValue;
|
public bool HasFlowTelemetry => FlowAddress.HasValue;
|
||||||
public bool SupportsRs485Preset => HasFlowTelemetry && Rs485Enabled;
|
public bool SupportsRs485Preset => HasFlowTelemetry && Rs485Enabled;
|
||||||
public bool SupportsRs485DirectControl => SupportsRs485Preset && Rs485MotorControlRegister > 0;
|
public bool SupportsRs485DirectControl => SupportsRs485Preset && Rs485MotorControlRegister > 0;
|
||||||
|
public bool UsesLegacyPlcDirectControl => Key == "NegativeAssistPump";
|
||||||
public bool HasSetpointCalibration => Rs485RawPerLitrePerMinute > 0;
|
public bool HasSetpointCalibration => Rs485RawPerLitrePerMinute > 0;
|
||||||
|
public bool HasConfirmedSetpointCalibration => HasSetpointCalibration && Rs485CalibrationConfirmed;
|
||||||
public bool IsFlowEstablished => !HasFlowTelemetry || (FlowAvailable && FlowValue >= FlowEstablishedThreshold);
|
public bool IsFlowEstablished => !HasFlowTelemetry || (FlowAvailable && FlowValue >= FlowEstablishedThreshold);
|
||||||
public string Rs485SlaveAddressDisplay => SupportsRs485Preset ? Rs485SlaveAddress.ToString() : "-";
|
public string Rs485SlaveAddressDisplay => SupportsRs485Preset ? Rs485SlaveAddress.ToString() : "-";
|
||||||
|
public string CalibrationStatusText => !Rs485Enabled
|
||||||
|
? "未启用"
|
||||||
|
: !HasSetpointCalibration
|
||||||
|
? "未配置"
|
||||||
|
: Rs485CalibrationConfirmed
|
||||||
|
? "已确认"
|
||||||
|
: "待确认";
|
||||||
public string SetpointReadbackDisplay => !SupportsRs485Preset
|
public string SetpointReadbackDisplay => !SupportsRs485Preset
|
||||||
? "-"
|
? "-"
|
||||||
: SetpointAvailable
|
: SetpointAvailable
|
||||||
@@ -124,11 +141,11 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
1 => "运行",
|
1 => "运行",
|
||||||
2 => "暂停",
|
2 => "暂停",
|
||||||
null => "--",
|
null => "--",
|
||||||
_ => $"状态 {Rs485RunStatusCode}"
|
_ => $"状态{Rs485RunStatusCode}"
|
||||||
};
|
};
|
||||||
public string PumpGroupName => Key switch
|
public string PumpGroupName => Key switch
|
||||||
{
|
{
|
||||||
"PressureDropPump" => "压力降/抗塌陷",
|
"PressureDropPump" => "压力降",
|
||||||
"RecirculationMainPump" or "RecirculationReturnPump" or "RecirculationDrainagePump" => "再循环",
|
"RecirculationMainPump" or "RecirculationReturnPump" or "RecirculationDrainagePump" => "再循环",
|
||||||
"KinkResistancePump" => "抗扭结",
|
"KinkResistancePump" => "抗扭结",
|
||||||
"HemolysisDrainageSinglePump" or "HemolysisReturnSinglePump" or "HemolysisDualLumenPump" => "血细胞破坏",
|
"HemolysisDrainageSinglePump" or "HemolysisReturnSinglePump" or "HemolysisDualLumenPump" => "血细胞破坏",
|
||||||
@@ -144,7 +161,7 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
public string StateHint => IsRs485Busy
|
public string StateHint => IsRs485Busy
|
||||||
? string.IsNullOrWhiteSpace(Rs485BusyOperation) ? "RS485 操作中" : $"RS485 {Rs485BusyOperation}中"
|
? string.IsNullOrWhiteSpace(Rs485BusyOperation) ? "RS485 操作中" : $"RS485 {Rs485BusyOperation}中"
|
||||||
: !StateAvailable
|
: !StateAvailable
|
||||||
? "未取得 PLC 状态"
|
? "未收到状态反馈"
|
||||||
: !IsRunning
|
: !IsRunning
|
||||||
? "泵未启动"
|
? "泵未启动"
|
||||||
: IsFlowEstablished
|
: IsFlowEstablished
|
||||||
@@ -157,10 +174,15 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
: IsFlowEstablished
|
: IsFlowEstablished
|
||||||
? "#FF32B06A"
|
? "#FF32B06A"
|
||||||
: "#FFD38A16";
|
: "#FFD38A16";
|
||||||
|
public string CardPrimaryDisplay => UsesLegacyPlcDirectControl ? StateText : FlowDisplay;
|
||||||
public string FlowDisplay => !FlowAddress.HasValue ? "-" : FlowAvailable ? $"{FlowValue:F2} L/min" : "--";
|
public string FlowDisplay => !FlowAddress.HasValue ? "-" : FlowAvailable ? $"{FlowValue:F2} L/min" : "--";
|
||||||
public string ActionText => IsRs485Busy ? "处理中" : IsRunning ? "停止" : "启动";
|
public string ActionText => IsRs485Busy ? "处理中" : IsRunning ? "停止" : "启动";
|
||||||
|
public bool CanToggleRs485Action => IsRunning || HasConfirmedSetpointCalibration;
|
||||||
|
public string ToggleActionHint => CanToggleRs485Action ? string.Empty : "未完成流量换算标定确认";
|
||||||
public string Rs485ReadActionText => IsRs485Busy ? "处理中" : "读取";
|
public string Rs485ReadActionText => IsRs485Busy ? "处理中" : "读取";
|
||||||
public string Rs485WriteActionText => IsRs485Busy ? "处理中" : "写入";
|
public string Rs485WriteActionText => IsRs485Busy ? "处理中" : "写入";
|
||||||
|
public string SetpointStatusForeground => ResolveSetpointStatusForeground();
|
||||||
|
public string SetpointStatusBackground => ResolveSetpointStatusBackground();
|
||||||
|
|
||||||
partial void OnIsRunningChanged(bool value)
|
partial void OnIsRunningChanged(bool value)
|
||||||
{
|
{
|
||||||
@@ -168,6 +190,9 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(StateHint));
|
OnPropertyChanged(nameof(StateHint));
|
||||||
OnPropertyChanged(nameof(IndicatorColor));
|
OnPropertyChanged(nameof(IndicatorColor));
|
||||||
OnPropertyChanged(nameof(ActionText));
|
OnPropertyChanged(nameof(ActionText));
|
||||||
|
OnPropertyChanged(nameof(CanToggleRs485Action));
|
||||||
|
OnPropertyChanged(nameof(ToggleActionHint));
|
||||||
|
OnPropertyChanged(nameof(CardPrimaryDisplay));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnFlowValueChanged(double value)
|
partial void OnFlowValueChanged(double value)
|
||||||
@@ -177,6 +202,7 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(StateText));
|
OnPropertyChanged(nameof(StateText));
|
||||||
OnPropertyChanged(nameof(StateHint));
|
OnPropertyChanged(nameof(StateHint));
|
||||||
OnPropertyChanged(nameof(IndicatorColor));
|
OnPropertyChanged(nameof(IndicatorColor));
|
||||||
|
OnPropertyChanged(nameof(CardPrimaryDisplay));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnStateAvailableChanged(bool value)
|
partial void OnStateAvailableChanged(bool value)
|
||||||
@@ -184,6 +210,7 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(StateText));
|
OnPropertyChanged(nameof(StateText));
|
||||||
OnPropertyChanged(nameof(StateHint));
|
OnPropertyChanged(nameof(StateHint));
|
||||||
OnPropertyChanged(nameof(IndicatorColor));
|
OnPropertyChanged(nameof(IndicatorColor));
|
||||||
|
OnPropertyChanged(nameof(CardPrimaryDisplay));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnFlowAvailableChanged(bool value)
|
partial void OnFlowAvailableChanged(bool value)
|
||||||
@@ -193,6 +220,7 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(StateText));
|
OnPropertyChanged(nameof(StateText));
|
||||||
OnPropertyChanged(nameof(StateHint));
|
OnPropertyChanged(nameof(StateHint));
|
||||||
OnPropertyChanged(nameof(IndicatorColor));
|
OnPropertyChanged(nameof(IndicatorColor));
|
||||||
|
OnPropertyChanged(nameof(CardPrimaryDisplay));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnRs485EnabledChanged(bool value)
|
partial void OnRs485EnabledChanged(bool value)
|
||||||
@@ -200,6 +228,7 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(SupportsRs485Preset));
|
OnPropertyChanged(nameof(SupportsRs485Preset));
|
||||||
OnPropertyChanged(nameof(SupportsRs485DirectControl));
|
OnPropertyChanged(nameof(SupportsRs485DirectControl));
|
||||||
OnPropertyChanged(nameof(Rs485SlaveAddressDisplay));
|
OnPropertyChanged(nameof(Rs485SlaveAddressDisplay));
|
||||||
|
OnPropertyChanged(nameof(CalibrationStatusText));
|
||||||
OnPropertyChanged(nameof(SetpointReadbackDisplay));
|
OnPropertyChanged(nameof(SetpointReadbackDisplay));
|
||||||
OnPropertyChanged(nameof(RawSetpointDisplay));
|
OnPropertyChanged(nameof(RawSetpointDisplay));
|
||||||
}
|
}
|
||||||
@@ -210,10 +239,32 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
|
|
||||||
partial void OnRs485RawPerLitrePerMinuteChanged(double value)
|
partial void OnRs485RawPerLitrePerMinuteChanged(double value)
|
||||||
{
|
{
|
||||||
|
Rs485CalibrationConfirmed = false;
|
||||||
OnPropertyChanged(nameof(HasSetpointCalibration));
|
OnPropertyChanged(nameof(HasSetpointCalibration));
|
||||||
|
OnPropertyChanged(nameof(HasConfirmedSetpointCalibration));
|
||||||
|
OnPropertyChanged(nameof(CalibrationStatusText));
|
||||||
|
OnPropertyChanged(nameof(CanToggleRs485Action));
|
||||||
|
OnPropertyChanged(nameof(ToggleActionHint));
|
||||||
OnPropertyChanged(nameof(SetpointReadbackDisplay));
|
OnPropertyChanged(nameof(SetpointReadbackDisplay));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnRs485RawOffsetChanged(double value)
|
||||||
|
{
|
||||||
|
Rs485CalibrationConfirmed = false;
|
||||||
|
OnPropertyChanged(nameof(HasConfirmedSetpointCalibration));
|
||||||
|
OnPropertyChanged(nameof(CalibrationStatusText));
|
||||||
|
OnPropertyChanged(nameof(CanToggleRs485Action));
|
||||||
|
OnPropertyChanged(nameof(ToggleActionHint));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnRs485CalibrationConfirmedChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasConfirmedSetpointCalibration));
|
||||||
|
OnPropertyChanged(nameof(CalibrationStatusText));
|
||||||
|
OnPropertyChanged(nameof(CanToggleRs485Action));
|
||||||
|
OnPropertyChanged(nameof(ToggleActionHint));
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSetpointFlowValueChanged(double value) => OnPropertyChanged(nameof(SetpointReadbackDisplay));
|
partial void OnSetpointFlowValueChanged(double value) => OnPropertyChanged(nameof(SetpointReadbackDisplay));
|
||||||
|
|
||||||
partial void OnRawSetpointValueChanged(int value) => OnPropertyChanged(nameof(RawSetpointDisplay));
|
partial void OnRawSetpointValueChanged(int value) => OnPropertyChanged(nameof(RawSetpointDisplay));
|
||||||
@@ -231,6 +282,12 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(RawSetpointDisplay));
|
OnPropertyChanged(nameof(RawSetpointDisplay));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnSetpointStatusTextChanged(string value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(SetpointStatusForeground));
|
||||||
|
OnPropertyChanged(nameof(SetpointStatusBackground));
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnRs485RunStatusCodeChanged(ushort? value) => OnPropertyChanged(nameof(Rs485RunStateText));
|
partial void OnRs485RunStatusCodeChanged(ushort? value) => OnPropertyChanged(nameof(Rs485RunStateText));
|
||||||
|
|
||||||
partial void OnIsRs485BusyChanged(bool value)
|
partial void OnIsRs485BusyChanged(bool value)
|
||||||
@@ -239,7 +296,64 @@ public partial class PumpControlChannel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(ActionText));
|
OnPropertyChanged(nameof(ActionText));
|
||||||
OnPropertyChanged(nameof(Rs485ReadActionText));
|
OnPropertyChanged(nameof(Rs485ReadActionText));
|
||||||
OnPropertyChanged(nameof(Rs485WriteActionText));
|
OnPropertyChanged(nameof(Rs485WriteActionText));
|
||||||
|
OnPropertyChanged(nameof(SetpointStatusForeground));
|
||||||
|
OnPropertyChanged(nameof(SetpointStatusBackground));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnRs485BusyOperationChanged(string value) => OnPropertyChanged(nameof(StateHint));
|
partial void OnRs485BusyOperationChanged(string value) => OnPropertyChanged(nameof(StateHint));
|
||||||
|
|
||||||
|
private string ResolveSetpointStatusForeground()
|
||||||
|
{
|
||||||
|
if (IsRs485Busy)
|
||||||
|
{
|
||||||
|
return "#FF0F6C81";
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = SetpointStatusText ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(text) || text.Contains("未读取", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "#FF60737E";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContainsAny(text, "成功", "完成", "已应用", "已更新", "已在运行", "已确认"))
|
||||||
|
{
|
||||||
|
return "#FF2B8F6A";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContainsAny(text, "失败", "禁止", "超出", "未配置", "未找到", "未选择", "离线", "不可用", "未启用", "待确认"))
|
||||||
|
{
|
||||||
|
return "#FFCC4A42";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "#FF123744";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveSetpointStatusBackground()
|
||||||
|
{
|
||||||
|
if (IsRs485Busy)
|
||||||
|
{
|
||||||
|
return "#FFE8F4F7";
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = SetpointStatusText ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(text) || text.Contains("未读取", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "#FFF2F6F8";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContainsAny(text, "成功", "完成", "已应用", "已更新", "已在运行", "已确认"))
|
||||||
|
{
|
||||||
|
return "#FFE7F5EF";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContainsAny(text, "失败", "禁止", "超出", "未配置", "未找到", "未选择", "离线", "不可用", "未启用", "待确认"))
|
||||||
|
{
|
||||||
|
return "#FFFBE9E7";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "#FFF3F7F8";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAny(string text, params string[] keywords) =>
|
||||||
|
keywords.Any(keyword => text.Contains(keyword, StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ public sealed class Rs485PumpBindingSettings
|
|||||||
{
|
{
|
||||||
public string PumpKey { get; set; } = string.Empty;
|
public string PumpKey { get; set; } = string.Empty;
|
||||||
public bool Enabled { get; set; } = true;
|
public bool Enabled { get; set; } = true;
|
||||||
|
public bool CalibrationConfirmed { get; set; }
|
||||||
public byte SlaveAddress { get; set; } = 1;
|
public byte SlaveAddress { get; set; } = 1;
|
||||||
public ushort ForwardSpeedRegister { get; set; } = 0x00A2;
|
public ushort ForwardSpeedRegister { get; set; } = 0x00A2;
|
||||||
public ushort ReverseSpeedRegister { get; set; } = 0x00A3;
|
public ushort ReverseSpeedRegister { get; set; } = 0x00A3;
|
||||||
|
|||||||
@@ -1,718 +0,0 @@
|
|||||||
using System.Net.Sockets;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Cardiopulmonarybypasssystems.Models;
|
|
||||||
using NModbus;
|
|
||||||
|
|
||||||
namespace Cardiopulmonarybypasssystems.Services;
|
|
||||||
|
|
||||||
public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDisposable
|
|
||||||
{
|
|
||||||
private const string IpAddress = "192.168.1.10";
|
|
||||||
private const int Port = 502;
|
|
||||||
private const byte SlaveId = 1;
|
|
||||||
private const ushort ProximalPressureRegister = 1330;
|
|
||||||
private const ushort DistalPressureRegister = 1380;
|
|
||||||
private const double FlowRegisterScale = 0.01d;
|
|
||||||
private const double KpaToMmHg = 7.50061683d;
|
|
||||||
private const ushort PressureRegisterBlockLength = (ushort)(DistalPressureRegister - ProximalPressureRegister + 2);
|
|
||||||
private const int SmoothingWindowSize = 5;
|
|
||||||
private static readonly TimeSpan ConnectionAttemptTimeout = TimeSpan.FromMilliseconds(300);
|
|
||||||
private static readonly TimeSpan ConnectionRetryInterval = TimeSpan.FromSeconds(5);
|
|
||||||
private static readonly IReadOnlyDictionary<string, ushort> FlowRegisters = new Dictionary<string, ushort>(StringComparer.Ordinal)
|
|
||||||
{
|
|
||||||
["PressureDropPump"] = 1000,
|
|
||||||
["RecirculationMainPump"] = 1010,
|
|
||||||
["RecirculationReturnPump"] = 1020,
|
|
||||||
["RecirculationDrainagePump"] = 1030,
|
|
||||||
["KinkResistancePump"] = 1040,
|
|
||||||
["HemolysisDrainageSinglePump"] = 1050,
|
|
||||||
["HemolysisReturnSinglePump"] = 1060,
|
|
||||||
["HemolysisDualLumenPump"] = 1070
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly IReadOnlyDictionary<string, string> FlowChannelNames = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
||||||
{
|
|
||||||
["PressureDropPump"] = "主泵流量",
|
|
||||||
["RecirculationMainPump"] = "再循环主泵流量",
|
|
||||||
["RecirculationReturnPump"] = "动脉回输流量",
|
|
||||||
["RecirculationDrainagePump"] = "静脉引流流量",
|
|
||||||
["KinkResistancePump"] = "抗扭结主泵流量",
|
|
||||||
["HemolysisDrainageSinglePump"] = "血细胞破坏-单腔引流/回输流量",
|
|
||||||
["HemolysisReturnSinglePump"] = "双腔插管试验回路流量",
|
|
||||||
["HemolysisDualLumenPump"] = "双腔插管试验回路流量(两个管腔)"
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly object _syncRoot = new();
|
|
||||||
private readonly Random _random = new();
|
|
||||||
private readonly ModbusFactory _factory = new();
|
|
||||||
private readonly Dictionary<string, Queue<double>> _channelWindows = new(StringComparer.Ordinal);
|
|
||||||
private readonly Dictionary<ushort, float> _engineeringFloatRegisters = new()
|
|
||||||
{
|
|
||||||
[1006] = 1.0f,
|
|
||||||
[1016] = 1.0f,
|
|
||||||
[1026] = 1.0f,
|
|
||||||
[1036] = 1.0f,
|
|
||||||
[1046] = 1.0f,
|
|
||||||
[1056] = 1.0f,
|
|
||||||
[1066] = 1.0f,
|
|
||||||
[1076] = 1.0f,
|
|
||||||
[1328] = 1.0f,
|
|
||||||
[1378] = 1.0f
|
|
||||||
};
|
|
||||||
private readonly List<DeviceChannel> _channels =
|
|
||||||
[
|
|
||||||
new() { Name = "主泵流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
|
|
||||||
new() { Name = "再循环主泵流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
|
|
||||||
new() { Name = "动脉回输流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
|
|
||||||
new() { Name = "静脉引流流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
|
|
||||||
new() { Name = "抗扭结主泵流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
|
|
||||||
new() { Name = "血细胞破坏-单腔引流/回输流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
|
|
||||||
new() { Name = "双腔插管试验回路流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
|
|
||||||
new() { Name = "双腔插管试验回路流量(两个管腔)", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
|
|
||||||
new() { Name = "远端压力", Unit = "mmHg", Value = 68, Min = 40, Max = 180 },
|
|
||||||
new() { Name = "近端压力", Unit = "mmHg", Value = 80, Min = 60, Max = 220 },
|
|
||||||
new() { Name = "负压辅助引流", Unit = "kPa", Value = 0, Min = -20, Max = 0 },
|
|
||||||
new() { Name = "模拟血液温度", Unit = "°C", Value = 37.1, Min = 34, Max = 40 },
|
|
||||||
new() { Name = "再循环率", Unit = "%", Value = 0, Min = 0, Max = 20 },
|
|
||||||
new() { Name = "游离血红蛋白", Unit = "g/L", Value = 0.028, Min = 0, Max = 0.08 },
|
|
||||||
new() { Name = "白细胞减少率", Unit = "%", Value = 7.1, Min = 0, Max = 20 }
|
|
||||||
];
|
|
||||||
// The 8 production pumps are actuator-controlled by servo RS485.
|
|
||||||
// Pump StartAddress values are retained only for telemetry compatibility and historical mapping.
|
|
||||||
private readonly List<PumpControlChannel> _pumpControls =
|
|
||||||
[
|
|
||||||
new() { Key = "NegativeAssistPump", Name = "负压泵", StartAddress = 0 },
|
|
||||||
new() { Key = "PressureDropPump", Name = "压力降/抗塌陷泵", StartAddress = 1, FlowAddress = FlowRegisters["PressureDropPump"] },
|
|
||||||
new() { Key = "RecirculationMainPump", Name = "再循环主泵", StartAddress = 2, FlowAddress = FlowRegisters["RecirculationMainPump"] },
|
|
||||||
new() { Key = "RecirculationReturnPump", Name = "回流泵", StartAddress = 3, FlowAddress = FlowRegisters["RecirculationReturnPump"] },
|
|
||||||
new() { Key = "RecirculationDrainagePump", Name = "引流泵", StartAddress = 4, FlowAddress = FlowRegisters["RecirculationDrainagePump"] },
|
|
||||||
new() { Key = "KinkResistancePump", Name = "抗扭结泵", StartAddress = 5, FlowAddress = FlowRegisters["KinkResistancePump"] },
|
|
||||||
new() { Key = "HemolysisDrainageSinglePump", Name = "血细胞破坏-单腔引流/回输泵", StartAddress = 6, FlowAddress = FlowRegisters["HemolysisDrainageSinglePump"] },
|
|
||||||
new() { Key = "HemolysisReturnSinglePump", Name = "双腔插管试验回路泵", StartAddress = 7, FlowAddress = FlowRegisters["HemolysisReturnSinglePump"] },
|
|
||||||
new() { Key = "HemolysisDualLumenPump", Name = "双腔插管试验回路泵(两个管腔)", StartAddress = 8, FlowAddress = FlowRegisters["HemolysisDualLumenPump"] }
|
|
||||||
];
|
|
||||||
private readonly List<ValveControlChannel> _valveControls =
|
|
||||||
[
|
|
||||||
new() { Key = "TestLoopValve1", Name = "测试回路阀 1", StartAddress = 10 },
|
|
||||||
new() { Key = "TestLoopValve2", Name = "测试回路阀 2", StartAddress = 11 }
|
|
||||||
];
|
|
||||||
|
|
||||||
private TcpClient? _tcpClient;
|
|
||||||
private IModbusMaster? _master;
|
|
||||||
private bool _connectionInitialized;
|
|
||||||
private Task? _connectionTask;
|
|
||||||
private DateTime _nextConnectionAttemptUtc = DateTime.MinValue;
|
|
||||||
private double? _proximalPressureRawKpa;
|
|
||||||
private double? _distalPressureRawKpa;
|
|
||||||
private double? _proximalPressureDecodedKpa;
|
|
||||||
private double? _distalPressureDecodedKpa;
|
|
||||||
|
|
||||||
private int HighestConfiguredCoilAddress => Math.Max(
|
|
||||||
_pumpControls.Max(item => item.StartAddress),
|
|
||||||
_valveControls.Max(item => item.StartAddress));
|
|
||||||
|
|
||||||
public bool IsLiveConnected
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
return _master is not null && _tcpClient?.Connected == true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string EndpointDescription => $"{IpAddress}:{Port} / Slave {SlaveId}";
|
|
||||||
public DateTime? LastSuccessfulReadAt => null;
|
|
||||||
public string LastErrorMessage => "未使用的兼容服务";
|
|
||||||
|
|
||||||
public IReadOnlyList<DeviceChannel> GetChannels()
|
|
||||||
{
|
|
||||||
EnsureConnectionScheduled();
|
|
||||||
return _channels;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<PumpControlChannel> GetPumpControls()
|
|
||||||
{
|
|
||||||
EnsureConnectionScheduled();
|
|
||||||
return _pumpControls;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<ValveControlChannel> GetValveControls()
|
|
||||||
{
|
|
||||||
EnsureConnectionScheduled();
|
|
||||||
return _valveControls;
|
|
||||||
}
|
|
||||||
|
|
||||||
public TelemetryUpdateSnapshot UpdateChannels()
|
|
||||||
{
|
|
||||||
EnsureConnectionScheduled();
|
|
||||||
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
var liveReadSucceeded = TryReadPumpStatesAndFlows();
|
|
||||||
TryReadPressureChannels(liveReadSucceeded);
|
|
||||||
SimulateAuxiliaryChannels(liveReadSucceeded);
|
|
||||||
SyncDerivedChannels();
|
|
||||||
return BuildSnapshot(BuildAlarms());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ushort? ReadHoldingRegister(ushort address)
|
|
||||||
{
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
if (!_engineeringFloatRegisters.TryGetValue(address, out var value))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (ushort)Math.Clamp((int)Math.Round(value), ushort.MinValue, ushort.MaxValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public float? ReadHoldingFloatRegister(ushort address)
|
|
||||||
{
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
return address switch
|
|
||||||
{
|
|
||||||
ProximalPressureRegister => (float)(_proximalPressureRawKpa ?? (_channels.First(channel => channel.Name == "近端压力").Value / KpaToMmHg)),
|
|
||||||
DistalPressureRegister => (float)(_distalPressureRawKpa ?? (_channels.First(channel => channel.Name == "远端压力").Value / KpaToMmHg)),
|
|
||||||
_ when _engineeringFloatRegisters.TryGetValue(address, out var value) => value,
|
|
||||||
_ => null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool WriteHoldingRegister(ushort address, ushort value)
|
|
||||||
{
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
if (!_engineeringFloatRegisters.ContainsKey(address))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_engineeringFloatRegisters[address] = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool WriteHoldingFloatRegister(ushort address, float value)
|
|
||||||
{
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
if (!_engineeringFloatRegisters.ContainsKey(address))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_engineeringFloatRegisters[address] = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetPumpRunning(string pumpKey, bool isRunning)
|
|
||||||
{
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
var pump = _pumpControls.FirstOrDefault(item => item.Key == pumpKey);
|
|
||||||
if (pump is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy PLC coil path retained only for non-RS485 pump compatibility.
|
|
||||||
if (PumpActuationProfiles.IsServoRs485ManagedPump(pump.Key))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pump.IsRunning = isRunning;
|
|
||||||
|
|
||||||
if (_master is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_master.WriteSingleCoil(SlaveId, (ushort)pump.StartAddress, isRunning);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
ReleaseConnection();
|
|
||||||
_nextConnectionAttemptUtc = DateTime.MinValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetValveOpen(string valveKey, bool isOpen)
|
|
||||||
{
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
var valve = _valveControls.FirstOrDefault(item => item.Key == valveKey);
|
|
||||||
if (valve is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
valve.IsOpen = isOpen;
|
|
||||||
|
|
||||||
if (_master is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_master.WriteSingleCoil(SlaveId, (ushort)valve.StartAddress, isOpen);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
ReleaseConnection();
|
|
||||||
_nextConnectionAttemptUtc = DateTime.MinValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
ReleaseConnection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnsureConnectionScheduled()
|
|
||||||
{
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
if (_master is not null && _tcpClient?.Connected == true)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_connectionTask is { IsCompleted: false })
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DateTime.UtcNow < _nextConnectionAttemptUtc)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_nextConnectionAttemptUtc = DateTime.UtcNow.Add(ConnectionRetryInterval);
|
|
||||||
_connectionTask = Task.Run(ConnectWithTimeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ConnectWithTimeout()
|
|
||||||
{
|
|
||||||
TcpClient? tcpClient = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
tcpClient = new TcpClient();
|
|
||||||
using var cancellation = new CancellationTokenSource(ConnectionAttemptTimeout);
|
|
||||||
tcpClient.ConnectAsync(IpAddress, Port, cancellation.Token).GetAwaiter().GetResult();
|
|
||||||
tcpClient.ReceiveTimeout = (int)ConnectionAttemptTimeout.TotalMilliseconds;
|
|
||||||
tcpClient.SendTimeout = (int)ConnectionAttemptTimeout.TotalMilliseconds;
|
|
||||||
var master = _factory.CreateMaster(tcpClient);
|
|
||||||
ApplySafeStartupState(master);
|
|
||||||
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
ReleaseConnection();
|
|
||||||
_tcpClient = tcpClient;
|
|
||||||
_master = master;
|
|
||||||
_connectionInitialized = true;
|
|
||||||
tcpClient = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
tcpClient?.Dispose();
|
|
||||||
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
ReleaseConnection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryReadPumpStatesAndFlows()
|
|
||||||
{
|
|
||||||
if (_master is null)
|
|
||||||
{
|
|
||||||
SimulatePumpFlows();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var coilStates = _master.ReadCoils(SlaveId, 0, (ushort)(HighestConfiguredCoilAddress + 1));
|
|
||||||
foreach (var pump in _pumpControls)
|
|
||||||
{
|
|
||||||
if (!PumpActuationProfiles.IsServoRs485ManagedPump(pump.Key))
|
|
||||||
{
|
|
||||||
pump.IsRunning = coilStates[pump.StartAddress];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var valve in _valveControls)
|
|
||||||
{
|
|
||||||
valve.IsOpen = coilStates[valve.StartAddress];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var pump in _pumpControls.Where(item => item.FlowAddress.HasValue))
|
|
||||||
{
|
|
||||||
var registerValue = _master.ReadHoldingRegisters(SlaveId, (ushort)pump.FlowAddress!.Value, 1)[0];
|
|
||||||
var flowValue = ConvertRegisterToFlow(registerValue);
|
|
||||||
pump.FlowValue = flowValue;
|
|
||||||
SetSmoothedValue(FlowChannelNames[pump.Key], flowValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
ReleaseConnection();
|
|
||||||
_nextConnectionAttemptUtc = DateTime.MinValue;
|
|
||||||
SimulatePumpFlows();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TryReadPressureChannels(bool liveReadSucceeded)
|
|
||||||
{
|
|
||||||
if (_master is null || !liveReadSucceeded)
|
|
||||||
{
|
|
||||||
_proximalPressureRawKpa = null;
|
|
||||||
_distalPressureRawKpa = null;
|
|
||||||
_proximalPressureDecodedKpa = null;
|
|
||||||
_distalPressureDecodedKpa = null;
|
|
||||||
SimulatePressureChannels();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var pressureRegisters = _master.ReadHoldingRegisters(SlaveId, ProximalPressureRegister, PressureRegisterBlockLength);
|
|
||||||
var distalOffset = DistalPressureRegister - ProximalPressureRegister;
|
|
||||||
var proximalRawKpa = ConvertRegistersToPressureKpa(pressureRegisters[0], pressureRegisters[1]);
|
|
||||||
var distalRawKpa = ConvertRegistersToPressureKpa(pressureRegisters[distalOffset], pressureRegisters[distalOffset + 1]);
|
|
||||||
_proximalPressureRawKpa = proximalRawKpa;
|
|
||||||
_distalPressureRawKpa = distalRawKpa;
|
|
||||||
_proximalPressureDecodedKpa = proximalRawKpa;
|
|
||||||
_distalPressureDecodedKpa = distalRawKpa;
|
|
||||||
SetSmoothedValue("近端压力", ConvertPressureKpaToMmHg(proximalRawKpa));
|
|
||||||
SetSmoothedValue("远端压力", ConvertPressureKpaToMmHg(distalRawKpa));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
_proximalPressureRawKpa = null;
|
|
||||||
_distalPressureRawKpa = null;
|
|
||||||
_proximalPressureDecodedKpa = null;
|
|
||||||
_distalPressureDecodedKpa = null;
|
|
||||||
ReleaseConnection();
|
|
||||||
_nextConnectionAttemptUtc = DateTime.MinValue;
|
|
||||||
SimulatePressureChannels();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SimulatePumpFlows()
|
|
||||||
{
|
|
||||||
foreach (var pump in _pumpControls.Where(item => item.FlowAddress.HasValue))
|
|
||||||
{
|
|
||||||
var target = pump.IsRunning ? SimulatedRunningTarget(pump.Key) : 0d;
|
|
||||||
var nextValue = pump.IsRunning
|
|
||||||
? target + Next(-0.10, 0.10)
|
|
||||||
: Math.Max(0d, pump.FlowValue * 0.35 + Next(-0.02, 0.02));
|
|
||||||
|
|
||||||
pump.FlowValue = Math.Clamp(nextValue, 0d, 7d);
|
|
||||||
SetSmoothedValue(FlowChannelNames[pump.Key], pump.FlowValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SimulatePressureChannels()
|
|
||||||
{
|
|
||||||
var pressurePumpRunning = Pump("PressureDropPump").IsRunning;
|
|
||||||
var proximalTarget = pressurePumpRunning ? 112d : 80d;
|
|
||||||
var distalTarget = pressurePumpRunning ? 94d : 68d;
|
|
||||||
|
|
||||||
SetSmoothedValue("近端压力", proximalTarget + Next(-3.0, 3.0));
|
|
||||||
SetSmoothedValue("远端压力", distalTarget + Next(-2.5, 2.5));
|
|
||||||
|
|
||||||
var proximal = Channel("近端压力");
|
|
||||||
var distal = Channel("远端压力");
|
|
||||||
if (distal.Value > proximal.Value - 2)
|
|
||||||
{
|
|
||||||
SetSmoothedValue("远端压力", Math.Max(distal.Min, proximal.Value - Next(6, 18)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SimulateAuxiliaryChannels(bool liveReadSucceeded)
|
|
||||||
{
|
|
||||||
var negativePump = Pump("NegativeAssistPump");
|
|
||||||
var negativeTarget = negativePump.IsRunning ? -6.67d : 0d;
|
|
||||||
SetSmoothedValue("负压辅助引流", negativeTarget + Next(-0.5, 0.5));
|
|
||||||
|
|
||||||
SetSmoothedValue("模拟血液温度", Channel("模拟血液温度").Value + Next(-0.15, 0.15));
|
|
||||||
SetSmoothedValue("游离血红蛋白", Channel("游离血红蛋白").Value + Next(-0.003, 0.003));
|
|
||||||
SetSmoothedValue("白细胞减少率", Channel("白细胞减少率").Value + Next(-0.4, 0.4));
|
|
||||||
|
|
||||||
if (!liveReadSucceeded)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var pump in _pumpControls.Where(item => item.FlowAddress.HasValue))
|
|
||||||
{
|
|
||||||
SetSmoothedValue(FlowChannelNames[pump.Key], pump.FlowValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SyncDerivedChannels()
|
|
||||||
{
|
|
||||||
var returnFlow = Channel("动脉回输流量").Value;
|
|
||||||
var drainageFlow = Channel("静脉引流流量").Value;
|
|
||||||
var recirculationRate = returnFlow <= 0.01
|
|
||||||
? 0d
|
|
||||||
: Math.Clamp((returnFlow - drainageFlow) / returnFlow * 100d, 0d, 100d);
|
|
||||||
SetSmoothedValue("再循环率", recirculationRate);
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<AlarmMessage> BuildAlarms()
|
|
||||||
{
|
|
||||||
var alarms = new List<AlarmMessage>();
|
|
||||||
var deltaPressure = Channel("近端压力").Value - Channel("远端压力").Value;
|
|
||||||
var recirculationRate = Channel("再循环率").Value;
|
|
||||||
|
|
||||||
if (deltaPressure > 24)
|
|
||||||
{
|
|
||||||
alarms.Add(new AlarmMessage
|
|
||||||
{
|
|
||||||
Timestamp = DateTime.Now,
|
|
||||||
Level = "高",
|
|
||||||
Message = $"压力降 ΔP {deltaPressure:F1} mmHg 偏高,请复核 D{ProximalPressureRegister}/D{DistalPressureRegister}。"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_connectionInitialized || _master is null)
|
|
||||||
{
|
|
||||||
alarms.Add(new AlarmMessage
|
|
||||||
{
|
|
||||||
Timestamp = DateTime.Now,
|
|
||||||
Level = "中",
|
|
||||||
Message = $"ModbusTcp 未连接,当前 M/D 泵控与流量使用本地平滑模拟。目标 {IpAddress}:{Port}。"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recirculationRate > 8)
|
|
||||||
{
|
|
||||||
alarms.Add(new AlarmMessage
|
|
||||||
{
|
|
||||||
Timestamp = DateTime.Now,
|
|
||||||
Level = "中",
|
|
||||||
Message = $"再循环率 {recirculationRate:F1}% 偏高,建议复核回路与泵状态。"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var pump in _pumpControls.Where(item => item.IsRunning && item.FlowAddress.HasValue && item.FlowValue < 0.15d))
|
|
||||||
{
|
|
||||||
alarms.Add(new AlarmMessage
|
|
||||||
{
|
|
||||||
Timestamp = DateTime.Now,
|
|
||||||
Level = "中",
|
|
||||||
Message = $"{pump.Name} 已启动但流量未建立,请检查回路、泵头和传感器。"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return alarms;
|
|
||||||
}
|
|
||||||
|
|
||||||
private TelemetryUpdateSnapshot BuildSnapshot(IReadOnlyList<AlarmMessage> alarms) => new()
|
|
||||||
{
|
|
||||||
Channels = _channels.Select(channel => new TelemetryChannelSnapshot
|
|
||||||
{
|
|
||||||
Name = channel.Name,
|
|
||||||
Value = channel.Value,
|
|
||||||
IsAvailable = true
|
|
||||||
}).ToList(),
|
|
||||||
PumpControls = _pumpControls.Select(pump => new PumpControlSnapshot
|
|
||||||
{
|
|
||||||
Key = pump.Key,
|
|
||||||
IsRunning = pump.IsRunning,
|
|
||||||
FlowValue = pump.FlowValue,
|
|
||||||
StateAvailable = true,
|
|
||||||
FlowAvailable = pump.FlowAddress.HasValue
|
|
||||||
}).ToList(),
|
|
||||||
ValveControls = _valveControls.Select(valve => new ValveControlSnapshot
|
|
||||||
{
|
|
||||||
Key = valve.Key,
|
|
||||||
IsOpen = valve.IsOpen,
|
|
||||||
StateAvailable = true
|
|
||||||
}).ToList(),
|
|
||||||
Alarms = alarms
|
|
||||||
.Select(alarm => new AlarmMessage
|
|
||||||
{
|
|
||||||
Timestamp = alarm.Timestamp,
|
|
||||||
Level = alarm.Level,
|
|
||||||
Message = alarm.Message
|
|
||||||
})
|
|
||||||
.ToList(),
|
|
||||||
ProximalPressureRawKpa = _proximalPressureRawKpa,
|
|
||||||
DistalPressureRawKpa = _distalPressureRawKpa,
|
|
||||||
IsLiveConnected = _master is not null && _tcpClient?.Connected == true,
|
|
||||||
EndpointDescription = EndpointDescription,
|
|
||||||
LastSuccessfulReadAt = null,
|
|
||||||
LastErrorMessage = LastErrorMessage
|
|
||||||
};
|
|
||||||
|
|
||||||
private void SetSmoothedValue(string channelName, double nextValue)
|
|
||||||
{
|
|
||||||
var channel = Channel(channelName);
|
|
||||||
var clampedValue = ShouldClampChannelValue(channelName)
|
|
||||||
? Math.Clamp(nextValue, channel.Min, channel.Max)
|
|
||||||
: nextValue;
|
|
||||||
var window = Window(channelName);
|
|
||||||
window.Enqueue(clampedValue);
|
|
||||||
|
|
||||||
while (window.Count > SmoothingWindowSize)
|
|
||||||
{
|
|
||||||
window.Dequeue();
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.Value = window.Average();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Queue<double> Window(string channelName)
|
|
||||||
{
|
|
||||||
if (_channelWindows.TryGetValue(channelName, out var window))
|
|
||||||
{
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
window = new Queue<double>();
|
|
||||||
_channelWindows[channelName] = window;
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplySafeStartupState(IModbusMaster master)
|
|
||||||
{
|
|
||||||
foreach (var pump in _pumpControls)
|
|
||||||
{
|
|
||||||
master.WriteSingleCoil(SlaveId, (ushort)pump.StartAddress, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var valve in _valveControls)
|
|
||||||
{
|
|
||||||
master.WriteSingleCoil(SlaveId, (ushort)valve.StartAddress, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
ApplyLocalSafeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyLocalSafeState()
|
|
||||||
{
|
|
||||||
foreach (var pump in _pumpControls)
|
|
||||||
{
|
|
||||||
pump.IsRunning = false;
|
|
||||||
pump.FlowValue = 0d;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var valve in _valveControls)
|
|
||||||
{
|
|
||||||
valve.IsOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var channelName in FlowChannelNames.Values)
|
|
||||||
{
|
|
||||||
SetChannelValueDirect(channelName, 0d);
|
|
||||||
}
|
|
||||||
|
|
||||||
SetChannelValueDirect("负压辅助引流", 0d);
|
|
||||||
SetChannelValueDirect("再循环率", 0d);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetChannelValueDirect(string channelName, double nextValue)
|
|
||||||
{
|
|
||||||
var channel = Channel(channelName);
|
|
||||||
var clampedValue = ShouldClampChannelValue(channelName)
|
|
||||||
? Math.Clamp(nextValue, channel.Min, channel.Max)
|
|
||||||
: nextValue;
|
|
||||||
var window = Window(channelName);
|
|
||||||
window.Clear();
|
|
||||||
window.Enqueue(clampedValue);
|
|
||||||
channel.Value = clampedValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReleaseConnection()
|
|
||||||
{
|
|
||||||
_master?.Dispose();
|
|
||||||
_tcpClient?.Dispose();
|
|
||||||
_master = null;
|
|
||||||
_tcpClient = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double SimulatedRunningTarget(string pumpKey) => pumpKey switch
|
|
||||||
{
|
|
||||||
"PressureDropPump" => 4.2d,
|
|
||||||
"RecirculationMainPump" => 4.8d,
|
|
||||||
"RecirculationReturnPump" => 4.7d,
|
|
||||||
"RecirculationDrainagePump" => 4.4d,
|
|
||||||
"KinkResistancePump" => 4.6d,
|
|
||||||
"HemolysisDrainageSinglePump" => 4.3d,
|
|
||||||
"HemolysisReturnSinglePump" => 4.3d,
|
|
||||||
"HemolysisDualLumenPump" => 4.1d,
|
|
||||||
_ => 4.0d
|
|
||||||
};
|
|
||||||
|
|
||||||
private static double ConvertRegisterToFlow(ushort rawValue) => rawValue * FlowRegisterScale;
|
|
||||||
|
|
||||||
private static double ConvertRegistersToPressureKpa(ushort firstWord, ushort secondWord)
|
|
||||||
{
|
|
||||||
var lowWordFirst = DecodeFloat(firstWord, secondWord, lowWordFirst: true);
|
|
||||||
var highWordFirst = DecodeFloat(firstWord, secondWord, lowWordFirst: false);
|
|
||||||
var lowWordFirstValid = IsPlausiblePressureKpa(lowWordFirst);
|
|
||||||
var highWordFirstValid = IsPlausiblePressureKpa(highWordFirst);
|
|
||||||
|
|
||||||
if (lowWordFirstValid && !highWordFirstValid)
|
|
||||||
{
|
|
||||||
return lowWordFirst;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lowWordFirstValid && highWordFirstValid)
|
|
||||||
{
|
|
||||||
return highWordFirst;
|
|
||||||
}
|
|
||||||
|
|
||||||
return lowWordFirst;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double ConvertPressureKpaToMmHg(double pressureKpa) => pressureKpa * KpaToMmHg;
|
|
||||||
|
|
||||||
private static float DecodeFloat(ushort firstWord, ushort secondWord, bool lowWordFirst)
|
|
||||||
{
|
|
||||||
var bits = lowWordFirst
|
|
||||||
? ((uint)secondWord << 16) | firstWord
|
|
||||||
: ((uint)firstWord << 16) | secondWord;
|
|
||||||
return BitConverter.Int32BitsToSingle(unchecked((int)bits));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsPlausiblePressureKpa(float value) =>
|
|
||||||
!float.IsNaN(value) && !float.IsInfinity(value) && value is > -1000f and < 1000f;
|
|
||||||
|
|
||||||
private PumpControlChannel Pump(string key) => _pumpControls.First(pump => pump.Key == key);
|
|
||||||
|
|
||||||
private static bool ShouldClampChannelValue(string channelName) =>
|
|
||||||
channelName is not "近端压力" and not "远端压力";
|
|
||||||
|
|
||||||
private DeviceChannel Channel(string name) => _channels.First(channel => channel.Name == name);
|
|
||||||
|
|
||||||
private double Next(double min, double max) => min + (_random.NextDouble() * (max - min));
|
|
||||||
}
|
|
||||||
@@ -580,7 +580,7 @@ public sealed class ModbusTelemetryService : IModbusTelemetryService, IDisposabl
|
|||||||
{
|
{
|
||||||
Timestamp = DateTime.Now,
|
Timestamp = DateTime.Now,
|
||||||
Level = "中",
|
Level = "中",
|
||||||
Message = $"ModbusTcp 未连接,当前不再使用本地模拟数据。目标 {_ipAddress}:{_port}。"
|
Message = $"ModbusTcp 未连接,请检查 PLC 通讯。目标 {_ipAddress}:{_port}。"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.IO.Ports;
|
using System.IO.Ports;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Cardiopulmonarybypasssystems.Models;
|
using Cardiopulmonarybypasssystems.Models;
|
||||||
@@ -20,6 +21,7 @@ public sealed class Rs485PumpFlowService : IRs485PumpFlowService
|
|||||||
private const int WriteRetryCount = 3;
|
private const int WriteRetryCount = 3;
|
||||||
private const int RetryDelayMs = 80;
|
private const int RetryDelayMs = 80;
|
||||||
private const int PostWriteSettleDelayMs = 60;
|
private const int PostWriteSettleDelayMs = 60;
|
||||||
|
private const int PostCommandVerifyDelayMs = 120;
|
||||||
|
|
||||||
private readonly ModbusFactory _factory = new();
|
private readonly ModbusFactory _factory = new();
|
||||||
private readonly object _syncRoot = new();
|
private readonly object _syncRoot = new();
|
||||||
@@ -129,7 +131,7 @@ public sealed class Rs485PumpFlowService : IRs485PumpFlowService
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
RawForwardSpeed = readbackForward,
|
RawForwardSpeed = readbackForward,
|
||||||
Message = $"RS485 写入未生效:目标值={rawForwardSpeed},回读值={readbackForward}。请确认驱动器上限或预设模式。"
|
Message = $"RS485 写入未生效:目标值 {rawForwardSpeed},回读值 {readbackForward}。请确认驱动器上限或预设模式。"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,20 +160,19 @@ public sealed class Rs485PumpFlowService : IRs485PumpFlowService
|
|||||||
return Execute(request, master =>
|
return Execute(request, master =>
|
||||||
{
|
{
|
||||||
var slave = request.PumpSettings.SlaveAddress;
|
var slave = request.PumpSettings.SlaveAddress;
|
||||||
var warnings = new List<string>();
|
|
||||||
if (rawMotorSpeed == 0)
|
if (rawMotorSpeed == 0)
|
||||||
{
|
{
|
||||||
rawMotorSpeed = 1;
|
rawMotorSpeed = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryWriteRegister(master, slave, DirectControlModeRegister, DirectControlModeValue, out _))
|
if (!TryWriteRegister(master, slave, DirectControlModeRegister, DirectControlModeValue, out var modeError))
|
||||||
{
|
{
|
||||||
warnings.Add("未切入 RS485 直接控制模式");
|
return Failure($"RS485 直启失败:未切入直接控制模式,{modeError}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryWriteRegister(master, slave, ReleaseRegister, ReleaseValue, out _))
|
if (!TryWriteRegister(master, slave, ReleaseRegister, ReleaseValue, out var releaseError))
|
||||||
{
|
{
|
||||||
warnings.Add("未执行释放命令");
|
return Failure($"RS485 直启失败:未执行释放命令,{releaseError}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryWriteSignedRegister(master, slave, request.PumpSettings.MotorControlRegister, rawMotorSpeed, out var writeError))
|
if (!TryWriteSignedRegister(master, slave, request.PumpSettings.MotorControlRegister, rawMotorSpeed, out var writeError))
|
||||||
@@ -179,11 +180,30 @@ public sealed class Rs485PumpFlowService : IRs485PumpFlowService
|
|||||||
return Failure($"RS485 直接启动失败:{writeError}");
|
return Failure($"RS485 直接启动失败:{writeError}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (PostCommandVerifyDelayMs > 0)
|
||||||
|
{
|
||||||
|
Thread.Sleep(PostCommandVerifyDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryReadRegister(master, slave, request.PumpSettings.RunStatusRegister, out var runStatusValue, out _))
|
||||||
|
{
|
||||||
|
if (runStatusValue != 1)
|
||||||
|
{
|
||||||
|
return Failure($"RS485 直启失败:运行状态返回 {runStatusValue},未确认启动");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Rs485PumpFlowOperationResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = $"RS485 直接启动成功,控制值 {rawMotorSpeed}",
|
||||||
|
RunStatus = runStatusValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return new Rs485PumpFlowOperationResult
|
return new Rs485PumpFlowOperationResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = BuildSuccessMessage($"RS485 直接启动成功,控制值={rawMotorSpeed}", warnings),
|
Message = $"RS485 启动命令已下发,控制值 {rawMotorSpeed},等待流量确认"
|
||||||
RunStatus = 1
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -193,22 +213,40 @@ public sealed class Rs485PumpFlowService : IRs485PumpFlowService
|
|||||||
return Execute(request, master =>
|
return Execute(request, master =>
|
||||||
{
|
{
|
||||||
var slave = request.PumpSettings.SlaveAddress;
|
var slave = request.PumpSettings.SlaveAddress;
|
||||||
var warnings = new List<string>();
|
|
||||||
if (!TryWriteSignedRegister(master, slave, request.PumpSettings.MotorControlRegister, 0, out var writeError))
|
if (!TryWriteSignedRegister(master, slave, request.PumpSettings.MotorControlRegister, 0, out var writeError))
|
||||||
{
|
{
|
||||||
return Failure($"RS485 直接停止失败:{writeError}");
|
return Failure($"RS485 直接停止失败:{writeError}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryWriteRegister(master, slave, SelfLockRegister, SelfLockValue, out _))
|
if (!TryWriteRegister(master, slave, SelfLockRegister, SelfLockValue, out var selfLockError))
|
||||||
{
|
{
|
||||||
warnings.Add("未执行自锁命令");
|
return Failure($"RS485 直停失败:未执行自锁命令,{selfLockError}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PostCommandVerifyDelayMs > 0)
|
||||||
|
{
|
||||||
|
Thread.Sleep(PostCommandVerifyDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryReadRegister(master, slave, request.PumpSettings.RunStatusRegister, out var runStatusValue, out _))
|
||||||
|
{
|
||||||
|
if (runStatusValue != 0)
|
||||||
|
{
|
||||||
|
return Failure($"RS485 直停失败:运行状态返回 {runStatusValue},未确认停止");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Rs485PumpFlowOperationResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "RS485 直接停止成功",
|
||||||
|
RunStatus = runStatusValue
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Rs485PumpFlowOperationResult
|
return new Rs485PumpFlowOperationResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = BuildSuccessMessage("RS485 直接停止成功", warnings),
|
Message = "RS485 停止命令已下发,等待流量确认"
|
||||||
RunStatus = 0
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Cardiopulmonarybypasssystems.Models;
|
using Cardiopulmonarybypasssystems.Models;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
@@ -25,6 +26,7 @@ public partial class MainViewModel
|
|||||||
private static readonly HashSet<string> Rs485PumpKeySet = [.. Rs485PumpKeys];
|
private static readonly HashSet<string> Rs485PumpKeySet = [.. Rs485PumpKeys];
|
||||||
private const double DefaultRs485RawPerLitrePerMinute = 1000d;
|
private const double DefaultRs485RawPerLitrePerMinute = 1000d;
|
||||||
private const double DefaultRs485RawOffset = 0d;
|
private const double DefaultRs485RawOffset = 0d;
|
||||||
|
private const int MaxRs485MotorCommand = short.MaxValue;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string rs485PortName = "COM3";
|
private string rs485PortName = "COM3";
|
||||||
@@ -56,15 +58,28 @@ public partial class MainViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool rs485AutoStartPumpAfterWrite;
|
private bool rs485AutoStartPumpAfterWrite;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool rs485AdvancedSettingsVisible;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string rs485StatusText = "RS485 待确认";
|
private string rs485StatusText = "RS485 待确认";
|
||||||
|
|
||||||
public ObservableCollection<string> Rs485ParityOptions { get; } = new(["Even", "Odd", "None"]);
|
public ObservableCollection<string> Rs485ParityOptions { get; } = new(["Even", "Odd", "None"]);
|
||||||
public ObservableCollection<PumpControlChannel> Rs485FlowPumpControls { get; } = [];
|
public ObservableCollection<PumpControlChannel> Rs485FlowPumpControls { get; } = [];
|
||||||
|
public ObservableCollection<PumpControlChannel> ActiveRs485FlowPumpControls { get; } = [];
|
||||||
|
public string Rs485AdvancedSettingsToggleText => Rs485AdvancedSettingsVisible ? "收起高级维护" : "展开高级维护";
|
||||||
public string Rs485ConnectionSummary =>
|
public string Rs485ConnectionSummary =>
|
||||||
$"{Rs485PortName} / {Rs485BaudRate}bps / {Rs485Parity} / {Rs485DataBits}-{Rs485StopBits}";
|
$"{Rs485PortName} / {Rs485BaudRate}bps / {Rs485Parity} / {Rs485DataBits}-{Rs485StopBits}";
|
||||||
|
public int Rs485EnabledPumpCount => Rs485FlowPumpControls.Count(item => item.Rs485Enabled);
|
||||||
|
public int Rs485CalibrationConfirmedPumpCount =>
|
||||||
|
Rs485FlowPumpControls.Count(item => item.Rs485Enabled && item.HasConfirmedSetpointCalibration);
|
||||||
|
public string Rs485CalibrationSummary => Rs485EnabledPumpCount == 0
|
||||||
|
? "当前未启用 RS485 泵通道。"
|
||||||
|
: Rs485CalibrationConfirmedPumpCount == Rs485EnabledPumpCount
|
||||||
|
? $"已确认全部 {Rs485EnabledPumpCount} 台泵的流量换算。"
|
||||||
|
: $"已确认 {Rs485CalibrationConfirmedPumpCount}/{Rs485EnabledPumpCount} 台泵的流量换算,其余仍待标定确认。";
|
||||||
public string Rs485ManualHint =>
|
public string Rs485ManualHint =>
|
||||||
"8 泵启停由伺服器 RS485 主控;目标流量会按换算系数写入驱动器速度寄存器,PLC 仅保留实时采集。";
|
"主操作区按 8 泵批量预设设计,可逐行录入目标流量后一次批量写入;PLC 仅保留实时采集。";
|
||||||
|
|
||||||
partial void OnRs485PortNameChanged(string value) => UpdateAndPersistRs485Settings();
|
partial void OnRs485PortNameChanged(string value) => UpdateAndPersistRs485Settings();
|
||||||
partial void OnRs485BaudRateChanged(int value) => UpdateAndPersistRs485Settings();
|
partial void OnRs485BaudRateChanged(int value) => UpdateAndPersistRs485Settings();
|
||||||
@@ -87,6 +102,7 @@ public partial class MainViewModel
|
|||||||
partial void OnRs485AutoSwitchPresetModeChanged(bool value) => UpdateAndPersistRs485Settings();
|
partial void OnRs485AutoSwitchPresetModeChanged(bool value) => UpdateAndPersistRs485Settings();
|
||||||
partial void OnRs485PersistPresetAfterWriteChanged(bool value) => UpdateAndPersistRs485Settings();
|
partial void OnRs485PersistPresetAfterWriteChanged(bool value) => UpdateAndPersistRs485Settings();
|
||||||
partial void OnRs485AutoStartPumpAfterWriteChanged(bool value) => UpdateAndPersistRs485Settings();
|
partial void OnRs485AutoStartPumpAfterWriteChanged(bool value) => UpdateAndPersistRs485Settings();
|
||||||
|
partial void OnRs485AdvancedSettingsVisibleChanged(bool value) => OnPropertyChanged(nameof(Rs485AdvancedSettingsToggleText));
|
||||||
|
|
||||||
private void InitializeRs485FlowControl()
|
private void InitializeRs485FlowControl()
|
||||||
{
|
{
|
||||||
@@ -115,6 +131,7 @@ public partial class MainViewModel
|
|||||||
pump.PropertyChanged += OnRs485PumpConfigurationChanged;
|
pump.PropertyChanged += OnRs485PumpConfigurationChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RefreshActiveRs485FlowPumpControls();
|
||||||
OnPropertyChanged(nameof(Rs485ConnectionSummary));
|
OnPropertyChanged(nameof(Rs485ConnectionSummary));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +162,8 @@ public partial class MainViewModel
|
|||||||
ApplyRs485Binding(pump, binding);
|
ApplyRs485Binding(pump, binding);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RefreshActiveRs485FlowPumpControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Rs485SerialSettings BuildRs485SerialSettings() => new()
|
private Rs485SerialSettings BuildRs485SerialSettings() => new()
|
||||||
@@ -167,6 +186,7 @@ public partial class MainViewModel
|
|||||||
{
|
{
|
||||||
PumpKey = pump.Key,
|
PumpKey = pump.Key,
|
||||||
Enabled = pump.Rs485Enabled,
|
Enabled = pump.Rs485Enabled,
|
||||||
|
CalibrationConfirmed = pump.Rs485CalibrationConfirmed,
|
||||||
SlaveAddress = pump.Rs485SlaveAddress,
|
SlaveAddress = pump.Rs485SlaveAddress,
|
||||||
ForwardSpeedRegister = pump.Rs485ForwardSpeedRegister,
|
ForwardSpeedRegister = pump.Rs485ForwardSpeedRegister,
|
||||||
ReverseSpeedRegister = pump.Rs485ReverseSpeedRegister,
|
ReverseSpeedRegister = pump.Rs485ReverseSpeedRegister,
|
||||||
@@ -191,6 +211,7 @@ public partial class MainViewModel
|
|||||||
{
|
{
|
||||||
PumpKey = Rs485PumpKeys[index],
|
PumpKey = Rs485PumpKeys[index],
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
|
CalibrationConfirmed = false,
|
||||||
SlaveAddress = (byte)(index + 1),
|
SlaveAddress = (byte)(index + 1),
|
||||||
MotorControlRegister = 0x0040,
|
MotorControlRegister = 0x0040,
|
||||||
RawPerLitrePerMinute = DefaultRs485RawPerLitrePerMinute,
|
RawPerLitrePerMinute = DefaultRs485RawPerLitrePerMinute,
|
||||||
@@ -209,6 +230,7 @@ public partial class MainViewModel
|
|||||||
?? new Rs485PumpBindingSettings { PumpKey = pump.Key };
|
?? new Rs485PumpBindingSettings { PumpKey = pump.Key };
|
||||||
|
|
||||||
pump.Rs485Enabled = binding.Enabled;
|
pump.Rs485Enabled = binding.Enabled;
|
||||||
|
pump.Rs485CalibrationConfirmed = binding.CalibrationConfirmed;
|
||||||
pump.Rs485SlaveAddress = binding.SlaveAddress;
|
pump.Rs485SlaveAddress = binding.SlaveAddress;
|
||||||
pump.Rs485ForwardSpeedRegister = binding.ForwardSpeedRegister;
|
pump.Rs485ForwardSpeedRegister = binding.ForwardSpeedRegister;
|
||||||
pump.Rs485ReverseSpeedRegister = binding.ReverseSpeedRegister;
|
pump.Rs485ReverseSpeedRegister = binding.ReverseSpeedRegister;
|
||||||
@@ -241,6 +263,14 @@ public partial class MainViewModel
|
|||||||
{
|
{
|
||||||
pump.SetpointStatusText = "未配置 L/min 与速度换算系数";
|
pump.SetpointStatusText = "未配置 L/min 与速度换算系数";
|
||||||
}
|
}
|
||||||
|
else if (!pump.Rs485CalibrationConfirmed)
|
||||||
|
{
|
||||||
|
pump.SetpointStatusText = "换算系数待标定确认";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pump.SetpointStatusText = "换算系数已确认";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnRs485PumpConfigurationChanged(object? sender, PropertyChangedEventArgs e)
|
private void OnRs485PumpConfigurationChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
@@ -254,6 +284,7 @@ public partial class MainViewModel
|
|||||||
and not nameof(PumpControlChannel.Rs485SlaveAddress)
|
and not nameof(PumpControlChannel.Rs485SlaveAddress)
|
||||||
and not nameof(PumpControlChannel.Rs485RawPerLitrePerMinute)
|
and not nameof(PumpControlChannel.Rs485RawPerLitrePerMinute)
|
||||||
and not nameof(PumpControlChannel.Rs485RawOffset)
|
and not nameof(PumpControlChannel.Rs485RawOffset)
|
||||||
|
and not nameof(PumpControlChannel.Rs485CalibrationConfirmed)
|
||||||
and not nameof(PumpControlChannel.Rs485MinFlowLpm)
|
and not nameof(PumpControlChannel.Rs485MinFlowLpm)
|
||||||
and not nameof(PumpControlChannel.Rs485MaxFlowLpm)
|
and not nameof(PumpControlChannel.Rs485MaxFlowLpm)
|
||||||
and not nameof(PumpControlChannel.Rs485ForwardSpeedRegister)
|
and not nameof(PumpControlChannel.Rs485ForwardSpeedRegister)
|
||||||
@@ -271,10 +302,48 @@ public partial class MainViewModel
|
|||||||
{
|
{
|
||||||
pump.SetpointStatusText = "未配置 L/min 与速度换算系数";
|
pump.SetpointStatusText = "未配置 L/min 与速度换算系数";
|
||||||
}
|
}
|
||||||
|
else if (!pump.Rs485CalibrationConfirmed)
|
||||||
|
{
|
||||||
|
pump.SetpointStatusText = "换算系数待标定确认";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pump.SetpointStatusText = "换算系数已确认";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.PropertyName == nameof(PumpControlChannel.Rs485Enabled))
|
||||||
|
{
|
||||||
|
RefreshActiveRs485FlowPumpControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
RaiseRs485CalibrationSummaryChanges();
|
||||||
UpdateAndPersistRs485Settings();
|
UpdateAndPersistRs485Settings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RefreshActiveRs485FlowPumpControls()
|
||||||
|
{
|
||||||
|
ActiveRs485FlowPumpControls.Clear();
|
||||||
|
foreach (var pump in Rs485FlowPumpControls.Where(item => item.Rs485Enabled))
|
||||||
|
{
|
||||||
|
ActiveRs485FlowPumpControls.Add(pump);
|
||||||
|
}
|
||||||
|
|
||||||
|
RaiseRs485CalibrationSummaryChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaiseRs485CalibrationSummaryChanges()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(Rs485EnabledPumpCount));
|
||||||
|
OnPropertyChanged(nameof(Rs485CalibrationConfirmedPumpCount));
|
||||||
|
OnPropertyChanged(nameof(Rs485CalibrationSummary));
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleRs485AdvancedSettings()
|
||||||
|
{
|
||||||
|
Rs485AdvancedSettingsVisible = !Rs485AdvancedSettingsVisible;
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateAndPersistRs485Settings()
|
private void UpdateAndPersistRs485Settings()
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(Rs485ConnectionSummary));
|
OnPropertyChanged(nameof(Rs485ConnectionSummary));
|
||||||
@@ -292,7 +361,7 @@ public partial class MainViewModel
|
|||||||
Rs485WriteTimeoutMs = 500;
|
Rs485WriteTimeoutMs = 500;
|
||||||
Rs485AutoSwitchPresetMode = false;
|
Rs485AutoSwitchPresetMode = false;
|
||||||
Rs485PersistPresetAfterWrite = false;
|
Rs485PersistPresetAfterWrite = false;
|
||||||
Rs485AutoStartPumpAfterWrite = true;
|
Rs485AutoStartPumpAfterWrite = false;
|
||||||
var primaryPump = Rs485FlowPumpControls.FirstOrDefault();
|
var primaryPump = Rs485FlowPumpControls.FirstOrDefault();
|
||||||
foreach (var pump in Rs485FlowPumpControls)
|
foreach (var pump in Rs485FlowPumpControls)
|
||||||
{
|
{
|
||||||
@@ -307,6 +376,7 @@ public partial class MainViewModel
|
|||||||
pump.Rs485MotorControlRegister = 0x0040;
|
pump.Rs485MotorControlRegister = 0x0040;
|
||||||
pump.Rs485RawPerLitrePerMinute = DefaultRs485RawPerLitrePerMinute;
|
pump.Rs485RawPerLitrePerMinute = DefaultRs485RawPerLitrePerMinute;
|
||||||
pump.Rs485RawOffset = DefaultRs485RawOffset;
|
pump.Rs485RawOffset = DefaultRs485RawOffset;
|
||||||
|
pump.Rs485CalibrationConfirmed = false;
|
||||||
pump.Rs485MinFlowLpm = 0;
|
pump.Rs485MinFlowLpm = 0;
|
||||||
pump.Rs485MaxFlowLpm = 1.0;
|
pump.Rs485MaxFlowLpm = 1.0;
|
||||||
pump.SetpointStatusText = pump.Rs485Enabled
|
pump.SetpointStatusText = pump.Rs485Enabled
|
||||||
@@ -321,30 +391,62 @@ public partial class MainViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void TestRs485Connection()
|
private async Task TestRs485Connection()
|
||||||
{
|
{
|
||||||
var pump = Rs485FlowPumpControls.FirstOrDefault(item => item.SupportsRs485Preset);
|
var pumps = Rs485FlowPumpControls
|
||||||
if (pump is null)
|
.Where(item => item.SupportsRs485Preset)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (pumps.Count == 0)
|
||||||
{
|
{
|
||||||
Rs485StatusText = "未找到已启用的 RS485 泵通道";
|
Rs485StatusText = "未找到已启用的 RS485 泵通道";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = _rs485PumpFlowService.TestConnection(BuildRs485Request(pump));
|
var successCount = 0;
|
||||||
ApplyRs485OperationResult(pump, result, "RS485 通讯确认");
|
foreach (var pump in pumps)
|
||||||
|
{
|
||||||
|
if (!TryBeginRs485PumpOperation(pump, "通讯确认"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = BuildRs485Request(pump);
|
||||||
|
var result = await Task.Run(() => _rs485PumpFlowService.TestConnection(request));
|
||||||
|
ApplyRs485OperationResult(pump, result, "RS485 通讯确认");
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EndRs485PumpOperation(pump);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var failedCount = pumps.Count - successCount;
|
||||||
|
var summary = failedCount == 0
|
||||||
|
? $"RS485 通讯确认完成:{successCount} 台全部正常。"
|
||||||
|
: $"RS485 通讯确认完成:成功 {successCount} 台,失败 {failedCount} 台。";
|
||||||
|
Rs485StatusText = summary;
|
||||||
|
LatestAction = summary;
|
||||||
|
TraceEvents.Insert(0, NewTrace("RS485 通讯确认", summary));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void RefreshAllPumpSetpoints()
|
private async Task RefreshAllPumpSetpoints()
|
||||||
{
|
{
|
||||||
foreach (var pump in Rs485FlowPumpControls.Where(item => item.SupportsRs485Preset))
|
foreach (var pump in Rs485FlowPumpControls.Where(item => item.SupportsRs485Preset))
|
||||||
{
|
{
|
||||||
ReadPumpSetpoint(pump);
|
await ReadPumpSetpoint(pump);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ReadPumpSetpoint(PumpControlChannel? pump)
|
private async Task ReadPumpSetpoint(PumpControlChannel? pump)
|
||||||
{
|
{
|
||||||
if (!TryPreparePumpForRs485(pump, requireCalibration: false, out var activePump, out _))
|
if (!TryPreparePumpForRs485(pump, requireCalibration: false, out var activePump, out _))
|
||||||
{
|
{
|
||||||
@@ -358,7 +460,8 @@ public partial class MainViewModel
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = _rs485PumpFlowService.ReadPumpPreset(BuildRs485Request(activePump));
|
var request = BuildRs485Request(activePump);
|
||||||
|
var result = await Task.Run(() => _rs485PumpFlowService.ReadPumpPreset(request));
|
||||||
ApplyRs485OperationResult(activePump, result, "读取泵预设");
|
ApplyRs485OperationResult(activePump, result, "读取泵预设");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -368,48 +471,100 @@ public partial class MainViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void WritePumpSetpoint(PumpControlChannel? pump)
|
private async Task WritePumpSetpoint(PumpControlChannel? pump)
|
||||||
{
|
{
|
||||||
if (!EnsureSessionEditable("RS485 预设流量写入"))
|
await TryWritePumpSetpointCore(pump, "RS485 预设流量写入", allowAutoStartAfterWrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task WriteAllPumpSetpoints()
|
||||||
|
{
|
||||||
|
if (!EnsureSessionEditable("RS485 批量预设流量写入"))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var candidates = Rs485FlowPumpControls
|
||||||
|
.Where(item => item.SupportsRs485Preset)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
Rs485StatusText = "未找到已启用的 RS485 泵通道";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var successCount = 0;
|
||||||
|
var failedCount = 0;
|
||||||
|
foreach (var pump in candidates)
|
||||||
|
{
|
||||||
|
if (await TryWritePumpSetpointCore(pump, "RS485 批量预设流量写入", allowAutoStartAfterWrite: false))
|
||||||
|
{
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = $"批量写入完成:成功 {successCount} 台,失败 {failedCount} 台。批量模式仅写入目标值,不自动启动。";
|
||||||
|
Rs485StatusText = summary;
|
||||||
|
LatestAction = summary;
|
||||||
|
TraceEvents.Insert(0, NewTrace("RS485 批量写入", summary));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryWritePumpSetpointCore(
|
||||||
|
PumpControlChannel? pump,
|
||||||
|
string operationName,
|
||||||
|
bool allowAutoStartAfterWrite)
|
||||||
|
{
|
||||||
|
if (!EnsureSessionEditable(operationName))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TryPreparePumpForRs485(pump, requireCalibration: true, out var activePump, out var targetFlow))
|
if (!TryPreparePumpForRs485(pump, requireCalibration: true, out var activePump, out var targetFlow))
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePump.IsRunning)
|
if (activePump.IsRunning)
|
||||||
{
|
{
|
||||||
activePump.SetpointStatusText = "泵运行中,禁止改写预设";
|
activePump.SetpointStatusText = "泵运行中,禁止改写预设";
|
||||||
Rs485StatusText = $"{activePump.Name} 运行中,禁止改写预设";
|
Rs485StatusText = $"{activePump.Name} 运行中,禁止改写预设";
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawValue = ConvertFlowToRawSpeed(activePump, targetFlow!.Value);
|
var rawValue = ConvertFlowToRawSpeed(activePump, targetFlow!.Value);
|
||||||
if (rawValue is < 0 or > ushort.MaxValue)
|
if (rawValue is < 0 or > MaxRs485MotorCommand)
|
||||||
{
|
{
|
||||||
activePump.SetpointStatusText = "换算后的速度值超出 0~65535";
|
activePump.SetpointStatusText = $"换算后的速度值超出 0~{MaxRs485MotorCommand}";
|
||||||
Rs485StatusText = $"{activePump.Name} 写入失败:速度值超出范围";
|
Rs485StatusText = $"{activePump.Name} 写入失败:速度值超出范围";
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryBeginRs485PumpOperation(activePump, "写入"))
|
if (!TryBeginRs485PumpOperation(activePump, "写入"))
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = _rs485PumpFlowService.WritePumpPreset(BuildRs485Request(activePump), (ushort)rawValue);
|
var request = BuildRs485Request(activePump);
|
||||||
|
var result = await Task.Run(() => _rs485PumpFlowService.WritePumpPreset(request, (ushort)rawValue));
|
||||||
ApplyRs485OperationResult(activePump, result, "写入泵预设");
|
ApplyRs485OperationResult(activePump, result, "写入泵预设");
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
{
|
{
|
||||||
activePump.PendingSetpointText = targetFlow.Value.ToString("F2", CultureInfo.InvariantCulture);
|
activePump.PendingSetpointText = targetFlow.Value.ToString("F2", CultureInfo.InvariantCulture);
|
||||||
CacheConfirmedRs485Setpoint(activePump, rawValue, targetFlow.Value);
|
CacheConfirmedRs485Setpoint(activePump, rawValue, targetFlow.Value);
|
||||||
TryAutoStartPumpAfterRs485Write(activePump, targetFlow.Value);
|
if (allowAutoStartAfterWrite)
|
||||||
|
{
|
||||||
|
await TryAutoStartPumpAfterRs485Write(activePump, targetFlow.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result.Success;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -417,7 +572,7 @@ public partial class MainViewModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryAutoStartPumpAfterRs485Write(PumpControlChannel pump, double targetFlow)
|
private async Task TryAutoStartPumpAfterRs485Write(PumpControlChannel pump, double targetFlow)
|
||||||
{
|
{
|
||||||
if (!Rs485AutoStartPumpAfterWrite)
|
if (!Rs485AutoStartPumpAfterWrite)
|
||||||
{
|
{
|
||||||
@@ -445,12 +600,14 @@ public partial class MainViewModel
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var directResult = _pumpActuationService.StartPump(BuildRs485Request(pump), rawMotorSpeed);
|
var request = BuildRs485Request(pump);
|
||||||
|
var directResult = await Task.Run(() => _pumpActuationService.StartPump(request, rawMotorSpeed));
|
||||||
ApplyRs485OperationResult(pump, directResult, "RS485 直接启泵");
|
ApplyRs485OperationResult(pump, directResult, "RS485 直接启泵");
|
||||||
if (directResult.Success)
|
if (directResult.Success)
|
||||||
{
|
{
|
||||||
CacheResolvedRs485Setpoint(pump, rawMotorSpeed);
|
CacheResolvedRs485Setpoint(pump, rawMotorSpeed);
|
||||||
ApplyOptimisticRs485PumpState(pump, isRunning: true);
|
ApplyPostCommandPumpState(pump, directResult, expectedRunning: true);
|
||||||
|
await RefreshTelemetryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -585,6 +742,14 @@ public partial class MainViewModel
|
|||||||
pump.IsRunning = result.RunStatus.Value == 1;
|
pump.IsRunning = result.RunStatus.Value == 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (IsRs485ManagedPump(pump))
|
||||||
|
{
|
||||||
|
pump.Rs485RunStatusCode = null;
|
||||||
|
if (!pump.PendingRs485RunningState.HasValue)
|
||||||
|
{
|
||||||
|
pump.StateAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LatestAction = $"{traceCategory}:{pump.Name} / {result.Message}";
|
LatestAction = $"{traceCategory}:{pump.Name} / {result.Message}";
|
||||||
TraceEvents.Insert(0, NewTrace(traceCategory, $"{pump.Name} / {result.Message}"));
|
TraceEvents.Insert(0, NewTrace(traceCategory, $"{pump.Name} / {result.Message}"));
|
||||||
@@ -601,6 +766,12 @@ public partial class MainViewModel
|
|||||||
rawMotorSpeed = 0;
|
rawMotorSpeed = 0;
|
||||||
message = string.Empty;
|
message = string.Empty;
|
||||||
|
|
||||||
|
if (!pump.HasConfirmedSetpointCalibration)
|
||||||
|
{
|
||||||
|
message = $"{pump.Name} 尚未完成流量换算标定确认";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var candidateRaw = pump.ConfirmedSetpointAvailable ? pump.ConfirmedRawSetpointValue : 0;
|
var candidateRaw = pump.ConfirmedSetpointAvailable ? pump.ConfirmedRawSetpointValue : 0;
|
||||||
if (candidateRaw <= 0)
|
if (candidateRaw <= 0)
|
||||||
{
|
{
|
||||||
@@ -608,9 +779,9 @@ public partial class MainViewModel
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidateRaw > short.MaxValue)
|
if (candidateRaw > MaxRs485MotorCommand)
|
||||||
{
|
{
|
||||||
message = $"{pump.Name} 的直接启停控制值超过 32767";
|
message = $"{pump.Name} 的直接启停控制值超过 {MaxRs485MotorCommand}";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,14 +840,26 @@ public partial class MainViewModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyOptimisticRs485PumpState(PumpControlChannel pump, bool isRunning)
|
private static void ApplyPostCommandPumpState(
|
||||||
|
PumpControlChannel pump,
|
||||||
|
Rs485PumpFlowOperationResult result,
|
||||||
|
bool expectedRunning)
|
||||||
{
|
{
|
||||||
pump.IsRunning = isRunning;
|
if (result.RunStatus.HasValue)
|
||||||
pump.StateAvailable = true;
|
{
|
||||||
pump.Rs485RunStatusCode = (ushort)(isRunning ? 1 : 0);
|
pump.PendingRs485RunningState = null;
|
||||||
|
pump.IsRunning = result.RunStatus.Value == 1;
|
||||||
|
pump.StateAvailable = result.RunStatus.Value is 0 or 1;
|
||||||
|
pump.Rs485RunStatusCode = result.RunStatus.Value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pump.PendingRs485RunningState = expectedRunning;
|
||||||
|
pump.StateAvailable = false;
|
||||||
|
pump.Rs485RunStatusCode = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryTogglePumpControlViaRs485(PumpControlChannel pump, bool nextState)
|
private async Task<bool> TryTogglePumpControlViaRs485(PumpControlChannel pump, bool nextState)
|
||||||
{
|
{
|
||||||
if (!ShouldUseRs485DirectPumpControl(pump))
|
if (!ShouldUseRs485DirectPumpControl(pump))
|
||||||
{
|
{
|
||||||
@@ -712,22 +895,26 @@ public partial class MainViewModel
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var startResult = _pumpActuationService.StartPump(BuildRs485Request(pump), rawMotorSpeed);
|
var request = BuildRs485Request(pump);
|
||||||
|
var startResult = await Task.Run(() => _pumpActuationService.StartPump(request, rawMotorSpeed));
|
||||||
ApplyRs485OperationResult(pump, startResult, "RS485 泵控");
|
ApplyRs485OperationResult(pump, startResult, "RS485 泵控");
|
||||||
if (startResult.Success)
|
if (startResult.Success)
|
||||||
{
|
{
|
||||||
CacheResolvedRs485Setpoint(pump, rawMotorSpeed);
|
CacheResolvedRs485Setpoint(pump, rawMotorSpeed);
|
||||||
ApplyOptimisticRs485PumpState(pump, isRunning: true);
|
ApplyPostCommandPumpState(pump, startResult, expectedRunning: true);
|
||||||
|
await RefreshTelemetryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var stopResult = _pumpActuationService.StopPump(BuildRs485Request(pump));
|
var stopRequest = BuildRs485Request(pump);
|
||||||
|
var stopResult = await Task.Run(() => _pumpActuationService.StopPump(stopRequest));
|
||||||
ApplyRs485OperationResult(pump, stopResult, "RS485 泵控");
|
ApplyRs485OperationResult(pump, stopResult, "RS485 泵控");
|
||||||
if (stopResult.Success)
|
if (stopResult.Success)
|
||||||
{
|
{
|
||||||
ApplyOptimisticRs485PumpState(pump, isRunning: false);
|
ApplyPostCommandPumpState(pump, stopResult, expectedRunning: false);
|
||||||
|
await RefreshTelemetryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||||||
private const double AntiCollapseTargetNegativePressure = -6.67;
|
private const double AntiCollapseTargetNegativePressure = -6.67;
|
||||||
private const double PressureKpaToMmHg = 7.50061683d;
|
private const double PressureKpaToMmHg = 7.50061683d;
|
||||||
private const int TrendHistoryCapacity = 60;
|
private const int TrendHistoryCapacity = 60;
|
||||||
private static readonly string LimitSettingsPath = Path.Combine(
|
private const string LimitSettingsFileName = "manufacturer-limits.json";
|
||||||
|
private static readonly string LegacyLimitSettingsPath = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"Cardiopulmonarybypasssystems",
|
"Cardiopulmonarybypasssystems",
|
||||||
"manufacturer-limits.json");
|
LimitSettingsFileName);
|
||||||
|
private static readonly string LimitSettingsPath = ResolveLimitSettingsPath();
|
||||||
private double? _antiCollapseBaselinePressureDrop;
|
private double? _antiCollapseBaselinePressureDrop;
|
||||||
private double? _antiCollapseBaselineFlow;
|
private double? _antiCollapseBaselineFlow;
|
||||||
private DateTime? _antiCollapseBaselineCapturedAt;
|
private DateTime? _antiCollapseBaselineCapturedAt;
|
||||||
@@ -49,6 +51,73 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||||||
private double? _proximalPressureRawKpa;
|
private double? _proximalPressureRawKpa;
|
||||||
private double? _distalPressureRawKpa;
|
private double? _distalPressureRawKpa;
|
||||||
|
|
||||||
|
private static string ResolveLimitSettingsPath()
|
||||||
|
{
|
||||||
|
var applicationPath = Path.Combine(AppContext.BaseDirectory, LimitSettingsFileName);
|
||||||
|
var developmentProjectDirectory = ResolveDevelopmentProjectDirectory();
|
||||||
|
if (!string.IsNullOrWhiteSpace(developmentProjectDirectory))
|
||||||
|
{
|
||||||
|
var projectPath = Path.Combine(developmentProjectDirectory, LimitSettingsFileName);
|
||||||
|
if (File.Exists(projectPath))
|
||||||
|
{
|
||||||
|
return projectPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return applicationPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveDevelopmentProjectDirectory()
|
||||||
|
{
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
Directory.GetCurrentDirectory(),
|
||||||
|
AppContext.BaseDirectory
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(candidate))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var directory = new DirectoryInfo(candidate);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "Cardiopulmonarybypasssystems.csproj")))
|
||||||
|
{
|
||||||
|
return directory.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MigrateLegacyLimitSettingsIfNeeded()
|
||||||
|
{
|
||||||
|
if (string.Equals(LegacyLimitSettingsPath, LimitSettingsPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(LegacyLimitSettingsPath) || File.Exists(LimitSettingsPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var directory = Path.GetDirectoryName(LimitSettingsPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Copy(LegacyLimitSettingsPath, LimitSettingsPath, overwrite: false);
|
||||||
|
}
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool engineeringRegisterPanelVisible;
|
private bool engineeringRegisterPanelVisible;
|
||||||
|
|
||||||
@@ -234,8 +303,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||||||
}
|
}
|
||||||
HemolysisTestParameters.PropertyChanged += OnHemolysisTestParametersPropertyChanged;
|
HemolysisTestParameters.PropertyChanged += OnHemolysisTestParametersPropertyChanged;
|
||||||
|
|
||||||
LoadManufacturerLimitSettings();
|
|
||||||
InitializeRs485FlowControl();
|
InitializeRs485FlowControl();
|
||||||
|
LoadManufacturerLimitSettings();
|
||||||
|
|
||||||
SelectedItem = InspectionItems.FirstOrDefault();
|
SelectedItem = InspectionItems.FirstOrDefault();
|
||||||
if (SelectedItem is not null)
|
if (SelectedItem is not null)
|
||||||
@@ -259,6 +328,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||||||
public ObservableCollection<DeviceChannel> Channels { get; }
|
public ObservableCollection<DeviceChannel> Channels { get; }
|
||||||
public ObservableCollection<PumpControlChannel> PumpControls { get; }
|
public ObservableCollection<PumpControlChannel> PumpControls { get; }
|
||||||
public ObservableCollection<ValveControlChannel> ValveControls { get; }
|
public ObservableCollection<ValveControlChannel> ValveControls { get; }
|
||||||
|
public IEnumerable<PumpControlChannel> NegativeAssistPumpControls => PumpControlsFor("NegativeAssistPump");
|
||||||
public IEnumerable<PumpControlChannel> PressureDropPumpControls => PumpControlsFor("NegativeAssistPump", "PressureDropPump");
|
public IEnumerable<PumpControlChannel> PressureDropPumpControls => PumpControlsFor("NegativeAssistPump", "PressureDropPump");
|
||||||
public IEnumerable<PumpControlChannel> RecirculationPumpControls => PumpControlsFor("RecirculationMainPump", "RecirculationReturnPump", "RecirculationDrainagePump");
|
public IEnumerable<PumpControlChannel> RecirculationPumpControls => PumpControlsFor("RecirculationMainPump", "RecirculationReturnPump", "RecirculationDrainagePump");
|
||||||
public IEnumerable<PumpControlChannel> KinkResistancePumpControls => PumpControlsFor("KinkResistancePump");
|
public IEnumerable<PumpControlChannel> KinkResistancePumpControls => PumpControlsFor("KinkResistancePump");
|
||||||
@@ -610,7 +680,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void TogglePumpControl(PumpControlChannel? pump)
|
private async Task TogglePumpControl(PumpControlChannel? pump)
|
||||||
{
|
{
|
||||||
if (!EnsureSessionEditable("泵控"))
|
if (!EnsureSessionEditable("泵控"))
|
||||||
{
|
{
|
||||||
@@ -623,7 +693,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var nextState = !pump.IsRunning;
|
var nextState = !pump.IsRunning;
|
||||||
if (TryTogglePumpControlViaRs485(pump, nextState))
|
if (await TryTogglePumpControlViaRs485(pump, nextState))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1722,14 +1792,6 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||||||
|
|
||||||
private ObservableCollection<EngineeringRegisterItem> BuildEngineeringRegisters() =>
|
private ObservableCollection<EngineeringRegisterItem> BuildEngineeringRegisters() =>
|
||||||
[
|
[
|
||||||
CreateEngineeringRegister("流量系数 1", 1006, true),
|
|
||||||
CreateEngineeringRegister("流量系数 2", 1016, true),
|
|
||||||
CreateEngineeringRegister("流量系数 3", 1026, true),
|
|
||||||
CreateEngineeringRegister("流量系数 4", 1036, true),
|
|
||||||
CreateEngineeringRegister("流量系数 5", 1046, true),
|
|
||||||
CreateEngineeringRegister("流量系数 6", 1056, true),
|
|
||||||
CreateEngineeringRegister("流量系数 7", 1066, true),
|
|
||||||
CreateEngineeringRegister("流量系数 8", 1076, true),
|
|
||||||
CreateEngineeringRegister("近端压力系数", 1328, true),
|
CreateEngineeringRegister("近端压力系数", 1328, true),
|
||||||
CreateEngineeringRegister("远端压力系数", 1378, true),
|
CreateEngineeringRegister("远端压力系数", 1378, true),
|
||||||
CreateEngineeringRegister("近端压力显示", 1330, false, usesFloatDisplay: true),
|
CreateEngineeringRegister("近端压力显示", 1330, false, usesFloatDisplay: true),
|
||||||
@@ -2335,6 +2397,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
MigrateLegacyLimitSettingsIfNeeded();
|
||||||
|
|
||||||
if (!File.Exists(LimitSettingsPath))
|
if (!File.Exists(LimitSettingsPath))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -2698,6 +2762,33 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||||||
|
|
||||||
if (IsRs485ManagedPump(pump))
|
if (IsRs485ManagedPump(pump))
|
||||||
{
|
{
|
||||||
|
if (pump.PendingRs485RunningState.HasValue)
|
||||||
|
{
|
||||||
|
if (pump.FlowAvailable)
|
||||||
|
{
|
||||||
|
var inferredRunning = pump.IsFlowEstablished;
|
||||||
|
if (inferredRunning == pump.PendingRs485RunningState.Value)
|
||||||
|
{
|
||||||
|
pump.IsRunning = inferredRunning;
|
||||||
|
pump.StateAvailable = true;
|
||||||
|
pump.PendingRs485RunningState = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pump.StateAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pump.StateAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!pump.Rs485RunStatusCode.HasValue && pump.FlowAvailable)
|
||||||
|
{
|
||||||
|
pump.IsRunning = pump.IsFlowEstablished;
|
||||||
|
pump.StateAvailable = true;
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
162
Cardiopulmonarybypasssystems/manufacturer-limits.json
Normal file
162
Cardiopulmonarybypasssystems/manufacturer-limits.json
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
{
|
||||||
|
"ProductModel": "24Fr/32Fr \u53CC\u8154",
|
||||||
|
"ApplicablePopulation": "\u6210\u4EBA",
|
||||||
|
"RatedMaxFlow": 6,
|
||||||
|
"KinkResistanceMinimumFlow": 1,
|
||||||
|
"KinkResistanceOuterDiameter": 8,
|
||||||
|
"PressureDropLimit50": 20,
|
||||||
|
"PressureDropLimit75": 22,
|
||||||
|
"PressureDropLimit100": 24,
|
||||||
|
"AntiCollapseAllowedIncreaseRate": 50,
|
||||||
|
"RecirculationAllowedLimit": 8,
|
||||||
|
"Rs485SerialSettings": {
|
||||||
|
"PortName": "COM3",
|
||||||
|
"BaudRate": 9600,
|
||||||
|
"Parity": "Even",
|
||||||
|
"DataBits": 8,
|
||||||
|
"StopBits": 1,
|
||||||
|
"ReadTimeoutMs": 500,
|
||||||
|
"WriteTimeoutMs": 500,
|
||||||
|
"AutoSwitchPresetMode": false,
|
||||||
|
"PersistPresetAfterWrite": false,
|
||||||
|
"AutoStartPumpAfterWrite": false
|
||||||
|
},
|
||||||
|
"Rs485PumpBindings": [
|
||||||
|
{
|
||||||
|
"PumpKey": "PressureDropPump",
|
||||||
|
"Enabled": true,
|
||||||
|
"CalibrationConfirmed": false,
|
||||||
|
"SlaveAddress": 1,
|
||||||
|
"ForwardSpeedRegister": 162,
|
||||||
|
"ReverseSpeedRegister": 163,
|
||||||
|
"RunStatusRegister": 243,
|
||||||
|
"DeviceAddressRegister": 250,
|
||||||
|
"PresetModeRegister": 251,
|
||||||
|
"SavePresetRegister": 416,
|
||||||
|
"MotorControlRegister": 64,
|
||||||
|
"RawPerLitrePerMinute": 1000,
|
||||||
|
"RawOffset": 0,
|
||||||
|
"MinFlowLpm": 0,
|
||||||
|
"MaxFlowLpm": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PumpKey": "RecirculationMainPump",
|
||||||
|
"Enabled": true,
|
||||||
|
"CalibrationConfirmed": false,
|
||||||
|
"SlaveAddress": 2,
|
||||||
|
"ForwardSpeedRegister": 162,
|
||||||
|
"ReverseSpeedRegister": 163,
|
||||||
|
"RunStatusRegister": 243,
|
||||||
|
"DeviceAddressRegister": 250,
|
||||||
|
"PresetModeRegister": 251,
|
||||||
|
"SavePresetRegister": 416,
|
||||||
|
"MotorControlRegister": 64,
|
||||||
|
"RawPerLitrePerMinute": 1000,
|
||||||
|
"RawOffset": 0,
|
||||||
|
"MinFlowLpm": 0,
|
||||||
|
"MaxFlowLpm": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PumpKey": "RecirculationReturnPump",
|
||||||
|
"Enabled": true,
|
||||||
|
"CalibrationConfirmed": false,
|
||||||
|
"SlaveAddress": 3,
|
||||||
|
"ForwardSpeedRegister": 162,
|
||||||
|
"ReverseSpeedRegister": 163,
|
||||||
|
"RunStatusRegister": 243,
|
||||||
|
"DeviceAddressRegister": 250,
|
||||||
|
"PresetModeRegister": 251,
|
||||||
|
"SavePresetRegister": 416,
|
||||||
|
"MotorControlRegister": 64,
|
||||||
|
"RawPerLitrePerMinute": 1000,
|
||||||
|
"RawOffset": 0,
|
||||||
|
"MinFlowLpm": 0,
|
||||||
|
"MaxFlowLpm": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PumpKey": "RecirculationDrainagePump",
|
||||||
|
"Enabled": true,
|
||||||
|
"CalibrationConfirmed": false,
|
||||||
|
"SlaveAddress": 4,
|
||||||
|
"ForwardSpeedRegister": 162,
|
||||||
|
"ReverseSpeedRegister": 163,
|
||||||
|
"RunStatusRegister": 243,
|
||||||
|
"DeviceAddressRegister": 250,
|
||||||
|
"PresetModeRegister": 251,
|
||||||
|
"SavePresetRegister": 416,
|
||||||
|
"MotorControlRegister": 64,
|
||||||
|
"RawPerLitrePerMinute": 1000,
|
||||||
|
"RawOffset": 0,
|
||||||
|
"MinFlowLpm": 0,
|
||||||
|
"MaxFlowLpm": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PumpKey": "KinkResistancePump",
|
||||||
|
"Enabled": true,
|
||||||
|
"CalibrationConfirmed": false,
|
||||||
|
"SlaveAddress": 5,
|
||||||
|
"ForwardSpeedRegister": 162,
|
||||||
|
"ReverseSpeedRegister": 163,
|
||||||
|
"RunStatusRegister": 243,
|
||||||
|
"DeviceAddressRegister": 250,
|
||||||
|
"PresetModeRegister": 251,
|
||||||
|
"SavePresetRegister": 416,
|
||||||
|
"MotorControlRegister": 64,
|
||||||
|
"RawPerLitrePerMinute": 1000,
|
||||||
|
"RawOffset": 0,
|
||||||
|
"MinFlowLpm": 0,
|
||||||
|
"MaxFlowLpm": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PumpKey": "HemolysisDrainageSinglePump",
|
||||||
|
"Enabled": true,
|
||||||
|
"CalibrationConfirmed": false,
|
||||||
|
"SlaveAddress": 6,
|
||||||
|
"ForwardSpeedRegister": 162,
|
||||||
|
"ReverseSpeedRegister": 163,
|
||||||
|
"RunStatusRegister": 243,
|
||||||
|
"DeviceAddressRegister": 250,
|
||||||
|
"PresetModeRegister": 251,
|
||||||
|
"SavePresetRegister": 416,
|
||||||
|
"MotorControlRegister": 64,
|
||||||
|
"RawPerLitrePerMinute": 1000,
|
||||||
|
"RawOffset": 0,
|
||||||
|
"MinFlowLpm": 0,
|
||||||
|
"MaxFlowLpm": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PumpKey": "HemolysisReturnSinglePump",
|
||||||
|
"Enabled": true,
|
||||||
|
"CalibrationConfirmed": false,
|
||||||
|
"SlaveAddress": 7,
|
||||||
|
"ForwardSpeedRegister": 162,
|
||||||
|
"ReverseSpeedRegister": 163,
|
||||||
|
"RunStatusRegister": 243,
|
||||||
|
"DeviceAddressRegister": 250,
|
||||||
|
"PresetModeRegister": 251,
|
||||||
|
"SavePresetRegister": 416,
|
||||||
|
"MotorControlRegister": 64,
|
||||||
|
"RawPerLitrePerMinute": 1000,
|
||||||
|
"RawOffset": 0,
|
||||||
|
"MinFlowLpm": 0,
|
||||||
|
"MaxFlowLpm": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PumpKey": "HemolysisDualLumenPump",
|
||||||
|
"Enabled": true,
|
||||||
|
"CalibrationConfirmed": false,
|
||||||
|
"SlaveAddress": 8,
|
||||||
|
"ForwardSpeedRegister": 162,
|
||||||
|
"ReverseSpeedRegister": 163,
|
||||||
|
"RunStatusRegister": 243,
|
||||||
|
"DeviceAddressRegister": 250,
|
||||||
|
"PresetModeRegister": 251,
|
||||||
|
"SavePresetRegister": 416,
|
||||||
|
"MotorControlRegister": 64,
|
||||||
|
"RawPerLitrePerMinute": 1000,
|
||||||
|
"RawOffset": 0,
|
||||||
|
"MinFlowLpm": 0,
|
||||||
|
"MaxFlowLpm": 7
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user