diff --git a/UIShare/GlobalVariable/DeviceHealthMonitor.cs b/UIShare/GlobalVariable/DeviceHealthMonitor.cs new file mode 100644 index 0000000..b95d497 --- /dev/null +++ b/UIShare/GlobalVariable/DeviceHealthMonitor.cs @@ -0,0 +1,255 @@ +using DeviceCommand.Base; +using Logger; +using Model.Models; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; + +namespace UIShare.GlobalVariable +{ + /// + /// 设备健康监控器:独立于监控采样的心跳重连机制。 + /// + /// 设计原则——与检测值零冲突: + /// + /// 健康检查平时只读 (纯本地属性,零网络开销,不碰通信锁) + /// 仅在 IsConnected==false 时才获取 _commLock 执行重连,与监控采样天然串行化 + /// ModbusTcp 的 ConnectAsync 是幂等的(已连接时直接返回),不会中断正在进行的监控 + /// TCP 的 ConnectAsync 会重置连接,但监控的 catch 容忍单次失败,下次 tick 自动恢复 + /// + /// + /// + public class DeviceHealthMonitor : IDisposable + { + private readonly IDictionary _deviceMap; + private readonly SystemConfig _systemConfig; + private readonly string _scopeName; + + /// 健康检查定时器 + private Timer? _healthCheckTimer; + + /// 每个设备的连续失败计数 + private readonly ConcurrentDictionary _failureCounts = new(); + + /// 每个设备的重连尝试次数(用于计算退避阈值) + private readonly ConcurrentDictionary _reconnectAttempts = new(); + + /// 基础失败阈值(首次重连触发值) + private const int BaseFailureThreshold = 3; + + /// 退避上限(最大阈值) + private const int MaxBackoffThreshold = 15; + + /// 健康检查间隔(毫秒) + private readonly int _checkIntervalMs; + + private bool _disposed; + private readonly object _startStopLock = new(); + + /// + /// 创建健康监控器实例。 + /// + /// 当前作用域的设备字典 + /// 当前作用域的系统配置(用于更新 DeviceInfoVM.IsConnected) + /// 作用域名称(日志标识) + /// 健康检查间隔,默认 5000ms + public DeviceHealthMonitor( + IDictionary deviceMap, + SystemConfig systemConfig, + string scopeName, + int checkIntervalMs = 5000) + { + _deviceMap = deviceMap; + _systemConfig = systemConfig; + _scopeName = scopeName; + _checkIntervalMs = checkIntervalMs; + } + + /// 启动健康监控(幂等:多次调用只启动一次) + public void Start() + { + lock (_startStopLock) + { + if (_disposed || _healthCheckTimer != null) return; + _healthCheckTimer = new Timer( + OnHealthCheckTick, + null, + TimeSpan.FromSeconds(10), // 首次检查延迟 10 秒,避免启动时设备尚未连接完成 + TimeSpan.FromMilliseconds(_checkIntervalMs)); + LoggerHelper.Info($"[{_scopeName}] 设备健康监控已启动,检查间隔={_checkIntervalMs}ms,基础重连阈值={BaseFailureThreshold}次(指数退避上限={MaxBackoffThreshold})"); + } + } + + /// 停止健康监控 + public void Stop() + { + lock (_startStopLock) + { + _healthCheckTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _healthCheckTimer?.Dispose(); + _healthCheckTimer = null; + _failureCounts.Clear(); + _reconnectAttempts.Clear(); + } + } + + /// + /// 健康检查核心逻辑:遍历设备,检查连接状态,失败计数超阈值则重连。 + /// TCP 设备额外进行主动探活(*IDN?),以检测死连接(对端崩溃但 TCP 未收到 FIN)。 + /// + private async void OnHealthCheckTick(object? state) + { + if (_disposed || _deviceMap.Count == 0) return; + + // 快照避免枚举期间字典被修改 + var snapshot = _deviceMap.ToArray(); + + foreach (var kvp in snapshot) + { + if (_disposed) return; + + string deviceName = kvp.Key; + var device = kvp.Value; + + try + { + bool alive = device.IsConnected; + + // TCP 设备主动探活:IsConnected 只反映上次操作状态,无法检测死连接 + if (alive && device is Tcp tcpDevice) + { + alive = await ProbeTcpDeviceAsync(tcpDevice); + } + + if (alive) + { + // 连接正常:清零失败计数与退避 + _failureCounts.TryRemove(deviceName, out _); + _reconnectAttempts.TryRemove(deviceName, out _); + continue; + } + + // 连接断开:累加失败计数 + int failures = _failureCounts.AddOrUpdate(deviceName, 1, (_, count) => count + 1); + + // 计算当前退避阈值:基础值 + 重连尝试次数 × 2,上限为 MaxBackoffThreshold + int attempts = _reconnectAttempts.GetOrAdd(deviceName, 0); + int currentThreshold = Math.Min(BaseFailureThreshold + attempts * 2, MaxBackoffThreshold); + + if (failures < currentThreshold) + { + LoggerHelper.Warn( + $"[{_scopeName}] 设备 [{deviceName}] 连接断开,等待重连中 ({failures}/{currentThreshold})"); + continue; + } + + // 达到阈值:执行重连 + await ReconnectDeviceAsync(deviceName, device); + } + catch (Exception ex) + { + LoggerHelper.Error($"[{_scopeName}] 设备 [{deviceName}] 健康检查异常:{ex.Message}"); + } + } + } + + /// + /// 对 TCP 设备执行轻量级主动探活(SCPI *IDN?),2 秒超时。 + /// 通过 WriteReadAsync 内部获取 _commLock,与监控采样天然串行化。 + /// + /// true: 探活成功(连接确实存活); false: 探活失败(死连接) + private async Task ProbeTcpDeviceAsync(Tcp tcpDevice) + { + try + { + using var probeCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + string resp = await tcpDevice.WriteReadAsync("*IDN?\n", "\n", probeCts.Token); + return !string.IsNullOrWhiteSpace(resp); + } + catch + { + return false; + } + } + + /// + /// 重连单个设备。 + /// + /// 冲突避免机制:ConnectAsync 内部获取设备的 _commLock, + /// 如果此时监控采样正在通信,重连会等待锁释放后再执行, + /// 保证同一时刻只有一个操作在使用通信链路。 + /// + /// + private async Task ReconnectDeviceAsync(string deviceName, IBaseInterface device) + { + try + { + int attempts = _reconnectAttempts.AddOrUpdate(deviceName, 1, (_, c) => c + 1); + int nextThreshold = Math.Min(BaseFailureThreshold + attempts * 2, MaxBackoffThreshold); + LoggerHelper.Info($"[{_scopeName}] 设备 [{deviceName}] 连续失败触发重连(第 {attempts} 次重连,下次阈值={nextThreshold})..."); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + bool ok = await device.ConnectAsync(cts.Token); + + // 同步更新 DeviceInfoVM 的 UI 状态 + UpdateDeviceInfoState(deviceName, ok); + + if (ok) + { + _failureCounts.TryRemove(deviceName, out _); + _reconnectAttempts.TryRemove(deviceName, out _); + LoggerHelper.Info($"[{_scopeName}] 设备 [{deviceName}] 重连成功"); + } + else + { + LoggerHelper.Warn($"[{_scopeName}] 设备 [{deviceName}] 重连失败,将在下次检查时重试"); + } + } + catch (OperationCanceledException) + { + UpdateDeviceInfoState(deviceName, false); + LoggerHelper.Warn($"[{_scopeName}] 设备 [{deviceName}] 重连超时(10s),将在下次检查时重试"); + } + catch (Exception ex) + { + UpdateDeviceInfoState(deviceName, false); + LoggerHelper.Error($"[{_scopeName}] 设备 [{deviceName}] 重连异常:{ex.Message}"); + } + } + + /// + /// 通过 UI 线程更新 SystemConfig.DeviceList 中对应设备的 IsConnected 状态(驱动 UI 刷新)。 + /// Timer 回调运行在线程池线程上,必须切回 UI 线程才能触发 PropertyChanged。 + /// + private void UpdateDeviceInfoState(string deviceName, bool isConnected) + { + var info = _systemConfig?.DeviceList? + .FirstOrDefault(d => d != null && + string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase)); + if (info == null) return; + + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher == null || dispatcher.CheckAccess()) + { + // 已在 UI 线程(或无 Dispatcher),直接赋值 + info.IsConnected = isConnected; + } + else + { + // 切回 UI 线程赋值,避免跨线程 PropertyChanged 异常 + dispatcher.BeginInvoke(() => info.IsConnected = isConnected); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop(); + } + } +} diff --git a/UIShare/GlobalVariable/DeviceManager.cs b/UIShare/GlobalVariable/DeviceManager.cs index ee4eb56..59fcb21 100644 --- a/UIShare/GlobalVariable/DeviceManager.cs +++ b/UIShare/GlobalVariable/DeviceManager.cs @@ -28,6 +28,9 @@ namespace UIShare.GlobalVariable private readonly string _scopeName; private readonly IEventAggregator _eventAggregator; + /// 设备健康监控器:独立心跳检测 + 自动重连,与监控采样互不冲突 + private DeviceHealthMonitor? _healthMonitor; + /// 按 DeviceName 索引的设备字典,便于业务层按名取实例。 public IDictionary DeviceMap { get; private set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -252,6 +255,9 @@ namespace UIShare.GlobalVariable } await Task.WhenAll(tasks); + + // 所有设备连接完成后,启动健康监控(心跳重连) + StartHealthMonitor(); } @@ -320,6 +326,7 @@ namespace UIShare.GlobalVariable /// public async Task CloseAllDevicesAsync() { + StopHealthMonitor(); List tasks = new List(); lock (_lockObj) @@ -617,8 +624,25 @@ namespace UIShare.GlobalVariable return Activator.CreateInstance(type, cfg) as IBaseInterface; } + /// 启动设备健康监控(心跳检测 + 自动重连) + private void StartHealthMonitor() + { + StopHealthMonitor(); + _healthMonitor = new DeviceHealthMonitor(DeviceMap, _systemConfig, _scopeName); + _healthMonitor.Start(); + } + + /// 停止设备健康监控 + private void StopHealthMonitor() + { + _healthMonitor?.Stop(); + _healthMonitor?.Dispose(); + _healthMonitor = null; + } + public void Dispose() { + StopHealthMonitor(); if (string.IsNullOrEmpty(_scopeName)) return; // 遍历当前作用域用到的所有指纹,逐一移除本作用域的引用