diff --git a/RIZO.Admin.WebApi/Controllers/Plc/PlcDataController.cs b/RIZO.Admin.WebApi/Controllers/Plc/PlcDataController.cs new file mode 100644 index 0000000..a9a2b2f --- /dev/null +++ b/RIZO.Admin.WebApi/Controllers/Plc/PlcDataController.cs @@ -0,0 +1,181 @@ +using Microsoft.AspNetCore.Mvc; +using RIZO.Model.PLC; +using RIZO.Service.PLC.IService; + +namespace RIZO.Admin.WebApi.Controllers.Plc +{ + [ApiController] + [Route("api/[controller]")] + public class PlcDataController : ControllerBase + { + private readonly IPlcDataService _plcDataService; + private readonly ILogger _logger; + + public PlcDataController( + IPlcDataService plcDataService, + ILogger logger) + { + _plcDataService = plcDataService; + _logger = logger; + } + + /// + /// 获取所有工站数据概览 + /// + [HttpGet] + public ActionResult GetAllStationsData() + { + try + { + var allData = _plcDataService.GetAllStationData(); + var connectedCount = allData.Count(s => s.Value.IsConnected); + + var result = new AllStationsDataDto + { + Stations = allData.ToDictionary( + kvp => kvp.Key, + kvp => new StationDataDto + { + StationName = kvp.Value.StationName, + IsConnected = kvp.Value.IsConnected, + LastUpdateTime = kvp.Value.LastReadTime, + LastConnectTime = kvp.Value.LastConnectTime, + ReadFailureCount = kvp.Value.ReadFailureCount, + CurrentValues = kvp.Value.DataItems.ToDictionary( + di => di.Key, + di => di.Value.Value + ) + } + ), + ServerTime = DateTime.Now, + TotalStations = allData.Count, + ConnectedStations = connectedCount + }; + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取所有工站数据失败"); + return StatusCode(500, new { error = "获取数据失败" }); + } + } + + /// + /// 获取指定工站详细数据 + /// + [HttpGet("{stationName}")] + public ActionResult GetStationData(string stationName) + { + try + { + var stationData = _plcDataService.GetStationData(stationName); + if (stationData == null) + return NotFound(new { error = $"未找到工位: {stationName}" }); + + var result = new StationDataDto + { + StationName = stationData.StationName, + IsConnected = stationData.IsConnected, + LastUpdateTime = stationData.LastReadTime, + LastConnectTime = stationData.LastConnectTime, + ReadFailureCount = stationData.ReadFailureCount, + CurrentValues = stationData.DataItems.ToDictionary( + di => di.Key, + di => di.Value.Value + ) + }; + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取工位 {stationName} 数据失败"); + return StatusCode(500, new { error = "获取数据失败" }); + } + } + + /// + /// 获取指定数据项的详细信息 + /// + [HttpGet("{stationName}/data/{dataItemName}")] + public ActionResult GetDataItemDetail(string stationName, string dataItemName) + { + try + { + var stationData = _plcDataService.GetStationData(stationName); + if (stationData == null) + return NotFound(new { error = $"未找到工位: {stationName}" }); + + if (!stationData.DataItems.ContainsKey(dataItemName)) + return NotFound(new { error = $"未找到数据项: {dataItemName}" }); + + var dataItem = stationData.DataItems[dataItemName]; + return Ok(new + { + Name = dataItem.Name, + Value = dataItem.Value, + LastUpdateTime = dataItem.LastUpdateTime, + IsSuccess = dataItem.IsSuccess, + ErrorMessage = dataItem.ErrorMessage + }); + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取工位 {stationName} 数据项 {dataItemName} 失败"); + return StatusCode(500, new { error = "获取数据失败" }); + } + } + + /// + /// 手动重连指定工站 + /// + [HttpPost("{stationName}/reconnect")] + public async Task ReconnectStation(string stationName) + { + try + { + var success = await _plcDataService.ReconnectStationAsync(stationName); + if (success) + return Ok(new { message = $"工位 {stationName} 重连成功" }); + else + return BadRequest(new { error = $"工位 {stationName} 重连失败" }); + } + catch (Exception ex) + { + _logger.LogError(ex, $"重连工位 {stationName} 失败"); + return StatusCode(500, new { error = "重连失败" }); + } + } + + /// + /// 写入数据到PLC + /// + [HttpPost("{stationName}/write")] + public async Task WriteData(string stationName, [FromBody] WriteDataRequest request) + { + try + { + if (string.IsNullOrEmpty(request.DataItemName)) + return BadRequest(new { error = "数据项名称不能为空" }); + + var success = await _plcDataService.WriteDataAsync(stationName, request.DataItemName, request.Value); + if (success) + return Ok(new { message = "写入成功" }); + else + return BadRequest(new { error = "写入失败" }); + } + catch (Exception ex) + { + _logger.LogError(ex, $"写入工位 {stationName} 数据失败"); + return StatusCode(500, new { error = "写入失败" }); + } + } + } + + public class WriteDataRequest + { + public string DataItemName { get; set; } = string.Empty; + public object Value { get; set; } = new(); + } +} diff --git a/RIZO.Admin.WebApi/Program.cs b/RIZO.Admin.WebApi/Program.cs index f8f3d61..a7fd655 100644 --- a/RIZO.Admin.WebApi/Program.cs +++ b/RIZO.Admin.WebApi/Program.cs @@ -3,6 +3,7 @@ using Infrastructure.Converter; using MDM; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Localization; +using Microsoft.Extensions.Options; using NLog.Web; using RIZO.Admin.WebApi.Extensions; using RIZO.Admin.WebApi.PLC; @@ -13,8 +14,13 @@ using RIZO.Common.Cache; using RIZO.Common.DynamicApiSimple.Extens; using RIZO.Infrastructure.WebExtensions; using RIZO.Mall; +using RIZO.Model.PLC; +using RIZO.Service.PLC; +using RIZO.Service.PLC.IService; +using RIZO.Service.PLCBackground; using RIZO.ServiceCore.Signalr; using RIZO.ServiceCore.SqlSugar; +using S7.Net; using SqlSugar; using System.Globalization; using System.Text.Json; @@ -30,10 +36,10 @@ builder.Services.AddDynamicApi(); builder.Services.AddControllers(); // ===== 新增PLC服务注册 ===== -builder.Services.Configure>(builder.Configuration.GetSection("PlcConfigs")); -builder.Services.Configure(builder.Configuration.GetSection("GlobalPlcConfig")); -builder.Services.AddSingleton(); -builder.Services.AddScoped(); +//builder.Services.Configure>(builder.Configuration.GetSection("PlcConfigs")); +//builder.Services.Configure(builder.Configuration.GetSection("GlobalPlcConfig")); +//builder.Services.AddSingleton(); +//builder.Services.AddScoped(); // 新增:注册PLC后台监听服务(项目启动自动执行) builder.Services.AddHostedService(); // ========================== @@ -103,6 +109,48 @@ builder.Services.AddSwaggerConfig(); builder.Services.AddLogo(); // 添加本地化服务 builder.Services.AddLocalization(options => options.ResourcesPath = ""); +// 配置PLC设置 +//builder.Services.Configure>(builder.Configuration.GetSection("PlcSettings:Stations")); + +//// 注册PLC数据服务 +//builder.Services.AddSingleton(); +//builder.Services.AddHostedService(provider => provider.GetService() as BackgroundService); + +//builder.Services.Configure>(builder.Configuration.GetSection("PlcSettingss:Connections")); +//builder.Services.AddSingleton(sp => +//{ +// var configs = sp.GetRequiredService>>().Value; +// var logger = sp.GetRequiredService>(); +// var service = new RIZO.Service.PLC.PlcService2(logger); + +// foreach (var config in configs) +// { +// service.AddPlc( +// config.PlcId, +// ParseCpuType(config.CpuType), +// config.Ip, +// config.Rack, +// config.Slot +// ); +// } + +// // 后台启动连接 +// _ = service.ConnectAllAsync(); +// return service; +//}); + +//// CPU类型转换辅助方法 +//static CpuType ParseCpuType(string type) +//{ +// return Enum.Parse(type.Replace("-", "")); // "S71200" -> "S71200" +//} +// 配置服务 + // 注册PLC服务(单例模式) + + +// 注册后台任务服务 +builder.Services.AddHostedService(); + // 在应用程序启动的最开始处调用 var app = builder.Build(); InternalApp.ServiceProvider = app.Services; diff --git a/RIZO.Admin.WebApi/RIZO.Admin.WebApi.csproj b/RIZO.Admin.WebApi/RIZO.Admin.WebApi.csproj index 442c097..3066812 100644 --- a/RIZO.Admin.WebApi/RIZO.Admin.WebApi.csproj +++ b/RIZO.Admin.WebApi/RIZO.Admin.WebApi.csproj @@ -27,7 +27,6 @@ - diff --git a/RIZO.Admin.WebApi/appsettings.json b/RIZO.Admin.WebApi/appsettings.json index d75ff1c..741b8df 100644 --- a/RIZO.Admin.WebApi/appsettings.json +++ b/RIZO.Admin.WebApi/appsettings.json @@ -99,6 +99,53 @@ "GlobalConfig": { "ConnectTimeout": 5000, // 连接超时(毫秒) "ReadWriteTimeout": 5000 // 读写超时(毫秒) + }, + + "PlcSettings": { + "Stations": [ + { + "StationName": "装配站1", + "IpAddress": "192.168.0.10", + "Rack": 0, + "Slot": 1, + "ReadIntervalMs": 500, + "DataItems": [ + { + "Name": "产品计数", + "Address": "DB1.DBD0", + "VarType": "Int", + "DB": 1, + "StartByteAdr": 0 + }, + { + "Name": "运行状态", + "Address": "DB1.DBX2.0", + "VarType": "Bit", + "DB": 1, + "StartByteAdr": 2 + } + ] + } + ] + + }, + "PlcSettingss": { + "Connections": [ + { + "PlcId": "PLC1", + "CpuType": "S71200", + "Ip": "192.168.1.10", + "Rack": 0, + "Slot": 1 + }, + { + "PlcId": "PLC2", + "CpuType": "S71500", + "Ip": "192.168.1.20", + "Rack": 0, + "Slot": 1 + } + ] } } diff --git a/RIZO.Model/PLC/PlcConfig.cs b/RIZO.Model/PLC/PlcConfig.cs new file mode 100644 index 0000000..280750e --- /dev/null +++ b/RIZO.Model/PLC/PlcConfig.cs @@ -0,0 +1,76 @@ +using S7.Net; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RIZO.Model.PLC +{ + public class PlcConfig + { + /// + /// 工站名称/工位标识符 + /// + public string StationName { get; set; } + + /// + /// PLC设备的IP地址 + /// + public string IpAddress { get; set; } + + /// + /// S7协议中标识CPU所在的物理机架 + /// + public short Rack { get; set; } = 0; + + /// + /// S7协议中标识CPU在机架中的具体插槽 + /// + public short Slot { get; set; } = 1; + /// + /// 数据读取间隔时间(毫秒) + /// + public int ReadIntervalMs { get; set; } = 1000; + /// + /// 定义要从PLC读取的具体数据点和属性 + /// + public List DataItems { get; set; } = new(); + } + + /// + /// 定义要从PLC读取的具体数据点和属性 + /// + public class DataItemConfig + { + /// + /// 数据项的逻辑名称/别名 温度 + /// + public string Name { get; set; } + + /// + /// 地址 "DB1.DBD0"- DB块1,双字(Double Word),起始字节0 + /// + public string Address { get; set; } + + + + public S7.Net.DataType DataType { get; set; } + + /// + /// 含义: S7.Net库中定义的变量数据类型 + /// 作用: 告诉库如何解析从PLC读取的原始字节数据 + /// + public VarType VarType { get; set; } + + /// + /// 指定数据存储在哪个DB块中 + /// + public int DB { get; set; } + + /// + /// 数据在DB块中的起始字节地址 + /// + public int StartByteAdr { get; set; } + } +} diff --git a/RIZO.Model/PLC/PlcConnectionConfig.cs b/RIZO.Model/PLC/PlcConnectionConfig.cs new file mode 100644 index 0000000..00833b5 --- /dev/null +++ b/RIZO.Model/PLC/PlcConnectionConfig.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RIZO.Model.PLC +{ + public class PlcConnectionConfig + { + public string PlcId { get; set; } + public string CpuType { get; set; } // S71200/S71500 + public string Ip { get; set; } + public int Rack { get; set; } + public int Slot { get; set; } + } +} diff --git a/RIZO.Model/PLC/PlcDataItem.cs b/RIZO.Model/PLC/PlcDataItem.cs new file mode 100644 index 0000000..7ae3874 --- /dev/null +++ b/RIZO.Model/PLC/PlcDataItem.cs @@ -0,0 +1,116 @@ +using S7.Net; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RIZO.Model.PLC +{ + /// + /// 单个数据点模型 + /// + public class PlcDataItem + { + /// + /// 数据项的唯一标识符 "产品计数", "温度值", "电机状态" + /// + public string Name { get; set; } + + + + /// + /// 存储PLC返回的原始数据,类型根据实际VarType动态变化 + /// + public object Value { get; set; } + + + + /// + /// 最后一次成功读取数据的时间戳 + /// + public DateTime LastUpdateTime { get; set; } + + /// + /// 最近一次读取操作是否成功 + /// + public bool IsSuccess { get; set; } + + + + + /// + /// 读取失败时保存的错误信息 + /// + public string ErrorMessage { get; set; } + } + /// + /// 工站完整数据模型 + /// + public class StationData + { + /// + /// 工站的唯一标识符 + /// + public string StationName { get; set; } + /// + /// 该工站所有数据项的集合 + /// + public Dictionary DataItems { get; set; } = new(); + + /// + /// S7.Net库的PLC连接实例 + /// + public Plc PlcConnection { get; set; } + /// + /// 当前PLC连接状态 + /// + public bool IsConnected { get; set; } + /// + /// 最后一次成功读取所有数据项的时间 + /// + public DateTime LastReadTime { get; set; } + + public DateTime LastConnectTime { get; set; } + public int ReadFailureCount { get; set; } + } + + // API响应DTO + public class StationDataDto + { + /// + /// 工站名称(直接传递) + /// + public string StationName { get; set; } + + /// + /// 仅包含数据值的简化字典 + ///{ + ///"产品计数": { Value: 100, IsSuccess: true, LastUpdateTime: ..., ErrorMessage: "" }, + ///"温度值": { Value: 25.6f, IsSuccess: true, LastUpdateTime: ..., ErrorMessage: "" } + ///} + /// + public Dictionary CurrentValues { get; set; } = new(); + /// + /// 工站连接状态 + /// + public bool IsConnected { get; set; } + public DateTime LastUpdateTime { get; set; } + + public DateTime LastReadTime { get; set; } + public DateTime LastConnectTime { get; set; } + public int ReadFailureCount { get; set; } + } + + /// + /// 所有工站数据的集合 + /// + public class AllStationsDataDto + { + public Dictionary Stations { get; set; } = new(); + public DateTime ServerTime { get; set; } = DateTime.Now; + public int TotalStations { get; set; } + public int ConnectedStations { get; set; } + } + +} diff --git a/RIZO.Model/RIZO.Model.csproj b/RIZO.Model/RIZO.Model.csproj index a87c5fb..eb8e6ef 100644 --- a/RIZO.Model/RIZO.Model.csproj +++ b/RIZO.Model/RIZO.Model.csproj @@ -6,6 +6,10 @@ 1701;1702;1591;1570 + + + + diff --git a/RIZO.Service/PLC/IService/IPlcDataService.cs b/RIZO.Service/PLC/IService/IPlcDataService.cs new file mode 100644 index 0000000..7576c3c --- /dev/null +++ b/RIZO.Service/PLC/IService/IPlcDataService.cs @@ -0,0 +1,22 @@ +using RIZO.Model.PLC; +using S7.Net; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +namespace RIZO.Service.PLC.IService +{ + public interface IPlcDataService : IDisposable + { + StationData? GetStationData(string stationName); + Dictionary GetAllStationData(); + Task WriteDataAsync(string stationName, string dataItemName, object value); + Task ReconnectStationAsync(string stationName); + + + event Action? OnDataUpdated; + event Action? OnError; + } + +} + + diff --git a/RIZO.Service/PLC/PlcDataService.cs b/RIZO.Service/PLC/PlcDataService.cs new file mode 100644 index 0000000..84f88cf --- /dev/null +++ b/RIZO.Service/PLC/PlcDataService.cs @@ -0,0 +1,508 @@ +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 +{ + /// + /// BackgroundService是一个抽象基类,用于创建长时间运行的后台任务 + /// + public class PlcDataService : BackgroundService, IPlcDataService + { + private readonly ILogger _logger; + private readonly List _plcConfigs; + //这行代码创建了一个线程安全的字典,专门用于存储和管理多个工站的数据。 初始化后值不能再改变(引用不能变,但对象内容可以变) + private readonly ConcurrentDictionary _stationData = new(); + //全局互斥锁 同一时间只能有一个线程/任务访问受保护的资源。 + private readonly SemaphoreSlim _globalConnectionLock = new(1, 1); + private CancellationTokenSource _shutdownTokenSource = new(); + + public event Action? OnDataUpdated; + public event Action? OnError; + + public PlcDataService( + ILogger logger, + IOptions> plcConfigs) + { + _logger = logger; + _plcConfigs = plcConfigs.Value; + } + + /// + /// 后台任务初始化的时候会被调用 + /// + /// + /// + /// 当应用程序停止时(如 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 初始化代码块 + + /// + /// // 初始化所有工站连接 + /// + /// + /// + private async Task InitializeAllStationsAsync(CancellationToken cancellationToken) + { + // 并行初始化每个工站 Func + 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} 失败"); + } + } + + + /// + /// 测试 PLC 连接(带重试机制和超时控制) + /// 使用指数退避策略进行多次连接尝试,确保连接的可靠性 + /// + /// 要测试的 PLC 实例 + /// PLC 配置信息(包含工位名称等信息) + /// 取消令牌,用于支持外部取消操作 + /// 最大重试次数,默认为 3 次 + /// + /// 连接测试成功返回 true,所有重试都失败返回 false + /// + private async Task 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; + } + + /// + /// 持续读取指定工位的数据,具备连接监控、自动重连、错误恢复能力 + /// 这是一个长期运行的后台任务,直到收到取消信号才停止 + /// + /// 某个工站plc配置 + /// + /// + 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); + } + + /// + /// 读取单个数据项的值,更新状态并触发事件 + /// + /// + /// + /// + /// + /// + 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} 失败"); + } + } + + /// + /// 内部重连方法:清理旧连接、创建新连接、验证连接可用性 + /// 是连接故障恢复机制的核心实现,确保工位的持续可用性 + /// + 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 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 GetAllStationData() + { + return _stationData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + public async Task 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 + } + +} diff --git a/RIZO.Service/PLC/PlcService.cs b/RIZO.Service/PLC/PlcService.cs new file mode 100644 index 0000000..4aad988 --- /dev/null +++ b/RIZO.Service/PLC/PlcService.cs @@ -0,0 +1,129 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using S7.Net; // 确保引用 S7.Net 库 + +namespace RIZO.Service.PLC +{ + public class PlcService : IDisposable + { + private Plc _plc; + private readonly string _ipAddress; + private readonly CpuType _cpuType; + private readonly short _rack; + private readonly short _slot; + private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + private bool _disposed; + + public PlcService(string ipAddress, CpuType cpuType = CpuType.S71200, short rack = 0, short slot = 1) + { + _ipAddress = ipAddress; + _cpuType = cpuType; + _rack = rack; + _slot = slot; + } + + private async Task ConnectAsync(CancellationToken cancellationToken = default) + { + int maxRetries = 3; + int retryDelayMs = 2000; + int attempt = 0; + + while (attempt < maxRetries) + { + attempt++; + try + { + _plc = new Plc(_cpuType, _ipAddress, _rack, _slot); + await Task.Run(() => _plc.Open(), cancellationToken); // 在线程池中执行同步Open + Console.WriteLine($"成功连接到PLC: {_ipAddress} (尝试{attempt}/{maxRetries})"); + return; + } + catch (Exception ex) + { + Console.WriteLine($"连接尝试 {attempt}/{maxRetries} 失败: {ex.Message}"); + + if (attempt < maxRetries) + { + Console.WriteLine($"{retryDelayMs / 1000}秒后重试..."); + await Task.Delay(retryDelayMs, cancellationToken); + } + else + { + Console.WriteLine("无法建立PLC连接,请检查网络和设备状态"); + _plc?.Close(); + _plc = null; + } + } + } + } + + public async Task ReadAsync(string address, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_plc == null || !_plc.IsConnected) + { + await ConnectAsync(cancellationToken); + if (_plc == null || !_plc.IsConnected) + throw new InvalidOperationException("PLC未连接"); + } + + return await Task.Run(() => _plc.Read(address), cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine($"Read error: {ex.Message}"); + return null; + } + finally + { + _semaphore.Release(); + } + } + + public async Task WriteAsync(string address, object value, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_plc == null || !_plc.IsConnected) + { + await ConnectAsync(cancellationToken); + if (_plc == null || !_plc.IsConnected) + throw new InvalidOperationException("PLC未连接"); + } + + await Task.Run(() => _plc.Write(address, value), cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine($"Write error: {ex.Message}"); + } + finally + { + _semaphore.Release(); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; + + if (disposing) + { + _semaphore.Dispose(); + _plc?.Close(); + (_plc as IDisposable)?.Dispose(); + } + _disposed = true; + } + } +} \ No newline at end of file diff --git a/RIZO.Service/PLCBackground/PlcPollingServiceOP72.cs b/RIZO.Service/PLCBackground/PlcPollingServiceOP72.cs new file mode 100644 index 0000000..66dfbf9 --- /dev/null +++ b/RIZO.Service/PLCBackground/PlcPollingServiceOP72.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RIZO.Service.PLC; +using S7.Net; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RIZO.Service.PLCBackground +{ + + + public class PlcPollingServiceOP72 : BackgroundService + { + private readonly ILogger _logger; + private readonly PlcService _plcService; + private readonly TimeSpan _pollingInterval = TimeSpan.FromSeconds(5); + + public PlcPollingServiceOP72(ILogger logger) + { + _logger = logger; + // 配置PLC连接参数(根据实际设备修改) + _plcService = new PlcService("192.168.0.1", CpuType.S71500); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("PLC Polling Service started"); + + // 不断轮询PLC数据 + while (!stoppingToken.IsCancellationRequested) + { + try + { + // 示例:读取DB块数据 + var data1 = _plcService.Read("DB1.DBW0"); // 读取字 + var data2 = _plcService.Read("DB1.DBD4"); // 读取双字 + var flag = _plcService.Read("M10.0"); // 读取标志位 + + _logger.LogInformation($"DB1.DBW0: {data1}, DB1.DBD4: {data2}, M10.0: {flag}"); + + // 示例:写入数据到PLC + _plcService.Write("DB1.DBW10", (short)123); // 写入字 + _plcService.Write("DB1.DBD20", 45.67d); // 写入浮点数 + + await Task.Delay(_pollingInterval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "PLC polling error"); + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); // 错误后延迟重试 + } + } + + _plcService.Dispose(); + _logger.LogInformation("PLC Polling Service stopped"); + } + } +}