509 lines
21 KiB
C#
509 lines
21 KiB
C#
using Microsoft.Extensions.Hosting;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Options;
|
||
using RIZO.Model.PLC;
|
||
using RIZO.Service.PLC.IService;
|
||
using S7.Net;
|
||
using System;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Threading.Tasks;
|
||
|
||
namespace RIZO.Service.PLC
|
||
{
|
||
/// <summary>
|
||
/// BackgroundService是一个抽象基类,用于创建长时间运行的后台任务
|
||
/// </summary>
|
||
public class PlcDataService : BackgroundService, IPlcDataService
|
||
{
|
||
private readonly ILogger<PlcDataService> _logger;
|
||
private readonly List<PlcConfig> _plcConfigs;
|
||
//这行代码创建了一个线程安全的字典,专门用于存储和管理多个工站的数据。 初始化后值不能再改变(引用不能变,但对象内容可以变)
|
||
private readonly ConcurrentDictionary<string, StationData> _stationData = new();
|
||
//全局互斥锁 同一时间只能有一个线程/任务访问受保护的资源。
|
||
private readonly SemaphoreSlim _globalConnectionLock = new(1, 1);
|
||
private CancellationTokenSource _shutdownTokenSource = new();
|
||
|
||
public event Action<string, string, object>? OnDataUpdated;
|
||
public event Action<string, string, Exception>? OnError;
|
||
|
||
public PlcDataService(
|
||
ILogger<PlcDataService> logger,
|
||
IOptions<List<PlcConfig>> plcConfigs)
|
||
{
|
||
_logger = logger;
|
||
_plcConfigs = plcConfigs.Value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 后台任务初始化的时候会被调用
|
||
/// </summary>
|
||
/// <param name="stoppingToken"></param>
|
||
/// <returns></returns>
|
||
/// 当应用程序停止时(如 Ctrl+C、Docker 停止、系统关机),stoppingToken会被触发,让后台服务有机会清理资源并正常退出
|
||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||
{
|
||
_logger.LogInformation("PLC数据服务启动 - S7NetPlus v0.20.0");
|
||
_shutdownTokenSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||
var token = _shutdownTokenSource.Token;
|
||
|
||
// 初始化所有工站连接
|
||
await InitializeAllStationsAsync(token);
|
||
|
||
// 为每个工站创建独立的读取任务(相当于每个站一个线程)
|
||
Task[] readTasks = _plcConfigs.Select(config =>
|
||
ReadStationDataLoop(config, token)).ToArray();
|
||
|
||
_logger.LogInformation($"启动了 {readTasks.Length} 个工站读取任务");
|
||
|
||
// 等待所有任务完成或取消
|
||
await Task.WhenAny(Task.WhenAll(readTasks), Task.Delay(Timeout.Infinite, token));
|
||
}
|
||
#region 初始化代码块
|
||
|
||
/// <summary>
|
||
/// // 初始化所有工站连接
|
||
/// </summary>
|
||
/// <param name="cancellationToken"></param>
|
||
/// <returns></returns>
|
||
private async Task InitializeAllStationsAsync(CancellationToken cancellationToken)
|
||
{
|
||
// 并行初始化每个工站 Func<PlcConfig, Task>
|
||
var initTasks = _plcConfigs.Select(async config =>
|
||
{
|
||
try
|
||
{
|
||
await InitializeStationAsync(config, cancellationToken);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, $"初始化工位 {config.StationName} 时发生异常");
|
||
}
|
||
});
|
||
|
||
await Task.WhenAll(initTasks);
|
||
}
|
||
|
||
private async Task InitializeStationAsync(PlcConfig config, CancellationToken cancellationToken)
|
||
{
|
||
_logger.LogInformation($"正在初始化工位: {config.StationName} ({config.IpAddress})");
|
||
|
||
try
|
||
{
|
||
//异步锁
|
||
await _globalConnectionLock.WaitAsync(cancellationToken);
|
||
|
||
try
|
||
{
|
||
// 创建PLC连接实例
|
||
using var plc = new Plc(CpuType.S71200, config.IpAddress, config.Rack, config.Slot);
|
||
|
||
// 测试连接 (0.20.0 版本使用 OpenAsync/CloseAsync)
|
||
var isConnected = await TestConnectionWithRetryAsync(plc, config, cancellationToken);
|
||
|
||
if (!isConnected)
|
||
{
|
||
_logger.LogError($"无法连接到工位 {config.StationName} ({config.IpAddress})");
|
||
return;
|
||
}
|
||
|
||
// 工作站取到的数据
|
||
var stationData = new StationData
|
||
{
|
||
StationName = config.StationName,
|
||
PlcConnection = plc,
|
||
IsConnected = true,
|
||
LastReadTime = DateTime.Now,
|
||
ReadFailureCount = 0
|
||
};
|
||
|
||
// 初始化数据项
|
||
foreach (var itemConfig in config.DataItems)
|
||
{
|
||
stationData.DataItems[itemConfig.Name] = new PlcDataItem
|
||
{
|
||
Name = itemConfig.Name,
|
||
Value = null,
|
||
LastUpdateTime = DateTime.MinValue,
|
||
IsSuccess = false,
|
||
ErrorMessage = "尚未读取"
|
||
};
|
||
}
|
||
|
||
_stationData[config.StationName] = stationData;
|
||
_logger.LogInformation($"✅ 工位 {config.StationName} 初始化成功,配置了 {config.DataItems.Count} 个数据项");
|
||
}
|
||
finally
|
||
{
|
||
_globalConnectionLock.Release();
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, $"❌ 初始化工位 {config.StationName} 失败");
|
||
}
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// 测试 PLC 连接(带重试机制和超时控制)
|
||
/// 使用指数退避策略进行多次连接尝试,确保连接的可靠性
|
||
/// </summary>
|
||
/// <param name="plc">要测试的 PLC 实例</param>
|
||
/// <param name="config">PLC 配置信息(包含工位名称等信息)</param>
|
||
/// <param name="cancellationToken">取消令牌,用于支持外部取消操作</param>
|
||
/// <param name="maxRetries">最大重试次数,默认为 3 次</param>
|
||
/// <returns>
|
||
/// 连接测试成功返回 true,所有重试都失败返回 false
|
||
/// </returns>
|
||
private async Task<bool> TestConnectionWithRetryAsync(Plc plc, PlcConfig config, CancellationToken cancellationToken, int maxRetries = 3)
|
||
{
|
||
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||
{
|
||
try
|
||
{
|
||
_logger.LogDebug($"测试工位 {config.StationName} 连接 (尝试 {attempt}/{maxRetries})");
|
||
|
||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
|
||
|
||
// 0.20.0 版本的异步连接测试
|
||
await plc.OpenAsync().WaitAsync(timeoutCts.Token);
|
||
bool isConnected = plc.IsConnected;
|
||
// await plc.CloseAsync();
|
||
|
||
|
||
|
||
if (isConnected)
|
||
{
|
||
_logger.LogDebug($"✅ 工位 {config.StationName} 连接测试成功");
|
||
return true;
|
||
}
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, $"工位 {config.StationName} 连接测试失败 (尝试 {attempt}/{maxRetries})");
|
||
|
||
if (attempt < maxRetries)
|
||
{
|
||
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken); // 指数退避
|
||
}
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 持续读取指定工位的数据,具备连接监控、自动重连、错误恢复能力
|
||
/// 这是一个长期运行的后台任务,直到收到取消信号才停止
|
||
/// </summary>
|
||
/// <param name="config">某个工站plc配置</param>
|
||
/// <param name="cancellationToken"></param>
|
||
/// <returns></returns>
|
||
private async Task ReadStationDataLoop(PlcConfig config, CancellationToken cancellationToken)
|
||
{
|
||
_logger.LogInformation($"开始读取工位 {config.StationName} 数据 (间隔: {config.ReadIntervalMs}ms)");
|
||
|
||
// 获取之前工站数据存储对象
|
||
var stationData = _stationData[config.StationName];
|
||
// 连续失败计数器
|
||
var consecutiveFailures = 0;
|
||
// 最大容忍连续失败次数
|
||
const int maxConsecutiveFailures = 5;
|
||
|
||
while (!cancellationToken.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
if (!stationData.IsConnected || stationData.PlcConnection == null)
|
||
{
|
||
_logger.LogWarning($"工位 {config.StationName} 连接断开,尝试重连...");
|
||
await ReconnectStationInternalAsync(config, stationData, cancellationToken);
|
||
|
||
if (!stationData.IsConnected)
|
||
{
|
||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// 读取所有数据项
|
||
await ReadAllDataItemsAsync(config, stationData, cancellationToken);
|
||
|
||
stationData.LastReadTime = DateTime.Now;
|
||
// stationData.ReadFailureCount = 0;
|
||
consecutiveFailures = 0;
|
||
|
||
// 等待下次读取
|
||
await Task.Delay(config.ReadIntervalMs, cancellationToken);
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
_logger.LogInformation($"工位 {config.StationName} 读取循环被取消");
|
||
break;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
consecutiveFailures++;
|
||
// stationData.ReadFailureCount++;
|
||
stationData.IsConnected = false;
|
||
|
||
_logger.LogError(ex, $"读取工位 {config.StationName} 数据时发生错误 (连续失败: {consecutiveFailures})");
|
||
OnError?.Invoke(config.StationName, "ReadLoop", ex);
|
||
|
||
if (consecutiveFailures >= maxConsecutiveFailures)
|
||
{
|
||
_logger.LogError($"工位 {config.StationName} 连续失败 {maxConsecutiveFailures} 次,暂停读取");
|
||
await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken);
|
||
consecutiveFailures = 0;
|
||
}
|
||
else
|
||
{
|
||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 清理资源
|
||
await CleanupStationAsync(config.StationName);
|
||
_logger.LogInformation($" 工位 {config.StationName} 读取循环结束");
|
||
}
|
||
private async Task CleanupStationAsync(string stationName)
|
||
{
|
||
try
|
||
{
|
||
if (_stationData.TryRemove(stationName, out var stationData))
|
||
{
|
||
if (stationData.PlcConnection != null)
|
||
{
|
||
try { stationData.PlcConnection.Close(); } catch { }
|
||
//try { stationData.PlcConnection.Dispose(); } catch { }
|
||
}
|
||
_logger.LogInformation($"工位 {stationName} 资源清理完成");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, $"清理工位 {stationName} 资源时发生错误");
|
||
}
|
||
}
|
||
|
||
private async Task ReadAllDataItemsAsync(PlcConfig config, StationData stationData, CancellationToken cancellationToken)
|
||
{
|
||
var readTasks = stationData.DataItems.Values.Select(dataItem =>
|
||
ReadDataItemAsync(config, stationData, dataItem, cancellationToken)).ToArray();
|
||
|
||
|
||
await Task.WhenAll(readTasks);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读取单个数据项的值,更新状态并触发事件
|
||
/// </summary>
|
||
/// <param name="config"></param>
|
||
/// <param name="stationData"></param>
|
||
/// <param name="dataItem"></param>
|
||
/// <param name="cancellationToken"></param>
|
||
/// <returns></returns>
|
||
private async Task ReadDataItemAsync(PlcConfig config, StationData stationData, PlcDataItem dataItem, CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
if (stationData.PlcConnection == null || !stationData.PlcConnection.IsConnected)
|
||
throw new InvalidOperationException("PLC连接未就绪");
|
||
|
||
var dataItemConfig = config.DataItems.FirstOrDefault(x => x.Name == dataItem.Name);
|
||
if (dataItemConfig == null)
|
||
return;
|
||
|
||
// 确保连接打开 (0.20.0 异步操作)
|
||
if (!stationData.PlcConnection.IsConnected)
|
||
{
|
||
await stationData.PlcConnection.OpenAsync().WaitAsync(cancellationToken);
|
||
}
|
||
|
||
// 0.20.0 版本的异步读取
|
||
//DataType用于指定要读取 PLC 中哪个内存区域的数据,而 VarType用于表示 PLC 中变量的具体数据类型
|
||
var result = await stationData.PlcConnection.ReadAsync(
|
||
dataItemConfig.DataType,
|
||
dataItemConfig.DB,
|
||
dataItemConfig.StartByteAdr,
|
||
dataItemConfig.VarType, // 这是第四个参数
|
||
dataItemConfig.VarType == VarType.String ? 255 : 1, // 这是第五个参数 - varCount
|
||
cancellationToken: cancellationToken // 可选:如果你想在这里传递取消令牌
|
||
).WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
|
||
|
||
if (result != null)
|
||
{
|
||
var oldValue = dataItem.Value;
|
||
dataItem.Value = result;
|
||
dataItem.LastUpdateTime = DateTime.Now;
|
||
dataItem.IsSuccess = true;
|
||
dataItem.ErrorMessage = string.Empty;
|
||
|
||
// 触发数据更新事件(只有值变化时才触发)
|
||
if (!Equals(oldValue, result))
|
||
{
|
||
OnDataUpdated?.Invoke(config.StationName, dataItem.Name, result);
|
||
_logger.LogTrace($"[{config.StationName}] {dataItem.Name} = {result}");
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
dataItem.IsSuccess = false;
|
||
dataItem.ErrorMessage = ex.Message;
|
||
OnError?.Invoke(config.StationName, dataItem.Name, ex);
|
||
|
||
_logger.LogWarning(ex, $"读取工位 {config.StationName} 数据项 {dataItem.Name} 失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 内部重连方法:清理旧连接、创建新连接、验证连接可用性
|
||
/// 是连接故障恢复机制的核心实现,确保工位的持续可用性
|
||
/// </summary>
|
||
private async Task ReconnectStationInternalAsync(PlcConfig config, StationData stationData, CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
_logger.LogInformation($"尝试重新连接工位 {config.StationName}");
|
||
|
||
// 清理现有连接
|
||
if (stationData.PlcConnection != null)
|
||
{
|
||
try { stationData.PlcConnection.Close(); } catch { }
|
||
// try { stationData.PlcConnection.Dispose(); } catch { }
|
||
}
|
||
|
||
// 创建新连接
|
||
var newPlc = new Plc(CpuType.S71200, config.IpAddress, config.Rack, config.Slot);
|
||
|
||
var isConnected = await TestConnectionWithRetryAsync(newPlc, config, cancellationToken);
|
||
|
||
if (isConnected)
|
||
{
|
||
stationData.PlcConnection = newPlc;
|
||
stationData.IsConnected = true;
|
||
stationData.LastConnectTime = DateTime.Now;
|
||
stationData.ReadFailureCount = 0;
|
||
|
||
_logger.LogInformation($" 工位 {config.StationName} 重新连接成功");
|
||
}
|
||
else
|
||
{
|
||
newPlc.Close();
|
||
stationData.IsConnected = false;
|
||
_logger.LogWarning($"❌ 工位 {config.StationName} 重新连接失败");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
stationData.IsConnected = false;
|
||
_logger.LogError(ex, $"工位 {config.StationName} 重连过程中出错");
|
||
}
|
||
}
|
||
#endregion
|
||
|
||
#region 外部调用接口代码块
|
||
public async Task<bool> ReconnectStationAsync(string stationName)
|
||
{
|
||
if (!_stationData.ContainsKey(stationName))
|
||
return false;
|
||
|
||
var config = _plcConfigs.FirstOrDefault(c => c.StationName == stationName);
|
||
var stationData = _stationData[stationName];
|
||
|
||
await ReconnectStationInternalAsync(config!, stationData, CancellationToken.None);
|
||
return stationData.IsConnected;
|
||
}
|
||
|
||
|
||
|
||
public StationData? GetStationData(string stationName)
|
||
{
|
||
_stationData.TryGetValue(stationName, out var data);
|
||
return data;
|
||
}
|
||
|
||
// 获取所有工位的数据快照
|
||
public Dictionary<string, StationData> GetAllStationData()
|
||
{
|
||
return _stationData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||
}
|
||
|
||
public async Task<bool> WriteDataAsync(string stationName, string dataItemName, object value)
|
||
{
|
||
try
|
||
{
|
||
var stationData = GetStationData(stationName);
|
||
if (stationData == null || !stationData.IsConnected || stationData.PlcConnection == null)
|
||
return false;
|
||
|
||
var config = _plcConfigs.FirstOrDefault(c => c.StationName == stationName);
|
||
var dataItemConfig = config?.DataItems.FirstOrDefault(d => d.Name == dataItemName);
|
||
|
||
if (config == null || dataItemConfig == null)
|
||
return false;
|
||
|
||
// 确保连接打开
|
||
if (!stationData.PlcConnection.IsConnected)
|
||
{
|
||
await stationData.PlcConnection.OpenAsync();
|
||
}
|
||
|
||
// 0.20.0 版本的异步写入
|
||
await stationData.PlcConnection.WriteAsync(
|
||
dataItemConfig.DataType,
|
||
dataItemConfig.DB,
|
||
dataItemConfig.StartByteAdr,
|
||
value
|
||
);
|
||
|
||
_logger.LogInformation($" 向工位 {stationName} 写入数据 {dataItemName} = {value}");
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, $"写入工位 {stationName} 数据项 {dataItemName} 失败");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||
{
|
||
_logger.LogInformation(" PLC数据服务停止中...");
|
||
|
||
// 取消所有操作
|
||
await _shutdownTokenSource.CancelAsync();
|
||
|
||
// 清理所有连接
|
||
var cleanupTasks = _stationData.Keys.Select(CleanupStationAsync);
|
||
await Task.WhenAll(cleanupTasks);
|
||
|
||
_stationData.Clear();
|
||
_shutdownTokenSource.Dispose();
|
||
|
||
await base.StopAsync(cancellationToken);
|
||
_logger.LogInformation(" PLC数据服务已停止");
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
StopAsync(CancellationToken.None).Wait();
|
||
_shutdownTokenSource?.Dispose();
|
||
_globalConnectionLock?.Dispose();
|
||
GC.SuppressFinalize(this);
|
||
}
|
||
#endregion
|
||
}
|
||
|
||
}
|