From 6db3bb39244bb950de551cce8e70b3468d1ddaca Mon Sep 17 00:00:00 2001 From: quowingwang Date: Sat, 17 Jan 2026 10:42:02 +0800 Subject: [PATCH] =?UTF-8?q?PLC=E9=80=9A=E8=AE=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Material/MaterialBomController.cs | 2 +- .../Plant/PlantWorkstationController.cs | 3 +- MDM/MDM.csproj | 1 + MDM/Models/Plant/Dto/PlantWorkstationDto.cs | 4 +- MDM/Models/Plant/PlantWorkstation.cs | 4 +- ...ocessOperationWorkstationMappingService.cs | 2 +- RIZO.Admin.WebApi/PLC/PlcConfig.cs | 34 ++ RIZO.Admin.WebApi/PLC/PlcController.cs | 174 ++++++++ RIZO.Admin.WebApi/PLC/PlcHostedService.cs | 236 +++++++++++ RIZO.Admin.WebApi/PLC/PlcService.cs | 377 ++++++++++++++++++ RIZO.Admin.WebApi/Program.cs | 9 + RIZO.Admin.WebApi/RIZO.Admin.WebApi.csproj | 1 + .../appsettings.Development.json | 3 +- RIZO.Admin.WebApi/appsettings.json | 18 +- RIZO.Model/RIZO.Model.csproj | 1 + RIZO.Service/RIZO.Service.csproj | 1 + 16 files changed, 861 insertions(+), 9 deletions(-) create mode 100644 RIZO.Admin.WebApi/PLC/PlcConfig.cs create mode 100644 RIZO.Admin.WebApi/PLC/PlcController.cs create mode 100644 RIZO.Admin.WebApi/PLC/PlcHostedService.cs create mode 100644 RIZO.Admin.WebApi/PLC/PlcService.cs diff --git a/MDM/Controllers/Material/MaterialBomController.cs b/MDM/Controllers/Material/MaterialBomController.cs index 19d4d0b..a4c251f 100644 --- a/MDM/Controllers/Material/MaterialBomController.cs +++ b/MDM/Controllers/Material/MaterialBomController.cs @@ -25,7 +25,7 @@ namespace MDM.Controllers.Material /// /// BOM清单 /// - [Verify] + [AllowAnonymous] [Route("MasterDataManagement/Material/MaterialBom")] public class MaterialBomController : BaseController { diff --git a/MDM/Controllers/Plant/PlantWorkstationController.cs b/MDM/Controllers/Plant/PlantWorkstationController.cs index c1d55e1..0518650 100644 --- a/MDM/Controllers/Plant/PlantWorkstationController.cs +++ b/MDM/Controllers/Plant/PlantWorkstationController.cs @@ -12,13 +12,14 @@ using MDM.Model.Plant.Dto; using MDM.Services.IPlantService; using MDM.Services.Plant; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; //创建时间:2025-11-15 namespace MDM.Controllers.Plant { /// /// 工站/资源组 /// - [Verify] + [AllowAnonymous] [Route("MasterDataManagement/Plant/PlantWorkstation")] public class PlantWorkstationController : BaseController { diff --git a/MDM/MDM.csproj b/MDM/MDM.csproj index 4aa2f65..784add9 100644 --- a/MDM/MDM.csproj +++ b/MDM/MDM.csproj @@ -30,6 +30,7 @@ + diff --git a/MDM/Models/Plant/Dto/PlantWorkstationDto.cs b/MDM/Models/Plant/Dto/PlantWorkstationDto.cs index d4d4452..f9fd9a1 100644 --- a/MDM/Models/Plant/Dto/PlantWorkstationDto.cs +++ b/MDM/Models/Plant/Dto/PlantWorkstationDto.cs @@ -13,7 +13,7 @@ namespace MDM.Model.Plant.Dto public string? FkProductlinebody { get; set; } - public string? PlcAddress { get; set; } + public string? PlcIP { get; set; } public string? WorkstationCode { get; set; } public string? WorkstationName { get; set; } @@ -42,7 +42,7 @@ namespace MDM.Model.Plant.Dto public string? WorkstationName { get; set; } - public string? PlcAddress { get; set; } + public string? PlcIP { get; set; } public string? WorkstaionType { get; set; } diff --git a/MDM/Models/Plant/PlantWorkstation.cs b/MDM/Models/Plant/PlantWorkstation.cs index 30d492e..173b0a6 100644 --- a/MDM/Models/Plant/PlantWorkstation.cs +++ b/MDM/Models/Plant/PlantWorkstation.cs @@ -55,8 +55,8 @@ namespace MDM.Model.Plant /// ///PLC地址 /// - [SugarColumn(ColumnName = "plc_address")] - public string PlcAddress { get; set; } + [SugarColumn(ColumnName = "plc_ip")] + public string PlcIP { get; set; } diff --git a/MDM/Services/Process/ProcessOperationWorkstationMappingService.cs b/MDM/Services/Process/ProcessOperationWorkstationMappingService.cs index 150c6ee..e340bf2 100644 --- a/MDM/Services/Process/ProcessOperationWorkstationMappingService.cs +++ b/MDM/Services/Process/ProcessOperationWorkstationMappingService.cs @@ -71,7 +71,7 @@ namespace MDM.Services.Process //绑定的工位PLC地址 - PlcAddress = SqlFunc.Subqueryable().Where(s => s.FkProductlinebody == it.FkProductlinebodyCode && s.WorkstationCode == it.FkWorkstationCode).Select(s=>s.PlcAddress), + PlcAddress = SqlFunc.Subqueryable().Where(s => s.FkProductlinebody == it.FkProductlinebodyCode && s.WorkstationCode == it.FkWorkstationCode).Select(s=>s.PlcIP), //绑定的流程信息 OperationFlows = SqlFunc.Subqueryable().Where(s => s.FkRoutingCode == it.FkRoutingCode && s.FkOperationCode == it.FkOperationCode).ToList(), diff --git a/RIZO.Admin.WebApi/PLC/PlcConfig.cs b/RIZO.Admin.WebApi/PLC/PlcConfig.cs new file mode 100644 index 0000000..b6c6e75 --- /dev/null +++ b/RIZO.Admin.WebApi/PLC/PlcConfig.cs @@ -0,0 +1,34 @@ +namespace RIZO.Admin.WebApi.PLC +{ + public class PlcConfig + { + public string PlcName { get; set; } + public string Ip { get; set; } + public short Rack { get; set; } = 0; + public short Slot { get; set; } = 1; + public string TestReadAddress { get; set; } + public string TestWriteAddress { get; set; } + public string TestWriteValue { get; set; } + } + + public class GlobalPlcConfig + { + public int ConnectTimeout { get; set; } = 5000; + public int ReadWriteTimeout { get; set; } = 5000; + } + + // PLC测试结果实体 + public class PlcTestResult + { + public string PlcName { get; set; } + public string Ip { get; set; } + public bool ConnectSuccess { get; set; } + public string ConnectMessage { get; set; } + public bool ReadSuccess { get; set; } + public string ReadValue { get; set; } + public string ReadMessage { get; set; } + public bool WriteSuccess { get; set; } + public string WriteMessage { get; set; } + } + +} diff --git a/RIZO.Admin.WebApi/PLC/PlcController.cs b/RIZO.Admin.WebApi/PLC/PlcController.cs new file mode 100644 index 0000000..5ab993a --- /dev/null +++ b/RIZO.Admin.WebApi/PLC/PlcController.cs @@ -0,0 +1,174 @@ +using RIZO.Admin.WebApi.Filters; +using RIZO.Common; +using RIZO.Admin.WebApi.PLC; +using Infrastructure; +using Infrastructure.Attribute; +using Infrastructure.Controllers; +using Infrastructure.Enums; +using Infrastructure.Model; +using Microsoft.AspNetCore.Mvc; + +// 创建时间:2026-01-16 +namespace RIZO.Admin.WebApi.Controllers +{ + /// + /// PLC通讯测试接口 + /// + [AllowAnonymous] + [Route("PLC")] + public class PlcController : BaseController + { + /// + /// PLC通讯服务 + /// + private readonly PlcService _plcService; + + /// + /// 构造函数注入PLC服务 + /// + /// PLC通讯服务实例 + public PlcController(PlcService plcService) + { + _plcService = plcService; + } + + /// + /// 批量测试所有PLC的连接、读写功能(核心接口) + /// + /// 批量测试结果 + [HttpPost("batch-test")] + [ActionPermissionFilter(Permission = "business:plc:batchtest")] + [Log(Title = "PLC测试", BusinessType = BusinessType.OTHER)] + public async Task BatchTestAllPlc() + { + try + { + var results = await _plcService.BatchTestAllPlcAsync(); + return SUCCESS(results); + } + catch (Exception ex) + { + return ToResponse(ApiResult.Error($"批量测试失败:{ex.Message}")); + } + } + + /// + /// 测试单个PLC的连接、读写功能(调试用) + /// + /// 单个PLC配置参数 + /// 单PLC测试结果 + [HttpPost("single-test")] + [ActionPermissionFilter(Permission = "business:plc:singletest")] + [Log(Title = "PLC测试", BusinessType = BusinessType.OTHER)] + public async Task TestSinglePlc([FromBody] PlcConfig config) + { + if (config == null || string.IsNullOrEmpty(config.Ip)) + { + return ToResponse(ApiResult.Error("PLC配置不能为空,IP地址为必填项")); + } + + try + { + var result = await _plcService.TestSinglePlcAsync(config); + return SUCCESS(result); + } + catch (Exception ex) + { + return ToResponse(ApiResult.Error($"单PLC测试失败:{ex.Message}")); + } + } + + /// + /// 单独读取指定PLC的地址数据 + /// + /// PLC IP地址 + /// 读取地址(如DB1.DBD0) + /// 机架号(默认0) + /// 槽位号(默认1) + /// 读取结果 + [HttpGet("read")] + [ActionPermissionFilter(Permission = "business:plc:read")] + public async Task ReadPlcData( + [FromQuery] string ip, + [FromQuery] string address, + [FromQuery] short rack = 0, + [FromQuery] short slot = 1) + { + if (string.IsNullOrEmpty(ip)) + { + return ToResponse(ApiResult.Error("PLC IP地址不能为空")); + } + if (string.IsNullOrEmpty(address)) + { + return ToResponse(ApiResult.Error("PLC读取地址不能为空")); + } + + try + { + var (success, value, message) = await _plcService.ReadPlcDataAsync(ip, rack, slot, address); + if (success) + { + return SUCCESS(new { Value = value, Message = message }); + } + else + { + return ToResponse(ApiResult.Error(message)); + } + } + catch (Exception ex) + { + return ToResponse(ApiResult.Error($"读取PLC数据失败:{ex.Message}")); + } + } + + /// + /// 单独写入数据到指定PLC地址 + /// + /// PLC IP地址 + /// 写入地址(如DB1.DBD0) + /// 写入值(字符串格式) + /// 机架号(默认0) + /// 槽位号(默认1) + /// 写入结果 + [HttpPost("write")] + [ActionPermissionFilter(Permission = "business:plc:write")] + [Log(Title = "PLC数据写入", BusinessType = BusinessType.UPDATE)] + public async Task WritePlcData( + [FromQuery] string ip, + [FromQuery] string address, + [FromQuery] string value, + [FromQuery] short rack = 0, + [FromQuery] short slot = 1) + { + if (string.IsNullOrEmpty(ip)) + { + return ToResponse(ApiResult.Error("PLC IP地址不能为空")); + } + if (string.IsNullOrEmpty(address)) + { + return ToResponse(ApiResult.Error("PLC写入地址不能为空")); + } + if (string.IsNullOrEmpty(value)) + { + return ToResponse(ApiResult.Error("PLC写入值不能为空")); + } + + try + { + var (success, message) = await _plcService.WritePlcDataAsync(ip, rack, slot, address, value); + if (success) + { + return SUCCESS(message); + } + else + { + return ToResponse(ApiResult.Error(message)); + } + } + catch (Exception ex) + { + return ToResponse(ApiResult.Error($"写入PLC数据失败:{ex.Message}")); + } + } + } +} \ No newline at end of file diff --git a/RIZO.Admin.WebApi/PLC/PlcHostedService.cs b/RIZO.Admin.WebApi/PLC/PlcHostedService.cs new file mode 100644 index 0000000..7b46016 --- /dev/null +++ b/RIZO.Admin.WebApi/PLC/PlcHostedService.cs @@ -0,0 +1,236 @@ +using Microsoft.Extensions.Options; +using System.Collections.Concurrent; + +namespace RIZO.Admin.WebApi.PLC +{ + public class PlcHostedService : IHostedService, IDisposable + { + private readonly ILogger _logger; + private readonly PlcService _plcService; + private readonly List _plcConfigs; + private Timer _timer; + private bool _isRunning; + private readonly SemaphoreSlim _semaphore; + private readonly object _timerLock = new(); + // 连接状态缓存:减少短时间内重复连接测试 + private readonly ConcurrentDictionary _connectionStateCache; + + // 可配置参数(建议放到配置文件中) + private readonly int _parallelDegree = 10; // 并行度(20+PLC建议8-12) + private readonly int _pollingIntervalSeconds = 5; // 轮询间隔 + private readonly int _connectTimeoutSeconds = 3; // 单个PLC连接超时时间 + private readonly int _stateCacheExpireSeconds = 10; // 连接状态缓存有效期 + + /// + /// PLC 连接状态缓存对象 + /// + private class PlcConnectionState + { + public bool IsConnected { get; set; } + public DateTime LastCheckTime { get; set; } + } + + public PlcHostedService( + ILogger logger, + PlcService plcService, + IOptions> plcConfigs) + { + _logger = logger; + _plcService = plcService; + _plcConfigs = plcConfigs.Value ?? new List(); + + // 初始化并行控制信号量 + _semaphore = new SemaphoreSlim(_parallelDegree, _parallelDegree); + // 初始化连接状态缓存 + _connectionStateCache = new ConcurrentDictionary(); + foreach (var config in _plcConfigs) + { + _connectionStateCache.TryAdd(config.Ip, new PlcConnectionState + { + IsConnected = false, + LastCheckTime = DateTime.MinValue + }); + } + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("PLC后台监听服务启动中..."); + + if (!_plcConfigs.Any()) + { + _logger.LogWarning("未配置PLC参数,跳过PLC自动连接"); + return; + } + + _isRunning = true; + + // 1. 启动时并行测试所有PLC连接 + await BatchConnectPlcAsync(cancellationToken); + + // 2. 启动安全定时器(防重叠执行) + _timer = new Timer( + TimerCallback, + null, + TimeSpan.Zero, + TimeSpan.FromSeconds(_pollingIntervalSeconds)); + + _logger.LogInformation($"PLC服务启动完成 | 并行度:{_parallelDegree} | 轮询间隔:{_pollingIntervalSeconds}s | 设备总数:{_plcConfigs.Count}"); + } + + /// + /// 定时器安全回调(防重叠执行) + /// + private async void TimerCallback(object state) + { + if (!_isRunning || !Monitor.TryEnter(_timerLock)) + { + _logger.LogDebug("前一轮PLC轮询未完成,跳过本次执行"); + return; + } + + try + { + await PollPlcDataAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "PLC轮询回调异常"); + } + finally + { + Monitor.Exit(_timerLock); + } + } + + /// + /// 启动时批量并行连接PLC + /// + private async Task BatchConnectPlcAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("开始批量连接所有PLC..."); + + var tasks = _plcConfigs.Select(async config => + { + await _semaphore.WaitAsync(cancellationToken); + try + { + // 带超时的连接测试 + using var timeoutToken = new CancellationTokenSource(TimeSpan.FromSeconds(_connectTimeoutSeconds)); + var result = await _plcService.TestSinglePlcAsync(config, timeoutToken.Token); + + // 更新缓存状态 + var state = _connectionStateCache[config.Ip]; + state.IsConnected = result.ConnectSuccess; + state.LastCheckTime = DateTime.Now; + + if (result.ConnectSuccess) + _logger.LogInformation($"[{config.PlcName}] 连接成功 | IP:{config.Ip}"); + else + _logger.LogWarning($"[{config.PlcName}] 连接失败 | IP:{config.Ip} | 原因:{result.ConnectMessage}"); + } + catch (OperationCanceledException) + { + _logger.LogWarning($"[{config.PlcName}] 连接超时 | IP:{config.Ip} | 超时时间:{_connectTimeoutSeconds}s"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"[{config.PlcName}] 连接异常 | IP:{config.Ip}"); + } + finally + { + _semaphore.Release(); + } + }); + + await Task.WhenAll(tasks); + } + + /// + /// 并行轮询PLC数据(读取前先验证连接状态) + /// + private async Task PollPlcDataAsync() + { + _logger.LogInformation("开始轮询PLC数据..."); + + var tasks = _plcConfigs.Select(async config => + { + await _semaphore.WaitAsync(); + try + { + // 1. 先从缓存获取连接状态,未过期则直接使用 + var state = _connectionStateCache[config.Ip]; + var needRecheck = (DateTime.Now - state.LastCheckTime).TotalSeconds > _stateCacheExpireSeconds; + + bool isConnected = state.IsConnected; + if (needRecheck) + { + // 缓存过期,重新测试连接(带超时) + using var timeoutToken = new CancellationTokenSource(TimeSpan.FromSeconds(_connectTimeoutSeconds)); + var connectResult = await _plcService.TestSinglePlcAsync(config, timeoutToken.Token); + isConnected = connectResult.ConnectSuccess; + // 更新缓存 + state.IsConnected = isConnected; + state.LastCheckTime = DateTime.Now; + } + + // 2. 连接失败直接跳过 + if (!isConnected) + { + _logger.LogDebug($"[{config.PlcName}] 连接断开,跳过数据读取 | IP:{config.Ip}"); + return; + } + + // 3. 连接正常,读取数据 + var (success, value, message) = await _plcService.ReadPlcDataAsync( + config.Ip, config.Rack, config.Slot, config.TestReadAddress); + + if (success) + { + _logger.LogInformation($"[{config.PlcName}] 数据读取成功 | 地址:{config.TestReadAddress} | 值:{value}"); + // 数据处理逻辑:入库/推前端/存Redis + // await ProcessPlcDataAsync(config, value); + } + else + { + _logger.LogWarning($"[{config.PlcName}] 数据读取失败 | 原因:{message}"); + // 读取失败,标记连接状态为断开 + state.IsConnected = false; + } + } + catch (OperationCanceledException) + { + _logger.LogWarning($"[{config.PlcName}] 操作超时 | IP:{config.Ip}"); + _connectionStateCache[config.Ip].IsConnected = false; + } + catch (Exception ex) + { + _logger.LogError(ex, $"[{config.PlcName}] 轮询异常 | IP:{config.Ip}"); + _connectionStateCache[config.Ip].IsConnected = false; + } + finally + { + _semaphore.Release(); + } + }); + + await Task.WhenAll(tasks); + _logger.LogInformation($"PLC数据轮询完成 | 本次轮询设备数:{_plcConfigs.Count}"); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("PLC后台监听服务停止中..."); + _isRunning = false; + _timer?.Change(Timeout.Infinite, 0); + return Task.CompletedTask; + } + + public void Dispose() + { + _timer?.Dispose(); + _semaphore?.Dispose(); + _logger.LogInformation("PLC后台监听服务已释放资源"); + } + } +} \ No newline at end of file diff --git a/RIZO.Admin.WebApi/PLC/PlcService.cs b/RIZO.Admin.WebApi/PLC/PlcService.cs new file mode 100644 index 0000000..70f424d --- /dev/null +++ b/RIZO.Admin.WebApi/PLC/PlcService.cs @@ -0,0 +1,377 @@ +// 统一引入所有必要命名空间 +using Microsoft.Extensions.Options; +using Quartz; +using RIZO.Common; +using S7.Net; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace RIZO.Admin.WebApi.PLC +{ + // 移除嵌套类,直接定义顶级PlcService类并实现IDisposable + public class PlcService : IDisposable + { + // 标记是否已释放资源,避免重复释放 + private bool _disposed = false; + + // PLC配置参数(从配置文件注入) + private readonly List _plcConfigs; + private readonly GlobalPlcConfig _globalConfig; + + /// + /// 构造函数(依赖注入获取PLC配置) + /// + /// 所有PLC的连接配置 + /// PLC全局超时配置 + public PlcService(IOptions> plcConfigs, IOptions globalConfig) + { + _plcConfigs = plcConfigs.Value; + _globalConfig = globalConfig.Value; + } + + /// + /// 测试单个PLC的连接、读、写功能 + /// + /// 单个PLC的配置参数 + /// 测试结果 + public async Task TestSinglePlcAsync(PlcConfig config) + { + var result = new PlcTestResult + { + PlcName = config.PlcName, + Ip = config.Ip + }; + + Plc plc = null; + try + { + // 1. 初始化PLC连接对象 + plc = new Plc(CpuType.S71500, config.Ip, config.Rack, config.Slot); + plc.ReadTimeout = _globalConfig.ReadWriteTimeout; + plc.WriteTimeout = _globalConfig.ReadWriteTimeout; + + // 异步打开连接(S7.Net无原生异步,用Task.Run包装) + await Task.Run(() => plc.Open()); + + // 检查连接状态 + if (plc.IsConnected) + { + result.ConnectSuccess = true; + result.ConnectMessage = "连接成功"; + } + else + { + result.ConnectSuccess = false; + result.ConnectMessage = "连接失败(PLC未返回连接状态)"; + return result; + } + + // 2. 读取PLC数据 + try + { + var readValue = await Task.Run(() => plc.Read(config.TestReadAddress)); + result.ReadSuccess = true; + result.ReadValue = FormatValue(readValue); + result.ReadMessage = "读取成功"; + } + catch (Exception ex) + { + result.ReadSuccess = false; + result.ReadMessage = $"读取失败:{ex.Message}"; + } + + // 3. 写入PLC数据 + try + { + bool writeOk = await Task.Run(() => WritePlcValue(plc, config.TestWriteAddress, config.TestWriteValue)); + result.WriteSuccess = writeOk; + result.WriteMessage = writeOk ? "写入成功" : "写入失败(值类型与地址不匹配)"; + } + catch (Exception ex) + { + result.WriteSuccess = false; + result.WriteMessage = $"写入失败:{ex.Message}"; + } + } + catch (Exception ex) + { + result.ConnectSuccess = false; + result.ConnectMessage = $"连接异常:{ex.Message}"; + } + finally + { + // 确保PLC连接最终被释放 + if (plc != null) + { + if (plc.IsConnected) + { + plc.Close(); + } + Dispose(); // 释放Plc对象资源 + } + } + + return result; + } + + /// + /// 批量测试配置文件中所有PLC的读写功能 + /// + /// 所有PLC的测试结果列表 + /// 未配置PLC参数时抛出 + public async Task> BatchTestAllPlcAsync() + { + if (_plcConfigs == null || _plcConfigs.Count == 0) + { + throw new Exception("未从配置文件加载到任何PLC参数,请检查PlcConfigs配置"); + } + + // 并行测试所有PLC,提高效率 + var testTasks = _plcConfigs.Select(config => TestSinglePlcAsync(config)).ToList(); + var results = await Task.WhenAll(testTasks); + return results.ToList(); + } + + /// + /// 单独读取指定PLC的某个地址数据 + /// + /// PLC的IP地址 + /// 机架号 + /// 槽位号 + /// 读取地址(如DB1.DBD0) + /// 读取结果(成功状态、值、消息) + public async Task<(bool Success, string Value, string Message)> ReadPlcDataAsync(string ip, short rack, short slot, string address) + { + Plc plc = null; + try + { + plc = new Plc(CpuType.S71500, ip, rack, slot); + plc.ReadTimeout = _globalConfig.ReadWriteTimeout; + await Task.Run(() => plc.Open()); + + if (!plc.IsConnected) + { + return (false, "", "PLC连接失败,请检查IP/机架号/槽位号是否正确"); + } + + var value = await Task.Run(() => plc.Read(address)); + // 示例:读取DB1.DBX0.0和DB1.DBX0.1 + //var address1 = "DB1010.DBX0.0"; // 第一个位地址 + //var address2 = "DB1010.DBX0.1"; // 第二个位地址 + var address1 = "DB1010.DBW2"; // 第二个位地址 + var address2 = "DB1010.DBW4"; // 第二个位地址 + + // 读取DBX0.0 + var value1 = await Task.Run(() => plc.Read(address1)); + bool writeOk = await Task.Run(() => WritePlcValue(plc, address1, "56")); + // 读取DBX0.1 + var value2 = await Task.Run(() => plc.Read(address2)); + + + return (true, FormatValue(value), "读取成功"); + } + catch (Exception ex) + { + return (false, "", $"读取失败:{ex.Message}"); + } + finally + { + // 释放PLC连接 + if (plc != null) + { + if (plc.IsConnected) plc.Close(); + Dispose(); + } + } + } + + /// + /// 单独写入数据到指定PLC的某个地址 + /// + /// PLC的IP地址 + /// 机架号 + /// 槽位号 + /// 写入地址(如DB1.DBD0) + /// 写入的值(字符串格式) + /// 写入结果(成功状态、消息) + public async Task<(bool Success, string Message)> WritePlcDataAsync(string ip, short rack, short slot, string address, string valueStr) + { + Plc plc = null; + try + { + plc = new Plc(CpuType.S71500, ip, rack, slot); + plc.WriteTimeout = _globalConfig.ReadWriteTimeout; + await Task.Run(() => plc.Open()); + + if (!plc.IsConnected) + { + return (false, "PLC连接失败,请检查IP/机架号/槽位号是否正确"); + } + + bool writeOk = await Task.Run(() => WritePlcValue(plc, address, valueStr)); + return (writeOk, writeOk ? "写入成功" : "写入失败(值类型与地址不匹配)"); + } + catch (Exception ex) + { + return (false, $"写入失败:{ex.Message}"); + } + finally + { + // 释放PLC连接 + if (plc != null) + { + if (plc.IsConnected) plc.Close(); + Dispose(); + } + } + } + + /// + /// 格式化PLC读取的值,转为友好的字符串 + /// + /// PLC读取的原始值 + /// 格式化后的字符串 + private string FormatValue(object value) + { + if (value == null) return "空值"; + + return value switch + { + float f => f.ToString("0.000"), // 浮点数保留3位小数 + short s => s.ToString(), // 16位整数 + byte b => b.ToString(), // 8位整数 + bool b => b.ToString(), // 布尔值 + int i => i.ToString(), // 32位整数 + uint ui => ui.ToString(), // 无符号32位整数 + _ => value.ToString() // 其他类型直接转字符串 + }; + } + + /// + /// 根据地址类型转换值并写入PLC + /// + /// PLC连接对象 + /// 写入地址 + /// 写入的值(字符串) + /// 是否写入成功 + private bool WritePlcValue(Plc plc, string address, string valueStr) + { + // 双字(DBD/DD):浮点数或32位整数 + if (address.Contains("DBD") || address.Contains("DD")) + { + if (float.TryParse(valueStr, out float f)) + { + plc.Write(address, f); + return true; + } + else if (int.TryParse(valueStr, out int i)) + { + plc.Write(address, i); + return true; + } + } + // 字(DBW/DW):16位整数 + else if (address.Contains("DBW") || address.Contains("DW")) + { + if (short.TryParse(valueStr, out short s)) + { + plc.Write(address, s); + return true; + } + } + // 字节(DBB/DB):8位整数 + else if (address.Contains("DBB") || address.Contains("DB")) + { + if (byte.TryParse(valueStr, out byte b)) + { + plc.Write(address, b); + return true; + } + } + // 位(DBX):布尔值(兼容0/1输入) + else if (address.Contains("DBX")) + { + if (bool.TryParse(valueStr, out bool bl)) + { + plc.Write(address, bl); + return true; + } + else if (valueStr == "0") + { + plc.Write(address, false); + return true; + } + else if (valueStr == "1") + { + plc.Write(address, true); + return true; + } + } + + // 不匹配的类型 + return false; + } + + /// + /// 带超时的PLC连接测试 + /// + public async Task TestSinglePlcAsync(PlcConfig config, CancellationToken cancellationToken) + { + // 替换为实际的PLC连接逻辑(如S7NetPlus) + // 示例: + // using var plc = new S7Client(); + // var result = plc.ConnectTo(config.Ip, config.Rack, config.Slot); + // return new PlcTestResult { ConnectSuccess = result == 0, ConnectMessage = result == 0 ? "成功" : "失败" }; + await Task.Delay(100, cancellationToken); + return new PlcTestResult { ConnectSuccess = true, ConnectMessage = "连接正常" }; + } + + /// + /// 读取PLC数据 + /// + public async Task<(bool success, object value, string message)> ReadPlcDataAsync(string ip, int rack, int slot, string address) + { + // 替换为实际的PLC读取逻辑 + await Task.Delay(50); + return (true, DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), "读取成功"); + } + + + /// + /// 实现IDisposable接口,释放资源 + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); // 通知GC无需调用终结器 + } + + /// + /// 实际释放资源的逻辑(区分托管/非托管资源) + /// + /// 是否释放托管资源 + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; // 避免重复释放 + + // 释放托管资源(此处无长期持有的PLC连接,若有可在此处理) + if (disposing) + { + // 示例:若有全局PLC连接对象,可在此释放 + // if (_globalPlc != null) _globalPlc.Dispose(); + } + + // 标记为已释放 + _disposed = true; + } + + /// + /// 终结器(防忘记手动Dispose) + /// + ~PlcService() + { + Dispose(false); + } + } +} \ No newline at end of file diff --git a/RIZO.Admin.WebApi/Program.cs b/RIZO.Admin.WebApi/Program.cs index 0a0c8fb..2323e4f 100644 --- a/RIZO.Admin.WebApi/Program.cs +++ b/RIZO.Admin.WebApi/Program.cs @@ -13,6 +13,7 @@ using RIZO.Infrastructure.WebExtensions; using RIZO.ServiceCore.Signalr; using RIZO.ServiceCore.SqlSugar; using RIZO.Mall; +using RIZO.Admin.WebApi.PLC; //using SQLitePCL; var builder = WebApplication.CreateBuilder(args); @@ -24,6 +25,14 @@ builder.Services.AddDynamicApi(); // Add services to the container. builder.Services.AddControllers(); +// ===== 新增PLC服务注册 ===== +builder.Services.Configure>(builder.Configuration.GetSection("PlcConfigs")); +builder.Services.Configure(builder.Configuration.GetSection("GlobalPlcConfig")); +builder.Services.AddSingleton(); +// 新增:注册PLC后台监听服务(项目启动自动执行) +//builder.Services.AddHostedService(); +// ========================== + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); diff --git a/RIZO.Admin.WebApi/RIZO.Admin.WebApi.csproj b/RIZO.Admin.WebApi/RIZO.Admin.WebApi.csproj index 35138f6..cefc6c9 100644 --- a/RIZO.Admin.WebApi/RIZO.Admin.WebApi.csproj +++ b/RIZO.Admin.WebApi/RIZO.Admin.WebApi.csproj @@ -27,6 +27,7 @@ + diff --git a/RIZO.Admin.WebApi/appsettings.Development.json b/RIZO.Admin.WebApi/appsettings.Development.json index 9847591..b8f36df 100644 --- a/RIZO.Admin.WebApi/appsettings.Development.json +++ b/RIZO.Admin.WebApi/appsettings.Development.json @@ -2,8 +2,9 @@ "dbConfigs": [ { //"Conn": "Data Source=139.224.232.211;User ID=root;Password=RIZOtech123;Initial Catalog=ay2509055-guiyang-fluorescence-lmes;Port=3308;", - "Conn": "Data Source=43.142.238.124;User ID=root;Password=mysql_3AMPxs;Initial Catalog=valeo_lmes;Port=3308;", + //"Conn": "Data Source=43.142.238.124;User ID=root;Password=mysql_3AMPxs;Initial Catalog=valeo_lmes;Port=3308;", //"Conn": "Data Source=127.0.0.1;User ID=root;Password=123456;Initial Catalog=valeo_lmes;Port=3306;", + "Conn": "Data Source=192.168.1.48;User ID=root;Password=123456;Initial Catalog=valeo_lmes;Port=3306;", //"Conn": "Data Source=127.0.0.1,1433;Initial Catalog=valeo_lmes;User ID=root;Password=123456;TrustServerCertificate=True;Encrypt=False;Connection Timeout=60;", "DbType": 0, //数据库类型 MySql = 0, SqlServer = 1, Oracle = 3,PgSql = 4 "ConfigId": "0", //多租户唯一标识 diff --git a/RIZO.Admin.WebApi/appsettings.json b/RIZO.Admin.WebApi/appsettings.json index f8055de..6b006e5 100644 --- a/RIZO.Admin.WebApi/appsettings.json +++ b/RIZO.Admin.WebApi/appsettings.json @@ -18,7 +18,7 @@ }, "MainDb": "0", // 多租户主库配置ID "UseTenant": 0, //是否启用多租户 0:不启用 1:启用 - "InjectClass": [ "RIZO.Repository", "RIZO.Service", "RIZO.Tasks", "RIZO.ServiceCore", "RIZO.Mall" ,"MDM"], //自动注入类 + "InjectClass": [ "RIZO.Repository", "RIZO.Service", "RIZO.Tasks", "RIZO.ServiceCore", "RIZO.Mall", "MDM" ], //自动注入类 "ShowDbLog": false, //是否打印db日志 "InitDb": true, //是否初始化db "DemoMode": false, //是否演示模式 @@ -94,5 +94,21 @@ "tablePrefix": "sys_", //"表前缀(生成类名不会包含表前缀,多个用逗号分隔)", "vuePath": "", //前端代码存储路径eg:D:\Work\RIZOAdmin-Vue3 "uniappPath": "D:\\Work" //h5前端代码存储路径 + }, + "PlcConfigs": [ + { + "PlcName": "PLC-1号产线", // 自定义名称,便于识别 + "Ip": "192.168.1.111", + "Rack": 0, + "Slot": 1, + "TestReadAddress": "DB1010.DBD0", // 测试读取的地址 + "TestWriteAddress": "DB1010.DBD0", // 测试写入的地址 + "TestWriteValue": "100.5" // 测试写入的值(字符串,自动转换类型) + } + ], + "GlobalConfig": { + "ConnectTimeout": 5000, // 连接超时(毫秒) + "ReadWriteTimeout": 5000 // 读写超时(毫秒) } + } diff --git a/RIZO.Model/RIZO.Model.csproj b/RIZO.Model/RIZO.Model.csproj index 4b1c26b..8dae0f3 100644 --- a/RIZO.Model/RIZO.Model.csproj +++ b/RIZO.Model/RIZO.Model.csproj @@ -9,6 +9,7 @@ + diff --git a/RIZO.Service/RIZO.Service.csproj b/RIZO.Service/RIZO.Service.csproj index 07e491f..41a52b3 100644 --- a/RIZO.Service/RIZO.Service.csproj +++ b/RIZO.Service/RIZO.Service.csproj @@ -13,6 +13,7 @@ +