786 lines
28 KiB
C#
786 lines
28 KiB
C#
using System.Globalization;
|
||
using System.Net.Sockets;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Cardiopulmonarybypasssystems.Models;
|
||
using NModbus;
|
||
|
||
namespace Cardiopulmonarybypasssystems.Services;
|
||
|
||
public sealed class ModbusTelemetryService : IModbusTelemetryService, IDisposable
|
||
{
|
||
private const string DefaultIpAddress = "192.168.1.10";
|
||
private const int DefaultPort = 502;
|
||
private const byte DefaultSlaveId = 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 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 ModbusFactory _factory = new();
|
||
private readonly string _ipAddress;
|
||
private readonly int _port;
|
||
private readonly byte _slaveId;
|
||
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 = 0, Min = 40, Max = 180 },
|
||
new() { Name = "近端压力", Unit = "mmHg", Value = 0, Min = 60, Max = 220 },
|
||
new() { Name = "负压辅助引流", Unit = "kPa", Value = 0, Min = -20, Max = 0 },
|
||
new() { Name = "模拟血液温度", Unit = "°C", Value = 0, Min = 34, Max = 40 },
|
||
new() { Name = "再循环率", Unit = "%", Value = 0, Min = 0, Max = 20 },
|
||
new() { Name = "游离血红蛋白", Unit = "g/L", Value = 0, Min = 0, Max = 0.08 },
|
||
new() { Name = "白细胞减少率", Unit = "%", Value = 0, Min = 0, Max = 20 }
|
||
];
|
||
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 },
|
||
new() { Key = "CirculatingWaterTemperature", Name = "循环水温", StartAddress = 12 }
|
||
];
|
||
|
||
private TcpClient? _tcpClient;
|
||
private IModbusMaster? _master;
|
||
private Task? _connectionTask;
|
||
private DateTime _nextConnectionAttemptUtc = DateTime.MinValue;
|
||
private DateTime? _lastSuccessfulReadAt;
|
||
private double? _proximalPressureRawKpa;
|
||
private double? _distalPressureRawKpa;
|
||
private double? _proximalPressureDecodedKpa;
|
||
private double? _distalPressureDecodedKpa;
|
||
private string _lastErrorMessage = "等待首次连接";
|
||
|
||
public ModbusTelemetryService()
|
||
{
|
||
_ipAddress = Environment.GetEnvironmentVariable("CPB_MODBUS_IP") ?? DefaultIpAddress;
|
||
_port = ParseIntSetting("CPB_MODBUS_PORT", DefaultPort);
|
||
_slaveId = ParseByteSetting("CPB_MODBUS_SLAVE_ID", DefaultSlaveId);
|
||
ApplyUnavailableDeviceState();
|
||
}
|
||
|
||
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
|
||
{
|
||
get
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
return _lastSuccessfulReadAt;
|
||
}
|
||
}
|
||
}
|
||
|
||
public string LastErrorMessage
|
||
{
|
||
get
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
return _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(out var failedFlowRegisterCount);
|
||
var pressureReadSucceeded = TryReadPressureChannels(liveReadSucceeded);
|
||
SyncDerivedChannels();
|
||
if (liveReadSucceeded && pressureReadSucceeded && _master is not null)
|
||
{
|
||
_lastSuccessfulReadAt = DateTime.Now;
|
||
_lastErrorMessage = failedFlowRegisterCount > 0
|
||
? $"实时数据已更新,但有 {failedFlowRegisterCount} 路流量寄存器无数据"
|
||
: "实时数据正常";
|
||
}
|
||
|
||
return BuildSnapshot(BuildAlarms());
|
||
}
|
||
}
|
||
|
||
public ushort? ReadHoldingRegister(ushort address)
|
||
{
|
||
EnsureConnectionReadyForDirectAccess();
|
||
|
||
lock (_syncRoot)
|
||
{
|
||
|
||
if (_master is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
try
|
||
{
|
||
return _master.ReadHoldingRegisters(_slaveId, address, 1)[0];
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
HandleConnectionFailure($"读取寄存器 D{address} 失败:{ex.Message}");
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
public float? ReadHoldingFloatRegister(ushort address)
|
||
{
|
||
EnsureConnectionReadyForDirectAccess();
|
||
|
||
lock (_syncRoot)
|
||
{
|
||
if (_master is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
try
|
||
{
|
||
var registers = _master.ReadHoldingRegisters(_slaveId, address, 2);
|
||
return address is ProximalPressureRegister or DistalPressureRegister
|
||
? (float)ConvertRegistersToPressureKpa(registers[0], registers[1])
|
||
: DecodeFloat(registers[0], registers[1], lowWordFirst: true);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
HandleConnectionFailure($"读取浮点寄存器 D{address} 失败:{ex.Message}");
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
public bool WriteHoldingRegister(ushort address, ushort value)
|
||
{
|
||
EnsureConnectionReadyForDirectAccess();
|
||
|
||
lock (_syncRoot)
|
||
{
|
||
if (_master is null)
|
||
{
|
||
_lastErrorMessage = $"PLC 离线,未执行寄存器写入:D{address}";
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
_master.WriteSingleRegister(_slaveId, address, value);
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
HandleConnectionFailure($"写入寄存器 D{address} 失败:{ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
public bool WriteHoldingFloatRegister(ushort address, float value)
|
||
{
|
||
EnsureConnectionReadyForDirectAccess();
|
||
|
||
lock (_syncRoot)
|
||
{
|
||
if (_master is null)
|
||
{
|
||
_lastErrorMessage = $"PLC 离线,未执行浮点寄存器写入:D{address}";
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
var registers = EncodeFloat(value, lowWordFirst: true);
|
||
_master.WriteMultipleRegisters(_slaveId, address, registers);
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
HandleConnectionFailure($"写入浮点寄存器 D{address} 失败:{ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
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))
|
||
{
|
||
_lastErrorMessage = $"{pump.Name} 已切换为伺服器 RS485 主控,不再执行 PLC 泵控写入";
|
||
return;
|
||
}
|
||
|
||
if (_master is null)
|
||
{
|
||
_lastErrorMessage = $"PLC 离线,未执行泵控写入:{pump.Name}";
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
_master.WriteSingleCoil(_slaveId, (ushort)pump.StartAddress, isRunning);
|
||
pump.IsRunning = isRunning;
|
||
pump.StateAvailable = true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
HandleConnectionFailure($"泵控写入失败:{pump.Name} / {ex.Message}");
|
||
}
|
||
}
|
||
}
|
||
|
||
public void SetValveOpen(string valveKey, bool isOpen)
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
var valve = _valveControls.FirstOrDefault(item => item.Key == valveKey);
|
||
if (valve is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (_master is null)
|
||
{
|
||
_lastErrorMessage = $"PLC 离线,未执行阀控写入:{valve.Name}";
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
_master.WriteSingleCoil(_slaveId, (ushort)valve.StartAddress, isOpen);
|
||
valve.IsOpen = isOpen;
|
||
valve.StateAvailable = true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
HandleConnectionFailure($"阀控写入失败:{valve.Name} / {ex.Message}");
|
||
}
|
||
}
|
||
}
|
||
|
||
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 EnsureConnectionReadyForDirectAccess()
|
||
{
|
||
EnsureConnectionScheduled();
|
||
|
||
Task? connectionTask;
|
||
lock (_syncRoot)
|
||
{
|
||
if (_master is not null && _tcpClient?.Connected == true)
|
||
{
|
||
return;
|
||
}
|
||
|
||
connectionTask = _connectionTask;
|
||
}
|
||
|
||
if (connectionTask is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
connectionTask.Wait(ConnectionAttemptTimeout + TimeSpan.FromMilliseconds(300));
|
||
}
|
||
catch
|
||
{
|
||
// Read/write callers handle the offline state after the wait.
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
lock (_syncRoot)
|
||
{
|
||
ReleaseConnection();
|
||
_tcpClient = tcpClient;
|
||
_master = master;
|
||
_lastErrorMessage = "PLC 已连接,等待首帧数据";
|
||
tcpClient = null;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
tcpClient?.Dispose();
|
||
|
||
lock (_syncRoot)
|
||
{
|
||
HandleConnectionFailure($"连接失败:{ex.Message}");
|
||
}
|
||
}
|
||
}
|
||
|
||
private bool TryReadPumpStatesAndFlows(out int failedFlowRegisterCount)
|
||
{
|
||
failedFlowRegisterCount = 0;
|
||
if (_master is null)
|
||
{
|
||
ApplyUnavailableDeviceState();
|
||
_proximalPressureRawKpa = null;
|
||
_distalPressureRawKpa = null;
|
||
_proximalPressureDecodedKpa = null;
|
||
_distalPressureDecodedKpa = null;
|
||
_proximalPressureDecodedKpa = null;
|
||
_distalPressureDecodedKpa = null;
|
||
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];
|
||
pump.StateAvailable = true;
|
||
}
|
||
if (!pump.FlowAddress.HasValue)
|
||
{
|
||
pump.FlowAvailable = false;
|
||
}
|
||
}
|
||
|
||
foreach (var valve in _valveControls)
|
||
{
|
||
valve.IsOpen = coilStates[valve.StartAddress];
|
||
valve.StateAvailable = true;
|
||
}
|
||
|
||
foreach (var pump in _pumpControls.Where(item => item.FlowAddress.HasValue))
|
||
{
|
||
try
|
||
{
|
||
var registerValue = _master.ReadHoldingRegisters(_slaveId, (ushort)pump.FlowAddress!.Value, 1)[0];
|
||
var flowValue = ConvertRegisterToFlow(registerValue);
|
||
pump.FlowValue = flowValue;
|
||
pump.FlowAvailable = true;
|
||
SetChannelValue(FlowChannelNames[pump.Key], flowValue, true);
|
||
}
|
||
catch
|
||
{
|
||
failedFlowRegisterCount++;
|
||
pump.FlowAvailable = false;
|
||
SetChannelAvailability(FlowChannelNames[pump.Key], false);
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
HandleConnectionFailure($"读取泵控/流量失败:{ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private bool TryReadPressureChannels(bool liveReadSucceeded)
|
||
{
|
||
if (_master is null || !liveReadSucceeded)
|
||
{
|
||
_proximalPressureRawKpa = null;
|
||
_distalPressureRawKpa = null;
|
||
SetChannelAvailability("近端压力", false);
|
||
SetChannelAvailability("远端压力", false);
|
||
return false;
|
||
}
|
||
|
||
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;
|
||
SetChannelValue("近端压力", ConvertPressureKpaToMmHg(proximalRawKpa), true);
|
||
SetChannelValue("远端压力", ConvertPressureKpaToMmHg(distalRawKpa), true);
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
HandleConnectionFailure($"读取压力失败:{ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private void SyncDerivedChannels()
|
||
{
|
||
var returnFlow = Channel("动脉回输流量");
|
||
var drainageFlow = Channel("静脉引流流量");
|
||
if (!returnFlow.IsAvailable || !drainageFlow.IsAvailable || returnFlow.Value <= 0.01)
|
||
{
|
||
SetChannelAvailability("再循环率", false);
|
||
return;
|
||
}
|
||
|
||
var recirculationRate = Math.Clamp((returnFlow.Value - drainageFlow.Value) / returnFlow.Value * 100d, 0d, 100d);
|
||
SetChannelValue("再循环率", recirculationRate, true);
|
||
}
|
||
|
||
private List<AlarmMessage> BuildAlarms()
|
||
{
|
||
var alarms = new List<AlarmMessage>();
|
||
var proximal = Channel("近端压力");
|
||
var distal = Channel("远端压力");
|
||
var recirculation = Channel("再循环率");
|
||
|
||
if (proximal.IsAvailable && distal.IsAvailable)
|
||
{
|
||
var deltaPressure = proximal.Value - distal.Value;
|
||
if (deltaPressure > 24)
|
||
{
|
||
alarms.Add(new AlarmMessage
|
||
{
|
||
Timestamp = DateTime.Now,
|
||
Level = "高",
|
||
Message = $"压力降 ΔP {deltaPressure:F1} mmHg 偏高,请复核 近端压力/远端压力。"
|
||
});
|
||
}
|
||
}
|
||
|
||
if (_master is null)
|
||
{
|
||
alarms.Add(new AlarmMessage
|
||
{
|
||
Timestamp = DateTime.Now,
|
||
Level = "中",
|
||
Message = $"ModbusTcp 未连接,请检查 PLC 通讯。目标 {_ipAddress}:{_port}。"
|
||
});
|
||
}
|
||
|
||
if (recirculation.IsAvailable && recirculation.Value > 8)
|
||
{
|
||
alarms.Add(new AlarmMessage
|
||
{
|
||
Timestamp = DateTime.Now,
|
||
Level = "中",
|
||
Message = $"再循环率 {recirculation.Value:F1}% 偏高,建议复核回路与泵状态。"
|
||
});
|
||
}
|
||
|
||
foreach (var pump in _pumpControls.Where(item => item.StateAvailable && item.IsRunning && item.FlowAddress.HasValue && item.FlowAvailable && item.FlowValue < 0.15d))
|
||
{
|
||
alarms.Add(new AlarmMessage
|
||
{
|
||
Timestamp = DateTime.Now,
|
||
Level = "中",
|
||
Message = $"{pump.Name} 已启动但流量未建立,请检查回路、泵头和传感器。"
|
||
});
|
||
}
|
||
|
||
foreach (var pump in _pumpControls.Where(item => item.StateAvailable && item.FlowAddress.HasValue && !item.FlowAvailable))
|
||
{
|
||
alarms.Add(new AlarmMessage
|
||
{
|
||
Timestamp = DateTime.Now,
|
||
Level = "中",
|
||
Message = $"{pump.Name} 流量寄存器 D{pump.FlowAddress!.Value} 无数据,请核对 PLC 点表和寄存器映射。"
|
||
});
|
||
}
|
||
|
||
return alarms;
|
||
}
|
||
|
||
private TelemetryUpdateSnapshot BuildSnapshot(IReadOnlyList<AlarmMessage> alarms) => new()
|
||
{
|
||
Channels = _channels.Select(channel => new TelemetryChannelSnapshot
|
||
{
|
||
Name = channel.Name,
|
||
Value = channel.Value,
|
||
IsAvailable = channel.IsAvailable
|
||
}).ToList(),
|
||
PumpControls = _pumpControls.Select(pump => new PumpControlSnapshot
|
||
{
|
||
Key = pump.Key,
|
||
IsRunning = pump.IsRunning,
|
||
FlowValue = pump.FlowValue,
|
||
StateAvailable = pump.StateAvailable,
|
||
FlowAvailable = pump.FlowAvailable
|
||
}).ToList(),
|
||
ValveControls = _valveControls.Select(valve => new ValveControlSnapshot
|
||
{
|
||
Key = valve.Key,
|
||
IsOpen = valve.IsOpen,
|
||
StateAvailable = valve.StateAvailable
|
||
}).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 = _lastSuccessfulReadAt,
|
||
LastErrorMessage = _lastErrorMessage
|
||
};
|
||
|
||
private void ApplyUnavailableDeviceState()
|
||
{
|
||
foreach (var pump in _pumpControls)
|
||
{
|
||
pump.StateAvailable = false;
|
||
pump.FlowAvailable = false;
|
||
}
|
||
|
||
foreach (var valve in _valveControls)
|
||
{
|
||
valve.StateAvailable = false;
|
||
}
|
||
|
||
foreach (var channelName in FlowChannelNames.Values)
|
||
{
|
||
SetChannelAvailability(channelName, false);
|
||
}
|
||
|
||
SetChannelAvailability("近端压力", false);
|
||
SetChannelAvailability("远端压力", false);
|
||
SetChannelAvailability("负压辅助引流", false);
|
||
SetChannelAvailability("模拟血液温度", false);
|
||
SetChannelAvailability("再循环率", false);
|
||
SetChannelAvailability("游离血红蛋白", false);
|
||
SetChannelAvailability("白细胞减少率", false);
|
||
}
|
||
|
||
private void HandleConnectionFailure(string errorMessage)
|
||
{
|
||
ReleaseConnection();
|
||
_nextConnectionAttemptUtc = DateTime.MinValue;
|
||
_proximalPressureRawKpa = null;
|
||
_distalPressureRawKpa = null;
|
||
_proximalPressureDecodedKpa = null;
|
||
_distalPressureDecodedKpa = null;
|
||
_lastErrorMessage = errorMessage;
|
||
ApplyUnavailableDeviceState();
|
||
}
|
||
|
||
private void SetChannelValue(string channelName, double nextValue, bool isAvailable)
|
||
{
|
||
var channel = Channel(channelName);
|
||
channel.Value = ShouldClampChannelValue(channelName)
|
||
? Math.Clamp(nextValue, channel.Min, channel.Max)
|
||
: nextValue;
|
||
channel.IsAvailable = isAvailable;
|
||
}
|
||
|
||
private void SetChannelAvailability(string channelName, bool isAvailable)
|
||
{
|
||
Channel(channelName).IsAvailable = isAvailable;
|
||
}
|
||
|
||
private void ReleaseConnection()
|
||
{
|
||
_master?.Dispose();
|
||
_tcpClient?.Dispose();
|
||
_master = null;
|
||
_tcpClient = null;
|
||
}
|
||
|
||
private static int ParseIntSetting(string key, int fallback)
|
||
{
|
||
var rawValue = Environment.GetEnvironmentVariable(key);
|
||
return int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||
? parsed
|
||
: fallback;
|
||
}
|
||
|
||
private static byte ParseByteSetting(string key, byte fallback)
|
||
{
|
||
var rawValue = Environment.GetEnvironmentVariable(key);
|
||
return byte.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed is >= 1 and <= 247
|
||
? parsed
|
||
: fallback;
|
||
}
|
||
|
||
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 ushort[] EncodeFloat(float value, bool lowWordFirst)
|
||
{
|
||
var bits = unchecked((uint)BitConverter.SingleToInt32Bits(value));
|
||
var lowWord = (ushort)(bits & 0xFFFF);
|
||
var highWord = (ushort)((bits >> 16) & 0xFFFF);
|
||
return lowWordFirst
|
||
? [lowWord, highWord]
|
||
: [highWord, lowWord];
|
||
}
|
||
|
||
private static bool IsPlausiblePressureKpa(float value) =>
|
||
!float.IsNaN(value) && !float.IsInfinity(value) && value is > -1000f and < 1000f;
|
||
|
||
private static bool ShouldClampChannelValue(string channelName) =>
|
||
channelName is not "近端压力" and not "远端压力";
|
||
|
||
private DeviceChannel Channel(string name) => _channels.First(channel => channel.Name == name);
|
||
}
|