2026-01-25 09:45:38 +08:00

509 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}