diff --git a/src/Admin/ThingsGateway.Admin.Application/ThingsGateway.Admin.Application.csproj b/src/Admin/ThingsGateway.Admin.Application/ThingsGateway.Admin.Application.csproj index c6ea3cb53..644ccfc79 100644 --- a/src/Admin/ThingsGateway.Admin.Application/ThingsGateway.Admin.Application.csproj +++ b/src/Admin/ThingsGateway.Admin.Application/ThingsGateway.Admin.Application.csproj @@ -21,15 +21,19 @@ - + + + + - + + diff --git a/src/Admin/ThingsGateway.AdminServer/ThingsGateway.AdminServer.csproj b/src/Admin/ThingsGateway.AdminServer/ThingsGateway.AdminServer.csproj index b793bcef7..205281a7c 100644 --- a/src/Admin/ThingsGateway.AdminServer/ThingsGateway.AdminServer.csproj +++ b/src/Admin/ThingsGateway.AdminServer/ThingsGateway.AdminServer.csproj @@ -41,13 +41,20 @@ - + + + + + + + + - + diff --git a/src/Admin/ThingsGateway.Furion/ThingsGateway.Furion.csproj b/src/Admin/ThingsGateway.Furion/ThingsGateway.Furion.csproj index 05f8be9a5..146736a88 100644 --- a/src/Admin/ThingsGateway.Furion/ThingsGateway.Furion.csproj +++ b/src/Admin/ThingsGateway.Furion/ThingsGateway.Furion.csproj @@ -38,23 +38,27 @@ - - - + + + + + + + diff --git a/src/Admin/ThingsGateway.Razor/ThingsGateway.Razor.csproj b/src/Admin/ThingsGateway.Razor/ThingsGateway.Razor.csproj index 4f131f58b..619fb0bce 100644 --- a/src/Admin/ThingsGateway.Razor/ThingsGateway.Razor.csproj +++ b/src/Admin/ThingsGateway.Razor/ThingsGateway.Razor.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 910cedab5..a2c22c60e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,8 +1,8 @@ - 10.0.0.19 - 10.0.0.19 + 10.0.0.20 + 10.0.0.20 diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/LogConsole.razor.cs b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/LogConsole.razor.cs index d998a59d5..7055142d6 100644 --- a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/LogConsole.razor.cs +++ b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/LogConsole.razor.cs @@ -181,7 +181,7 @@ public partial class LogConsole : IDisposable string pattern = @"[\\/:*?""<>|]"; // 使用正则表达式将不符合规则的部分替换为下划线 string sanitizedFileName = Regex.Replace(HeaderText, pattern, "_"); - await DownloadService.DownloadFromStreamAsync(sanitizedFileName + DateTime.Now.ToFileDateTimeFormat(), memoryStream); + await DownloadService.DownloadFromStreamAsync($"{sanitizedFileName}{DateTime.Now.ToFileDateTimeFormat()}.txt", memoryStream); } else { diff --git a/src/Foundation/ThingsGateway.Foundation/ThingsGateway.Foundation.csproj b/src/Foundation/ThingsGateway.Foundation/ThingsGateway.Foundation.csproj index 50f2c62e8..a0174c482 100644 --- a/src/Foundation/ThingsGateway.Foundation/ThingsGateway.Foundation.csproj +++ b/src/Foundation/ThingsGateway.Foundation/ThingsGateway.Foundation.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Controller/RuntimeInfoController.cs b/src/Gateway/ThingsGateway.Gateway.Application/Controller/RuntimeInfoController.cs index 88c93b5b6..4bd20db3b 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Controller/RuntimeInfoController.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Controller/RuntimeInfoController.cs @@ -101,7 +101,7 @@ public class RuntimeInfoController : ControllerBase if (GlobalData.ReadOnlyRealAlarmVariables.TryGetValue(variableName, out var variable)) { await GlobalData.SysUserService.CheckApiDataScopeAsync(variable.CreateOrgId, variable.CreateUserId).ConfigureAwait(false); - GlobalData.AlarmHostedService.ConfirmAlarm(variable); + GlobalData.AlarmHostedService.ConfirmAlarm(variableName); } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/BusinessBase.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/BusinessBase.cs index 98352b446..6da72945c 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/BusinessBase.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/BusinessBase.cs @@ -94,7 +94,7 @@ public abstract class BusinessBase : DriverBase // 获取当前设备需要采集的设备 - CollectDevices = GlobalData.GetEnableDevices().Where(a => CurrentDevice.VariableRuntimes.Select(b => b.Value.DeviceId).ToHashSet().Contains(a.Key)).ToDictionary(a => a.Key, a => a.Value); + CollectDevices = GlobalData.GetEnableDevices().Where(a => variableRuntimes.Select(b => b.Value.DeviceId).ToHashSet().Contains(a.Key)).ToDictionary(a => a.Key, a => a.Value); VariableRuntimes = variableRuntimes.ToDictionary(); } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalAlarmModel.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalAlarmModel.cs index 2351ecff8..474040c4f 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalAlarmModel.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalAlarmModel.cs @@ -62,7 +62,7 @@ public abstract class BusinessBaseWithCacheIntervalAlarmModel a.Value.IsCollect == true).ToDictionary(); } else { diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalDeviceModel.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalDeviceModel.cs index e5871b918..0819e6e98 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalDeviceModel.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalDeviceModel.cs @@ -67,7 +67,7 @@ public abstract class BusinessBaseWithCacheIntervalDeviceModel a.Value.IsCollect == true).ToDictionary(); } else { diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalVariableModel.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalVariableModel.cs index f2923388b..668253552 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalVariableModel.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalVariableModel.cs @@ -55,7 +55,7 @@ public abstract class BusinessBaseWithCacheIntervalVariableModel : BusinessBa if (_businessPropertyWithCacheInterval.IsAllVariable) { VariableRuntimes = new(GlobalData.GetEnableVariables()); - CollectDevices = GlobalData.GetEnableDevices().ToDictionary(); + CollectDevices = GlobalData.GetEnableDevices().Where(a => a.Value.IsCollect == true).ToDictionary(); } else { diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Driver/Collect/CollectBase.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Collect/CollectBase.cs index 86707a111..5feb9527b 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Driver/Collect/CollectBase.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Collect/CollectBase.cs @@ -52,7 +52,7 @@ public abstract class CollectBase : DriverBase public override void AfterVariablesChanged() { var currentDevice = CurrentDevice; - VariableRuntimes = currentDevice.VariableRuntimes.Where(a => a.Value.Enable).ToDictionary(); + VariableRuntimes = currentDevice.VariableRuntimes.Where(a => a.Value.Enable).ToDictionary(a => a.Value.Name, a => a.Value); //预热脚本,加速编译 VariableRuntimes.Where(a => !string.IsNullOrWhiteSpace(a.Value.ReadExpressions)) diff --git a/src/Gateway/ThingsGateway.Gateway.Application/GlobalData/GlobalData.cs b/src/Gateway/ThingsGateway.Gateway.Application/GlobalData/GlobalData.cs index d7d7f119f..99ccb766b 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/GlobalData/GlobalData.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/GlobalData/GlobalData.cs @@ -387,7 +387,7 @@ public static class GlobalData /// /// 内部使用的报警配置变量字典 /// - internal static ConcurrentDictionary AlarmEnableVariables { get; } = new(); + internal static ConcurrentDictionary AlarmEnableVariables { get; } = new(); /// /// 内部使用的报警配置变量字典 diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceRunTime.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceRunTime.cs index fa377246d..db88af713 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceRunTime.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceRunTime.cs @@ -128,7 +128,7 @@ public class DeviceRuntime : Device, IDisposable [Newtonsoft.Json.JsonIgnore] [AdaptIgnore] [AutoGenerateColumn(Ignore = true)] - public IReadOnlyDictionary? ReadVariableRuntimes => VariableRuntimes; + public IReadOnlyDictionary? ReadVariableRuntimes => VariableRuntimes; /// /// 设备变量 @@ -137,7 +137,7 @@ public class DeviceRuntime : Device, IDisposable [Newtonsoft.Json.JsonIgnore] [AdaptIgnore] [AutoGenerateColumn(Ignore = true)] - internal ConcurrentDictionary? VariableRuntimes { get; set; } = new(Environment.ProcessorCount, 1000); + internal ConcurrentDictionary? VariableRuntimes { get; set; } = new(Environment.ProcessorCount, 1000); #region 采集 /// diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableRunTime.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableRunTime.cs index 806fc9d42..293d4b26e 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableRunTime.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableRunTime.cs @@ -316,31 +316,31 @@ public class VariableRuntime : Variable, IVariable, IDisposable #endregion 报警 public void Init(DeviceRuntime deviceRuntime) { - DeviceRuntime?.VariableRuntimes?.TryRemove(Name, out _); + DeviceRuntime?.VariableRuntimes?.TryRemove(Id, out _); DeviceRuntime = deviceRuntime; - DeviceRuntime.VariableRuntimes.TryAdd(Name, this); + DeviceRuntime.VariableRuntimes.TryAdd(Id, this); GlobalData.IdVariables.TryRemove(Id, out _); GlobalData.IdVariables.TryAdd(Id, this); GlobalData.Variables.TryRemove(Name, out _); GlobalData.Variables.TryAdd(Name, this); if (AlarmEnable) { - GlobalData.AlarmEnableVariables.TryRemove(Name, out _); - GlobalData.AlarmEnableVariables.TryAdd(Name, this); + GlobalData.AlarmEnableVariables.TryRemove(Id, out _); + GlobalData.AlarmEnableVariables.TryAdd(Id, this); } } public void Dispose() { - DeviceRuntime?.VariableRuntimes?.TryRemove(Name, out _); + DeviceRuntime?.VariableRuntimes?.TryRemove(Id, out _); GlobalData.IdVariables.TryRemove(Id, out _); GlobalData.Variables.TryRemove(Name, out _); - GlobalData.AlarmEnableVariables.TryRemove(Name, out _); + GlobalData.AlarmEnableVariables.TryRemove(Id, out _); GC.SuppressFinalize(this); } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/AlarmHostedService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/AlarmHostedService.cs index 2ee868b8f..4c44f93ae 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/AlarmHostedService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/AlarmHostedService.cs @@ -376,12 +376,15 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic } - public void ConfirmAlarm(VariableRuntime item) + public void ConfirmAlarm(string variableName) { // 如果是确认报警事件 - item.EventType = EventTypeEnum.Confirm; - item.EventTime = DateTime.Now; - GlobalData.AlarmChange(item.Adapt()); + if (GlobalData.RealAlarmVariables.TryGetValue(variableName, out var variableRuntime)) + { + variableRuntime.EventType = EventTypeEnum.Confirm; + variableRuntime.EventTime = DateTime.Now; + GlobalData.AlarmChange(variableRuntime.Adapt()); + } } #endregion 核心实现 @@ -425,7 +428,7 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic foreach (var item in GlobalData.RealAlarmVariables) { - if (!GlobalData.AlarmEnableVariables.ContainsKey(item.Key)) + if (!GlobalData.AlarmEnableVariables.ContainsKey(item.Value.Id)) { if (GlobalData.RealAlarmVariables.TryRemove(item.Key, out var oldAlarm)) { diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/IAlarmHostedService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/IAlarmHostedService.cs index 63bf3bd28..6597a91d9 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/IAlarmHostedService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/IAlarmHostedService.cs @@ -17,6 +17,5 @@ public interface IAlarmHostedService : IHostedService /// /// 确认报警 /// - /// - void ConfirmAlarm(VariableRuntime item); + void ConfirmAlarm(string variableName); } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/ThingsGateway.Gateway.Application.csproj b/src/Gateway/ThingsGateway.Gateway.Application/ThingsGateway.Gateway.Application.csproj index a2bfb7a63..709232ea8 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/ThingsGateway.Gateway.Application.csproj +++ b/src/Gateway/ThingsGateway.Gateway.Application/ThingsGateway.Gateway.Application.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor.cs index 52c136881..e83e094a9 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor.cs +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor.cs @@ -466,7 +466,7 @@ public partial class ChannelDeviceTree : IDisposable { var op = new DialogOption() { - IsScrolling = false, + IsScrolling = true, ShowMaximizeButton = true, Size = Size.ExtraLarge, Title = item.Text, @@ -515,7 +515,7 @@ public partial class ChannelDeviceTree : IDisposable var op = new DialogOption() { - IsScrolling = false, + IsScrolling = true, ShowMaximizeButton = true, Size = Size.ExtraLarge, Title = item.Text, diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Variable/VariableEditComponent.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Variable/VariableEditComponent.razor.cs index af859f38a..1cc17bded 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Variable/VariableEditComponent.razor.cs +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Variable/VariableEditComponent.razor.cs @@ -147,7 +147,7 @@ public partial class VariableEditComponent var op = new DialogOption() { - IsScrolling = false, + IsScrolling = true, ShowMaximizeButton = true, Size = Size.ExtraLarge, Title = DeviceLocalizer["SaveDevice"], diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Variable/VariableRuntimeInfo.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Variable/VariableRuntimeInfo.razor.cs index 0bd8f5e25..de5195391 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Variable/VariableRuntimeInfo.razor.cs +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Variable/VariableRuntimeInfo.razor.cs @@ -130,7 +130,7 @@ public partial class VariableRuntimeInfo : IDisposable { var op = new DialogOption() { - IsScrolling = false, + IsScrolling = true, ShowMaximizeButton = true, Size = Size.ExtraLarge, Title = RazorLocalizer["BatchEdit"], diff --git a/src/Plugin.sln b/src/Plugin.sln index d1b832911..d3ed0fe40 100644 --- a/src/Plugin.sln +++ b/src/Plugin.sln @@ -6,8 +6,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "other", "other", "{0B748352 ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig Directory.Build.props = Directory.Build.props - ..\git_pull.bat = ..\git_pull.bat - PluginVersion.props = PluginVersion.props Version.props = Version.props EndProjectSection EndProject diff --git a/src/Plugin/ThingsGateway.Debug.Photino/Configuration/Menu.json b/src/Plugin/ThingsGateway.Debug.Photino/Configuration/Menu.json index bdd434851..bea706cc4 100644 --- a/src/Plugin/ThingsGateway.Debug.Photino/Configuration/Menu.json +++ b/src/Plugin/ThingsGateway.Debug.Photino/Configuration/Menu.json @@ -31,11 +31,15 @@ "Url": "/OpcUaMaster", "Text": "OpcUaMaster" }, + { + "Url": "/OpcUaMaster105", + "Text": "OpcUaMaster105" + }, { "Url": "/OpcDaMaster", "Text": "OpcDaMaster" } - + ] } } diff --git a/src/Plugin/ThingsGateway.Debug.Photino/Configuration/MenuPro.json b/src/Plugin/ThingsGateway.Debug.Photino/Configuration/MenuPro.json index 246ac86ed..5d7a676b8 100644 --- a/src/Plugin/ThingsGateway.Debug.Photino/Configuration/MenuPro.json +++ b/src/Plugin/ThingsGateway.Debug.Photino/Configuration/MenuPro.json @@ -31,6 +31,10 @@ "Url": "/OpcUaMaster", "Text": "OpcUaMaster" }, + { + "Url": "/OpcUaMaster105", + "Text": "OpcUaMaster105" + }, { "Url": "/OpcDaMaster", "Text": "OpcDaMaster" diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa/Utils/CollectionExtension.cs b/src/Plugin/ThingsGateway.Foundation.OpcUa/Utils/CollectionExtension.cs index ee8b0dced..e49e38f96 100644 --- a/src/Plugin/ThingsGateway.Foundation.OpcUa/Utils/CollectionExtension.cs +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa/Utils/CollectionExtension.cs @@ -12,8 +12,6 @@ namespace ThingsGateway.Foundation.OpcUa; internal static class CollectionExtension { - - /// /// 将项目列表分解为特定大小的块 /// diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa105/GlobalUsings.cs b/src/Plugin/ThingsGateway.Foundation.OpcUa105/GlobalUsings.cs new file mode 100644 index 000000000..a186d1e2d --- /dev/null +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa105/GlobalUsings.cs @@ -0,0 +1,16 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +global using Newtonsoft.Json.Linq; + +global using Opc.Ua; +global using Opc.Ua.Client; +global using Opc.Ua.Client.ComplexTypes; +global using Opc.Ua.Configuration; \ No newline at end of file diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa105/Locales/en-US.json b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Locales/en-US.json new file mode 100644 index 000000000..9f5d7fa97 --- /dev/null +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Locales/en-US.json @@ -0,0 +1,37 @@ +{ + "ThingsGateway.Foundation.OpcUa105.OpcUaProperty": { + "OpcUrl": "OpcUrl", + "UserName": "UserName", + "Password": "Password", + "CheckDomain": "CheckDomain", + "UpdateRate": "UpdateRate", + "ActiveSubscribe": "ActiveSubscribe", + "GroupSize": "GroupSize", + "DeadBand": "DeadBand", + "KeepAliveInterval": "KeepAliveInterval(ms)", + "UseSecurity": "IsUseSecurity", + "LoadType": "LoadType", + "AutoAcceptUntrustedCertificates": "AutoAcceptUntrustedCertificates", + "ExportC": "ExportCertificate", + + "Connect": "Connect", + "Disconnect": "Disconnect", + "Add": "Add", + "Remove": "Remove", + "Read": "Read", + "Write": "Write", + "ShowImport": "View OPC space", + "RegisterAddress": "RegisterAddress", + "WriteValue": "WriteValue", + + "NoVariablesAvailable": "No variables available", + "Success": "Success", + "Close": "Close", + "Save": "Import to system", + "Export": "Export", + "ShowSubvariable": "ShowSubvariable" + }, + + "ThingsGateway.Foundation.OpcUa105.OpcUaResource": { + } +} \ No newline at end of file diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa105/Locales/zh-CN.json b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Locales/zh-CN.json new file mode 100644 index 000000000..0c5ddece3 --- /dev/null +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Locales/zh-CN.json @@ -0,0 +1,36 @@ +{ + "ThingsGateway.Foundation.OpcUa105.OpcUaProperty": { + "OpcUrl": "OpcUrl", + "UserName": "登录账号", + "Password": "登录密码", + "CheckDomain": "检查域", + "UpdateRate": "推送间隔", + "ActiveSubscribe": "订阅", + "GroupSize": "分组大小", + "DeadBand": "死区", + "KeepAliveInterval": "心跳间隔(ms)", + "UseSecurity": "安全策略", + "LoadType": "加载服务端数据类型", + "AutoAcceptUntrustedCertificates": "自动接受不受信任的证书", + "ExportC": "导出证书", + "Connect": "连接", + "Disconnect": "断开", + "Add": "添加", + "Remove": "移除", + "Read": "读取", + "Write": "写入", + "ShowImport": "查看OPC空间", + "RegisterAddress": "地址", + "WriteValue": "写入值", + + "NoVariablesAvailable": "无可用变量", + "Success": "成功", + "Close": "关闭", + "Save": "导入到系统", + "Export": "导出", + "ShowSubvariable": "显示变量子节点" + }, + + "ThingsGateway.Foundation.OpcUa105.OpcUaResource": { + } +} \ No newline at end of file diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa105/OpcUaMaster/OPCUAStatusEventArgs.cs b/src/Plugin/ThingsGateway.Foundation.OpcUa105/OpcUaMaster/OPCUAStatusEventArgs.cs new file mode 100644 index 000000000..4def5df80 --- /dev/null +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa105/OpcUaMaster/OPCUAStatusEventArgs.cs @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Foundation.OpcUa105; + +/// +/// 读取属性过程中用于描述的 +/// +public class OPCNodeAttribute +{ + /// + /// 属性的名称 + /// + public string Name { get; set; } + + /// + /// 操作结果状态描述 + /// + public StatusCode StatusCode { get; set; } + + /// + /// 属性的类型描述 + /// + public string Type { get; set; } + + /// + /// 属性的值,如果读取错误,返回文本描述 + /// + public object Value { get; set; } +} diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa105/OpcUaMaster/OpcUaMaster.cs b/src/Plugin/ThingsGateway.Foundation.OpcUa105/OpcUaMaster/OpcUaMaster.cs new file mode 100644 index 000000000..825368ced --- /dev/null +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa105/OpcUaMaster/OpcUaMaster.cs @@ -0,0 +1,1234 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +//修改自https://github.com/dathlin/OpcUaHelper 与OPC基金会net库 + +namespace ThingsGateway.Foundation.OpcUa105; + +/// +/// 订阅委托 +/// +/// +public delegate void DataChangedEventHandler((VariableNode variableNode, DataValue dataValue, JToken jToken) value); + +/// +/// 日志输出 +/// +public delegate void LogEventHandler(byte level, object sender, string message, Exception ex); + +/// +/// OpcUaMaster +/// +public class OpcUaMaster : IDisposable +{ + #region 属性,变量等 + + /// + /// Raised after the client status change + /// + public LogEventHandler LogEvent; + + /// + /// 当前配置 + /// + public OpcUaProperty OpcUaProperty; + + /// + /// ProductUri + /// + public string ProductUri = "https://thingsgateway.cn/"; + + /// + /// 当前保存的变量名称列表 + /// + public List> Variables = new(); + + /// + /// 当前的变量名称/OPC变量节点 + /// + private readonly Dictionary _variableDicts = new(); + + private readonly object checkLock = new(); + + /// + /// 当前的订阅组,组名称/组 + /// + private readonly Dictionary dic_subscriptions = new(); + + private readonly ApplicationInstance m_application = new(); + + private readonly ApplicationConfiguration m_configuration; + private EventHandler m_ConnectComplete; + private EventHandler m_KeepAliveComplete; + private EventHandler m_ReconnectComplete; + private SessionReconnectHandler m_reConnectHandler; + private EventHandler m_ReconnectStarting; + private ISession m_session; + + private ComplexTypeSystem typeSystem; + + /// + /// 默认的构造函数,实例化一个新的OPC UA类 + /// + public OpcUaMaster() + { + var certificateValidator = new CertificateValidator(); + certificateValidator.CertificateValidation += CertificateValidation; + + // 构建应用程序配置 + m_configuration = new ApplicationConfiguration + { + ApplicationName = OPCUAName, + ApplicationType = ApplicationType.Client, + CertificateValidator = certificateValidator, + ApplicationUri = Utils.Format(@"urn:{0}:{1}", System.Net.Dns.GetHostName(), OPCUAName), + ProductUri = ProductUri, + + ServerConfiguration = new ServerConfiguration + { + MaxSubscriptionCount = 100000, + MaxMessageQueueSize = 1000000, + MaxNotificationQueueSize = 1000000, + MaxPublishRequestCount = 10000000, + }, + + SecurityConfiguration = new SecurityConfiguration + { + UseValidatedCertificates = true, + AutoAcceptUntrustedCertificates = true,//自动接受证书 + RejectSHA1SignedCertificates = true, + AddAppCertToTrustedStore = true, + SendCertificateChain = true, + MinimumCertificateKeySize = 1024, + SuppressNonceValidationErrors = true, + + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.X509Store, + StorePath = "CurrentUser\\" + OPCUAName, + SubjectName = $"CN={OPCUAName}, C=CN, S=GUANGZHOU, O=ThingsGateway, DC=" + System.Net.Dns.GetHostName(), + }, + + TrustedIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = AppContext.BaseDirectory + @"OPCUAClientCertificate\pki\trustedIssuer", + }, + TrustedPeerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = AppContext.BaseDirectory + @"OPCUAClientCertificate\pki\trustedPeer", + }, + RejectedCertificateStore = new CertificateStoreIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = AppContext.BaseDirectory + @"OPCUAClientCertificate\pki\rejected", + }, + UserIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = AppContext.BaseDirectory + @"OPCUAClientCertificate\pki\issuerUser", + }, + TrustedUserCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = AppContext.BaseDirectory + @"OPCUAClientCertificate\pki\trustedUser", + } + }, + + TransportQuotas = new TransportQuotas + { + OperationTimeout = 6000000, + MaxStringLength = int.MaxValue, + MaxByteStringLength = int.MaxValue, + MaxArrayLength = 65535, + MaxMessageSize = 419430400, + MaxBufferSize = 65535, + ChannelLifetime = 300000, + SecurityTokenLifetime = 3600000 + }, + ClientConfiguration = new ClientConfiguration + { + DefaultSessionTimeout = 60000, + MinSubscriptionLifetime = 10000, + + }, + + }; + + certificateValidator.Update(m_configuration); + + m_configuration.Validate(ApplicationType.Client); + m_application.ApplicationConfiguration = m_configuration; + } + + /// + /// Raised after successfully connecting to or disconnecing from a server. + /// + public event EventHandler ConnectComplete + { + add { m_ConnectComplete += value; } + remove { m_ConnectComplete -= value; } + } + + /// + /// 订阅 + /// + public event DataChangedEventHandler DataChangedHandler; + + /// + /// Raised when a good keep alive from the server arrives. + /// + public event EventHandler KeepAliveComplete + { + add { m_KeepAliveComplete += value; } + remove { m_KeepAliveComplete -= value; } + } + + /// + /// Raised when a reconnect operation completes. + /// + public event EventHandler ReconnectComplete + { + add { m_ReconnectComplete += value; } + remove { m_ReconnectComplete -= value; } + } + + /// + /// Raised when a reconnect operation starts. + /// + public event EventHandler ReconnectStarting + { + add { m_ReconnectStarting += value; } + remove { m_ReconnectStarting -= value; } + } + + /// + /// 配置信息 + /// + public ApplicationConfiguration AppConfig => m_configuration; + + /// + /// 连接状态 + /// + public bool Connected => m_session?.Connected == true; + + /// + /// OpcUaMaster + /// + public string OPCUAName { get; set; } = "ThingsGateway"; + + /// + /// SessionReconnectHandler + /// + public SessionReconnectHandler ReConnectHandler => m_reConnectHandler; + + /// + /// 当前活动会话。 + /// + public ISession Session => m_session; + + #endregion 属性,变量等 + + #region 订阅 + + /// + /// 新增订阅,需要指定订阅组名称,订阅的tag名数组 + /// + public async Task AddSubscriptionAsync(string subscriptionName, string[] items, bool loadType = true) + { + Subscription m_subscription = new(m_session.DefaultSubscription) + { + PublishingEnabled = true, + PublishingInterval = 0, + KeepAliveCount = uint.MaxValue, + LifetimeCount = uint.MaxValue, + MaxNotificationsPerPublish = uint.MaxValue, + Priority = 100, + DisplayName = subscriptionName + }; + List monitoredItems = new(); + var variableNodes = loadType ? await ReadNodesAsync(items).ConfigureAwait(false) : null; + for (int i = 0; i < items.Length; i++) + { + try + { + var item = new MonitoredItem + { + StartNodeId = loadType ? variableNodes[i].NodeId : items[i], + AttributeId = Attributes.Value, + DisplayName = items[i], + Filter = OpcUaProperty.DeadBand == 0 ? null : new DataChangeFilter() { DeadbandValue = OpcUaProperty.DeadBand, DeadbandType = (int)DeadbandType.Absolute, Trigger = DataChangeTrigger.StatusValue }, + SamplingInterval = OpcUaProperty?.UpdateRate ?? 1000, + }; + item.Notification += Callback; + monitoredItems.Add(item); + } + catch (Exception ex) + { + Log(3, ex, $"Failed to initialize {items[i]} variable subscription"); + } + } + m_subscription.AddItems(monitoredItems); + + m_session.AddSubscription(m_subscription); + + m_subscription.Create(); + + foreach (var item in m_subscription.MonitoredItems.Where(a => a.Status.Error != null && StatusCode.IsBad(a.Status.Error.StatusCode))) + { + item.Filter = OpcUaProperty.DeadBand == 0 ? null : new DataChangeFilter() { DeadbandValue = OpcUaProperty.DeadBand, DeadbandType = (int)DeadbandType.None, Trigger = DataChangeTrigger.StatusValue }; + } + m_subscription.ApplyChanges(); + + var isError = m_subscription.MonitoredItems.Any(a => a.Status.Error != null && StatusCode.IsBad(a.Status.Error.StatusCode)); + if (isError) + { + Log(3, null, $"Failed to create subscription for the following variables:{Environment.NewLine}{m_subscription.MonitoredItems.Where( + a => a.Status.Error != null && StatusCode.IsBad(a.Status.Error.StatusCode)) + .Select(a => $"{a.StartNodeId}:{a.Status.Error}").ToJsonString()}"); + } + + lock (dic_subscriptions) + { + if (dic_subscriptions.TryGetValue(subscriptionName, out var existingSubscription)) + { + // remove + existingSubscription.Delete(true); + m_session.RemoveSubscription(existingSubscription); + try { existingSubscription.Dispose(); } catch { } + dic_subscriptions[subscriptionName] = m_subscription; + } + else + { + dic_subscriptions.TryAdd(subscriptionName, m_subscription); + } + } + } + + /// + /// 浏览一个节点的引用 + /// + /// 节点值 + /// 引用节点描述 + public async Task BrowseNodeReferenceAsync(string tag) + { + NodeId sourceId = new(tag); + + // 该节点可以读取到方法 + BrowseDescription nodeToBrowse1 = new() + { + NodeId = sourceId, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.Aggregates, + IncludeSubtypes = true, + NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method), + ResultMask = (uint)BrowseResultMask.All + }; + + // find all nodes organized by the node. + BrowseDescription nodeToBrowse2 = new() + { + NodeId = sourceId, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.Organizes, + IncludeSubtypes = true, + NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable), + ResultMask = (uint)BrowseResultMask.All + }; + + BrowseDescriptionCollection nodesToBrowse = new() + { + nodeToBrowse1, + nodeToBrowse2 + }; + + // fetch references from the server. + ReferenceDescriptionCollection references = await OpcUaUtils.BrowseAsync(m_session, nodesToBrowse, false).ConfigureAwait(false); + + return references.ToArray(); + } + + /// + /// 调用服务器的方法 + /// + /// 方法的父节点tag + /// 方法的节点tag + /// 传递的参数 + /// 输出的结果值 + public object[] CallMethodByNodeId(string tagParent, string tag, params object[] args) + { + if (m_session == null) + { + return null; + } + + IList outputArguments = m_session.Call( + new NodeId(tagParent), + new NodeId(tag), + args); + + return outputArguments.ToArray(); + } + + /// + /// 连接到服务器 + /// + public async Task ConnectAsync(CancellationToken cancellationToken) + { + await ConnectAsync(OpcUaProperty.OpcUrl, cancellationToken).ConfigureAwait(false); + } + + /// + /// 断开连接。 + /// + public void Disconnect() + { + PrivateDisconnect(); + // disconnect any existing session. + if (m_session != null) + { + m_session = null; + } + } + + /// + public void Dispose() + { + Disconnect(); + } + + /// + /// 获取变量说明 + /// + /// + public string GetAddressDescription() + { + return "ItemId"; + } + + /// + /// 读取历史数据 + /// + /// 节点的索引 + /// 开始时间 + /// 结束时间 + /// 读取的个数 + /// 是否包含边界 + /// cancellationToken + /// 读取的数据列表 + public async Task> ReadHistoryRawDataValues(string tag, DateTime start, DateTime end, uint count = 1, bool containBound = false, CancellationToken cancellationToken = default) + { + HistoryReadValueId m_nodeToContinue = new() + { + NodeId = new NodeId(tag), + }; + + ReadRawModifiedDetails m_details = new() + { + StartTime = start, + EndTime = end, + NumValuesPerNode = count, + IsReadModified = false, + ReturnBounds = containBound + }; + + HistoryReadValueIdCollection nodesToRead = new() + { + m_nodeToContinue + }; + + var result = await m_session.HistoryReadAsync( + null, + new ExtensionObject(m_details), + TimestampsToReturn.Both, + false, + nodesToRead, + cancellationToken).ConfigureAwait(false); + var results = result.Results; + var diagnosticInfos = result.DiagnosticInfos; + ClientBase.ValidateResponse(results, nodesToRead); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, nodesToRead); + + if (StatusCode.IsBad(results[0].StatusCode)) + { + throw new ServiceResultException(results[0].StatusCode); + } + + HistoryData values = ExtensionObject.ToEncodeable(results[0].HistoryData) as HistoryData; + return values.DataValues; + } + + /// + /// 从服务器读取值 + /// + public async Task> ReadJTokenValueAsync(string[] tags, CancellationToken cancellationToken = default) + { + var result = await ReadJTokenValueAsync(tags.Select(a => new NodeId(a)).ToArray(), cancellationToken).ConfigureAwait(false); + return result; + } + + /// + /// 读取一个节点的所有属性 + /// + public async Task> ReadNoteAttributeAsync(string tag, uint attributesId, CancellationToken cancellationToken = default) + { + BrowseDescriptionCollection nodesToBrowse = new(); + ReadValueIdCollection nodesToRead = new(); + NodeId sourceId = new(tag); + + ReadValueId nodeToRead = new() + { + NodeId = sourceId, + AttributeId = attributesId + }; + nodesToRead.Add(nodeToRead); + BrowseDescription nodeToBrowse = new() + { + NodeId = sourceId, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HasProperty, + IncludeSubtypes = true, + NodeClassMask = 0, + ResultMask = (uint)BrowseResultMask.All + }; + nodesToBrowse.Add(nodeToBrowse); + + var result1 = await ReadNoteAttributeAsync(nodesToBrowse, nodesToRead, cancellationToken).ConfigureAwait(false); + + var result2 = result1.Values.FirstOrDefault(); + return result2; + } + + /// + /// 读取节点的所有属性 + /// + public async Task>> ReadNoteAttributeAsync(List tags, CancellationToken cancellationToken) + { + BrowseDescriptionCollection nodesToBrowse = new(); + ReadValueIdCollection nodesToRead = new(); + foreach (var tag in tags) + { + NodeId sourceId = new(tag); + + for (uint ii = Attributes.NodeClass; ii <= Attributes.UserExecutable; ii++) + { + ReadValueId nodeToRead = new() + { + NodeId = sourceId, + AttributeId = ii + }; + nodesToRead.Add(nodeToRead); + } + BrowseDescription nodeToBrowse = new() + { + NodeId = sourceId, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HasProperty, + IncludeSubtypes = true, + NodeClassMask = 0, + ResultMask = (uint)BrowseResultMask.All + }; + nodesToBrowse.Add(nodeToBrowse); + } + + return await ReadNoteAttributeAsync(nodesToBrowse, nodesToRead, cancellationToken).ConfigureAwait(false); + } + + /// + /// 读取一个节点的所有属性 + /// + /// 节点信息 + /// 节点的特性值 + public OPCNodeAttribute[] ReadNoteAttributes(string tag) + { + NodeId sourceId = new(tag); + ReadValueIdCollection nodesToRead = new(); + + for (uint ii = Attributes.NodeClass; ii <= Attributes.UserExecutable; ii++) + { + ReadValueId nodeToRead = new() + { + NodeId = sourceId, + AttributeId = ii + }; + nodesToRead.Add(nodeToRead); + } + + int startOfProperties = nodesToRead.Count; + + // find all of the pror of the node. + BrowseDescription nodeToBrowse1 = new() + { + NodeId = sourceId, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HasProperty, + IncludeSubtypes = true, + NodeClassMask = 0, + ResultMask = (uint)BrowseResultMask.All + }; + + BrowseDescriptionCollection nodesToBrowse = new() + { + nodeToBrowse1 + }; + + // fetch property references from the server. + ReferenceDescriptionCollection references = OpcUaUtils.Browse(m_session, nodesToBrowse, false); + + if (references == null) + { + return Array.Empty(); + } + + for (int ii = 0; ii < references.Count; ii++) + { + // ignore external references. + if (references[ii].NodeId.IsAbsolute) + { + continue; + } + + ReadValueId nodeToRead = new() + { + NodeId = (NodeId)references[ii].NodeId, + AttributeId = Attributes.Value + }; + nodesToRead.Add(nodeToRead); + } + + // read all values. + + m_session.Read( + null, + 0, + TimestampsToReturn.Neither, + nodesToRead, + out DataValueCollection results, + out DiagnosticInfoCollection diagnosticInfos); + + ClientBase.ValidateResponse(results, nodesToRead); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, nodesToRead); + + // process results. + + List nodeAttribute = new(); + for (int ii = 0; ii < results.Count; ii++) + { + OPCNodeAttribute item = new(); + + // process attribute value. + if (ii < startOfProperties) + { + // ignore attributes which are invalid for the node. + if (results[ii].StatusCode == StatusCodes.BadAttributeIdInvalid) + { + continue; + } + + // get the name of the attribute. + item.Name = Attributes.GetBrowseName(nodesToRead[ii].AttributeId); + + // display any unexpected error. + if (StatusCode.IsBad(results[ii].StatusCode)) + { + item.Type = Utils.Format("{0}", Attributes.GetDataTypeId(nodesToRead[ii].AttributeId)); + item.Value = Utils.Format("{0}", results[ii].StatusCode); + } + + // display the value. + else + { + TypeInfo typeInfo = TypeInfo.Construct(results[ii].Value); + + item.Type = typeInfo.BuiltInType.ToString(); + + if (typeInfo.ValueRank >= ValueRanks.OneOrMoreDimensions) + { + item.Type += "[]"; + } + + item.Value = results[ii].Value;//Utils.Format("{0}", results[ii].Value); + } + } + + // process property value. + else + { + // ignore properties which are invalid for the node. + if (results[ii].StatusCode == StatusCodes.BadNodeIdUnknown) + { + continue; + } + + // get the name of the property. + item.Name = Utils.Format("{0}", references[ii - startOfProperties]); + + // display any unexpected error. + if (StatusCode.IsBad(results[ii].StatusCode)) + { + item.Type = String.Empty; + item.Value = Utils.Format("{0}", results[ii].StatusCode); + } + + // display the value. + else + { + TypeInfo typeInfo = TypeInfo.Construct(results[ii].Value); + + item.Type = typeInfo.BuiltInType.ToString(); + + if (typeInfo.ValueRank >= ValueRanks.OneOrMoreDimensions) + { + item.Type += "[]"; + } + + item.Value = results[ii].Value; + } + } + + nodeAttribute.Add(item); + } + + return nodeAttribute.ToArray(); + } + + /// + /// 移除所有的订阅消息 + /// + public void RemoveAllSubscription() + { + lock (dic_subscriptions) + { + foreach (var item in dic_subscriptions) + { + item.Value.Delete(true); + m_session.RemoveSubscription(item.Value); + try { item.Value.Dispose(); } catch { } + } + dic_subscriptions.Clear(); + } + } + + /// + /// 移除订阅消息 + /// + /// 组名称 + public void RemoveSubscription(string subscriptionName) + { + lock (dic_subscriptions) + { + if (dic_subscriptions.TryGetValue(subscriptionName, out var subscription)) + { + // remove + subscription.Delete(true); + m_session.RemoveSubscription(subscription); + try { subscription.Dispose(); } catch { } + dic_subscriptions.Remove(subscriptionName); + } + } + } + + /// + /// 异步写opc标签 + /// + public async Task>> WriteNodeAsync(Dictionary writeInfoLists, CancellationToken cancellationToken = default) + { + Dictionary> results = new(); + try + { + WriteValueCollection valuesToWrite = new(); + foreach (var item in writeInfoLists) + { + WriteValue valueToWrite = new() + { + NodeId = new NodeId(item.Key), + AttributeId = Attributes.Value, + }; + var variableNode = await ReadNodeAsync(item.Key, false, cancellationToken).ConfigureAwait(false); + var dataValue = JsonUtils.Decode( + m_session.MessageContext, + variableNode.DataType, + TypeInfo.GetBuiltInType(variableNode.DataType, m_session.SystemContext.TypeTable), + item.Value.CalculateActualValueRank(), + item.Value + ); + valueToWrite.Value = dataValue; + + valuesToWrite.Add(valueToWrite); + } + + var result = await m_session.WriteAsync( + requestHeader: null, + nodesToWrite: valuesToWrite, cancellationToken).ConfigureAwait(false); + + ClientBase.ValidateResponse(result.Results, valuesToWrite); + ClientBase.ValidateDiagnosticInfos(result.DiagnosticInfos, valuesToWrite); + + var keys = writeInfoLists.Keys.ToList(); + for (int i = 0; i < keys.Count; i++) + { + if (!StatusCode.IsGood(result.Results[i])) + results.Add(keys[i], Tuple.Create(false, result.Results[i].ToString())); + else + { + results.Add(keys[i], Tuple.Create(true, "Success")); + } + } + + return results; + } + catch (Exception ex) + { + var keys = writeInfoLists.Keys.ToList(); + foreach (var item in keys) + { + results.Add(item, Tuple.Create(false, ex.Message)); + } + return results; + } + } + + private void Callback(MonitoredItem monitoreditem, MonitoredItemNotificationEventArgs monitoredItemNotificationEventArgs) + { + try + { + if (m_session != null) + { + var variableNode = ReadNode(monitoreditem.StartNodeId.ToString(), false); + foreach (var value in monitoreditem.DequeueValues()) + { + if (value.Value != null) + { + var data = JsonUtils.Encode(m_session.MessageContext, TypeInfo.GetBuiltInType(variableNode.DataType, m_session.SystemContext.TypeTable), value.Value); + if (data == null && value.Value != null) + { + Log(3, null, $"{monitoreditem.StartNodeId}Conversion error, original value is{value.Value}"); + var data1 = JsonUtils.Encode(m_session.MessageContext, TypeInfo.GetBuiltInType(variableNode.DataType, m_session.SystemContext.TypeTable), value.Value); + } + DataChangedHandler?.Invoke((variableNode, value, data!)); + } + else + { + var data = JValue.CreateNull(); + DataChangedHandler?.Invoke((variableNode, value, data)); + } + } + } + } + catch (Exception ex) + { + Log(3, ex, $"{monitoreditem.StartNodeId}Subscription processing error"); + } + } + + #endregion 订阅 + + #region 连接 + + /// + /// 检查/创建证书 + /// + /// + public async Task CheckApplicationInstanceCertificate() + { + await m_application.CheckApplicationInstanceCertificates(true, 1200).ConfigureAwait(false); + } + /// + /// Creates a new session. + /// + /// The new session object. + private async Task ConnectAsync(string serverUrl, CancellationToken cancellationToken) + { + PrivateDisconnect(); + + if (m_configuration == null) + { + throw new ArgumentNullException(nameof(m_configuration)); + } + var useSecurity = OpcUaProperty?.UseSecurity ?? true; + + EndpointDescription endpointDescription = await OpcUaUtils.SelectEndpointAsync(m_configuration, serverUrl, useSecurity, 10000).ConfigureAwait(false); + EndpointConfiguration endpointConfiguration = EndpointConfiguration.Create(m_configuration); + ConfiguredEndpoint endpoint = new(null, endpointDescription, endpointConfiguration); + UserIdentity userIdentity; + if (!string.IsNullOrEmpty(OpcUaProperty.UserName)) + { + userIdentity = new UserIdentity(OpcUaProperty.UserName, OpcUaProperty.Password); + } + else + { + userIdentity = new UserIdentity(new AnonymousIdentityToken()); + } + //创建本地证书 + if (useSecurity) + await m_application.CheckApplicationInstanceCertificates(true, 1200, cancellationToken).ConfigureAwait(false); + + m_session = await Opc.Ua.Client.Session.Create( + + m_configuration, + endpoint, + false, + OpcUaProperty.CheckDomain, + (string.IsNullOrEmpty(OPCUAName)) ? m_configuration.ApplicationName : OPCUAName, + 60000, + userIdentity, + null, cancellationToken + ).ConfigureAwait(false); + typeSystem = new ComplexTypeSystem(m_session); + + m_session.KeepAliveInterval = OpcUaProperty.KeepAliveInterval == 0 ? 60000 : OpcUaProperty.KeepAliveInterval; + m_session.KeepAlive += Session_KeepAlive; + + // raise an event. + DoConnectComplete(true); + + Log(2, null, "Connected"); + + //如果是订阅模式,连接时添加订阅组 + if (OpcUaProperty.ActiveSubscribe) + { + foreach (var item in Variables) + { + await AddSubscriptionAsync(Guid.NewGuid().ToString(), item.ToArray(), OpcUaProperty.LoadType).ConfigureAwait(false); + } + } + return m_session; + } + + private void PrivateDisconnect() + { + bool state = m_session?.Connected == true; + + if (m_reConnectHandler != null) + { + try { m_reConnectHandler.Dispose(); } catch { } + m_reConnectHandler = null; + } + if (m_session != null) + { + m_session.KeepAlive -= Session_KeepAlive; + m_session.Close(10000); + } + + if (state) + { + Log(2, null, "Disconnected"); + DoConnectComplete(false); + } + } + + #endregion 连接 + + #region 读取/写入 + + /// + /// 从服务器读取值 + /// + private async Task> ReadJTokenValueAsync(NodeId[] nodeIds, CancellationToken cancellationToken = default) + { + if (m_session == null) + { + throw new ArgumentNullException(nameof(m_session)); + } + ReadValueIdCollection nodesToRead = new(); + for (int i = 0; i < nodeIds.Length; i++) + { + nodesToRead.Add(new ReadValueId() + { + NodeId = nodeIds[i], + AttributeId = Attributes.Value + }); + } + + // 读取当前的值 + var result = await m_session.ReadAsync( + null, + 0, + TimestampsToReturn.Both, + nodesToRead, + cancellationToken).ConfigureAwait(false); + var results = result.Results; + var diagnosticInfos = result.DiagnosticInfos; + ClientBase.ValidateResponse(results, nodesToRead); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, nodesToRead); + List<(string, DataValue, JToken)> jTokens = new(); + for (int i = 0; i < results.Count; i++) + { + var variableNode = await ReadNodeAsync(nodeIds[i].ToString(), false, cancellationToken).ConfigureAwait(false); + var type = TypeInfo.GetBuiltInType(variableNode.DataType, m_session.SystemContext.TypeTable); + var jToken = JsonUtils.Encode(m_session.MessageContext, type, results[i].Value); + jTokens.Add((variableNode.NodeId.ToString(), results[i], jToken)); + } + return jTokens.ToList(); + } + + /// + /// 从服务器或缓存读取节点 + /// + private VariableNode ReadNode(string nodeIdStr, bool isOnlyServer = true) + { + if (!isOnlyServer) + { + if (_variableDicts.TryGetValue(nodeIdStr, out var value)) + { + return value; + } + } + NodeId nodeToRead = new(nodeIdStr); + var node = (VariableNode)m_session.ReadNode(nodeToRead, NodeClass.Variable, false); + _variableDicts.AddOrUpdate(nodeIdStr, node); + return node; + } + + /// + /// 从服务器或缓存读取节点 + /// + private async Task ReadNodeAsync(string nodeIdStr, bool isOnlyServer = true, CancellationToken cancellationToken = default) + { + if (!isOnlyServer) + { + if (_variableDicts.TryGetValue(nodeIdStr, out var value)) + { + return value; + } + } + NodeId nodeToRead = new(nodeIdStr); + var node = (VariableNode)await m_session.ReadNodeAsync(nodeToRead, NodeClass.Variable, false, cancellationToken).ConfigureAwait(false); + if (OpcUaProperty.LoadType) + await typeSystem.LoadType(node.DataType, ct: cancellationToken).ConfigureAwait(false); + _variableDicts.AddOrUpdate(nodeIdStr, node); + return node; + } + + /// + /// 从服务器读取节点 + /// + private async Task> ReadNodesAsync(string[] nodeIdStrs, CancellationToken cancellationToken = default) + { + List result = new(nodeIdStrs.Length); + foreach (var items in nodeIdStrs.ChunkBetter(OpcUaProperty.GroupSize)) + { + List nodeIds = new List(); + + foreach (var item in items) + { + NodeId nodeToRead = new(item); + nodeIds.Add(nodeToRead); + } + (IList, IList) nodes = await m_session.ReadNodesAsync(nodeIds, NodeClass.Variable, false, cancellationToken).ConfigureAwait(false); + for (int i = 0; i < nodes.Item1.Count; i++) + { + if (StatusCode.IsGood(nodes.Item2[i].StatusCode)) + { + var node = ((VariableNode)nodes.Item1[i]); + await typeSystem.LoadType(node.DataType, ct: cancellationToken).ConfigureAwait(false); + _variableDicts.AddOrUpdate(nodeIdStrs[i], node); + } + else + { + Log(3, null, $"Failed to obtain server node information: {nodes.Item2[i]}"); + } + } + result.AddRange(nodes.Item1); + } + + return result; + } + + #endregion 读取/写入 + + #region 私有方法 + + private void CertificateValidation(CertificateValidator sender, CertificateValidationEventArgs eventArgs) + { + if (ServiceResult.IsGood(eventArgs.Error)) + eventArgs.Accept = true; + else if (OpcUaProperty.AutoAcceptUntrustedCertificates) + eventArgs.Accept = true; + else + throw new Exception(string.Format("Verification certificate failed with error code: {0}: {1}", eventArgs.Error.Code, eventArgs.Error.AdditionalInfo)); + } + + /// + /// Raises the connect complete event on the main GUI thread. + /// + private void DoConnectComplete(bool state) + { + m_ConnectComplete?.Invoke(this, state); + } + + /// + /// Report the client status + /// + /// Whether the status represents an error. + /// exception + /// The status message. + /// Arguments used to format the status message. + private void Log(byte logLevel, Exception exception, string status, params object[] args) + { + LogEvent?.Invoke(logLevel, this, string.Format(status, args), exception); + } + + private async Task>> ReadNoteAttributeAsync(BrowseDescriptionCollection nodesToBrowse, ReadValueIdCollection nodesToRead, CancellationToken cancellationToken) + { + int startOfProperties = nodesToRead.Count; + + ReferenceDescriptionCollection references = await OpcUaUtils.BrowseAsync(m_session, nodesToBrowse, false, cancellationToken).ConfigureAwait(false); + + if (references == null) + { + throw new("浏览失败"); + } + + for (int ii = 0; ii < references.Count; ii++) + { + if (references[ii].NodeId.IsAbsolute) + { + continue; + } + + ReadValueId nodeToRead = new() + { + NodeId = (NodeId)references[ii].NodeId, + AttributeId = Attributes.Value + }; + nodesToRead.Add(nodeToRead); + } + + var result = await m_session.ReadAsync( + null, + 0, + TimestampsToReturn.Neither, + nodesToRead, cancellationToken).ConfigureAwait(false); + + ClientBase.ValidateResponse(result.Results, nodesToRead); + ClientBase.ValidateDiagnosticInfos(result.DiagnosticInfos, nodesToRead); + + Dictionary> nodeAttributes = new(); + for (int ii = 0; ii < result.Results.Count; ii++) + { + DataValue nodeValue = result.Results[ii]; + var nodeToRead = nodesToRead[ii]; + OPCNodeAttribute item = new(); + if (ii < startOfProperties) + { + if (nodeValue.StatusCode == StatusCodes.BadAttributeIdInvalid) + { + continue; + } + + item.Name = Attributes.GetBrowseName(nodesToRead[ii].AttributeId); + if (StatusCode.IsBad(nodeValue.StatusCode)) + { + item.Type = Utils.Format("{0}", Attributes.GetDataTypeId(nodesToRead[ii].AttributeId)); + item.Value = Utils.Format("{0}", nodeValue.StatusCode); + } + else + { + TypeInfo typeInfo = TypeInfo.Construct(nodeValue.Value); + item.Type = typeInfo.BuiltInType.ToString(); + + if (typeInfo.ValueRank >= ValueRanks.OneOrMoreDimensions) + { + item.Type += "[]"; + } + if (item.Name == nameof(Attributes.NodeClass)) + { + item.Value = ((NodeClass)nodeValue.Value).ToString(); + } + else if (item.Name == nameof(Attributes.EventNotifier)) + { + item.Value = ((EventNotifierType)nodeValue.Value).ToString(); + } + else + item.Value = nodeValue.Value; + } + } + + if (nodeAttributes.ContainsKey(nodeToRead.NodeId.ToString())) + { + nodeAttributes[nodeToRead.NodeId.ToString()].Add(item); + } + else + { + nodeAttributes.Add(nodeToRead.NodeId.ToString(), new() { item }); + } + } + return nodeAttributes; + } + + /// + /// 连接处理器连接事件处理完成。 + /// + private void Server_ReconnectComplete(object? sender, EventArgs e) + { + try + { + if (!Object.ReferenceEquals(sender, m_reConnectHandler)) + { + return; + } + // if session recovered, Session property is null + if (m_reConnectHandler.Session != null) + { + // ensure only a new instance is disposed + // after reactivate, the same session instance may be returned + if (!Object.ReferenceEquals(m_session, m_reConnectHandler.Session)) + { + var session = m_session; + m_session = m_reConnectHandler.Session; + Utils.SilentDispose(session); + } + } + + m_reConnectHandler.Dispose(); + m_reConnectHandler = null; + + // raise any additional notifications. + m_ReconnectComplete?.Invoke(this, e); + } + catch (Exception ex) + { + Log(3, ex, $"{nameof(Server_ReconnectComplete)}"); + } + } + + private void Session_KeepAlive(ISession session, KeepAliveEventArgs e) + { + lock (checkLock) + { + if (!Object.ReferenceEquals(session, m_session)) + { + return; + } + + if (ServiceResult.IsBad(e.Status)) + { + if (m_reConnectHandler == null) + { + Log(3, null, "Reconnecting in {0}s", 1); + m_ReconnectStarting?.Invoke(this, e); + + m_reConnectHandler = new SessionReconnectHandler(true, 10000); + m_reConnectHandler.BeginReconnect(m_session, 1000, Server_ReconnectComplete); + + e.CancelKeepAlive = true; + } + } + + + // raise any additional notifications. + m_KeepAliveComplete?.Invoke(this, e); + } + } + + #endregion 私有方法 +} diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa105/OpcUaMaster/OpcUaProperty.cs b/src/Plugin/ThingsGateway.Foundation.OpcUa105/OpcUaMaster/OpcUaProperty.cs new file mode 100644 index 000000000..5e7cac54b --- /dev/null +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa105/OpcUaMaster/OpcUaProperty.cs @@ -0,0 +1,80 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Foundation.OpcUa105; + +/// +/// OpcUaMaster配置项 +/// +public class OpcUaProperty +{ + /// + /// 是否订阅 + /// + public bool ActiveSubscribe { get; set; } = true; + + /// + /// 自动接受证书 + /// + public bool AutoAcceptUntrustedCertificates { get; set; } = true; + + /// + /// 检查域 + /// + public bool CheckDomain { get; set; } + + /// + /// 死区 + /// + public double DeadBand { get; set; } = 0; + + /// + /// 分组大小 + /// + public int GroupSize { get; set; } = 500; + + public int KeepAliveInterval { get; set; } = 3000; + + /// + /// 加载服务端数据类型 + /// + public bool LoadType { get; set; } = true; + + /// + /// OpcUrl + /// + public string OpcUrl { get; set; } = "opc.tcp://127.0.0.1:49320"; + + /// + /// 登录密码 + /// + public string Password { get; set; } + + /// + /// 更新间隔 + /// + public int UpdateRate { get; set; } = 1000; + + /// + /// 登录账号 + /// + public string UserName { get; set; } + + /// + /// 安全策略 + /// + public bool UseSecurity { get; set; } = false; + + /// + public override string ToString() + { + return OpcUrl; + } +} diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa105/ThingsGateway.Foundation.OpcUa105.csproj b/src/Plugin/ThingsGateway.Foundation.OpcUa105/ThingsGateway.Foundation.OpcUa105.csproj new file mode 100644 index 000000000..d424ee6ee --- /dev/null +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa105/ThingsGateway.Foundation.OpcUa105.csproj @@ -0,0 +1,25 @@ + + + + + + + net48;netstandard2.1;net6.0; + 工业设备通讯协议-OpcUa协议 + True + + + + + + + + + + + + Never + + + + diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/CollectionExtension.cs b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/CollectionExtension.cs new file mode 100644 index 000000000..47e82dc2d --- /dev/null +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/CollectionExtension.cs @@ -0,0 +1,62 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Foundation.OpcUa105; + +internal static class CollectionExtension +{ + /// + /// 将项目列表分解为特定大小的块 + /// + /// + /// 原数组 + /// 分组大小 + /// 是否ToList + /// + internal static IEnumerable> ChunkBetter(this IEnumerable source, int chunkSize, bool isToList = false) + { + if (chunkSize <= 0) + chunkSize = source.Count(); + var pos = 0; + while (source.Skip(pos).Any()) + { + var chunk = source.Skip(pos).Take(chunkSize); + yield return isToList ? chunk.ToList() : chunk; + pos += chunkSize; + } + } + + /// + /// 移除符合条件的元素 + /// + /// + /// + /// + internal static void RemoveWhere(this ICollection @this, Func @where) + { + foreach (var obj in @this.Where(where).ToList()) + { + @this.Remove(obj); + } + } + + /// + /// 异步Select + /// + /// + /// + /// + /// + /// + internal static Task SelectAsync(this IEnumerable source, Func> selector) + { + return Task.WhenAll(source.Select(selector)); + } +} diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/DictionaryExtension.cs b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/DictionaryExtension.cs new file mode 100644 index 000000000..28dd33b5e --- /dev/null +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/DictionaryExtension.cs @@ -0,0 +1,106 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace ThingsGateway.Foundation.OpcUa105; + +/// +/// DictionaryExtension +/// +internal static class DictionaryExtension +{ + #region 字典扩展 + + /// + /// 移除满足条件的项目。 + /// + /// + /// + /// + /// + /// + internal static int RemoveWhen(this ConcurrentDictionary pairs, Func, bool> func) where TKey : notnull + { + var list = new List(); + foreach (var item in pairs) + { + if (func?.Invoke(item) == true) + { + list.Add(item.Key); + } + } + + var count = 0; + foreach (var item in list) + { + if (pairs.TryRemove(item, out _)) + { + count++; + } + } + return count; + } + +#if NET45_OR_GREATER || NETSTANDARD2_0_OR_GREATER + + /// + /// 尝试添加 + /// + /// + /// + /// + /// + /// + /// + internal static bool TryAdd(this Dictionary dictionary, TKey tkey, TValue value) + { + if (dictionary.ContainsKey(tkey)) + { + return false; + } + dictionary.Add(tkey, value); + return true; + } + +#endif + + /// + /// 尝试添加 + /// + /// + /// + /// + /// + /// + /// + internal static void AddOrUpdate(this Dictionary dictionary, TKey tkey, TValue value) where TKey : notnull + { + if (!dictionary.TryAdd(tkey, value)) + { + dictionary[tkey] = value; + } + } + + /// + /// 获取值。如果键不存在,则返回默认值。 + /// + /// + /// + /// + /// + /// + internal static TValue GetValue(this Dictionary dictionary, TKey tkey) where TKey : notnull + { + return dictionary.TryGetValue(tkey, out var value) ? value : default; + } + + #endregion 字典扩展 +} \ No newline at end of file diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/JsonUtils.cs b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/JsonUtils.cs new file mode 100644 index 000000000..7bc6882f9 --- /dev/null +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/JsonUtils.cs @@ -0,0 +1,530 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Newtonsoft.Json; + +using System.Collections; +using System.Text; +using System.Xml; + +#pragma warning disable CS8605 // 取消装箱可能为 null 的值。 + +namespace ThingsGateway.Foundation.OpcUa105; + +/// +/// 扩展方法 +/// +public static class JsonUtils +{ + #region Decode + + /// + /// 解析获取DataValue + /// + /// + public static DataValue Decode( + IServiceMessageContext Context, + NodeId dataTypeId, + BuiltInType builtInType, + int valueRank, + JToken json + ) + { + var data = DecoderObject(Context, dataTypeId, builtInType, valueRank, json); + var dataValue = new DataValue(new Variant(data)); + return dataValue; + } + + /// + /// 解析获取object + /// + /// + public static object DecoderObject( + IServiceMessageContext Context, + NodeId dataTypeId, + BuiltInType builtInType, + int valueRank, + JToken json + ) + { + object newData; + switch (builtInType) + { + case BuiltInType.ExtensionObject: + newData = new + { + Value = new + { + TypeId = new { Id = dataTypeId.Identifier, Namespace = dataTypeId.NamespaceIndex }, + Body = json + } + }.ToJsonString(); + break; + + case BuiltInType.Variant: + var type = TypeInfo.GetDataTypeId(GetSystemType(json.Type)); + newData = new + { + Value = new + { + Type = type.Identifier, + Body = json + } + }.ToJsonString(); + break; + + default: + newData = new + { + Value = json + }.ToJsonString(); + break; + } + + using var decoder = new JsonDecoder(newData.ToString(), Context); + var data = DecodeRawData(decoder, builtInType, valueRank, "Value"); + return data; + } + + /// + /// DecodeRawData + /// + /// + private static object DecodeRawData(JsonDecoder decoder, BuiltInType builtInType, int ValueRank, string fieldName) + { + if (builtInType != 0) + { + if (ValueRank == ValueRanks.Scalar) + { + switch (builtInType) + { + case BuiltInType.Null: { var variant = decoder.ReadVariant(fieldName); return variant.Value; } + case BuiltInType.Boolean: { return decoder.ReadBoolean(fieldName); } + case BuiltInType.SByte: { return decoder.ReadSByte(fieldName); } + case BuiltInType.Byte: { return decoder.ReadByte(fieldName); } + case BuiltInType.Int16: { return decoder.ReadInt16(fieldName); } + case BuiltInType.UInt16: { return decoder.ReadUInt16(fieldName); } + case BuiltInType.Int32: { return decoder.ReadInt32(fieldName); } + case BuiltInType.UInt32: { return decoder.ReadUInt32(fieldName); } + case BuiltInType.Int64: { return decoder.ReadInt64(fieldName); } + case BuiltInType.UInt64: { return decoder.ReadUInt64(fieldName); } + case BuiltInType.Float: { return decoder.ReadFloat(fieldName); } + case BuiltInType.Double: { return decoder.ReadDouble(fieldName); } + case BuiltInType.String: { return decoder.ReadField(fieldName, out var cancellationToken) ? cancellationToken?.ToString() : null; } + case BuiltInType.DateTime: { return decoder.ReadDateTime(fieldName); } + case BuiltInType.Guid: { return decoder.ReadGuid(fieldName); } + case BuiltInType.ByteString: { return decoder.ReadByteString(fieldName); } + case BuiltInType.XmlElement: { return decoder.ReadXmlElement(fieldName); } + case BuiltInType.NodeId: { return decoder.ReadNodeId(fieldName); } + case BuiltInType.ExpandedNodeId: { return decoder.ReadExpandedNodeId(fieldName); } + case BuiltInType.StatusCode: { return decoder.ReadStatusCode(fieldName); } + case BuiltInType.QualifiedName: { return decoder.ReadQualifiedName(fieldName); } + case BuiltInType.LocalizedText: { return decoder.ReadLocalizedText(fieldName); } + case BuiltInType.ExtensionObject: { return decoder.ReadExtensionObject(fieldName); } + case BuiltInType.DataValue: { return decoder.ReadDataValue(fieldName); } + case BuiltInType.Enumeration: + { + Type type = TypeInfo.GetSystemType(builtInType, ValueRank); + return type.IsEnum ? decoder.ReadEnumerated(fieldName, type) : decoder.ReadInt32(fieldName); + } + case BuiltInType.DiagnosticInfo: { return decoder.ReadDiagnosticInfo(fieldName); } + case BuiltInType.Variant: { return decoder.ReadVariant(fieldName); } + } + } + if (ValueRank >= ValueRanks.OneDimension) + { + return decoder.ReadArray(fieldName, ValueRank, builtInType); + } + } + return null; + } + + #endregion Decode + + #region Encode + + /// + /// OPCUAValue解析为Jtoken + /// + /// + /// + /// + /// + internal static JToken Encode( + IServiceMessageContext Context, + BuiltInType type, + object value + ) + { + //对于Integer,Int64,Number等会转化为string JValue! + + using var encoder = CreateEncoder(Context, null, false); + Encode(encoder, type, "Value", value); + var textbuffer = encoder.CloseAndReturnText(); + using var stringReader = new StringReader(textbuffer); + using var jsonReader = new JsonTextReader(stringReader); + var jToken = JToken.Load(jsonReader); + return jToken["Value"]; + } + + /// + /// CreateEncoder + /// + /// + private static JsonEncoder CreateEncoder( + IServiceMessageContext context, + Stream stream, + bool useReversibleEncoding = false, + bool topLevelIsArray = false, + bool includeDefaultValues = true, + bool includeDefaultNumbers = true + ) + { + return new JsonEncoder(context, useReversibleEncoding, topLevelIsArray, stream) + { + IncludeDefaultValues = includeDefaultValues, + IncludeDefaultNumberValues = includeDefaultNumbers + }; + } + + private static void Encode(JsonEncoder encoder, BuiltInType builtInType, string fieldName, object value) + { + bool isArray = (value?.GetType().IsArray ?? false) && (builtInType != BuiltInType.ByteString); + bool isCollection = (value is IList) && (builtInType != BuiltInType.ByteString); + if (!isArray && !isCollection) + { + switch (builtInType) + { + case BuiltInType.Null: { encoder.WriteVariant(fieldName, new Variant(value)); return; } + //case BuiltInType.Boolean: { encoder.WriteBoolean(fieldName, (bool)value); return; } + //case BuiltInType.SByte: { encoder.WriteSByte(fieldName, (sbyte)value); return; } + //case BuiltInType.Byte: { encoder.WriteByte(fieldName, (byte)value); return; } + //case BuiltInType.Int16: { encoder.WriteInt16(fieldName, (short)value); return; } + //case BuiltInType.UInt16: { encoder.WriteUInt16(fieldName, (ushort)value); return; } + //case BuiltInType.Int32: { encoder.WriteInt32(fieldName, (int)value); return; } + //case BuiltInType.UInt32: { encoder.WriteUInt32(fieldName, (uint)value); return; } + //case BuiltInType.Int64: { encoder.WriteInt64(fieldName, (long)value); return; } + //case BuiltInType.UInt64: { encoder.WriteUInt64(fieldName, (ulong)value); return; } + //case BuiltInType.Float: { encoder.WriteFloat(fieldName, (float)value); return; } + //case BuiltInType.Double: { encoder.WriteDouble(fieldName, (double)value); return; } + + //case BuiltInType.Integer: { encoder.WriteInt64(fieldName, (long)value); return; } + //case BuiltInType.Number: { encoder.WriteInt64(fieldName, (long)value); return; } + //case BuiltInType.UInteger: { encoder.WriteUInt64(fieldName, (ulong)value); return; } + //case BuiltInType.String: { encoder.WriteString(fieldName, value?.ToString()); return; } + //case BuiltInType.DateTime: { encoder.WriteDateTime(fieldName, (DateTime)value); return; } + case BuiltInType.Boolean: + { + encoder.WriteBoolean(fieldName, Convert.ToBoolean(value)); + return; + } + case BuiltInType.SByte: + { + encoder.WriteSByte(fieldName, Convert.ToSByte(value)); + return; + } + case BuiltInType.Byte: + { + encoder.WriteByte(fieldName, Convert.ToByte(value)); + return; + } + case BuiltInType.Int16: + { + encoder.WriteInt16(fieldName, Convert.ToInt16(value)); + return; + } + case BuiltInType.UInt16: + { + encoder.WriteUInt16(fieldName, Convert.ToUInt16(value)); + return; + } + case BuiltInType.Int32: + { + encoder.WriteInt32(fieldName, Convert.ToInt32(value)); + return; + } + case BuiltInType.UInt32: + { + encoder.WriteUInt32(fieldName, Convert.ToUInt32(value)); + return; + } + case BuiltInType.Int64: + { + encoder.WriteInt64(fieldName, Convert.ToInt64(value)); + return; + } + case BuiltInType.UInt64: + { + encoder.WriteUInt64(fieldName, Convert.ToUInt64(value)); + return; + } + case BuiltInType.Float: + { + encoder.WriteFloat(fieldName, Convert.ToSingle(value)); + return; + } + case BuiltInType.Double: + { + encoder.WriteDouble(fieldName, Convert.ToDouble(value)); + return; + } + case BuiltInType.Integer: + { + encoder.WriteInt64(fieldName, Convert.ToInt64(value)); + return; + } + case BuiltInType.Number: + { + encoder.WriteInt64(fieldName, Convert.ToInt64(value)); + return; + } + case BuiltInType.UInteger: + { + encoder.WriteUInt64(fieldName, Convert.ToUInt64(value)); + return; + } + case BuiltInType.String: + { + encoder.WriteString(fieldName, Convert.ToString(value)); + return; + } + case BuiltInType.DateTime: + { + encoder.WriteDateTime(fieldName, Convert.ToDateTime(value)); + return; + } + + case BuiltInType.Guid: { encoder.WriteGuid(fieldName, (Uuid)value); return; } + case BuiltInType.ByteString: { encoder.WriteByteString(fieldName, (byte[])value); return; } + case BuiltInType.XmlElement: { encoder.WriteXmlElement(fieldName, (XmlElement)value); return; } + case BuiltInType.NodeId: { encoder.WriteNodeId(fieldName, (NodeId)value); return; } + case BuiltInType.ExpandedNodeId: { encoder.WriteExpandedNodeId(fieldName, (ExpandedNodeId)value); return; } + case BuiltInType.StatusCode: { encoder.WriteStatusCode(fieldName, (StatusCode)value); return; } + case BuiltInType.QualifiedName: { encoder.WriteQualifiedName(fieldName, (QualifiedName)value); return; } + case BuiltInType.LocalizedText: { encoder.WriteLocalizedText(fieldName, (LocalizedText)value); return; } + case BuiltInType.ExtensionObject: { encoder.WriteExtensionObject(fieldName, (ExtensionObject)value); return; } + case BuiltInType.DataValue: { encoder.WriteDataValue(fieldName, (DataValue)value); return; } + case BuiltInType.Enumeration: + { + if (value?.GetType().IsEnum == true) + { + encoder.WriteEnumerated(fieldName, (Enum)value); + } + else + { + encoder.WriteEnumerated(fieldName, (Enumeration)value); + } + return; + } + case BuiltInType.Variant: { encoder.WriteVariant(fieldName, new Variant(value)); return; } + case BuiltInType.DiagnosticInfo: { encoder.WriteDiagnosticInfo(fieldName, (DiagnosticInfo)value); return; } + + //case BuiltInType.Boolean: + //case BuiltInType.SByte: + //case BuiltInType.Byte: + //case BuiltInType.Int16: + //case BuiltInType.UInt16: + //case BuiltInType.Int32: + //case BuiltInType.UInt32: + //case BuiltInType.Int64: + //case BuiltInType.UInt64: + //case BuiltInType.Float: + //case BuiltInType.Double: + //case BuiltInType.String: + //case BuiltInType.Number: + //case BuiltInType.Integer: + //case BuiltInType.UInteger: + // { encoder.WriteString(fieldName, value?.ToString()); return; } + } + } + else + { + Array c = value as Array; + encoder.WriteArray(fieldName, c, c.Rank, builtInType); + } + } + + #endregion Encode + + #region json + + /// + /// 维度 + /// + /// + /// + internal static int CalculateActualValueRank(this JToken jToken) + { + if (jToken.Type != JTokenType.Array) + return -1; + + var jArray = jToken.ToArray(); + int numDimensions = 1; + + while (jArray.GetElementsType() == JTokenType.Array) + { + jArray = jArray.Children().ToArray(); + numDimensions++; + } + return numDimensions; + } + + private static bool ElementsHasSameType(this JToken[] jTokens) + { + var checkType = jTokens[0].Type == JTokenType.Integer ? JTokenType.Float : jTokens[0].Type; + return jTokens + .Select(x => (x.Type == JTokenType.Integer) ? JTokenType.Float : x.Type) + .All(t => t == checkType); + } + + private static JTokenType GetElementsType(this JToken[] jTokens) + { + if (!jTokens.ElementsHasSameType()) + throw new Exception("The array sent must have the same type of element in each dimension"); + return jTokens.First().Type; + } + + private static Type GetSystemType(JTokenType jsonType) + { + return jsonType switch + { + JTokenType.None => typeof(string), + JTokenType.Object => typeof(string), + JTokenType.Array => typeof(Array), + JTokenType.Constructor => typeof(string), + JTokenType.Property => typeof(string), + JTokenType.Comment => typeof(string), + JTokenType.Integer => typeof(long), + JTokenType.Float => typeof(float), + JTokenType.String => typeof(string), + JTokenType.Boolean => typeof(bool), + JTokenType.Null => typeof(string), + JTokenType.Undefined => typeof(string), + JTokenType.Date => typeof(DateTime), + JTokenType.Raw => typeof(string), + JTokenType.Bytes => typeof(byte[]), + JTokenType.Guid => typeof(Guid), + JTokenType.Uri => typeof(Uri), + JTokenType.TimeSpan => typeof(TimeSpan), + _ => null, + }; + } + + #endregion json + + #region Json序列化和反序列化 + + /// + /// 从字符串到json + /// + /// + /// + /// + internal static object FromJsonString(this string json, Type type) + { + return Newtonsoft.Json.JsonConvert.DeserializeObject(json, type); + } + + /// + /// 从字符串到json + /// + /// + /// + /// + internal static T FromJsonString(this string json) + { + return (T)FromJsonString(json, typeof(T)); + } + + /// + /// Json反序列化 + /// + /// 反序列化类型 + /// 数据 + /// + internal static T JsonDeserializeFromBytes(byte[] datas) + { + return (T)JsonDeserializeFromBytes(datas, typeof(T)); + } + + /// + /// Json反序列化 + /// + /// + /// + /// + internal static object JsonDeserializeFromBytes(byte[] datas, Type type) + { + return FromJsonString(Encoding.UTF8.GetString(datas), type); + } + + /// + /// Json反序列化 + /// + /// 反序列化类型 + /// 文件路径 + /// + internal static T JsonDeserializeFromFile(string path) + { + return JsonDeserializeFromString(File.ReadAllText(path)); + } + + /// + /// Json反序列化 + /// + /// 类型 + /// json字符串 + /// + internal static T JsonDeserializeFromString(string json) + { + return FromJsonString(json); + } + + /// + /// Json序列化数据对象 + /// + /// 数据对象 + /// + internal static byte[] JsonSerializeToBytes(object obj) + { + return Encoding.UTF8.GetBytes(ToJsonString(obj)); + } + + /// + /// Json序列化至文件 + /// + /// + /// + internal static void JsonSerializeToFile(object obj, string path) + { + using (var fileStream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite)) + { + var date = JsonSerializeToBytes(obj); + fileStream.Write(date, 0, date.Length); + fileStream.Close(); + } + } + + /// + /// 转换为Json + /// + /// + /// + /// + internal static string ToJsonString(this object item, bool isIndented = false) + { + if (isIndented) + return Newtonsoft.Json.JsonConvert.SerializeObject(item, Newtonsoft.Json.Formatting.Indented); + else + return Newtonsoft.Json.JsonConvert.SerializeObject(item); + } + + #endregion Json序列化和反序列化 +} diff --git a/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/OpcUaUtils.cs b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/OpcUaUtils.cs new file mode 100644 index 000000000..33511f748 --- /dev/null +++ b/src/Plugin/ThingsGateway.Foundation.OpcUa105/Utils/OpcUaUtils.cs @@ -0,0 +1,1153 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Text; + +namespace ThingsGateway.Foundation.OpcUa105; + +/// +/// 辅助类 +/// +public class OpcUaUtils +{ + + /// + /// Finds the endpoint that best matches the current settings. + /// + /// The application configuration. + /// The discovery URL. + /// if set to true select an endpoint that uses security. + /// The timeout for the discover operation. + /// The best available endpoint. + public static async Task SelectEndpointAsync( + ApplicationConfiguration application, + string discoveryUrl, + bool useSecurity, + int discoverTimeout + ) + { + var uri = CoreClientUtils.GetDiscoveryUrl(discoveryUrl); + var endpointConfiguration = EndpointConfiguration.Create(); + endpointConfiguration.OperationTimeout = discoverTimeout; + + using (var client = DiscoveryClient.Create(application, uri, endpointConfiguration)) + { + // Connect to the server's discovery endpoint and find the available configuration. + Uri url = new Uri(client.Endpoint.EndpointUrl); + var endpoints = await client.GetEndpointsAsync(null).ConfigureAwait(false); + var selectedEndpoint = CoreClientUtils.SelectEndpoint(application, url, endpoints, useSecurity); + + Uri endpointUrl = Utils.ParseUri(selectedEndpoint.EndpointUrl); + if (endpointUrl != null && endpointUrl.Scheme == uri.Scheme) + { + UriBuilder builder = new UriBuilder(endpointUrl); + builder.Host = uri.DnsSafeHost; + builder.Port = uri.Port; + selectedEndpoint.EndpointUrl = builder.ToString(); + } + + return selectedEndpoint; + } + } + + + /// + /// Browses the address space and returns the references found. + /// + /// The session. + /// The set of browse operations to perform. + /// if set to true a exception will be thrown on an error. + /// + /// The references found. Null if an error occurred. + /// + public static ReferenceDescriptionCollection Browse(ISession session, BrowseDescriptionCollection nodesToBrowse, bool throwOnError) + { + try + { + ReferenceDescriptionCollection references = new(); + BrowseDescriptionCollection unprocessedOperations = new(); + + while (nodesToBrowse.Count > 0) + { + // start the browse operation. + + session.Browse( + null, + null, + 0, + nodesToBrowse, + out BrowseResultCollection results, + out DiagnosticInfoCollection diagnosticInfos); + + ClientBase.ValidateResponse(results, nodesToBrowse); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, nodesToBrowse); + + ByteStringCollection continuationPoints = new(); + + for (int ii = 0; ii < nodesToBrowse.Count; ii++) + { + // check for error. + if (StatusCode.IsBad(results[ii].StatusCode)) + { + // this error indicates that the server does not have enough simultaneously active + // continuation points. This request will need to be resent after the other operations + // have been completed and their continuation points released. + if (results[ii].StatusCode == StatusCodes.BadNoContinuationPoints) + { + unprocessedOperations.Add(nodesToBrowse[ii]); + } + + continue; + } + + // check if all references have been fetched. + if (results[ii].References.Count == 0) + { + continue; + } + + // save results. + references.AddRange(results[ii].References); + + // check for continuation point. + if (results[ii].ContinuationPoint != null) + { + continuationPoints.Add(results[ii].ContinuationPoint); + } + } + + // process continuation points. + ByteStringCollection revisedContiuationPoints = new(); + + while (continuationPoints.Count > 0) + { + // continue browse operation. + session.BrowseNext( + null, + true, + continuationPoints, + out results, + out diagnosticInfos); + + ClientBase.ValidateResponse(results, continuationPoints); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, continuationPoints); + + for (int ii = 0; ii < continuationPoints.Count; ii++) + { + // check for error. + if (StatusCode.IsBad(results[ii].StatusCode)) + { + continue; + } + + // check if all references have been fetched. + if (results[ii].References.Count == 0) + { + continue; + } + + // save results. + references.AddRange(results[ii].References); + + // check for continuation point. + if (results[ii].ContinuationPoint != null) + { + revisedContiuationPoints.Add(results[ii].ContinuationPoint); + } + } + + // check if browsing must continue; + revisedContiuationPoints = continuationPoints; + } + + // check if unprocessed results exist. + nodesToBrowse = unprocessedOperations; + } + + // return complete list. + return references; + } + catch (Exception exception) + { + if (throwOnError) + { + throw new ServiceResultException(exception, StatusCodes.BadUnexpectedError); + } + + return null; + } + } + + /// + /// 浏览地址空间 + /// + /// + /// + /// + /// + /// + /// + public static async Task BrowseAsync(ISession session, BrowseDescriptionCollection nodesToBrowse, bool throwOnError, CancellationToken cancellationToken = default) + { + try + { + ReferenceDescriptionCollection references = new(); + BrowseDescriptionCollection unprocessedOperations = new(); + + while (nodesToBrowse.Count > 0) + { + // start the browse operation. + + var result = await session.BrowseAsync( + null, + null, + 0, + nodesToBrowse, cancellationToken).ConfigureAwait(false); + var results = result.Results; + var diagnosticInfos = result.DiagnosticInfos; + ClientBase.ValidateResponse(results, nodesToBrowse); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, nodesToBrowse); + + var continuationPoints = PrepareBrowseNext(result.Results); + + for (int ii = 0; ii < nodesToBrowse.Count; ii++) + { + // check if all references have been fetched. + if (results[ii].References.Count == 0) + { + continue; + } + + // check for error. + if (StatusCode.IsBad(results[ii].StatusCode)) + { + // this error indicates that the server does not have enough simultaneously active + // continuation points. This request will need to be resent after the other operations + // have been completed and their continuation points released. + if (results[ii].StatusCode == StatusCodes.BadNoContinuationPoints) + { + unprocessedOperations.Add(nodesToBrowse[ii]); + } + + continue; + } + + // save results. + references.AddRange(results[ii].References); + } + + while (continuationPoints.Any()) + { + // continue browse operation. + var nextResult = await session.BrowseNextAsync( + null, + false, + continuationPoints + , cancellationToken).ConfigureAwait(false); + results = nextResult.Results; + diagnosticInfos = nextResult.DiagnosticInfos; + ClientBase.ValidateResponse(results, continuationPoints); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, continuationPoints); + + for (int ii = 0; ii < continuationPoints.Count; ii++) + { + // check if all references have been fetched. + if (results[ii].References.Count == 0) + { + continue; + } + + // check for error. + if (StatusCode.IsBad(results[ii].StatusCode)) + { + continue; + } + + // save results. + references.AddRange(results[ii].References); + } + + // check if browsing must continue; + continuationPoints = PrepareBrowseNext(nextResult.Results); + } + + // check if unprocessed results exist. + nodesToBrowse = unprocessedOperations; + } + + // return complete list. + return references; + } + catch (Exception exception) + { + if (throwOnError) + { + throw new ServiceResultException(exception, StatusCodes.BadUnexpectedError); + } + + return null; + } + } + + /// + /// 浏览地址空间 + /// + /// + /// + /// + /// + /// + /// + public static async Task BrowseAsync(ISession session, BrowseDescription nodeToBrowse, bool throwOnError, CancellationToken cancellationToken = default) + { + try + { + ReferenceDescriptionCollection references = new(); + + // construct browse request. + BrowseDescriptionCollection nodesToBrowse = new() + { + nodeToBrowse + }; + + // start the browse operation. + + var result = await session.BrowseAsync( + null, + null, + 0, + nodesToBrowse, cancellationToken).ConfigureAwait(false); + var results = result.Results; + var diagnosticInfos = result.DiagnosticInfos; + ClientBase.ValidateResponse(results, nodesToBrowse); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, nodesToBrowse); + + do + { + // check for error. + if (StatusCode.IsBad(results[0].StatusCode)) + { + throw new ServiceResultException(results[0].StatusCode); + } + + // process results. + for (int ii = 0; ii < results[0].References.Count; ii++) + { + references.Add(results[0].References[ii]); + } + + // check if all references have been fetched. + if (results[0].References.Count == 0 || results[0].ContinuationPoint == null) + { + break; + } + + // continue browse operation. + ByteStringCollection continuationPoints = new() + { + results[0].ContinuationPoint + }; + + var nextResult = await session.BrowseNextAsync( + null, + false, + continuationPoints, cancellationToken).ConfigureAwait(false); + results = nextResult.Results; + diagnosticInfos = nextResult.DiagnosticInfos; + ClientBase.ValidateResponse(results, continuationPoints); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, continuationPoints); + } + while (true); + + //return complete list. + return references; + } + catch (Exception exception) + { + if (throwOnError) + { + throw new ServiceResultException(exception, StatusCodes.BadUnexpectedError); + } + + return null; + } + } + + /// + /// 浏览地址空间并返回指定类型的所有节点 + /// + /// + /// + /// + /// + /// + public static async Task BrowseSuperTypesAsync(ISession session, NodeId typeId, bool throwOnError) + { + ReferenceDescriptionCollection supertypes = new(); + + try + { + // find all of the children of the field. + BrowseDescription nodeToBrowse = new() + { + NodeId = typeId, + BrowseDirection = BrowseDirection.Inverse, + ReferenceTypeId = ReferenceTypeIds.HasSubtype, + IncludeSubtypes = false, // more efficient to use IncludeSubtypes=False when possible. + NodeClassMask = 0, // the HasSubtype reference already restricts the targets to Types. + ResultMask = (uint)BrowseResultMask.All + }; + + ReferenceDescriptionCollection references = await BrowseAsync(session, nodeToBrowse, throwOnError).ConfigureAwait(false); + + while (references != null && references.Count > 0) + { + // should never be more than one supertype. + supertypes.Add(references[0]); + + // only follow references within this server. + if (references[0].NodeId.IsAbsolute) + { + break; + } + + // get the references for the next level up. + nodeToBrowse.NodeId = (NodeId)references[0].NodeId; + references = await BrowseAsync(session, nodeToBrowse, throwOnError).ConfigureAwait(false); + } + + // return complete list. + return supertypes; + } + catch (Exception exception) + { + if (throwOnError) + { + throw new ServiceResultException(exception, StatusCodes.BadUnexpectedError); + } + + return null; + } + } + + /// + /// Collects the fields for the instance. + /// + public static async Task CollectFieldsForInstanceAsync(ISession session, NodeId instanceId, SimpleAttributeOperandCollection fields, List fieldNodeIds) + { + Dictionary foundNodes = new(); + QualifiedNameCollection parentPath = new(); + await CollectFieldsAsync(session, instanceId, parentPath, fields, fieldNodeIds, foundNodes).ConfigureAwait(false); + } + + /// + /// Collects the fields for the type. + /// + public static async Task CollectFieldsForType(ISession session, NodeId typeId, SimpleAttributeOperandCollection fields, List fieldNodeIds) + { + // get the supertypes. + ReferenceDescriptionCollection supertypes = await OpcUaUtils.BrowseSuperTypesAsync(session, typeId, false).ConfigureAwait(false); + + if (supertypes == null) + { + return; + } + + // process the types starting from the top of the tree. + Dictionary foundNodes = new(); + QualifiedNameCollection parentPath = new(); + + for (int ii = supertypes.Count - 1; ii >= 0; ii--) + { + await CollectFieldsAsync(session, (NodeId)supertypes[ii].NodeId, parentPath, fields, fieldNodeIds, foundNodes).ConfigureAwait(false); + } + + // collect the fields for the selected type. + await CollectFieldsAsync(session, typeId, parentPath, fields, fieldNodeIds, foundNodes).ConfigureAwait(false); + } + + /// + /// Constructs an event object from a notification. + /// + /// The session. + /// The monitored item that produced the notification. + /// The notification. + /// The known event types. + /// Mapping between event types and known event types. + /// + /// The event object. Null if the notification is not a valid event type. + /// + public static async Task ConstructEventAsync( + ISession session, + MonitoredItem monitoredItem, + EventFieldList notification, + Dictionary knownEventTypes, + Dictionary eventTypeMappings) + { + // find the event type. + NodeId eventTypeId = FindEventType(monitoredItem, notification); + + if (eventTypeId == null) + { + return null; + } + + // look up the known event type. + Type knownType = null; + if (eventTypeMappings.TryGetValue(eventTypeId, out NodeId knownTypeId)) + { + knownType = knownEventTypes[knownTypeId]; + } + + // try again. + if (knownType == null) + { + if (knownEventTypes.TryGetValue(eventTypeId, out knownType)) + { + knownTypeId = eventTypeId; + eventTypeMappings.Add(eventTypeId, eventTypeId); + } + } + + // try mapping it to a known type. + if (knownType == null) + { + // browse for the supertypes of the event type. + ReferenceDescriptionCollection supertypes = await OpcUaUtils.BrowseSuperTypesAsync(session, eventTypeId, false).ConfigureAwait(false); + + // can't do anything with unknown types. + if (supertypes == null) + { + return null; + } + + // find the first supertype that matches a known event type. + for (int ii = 0; ii < supertypes.Count; ii++) + { + NodeId superTypeId = (NodeId)supertypes[ii].NodeId; + + if (knownEventTypes.TryGetValue(superTypeId, out knownType)) + { + knownTypeId = superTypeId; + eventTypeMappings.Add(eventTypeId, superTypeId); + } + + if (knownTypeId != null) + { + break; + } + } + + // can't do anything with unknown types. + if (knownTypeId == null) + { + return null; + } + } + + // construct the event based on the known event type. + BaseEventState e = (BaseEventState)Activator.CreateInstance(knownType, [null]); + + // get the filter which defines the contents of the notification. + EventFilter filter = monitoredItem.Status.Filter as EventFilter; + + // initialize the event with the values in the notification. + e.Update(session.SystemContext, filter.SelectClauses, notification); + + // save the orginal notification. + e.Handle = notification; + + return e; + } + + /// + /// Discovers the servers on the local machine. + /// + /// The configuration. + /// A list of server urls. + public static IList DiscoverServers(ApplicationConfiguration configuration) + { + List serverUrls = new(); + + // set a short timeout because this is happening in the drop down event. + EndpointConfiguration endpointConfiguration = EndpointConfiguration.Create(configuration); + endpointConfiguration.OperationTimeout = 5000; + + // Connect to the local discovery server and find the available servers. + using (DiscoveryClient client = DiscoveryClient.Create(new Uri("opc.tcp://localhost:4840"), endpointConfiguration)) + { + ApplicationDescriptionCollection servers = client.FindServers(null); + + // populate the drop down list with the discovery URLs for the available servers. + for (int ii = 0; ii < servers.Count; ii++) + { + if (servers[ii].ApplicationType == Opc.Ua.ApplicationType.DiscoveryServer) + { + continue; + } + + for (int jj = 0; jj < servers[ii].DiscoveryUrls.Count; jj++) + { + string discoveryUrl = servers[ii].DiscoveryUrls[jj]; + + // Many servers will use the '/discovery' suffix for the discovery endpoint. + // The URL without this prefix should be the base URL for the server. + if (discoveryUrl.EndsWith("/discovery")) + { + discoveryUrl = discoveryUrl.Substring(0, discoveryUrl.Length - "/discovery".Length); + } + + // ensure duplicates do not get added. + if (!serverUrls.Contains(discoveryUrl)) + { + serverUrls.Add(discoveryUrl); + } + } + } + } + + return serverUrls; + } + + /// + /// Finds the type of the event for the notification. + /// + /// The monitored item. + /// The notification. + /// The NodeId of the EventType. + public static NodeId FindEventType(MonitoredItem monitoredItem, EventFieldList notification) + { + if (monitoredItem.Status.Filter is EventFilter filter) + { + for (int ii = 0; ii < filter.SelectClauses.Count; ii++) + { + SimpleAttributeOperand clause = filter.SelectClauses[ii]; + + if (clause.BrowsePath.Count == 1 && clause.BrowsePath[0] == BrowseNames.EventType) + { + return notification.EventFields[ii].Value as NodeId; + } + } + } + + return null; + } + + /// + /// 指定的属性的显示文本。 + /// + public static string GetAttributeDisplayText(ISession session, uint attributeId, Variant value) + { + if (value == Variant.Null) + { + return String.Empty; + } + + switch (attributeId) + { + case Attributes.AccessLevel: + case Attributes.UserAccessLevel: + { + byte? field = value.Value as byte?; + + if (field != null) + { + return GetAccessLevelDisplayText(field.Value); + } + + break; + } + + case Attributes.EventNotifier: + { + byte? field = value.Value as byte?; + + if (field != null) + { + return GetEventNotifierDisplayText(field.Value); + } + + break; + } + + case Attributes.DataType: + { + return session.NodeCache.GetDisplayText(value.Value as NodeId); + } + + case Attributes.ValueRank: + { + int? field = value.Value as int?; + + if (field != null) + { + return GetValueRankDisplayText(field.Value); + } + + break; + } + + case Attributes.NodeClass: + { + int? field = value.Value as int?; + + if (field != null) + { + return ((NodeClass)field.Value).ToString(); + } + + break; + } + + case Attributes.NodeId: + { + NodeId field = value.Value as NodeId; + + if (!NodeId.IsNull(field)) + { + return field.ToString(); + } + + return "Null"; + } + } + + // check for byte strings. + if (value.Value is byte[]) + { + return Utils.ToHexString(value.Value as byte[]); + } + + // use default format. + return value.ToString(); + } + + /// + /// Finds the endpoint that best matches the current settings. + /// + /// The discovery URL. + /// if set to true select an endpoint that uses security. + /// The best available endpoint. + public static EndpointDescription SelectEndpoint(string discoveryUrl, bool useSecurity) + { + // needs to add the '/discovery' back onto non-UA TCP URLs. + if (!discoveryUrl.StartsWith(Utils.UriSchemeOpcTcp)) + { + if (!discoveryUrl.EndsWith("/discovery")) + { + discoveryUrl += "/discovery"; + } + } + + // parse the selected URL. + Uri uri = new(discoveryUrl); + + // set a short timeout because this is happening in the drop down event. + EndpointConfiguration configuration = EndpointConfiguration.Create(); + configuration.OperationTimeout = 5000; + + EndpointDescription selectedEndpoint = null; + + // Connect to the server's discovery endpoint and find the available configuration. + using (DiscoveryClient client = DiscoveryClient.Create(uri, configuration)) + { + EndpointDescriptionCollection endpoints = client.GetEndpoints(null); + + // select the best endpoint to use based on the selected URL and the UseSecurity checkbox. + for (int ii = 0; ii < endpoints.Count; ii++) + { + EndpointDescription endpoint = endpoints[ii]; + + // check for a match on the URL scheme. + if (endpoint.EndpointUrl.StartsWith(uri.Scheme)) + { + // check if security was requested. + if (useSecurity) + { + if (endpoint.SecurityMode == MessageSecurityMode.None) + { + continue; + } + } + else + { + if (endpoint.SecurityMode != MessageSecurityMode.None) + { + continue; + } + } + + // pick the first available endpoint by default. + selectedEndpoint ??= endpoint; + + // The security level is a relative measure assigned by the server to the + // endpoints that it returns. Clients should always pick the highest level + // unless they have a reason not too. + if (endpoint.SecurityLevel > selectedEndpoint.SecurityLevel) + { + selectedEndpoint = endpoint; + } + } + } + + // pick the first available endpoint by default. + if (selectedEndpoint == null && endpoints.Count > 0) + { + selectedEndpoint = endpoints[0]; + } + } + + // if a server is behind a firewall it may return URLs that are not accessible to the client. + // This problem can be avoided by assuming that the domain in the URL used to call + // GetEndpoints can be used to access any of the endpoints. This code makes that conversion. + // Note that the conversion only makes sense if discovery uses the same protocol as the endpoint. + + Uri endpointUrl = Utils.ParseUri(selectedEndpoint.EndpointUrl); + + if (endpointUrl != null && endpointUrl.Scheme == uri.Scheme) + { + UriBuilder builder = new(endpointUrl) + { + Host = uri.DnsSafeHost, + Port = uri.Port + }; + selectedEndpoint.EndpointUrl = builder.ToString(); + } + + // return the selected endpoint. + return selectedEndpoint; + } + + /// + /// 返回一组相对路径的节点id + /// + public static async Task> TranslateBrowsePaths( + ISession session, + NodeId startNodeId, + NamespaceTable namespacesUris, CancellationToken cancellationToken, + params string[] relativePaths) + { + // build the list of browse paths to follow by parsing the relative paths. + BrowsePathCollection browsePaths = new(); + + if (relativePaths != null) + { + for (int ii = 0; ii < relativePaths.Length; ii++) + { + BrowsePath browsePath = new() + { + RelativePath = RelativePath.Parse( + relativePaths[ii], + session.TypeTree, + namespacesUris, + session.NamespaceUris), + + StartingNode = startNodeId + }; + + browsePaths.Add(browsePath); + } + } + + // make the call to the server. + + var result = await session.TranslateBrowsePathsToNodeIdsAsync( + null, + browsePaths, + cancellationToken).ConfigureAwait(false); + BrowsePathResultCollection results = result.Results; + DiagnosticInfoCollection diagnosticInfos = result.DiagnosticInfos; + // ensure that the server returned valid results. + ClientBase.ValidateResponse(results, browsePaths); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, browsePaths); + + // collect the list of node ids found. + List nodes = new(); + + for (int ii = 0; ii < results.Count; ii++) + { + // check if the start node actually exists. + if (StatusCode.IsBad(results[ii].StatusCode)) + { + nodes.Add(null); + continue; + } + + // an empty list is returned if no node was found. + if (results[ii].Targets.Count == 0) + { + nodes.Add(null); + continue; + } + + // Multiple matches are possible, however, the node that matches the type model is the + // one we are interested in here. The rest can be ignored. + BrowsePathTarget target = results[ii].Targets[0]; + + if (target.RemainingPathIndex != UInt32.MaxValue) + { + nodes.Add(null); + continue; + } + + // The targetId is an ExpandedNodeId because it could be node in another server. + // The ToNodeId function is used to convert a local NodeId stored in a ExpandedNodeId to a NodeId. + nodes.Add(ExpandedNodeId.ToNodeId(target.TargetId, session.NamespaceUris)); + } + + // return whatever was found. + return nodes; + } + + /// + /// Collects the fields for the instance node. + /// + /// The session. + /// The node id. + /// The parent path. + /// The event fields. + /// The node id for the declaration of the field. + /// The table of found nodes. + private static async Task CollectFieldsAsync( + ISession session, + NodeId nodeId, + QualifiedNameCollection parentPath, + SimpleAttributeOperandCollection fields, + List fieldNodeIds, + Dictionary foundNodes) + { + // find all of the children of the field. + BrowseDescription nodeToBrowse = new() + { + NodeId = nodeId, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.Aggregates, + IncludeSubtypes = true, + NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable), + ResultMask = (uint)BrowseResultMask.All + }; + + ReferenceDescriptionCollection children = await OpcUaUtils.BrowseAsync(session, nodeToBrowse, false).ConfigureAwait(false); + + if (children == null) + { + return; + } + + // process the children. + for (int ii = 0; ii < children.Count; ii++) + { + ReferenceDescription child = children[ii]; + + if (child.NodeId.IsAbsolute) + { + continue; + } + + // construct browse path. + QualifiedNameCollection browsePath = new(parentPath) + { + child.BrowseName + }; + + // check if the browse path is already in the list. + int index = ContainsPath(fields, browsePath); + + if (index < 0) + { + SimpleAttributeOperand field = new() + { + TypeDefinitionId = ObjectTypeIds.BaseEventType, + BrowsePath = browsePath, + AttributeId = (child.NodeClass == NodeClass.Variable) ? Attributes.Value : Attributes.NodeId + }; + + fields.Add(field); + fieldNodeIds.Add((NodeId)child.NodeId); + } + + // recusively find all of the children. + NodeId targetId = (NodeId)child.NodeId; + + // need to guard against loops. + if (foundNodes.TryAdd(targetId, browsePath)) + { + await CollectFieldsAsync(session, (NodeId)child.NodeId, browsePath, fields, fieldNodeIds, foundNodes).ConfigureAwait(false); + } + } + } + + /// + /// 判断指定的select子句包含的浏览路径。 + /// + private static int ContainsPath(SimpleAttributeOperandCollection selectClause, QualifiedNameCollection browsePath) + { + for (int ii = 0; ii < selectClause.Count; ii++) + { + SimpleAttributeOperand field = selectClause[ii]; + + if (field.BrowsePath.Count != browsePath.Count) + { + continue; + } + + bool match = true; + + for (int jj = 0; jj < field.BrowsePath.Count; jj++) + { + if (field.BrowsePath[jj] != browsePath[jj]) + { + match = false; + break; + } + } + + if (match) + { + return ii; + } + } + + return -1; + } + + /// + ///访问级别属性的显示文本。 + /// + private static string GetAccessLevelDisplayText(byte accessLevel) + { + StringBuilder buffer = new(); + + if (accessLevel == AccessLevels.None) + { + buffer.Append("None"); + } + + if ((accessLevel & AccessLevels.CurrentRead) == AccessLevels.CurrentRead) + { + buffer.Append("Read"); + } + + if ((accessLevel & AccessLevels.CurrentWrite) == AccessLevels.CurrentWrite) + { + if (buffer.Length > 0) + { + buffer.Append(" | "); + } + + buffer.Append("Write"); + } + + if ((accessLevel & AccessLevels.HistoryRead) == AccessLevels.HistoryRead) + { + if (buffer.Length > 0) + { + buffer.Append(" | "); + } + + buffer.Append("HistoryRead"); + } + + if ((accessLevel & AccessLevels.HistoryWrite) == AccessLevels.HistoryWrite) + { + if (buffer.Length > 0) + { + buffer.Append(" | "); + } + + buffer.Append("HistoryWrite"); + } + + if ((accessLevel & AccessLevels.SemanticChange) == AccessLevels.SemanticChange) + { + if (buffer.Length > 0) + { + buffer.Append(" | "); + } + + buffer.Append("SemanticChange"); + } + + return buffer.ToString(); + } + + /// + /// 事件通知属性的显示文本 + /// + private static string GetEventNotifierDisplayText(byte eventNotifier) + { + StringBuilder buffer = new(); + + if (eventNotifier == EventNotifiers.None) + { + buffer.Append("None"); + } + + if ((eventNotifier & EventNotifiers.SubscribeToEvents) == EventNotifiers.SubscribeToEvents) + { + buffer.Append("Subscribe"); + } + + if ((eventNotifier & EventNotifiers.HistoryRead) == EventNotifiers.HistoryRead) + { + if (buffer.Length > 0) + { + buffer.Append(" | "); + } + + buffer.Append("HistoryRead"); + } + + if ((eventNotifier & EventNotifiers.HistoryWrite) == EventNotifiers.HistoryWrite) + { + if (buffer.Length > 0) + { + buffer.Append(" | "); + } + + buffer.Append("HistoryWrite"); + } + + return buffer.ToString(); + } + + private static string GetValueRankDisplayText(int valueRank) + { + return valueRank switch + { + ValueRanks.Any => "Any", + ValueRanks.Scalar => "Scalar", + ValueRanks.ScalarOrOneDimension => "ScalarOrOneDimension", + ValueRanks.OneOrMoreDimensions => "OneOrMoreDimensions", + ValueRanks.OneDimension => "OneDimension", + ValueRanks.TwoDimensions => "TwoDimensions", + _ => valueRank.ToString(), + }; + } + + /// + /// Create the continuation point collection from the browse result + /// collection for the BrowseNext service. + /// + /// The browse result collection to use. + /// The collection of continuation points for the BrowseNext service. + private static ByteStringCollection PrepareBrowseNext(BrowseResultCollection browseResultCollection) + { + var continuationPoints = new ByteStringCollection(); + foreach (var browseResult in browseResultCollection) + { + if (browseResult.ContinuationPoint != null) + { + continuationPoints.Add(browseResult.ContinuationPoint); + } + } + return continuationPoints; + } +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa/Directory.build.targets b/src/Plugin/ThingsGateway.Plugin.OpcUa/Directory.build.targets index f43cce445..2580bc088 100644 --- a/src/Plugin/ThingsGateway.Plugin.OpcUa/Directory.build.targets +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa/Directory.build.targets @@ -11,7 +11,7 @@ - + diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa/OpcUaServer/Core/ThingsGatewayServer.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa/OpcUaServer/Core/ThingsGatewayServer.cs index 295625d65..b295e767a 100644 --- a/src/Plugin/ThingsGateway.Plugin.OpcUa/OpcUaServer/Core/ThingsGatewayServer.cs +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa/OpcUaServer/Core/ThingsGatewayServer.cs @@ -167,7 +167,7 @@ public partial class ThingsGatewayServer : StandardServer configuration.SecurityConfiguration.UserIssuerCertificates != null) { CertificateValidator certificateValidator = new(); - certificateValidator.Update(configuration.SecurityConfiguration).Wait(); + certificateValidator.Update(configuration).GetAwaiter().GetResult(); certificateValidator.Update(configuration.SecurityConfiguration.UserIssuerCertificates, configuration.SecurityConfiguration.TrustedUserCertificates, configuration.SecurityConfiguration.RejectedCertificateStore); diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa/OpcUaServer/OpcUaServer.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa/OpcUaServer/OpcUaServer.cs index de73c7a41..3af61c117 100644 --- a/src/Plugin/ThingsGateway.Plugin.OpcUa/OpcUaServer/OpcUaServer.cs +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa/OpcUaServer/OpcUaServer.cs @@ -57,7 +57,7 @@ public partial class OpcUaServer : BusinessBase { VariableRuntimes = new(GlobalData.GetEnableVariables()); - CollectDevices = GlobalData.GetEnableDevices().ToDictionary(); + CollectDevices = GlobalData.GetEnableDevices().Where(a => a.Value.IsCollect == true).ToDictionary(); } else { diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa/Pages/OpcUaMaster.razor b/src/Plugin/ThingsGateway.Plugin.OpcUa/Pages/OpcUaMaster.razor index ba8a8184c..130d03e94 100644 --- a/src/Plugin/ThingsGateway.Plugin.OpcUa/Pages/OpcUaMaster.razor +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa/Pages/OpcUaMaster.razor @@ -1,4 +1,4 @@ -@page "/OpcUaMaster" +@page "/OpcUaMaster105" @using BootstrapBlazor.Components @using ThingsGateway.Extension @using ThingsGateway.Foundation diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa/ThingsGateway.Plugin.OpcUa.csproj b/src/Plugin/ThingsGateway.Plugin.OpcUa/ThingsGateway.Plugin.OpcUa.csproj index f6d9fc08b..d5bcb1fee 100644 --- a/src/Plugin/ThingsGateway.Plugin.OpcUa/ThingsGateway.Plugin.OpcUa.csproj +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa/ThingsGateway.Plugin.OpcUa.csproj @@ -49,7 +49,7 @@ contentFiles;compile;build;buildMultitargeting;buildTransitive;analyzers; - + contentFiles;compile;build;buildMultitargeting;buildTransitive;analyzers; diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/Directory.build.targets b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Directory.build.targets new file mode 100644 index 000000000..2580bc088 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Directory.build.targets @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + $(TargetDir) + + + + + + + + + + + + + + + + + + true + Content + + + + + diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/GlobalUsings.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/GlobalUsings.cs new file mode 100644 index 000000000..056310a97 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/GlobalUsings.cs @@ -0,0 +1,14 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +global using System; +global using System.Collections.Generic; + +global using ThingsGateway.Foundation; \ No newline at end of file diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/Locales/en-US.json b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Locales/en-US.json new file mode 100644 index 000000000..3f7715eba --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Locales/en-US.json @@ -0,0 +1,38 @@ +{ + "ThingsGateway.Plugin.OpcUa105.OpcUaImportVariable": { + "NoVariablesAvailable": "No variables available", + "Success": "Success" + }, + "ThingsGateway.Foundation.OpcUa105.OpcUaProperty": { + "OpcUrl": "OpcUrl", + "UserName": "UserName", + "Password": "Password", + "CheckDomain": "CheckDomain", + "UpdateRate": "UpdateRate", + "ActiveSubscribe": "ActiveSubscribe", + "GroupSize": "GroupSize", + "DeadBand": "DeadBand", + "KeepAliveInterval": "KeepAliveInterval(ms)", + "UseSecurity": "UseSecurity", + "LoadType": "LoadType", + "AutoAcceptUntrustedCertificates": "AutoAcceptUntrustedCertificates", + "ExportC": "ExportCertificate", + "SourceTimestampEnable": "SourceTimestampEnable", + "ReIntervalTime": "ReIntervalTime", + "RetryCount": "RetryCount" + }, + "ThingsGateway.Plugin.OpcUa105.OpcUaServerProperty": { + "IsAllVariable": "IsAllVariable", + "OpcUaStringUrl": "OpcUaStringUrl", + "BigTextSubjectName": "SubjectName", + "BigTextApplicationUri": "ApplicationUri", + "SecurityPolicy": "SecurityPolicy", + "AutoAcceptUntrustedCertificates": "AutoAcceptUntrustedCertificates" + }, + "ThingsGateway.Plugin.OpcUa105.OpcUaServerVariableProperty": { + "DataType": "DataType" + }, + "ThingsGateway.Plugin.OpcUa105.OpcUaServer": { + "CanStartService": "Can start service" + } +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/Locales/zh-CN.json b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Locales/zh-CN.json new file mode 100644 index 000000000..e913082f9 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Locales/zh-CN.json @@ -0,0 +1,39 @@ +{ + "ThingsGateway.Plugin.OpcUa105.OpcUaImportVariable": { + "NoVariablesAvailable": "无可用变量", + "Success": "成功" + }, + + "ThingsGateway.Plugin.OpcUa105.OpcUaMasterProperty": { + "OpcUrl": "OpcUrl", + "UserName": "登录账号", + "Password": "登录密码", + "CheckDomain": "检查域", + "UpdateRate": "推送间隔", + "ActiveSubscribe": "订阅", + "GroupSize": "分组大小", + "DeadBand": "死区", + "KeepAliveInterval": "心跳间隔(ms)", + "UseSecurity": "安全策略", + "LoadType": "加载服务端数据类型", + "AutoAcceptUntrustedCertificates": "自动接受不受信任的证书", + "SourceTimestampEnable": "服务端时间戳", + "ExportC": "导出证书", + "ReIntervalTime": "离线恢复时间", + "RetryCount": "失败重试次数" + }, + "ThingsGateway.Plugin.OpcUa105.OpcUaServerProperty": { + "IsAllVariable": "选择全部变量", + "OpcUaStringUrl": "服务地址", + "BigTextSubjectName": "SubjectName", + "BigTextApplicationUri": "ApplicationUri", + "SecurityPolicy": "安全策略", + "AutoAcceptUntrustedCertificates": "自动接受不受信任的证书" + }, + "ThingsGateway.Plugin.OpcUa105.OpcUaServerVariableProperty": { + "DataType": "数据类型" + }, + "ThingsGateway.Plugin.OpcUa105.OpcUaServer": { + "CanStartService": "无法启动服务" + } +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaMaster/OpcUaMaster.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaMaster/OpcUaMaster.cs new file mode 100644 index 000000000..03e70d0c0 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaMaster/OpcUaMaster.cs @@ -0,0 +1,359 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Newtonsoft.Json.Linq; + +using Opc.Ua; + +using ThingsGateway.Foundation.Extension.Generic; +using ThingsGateway.Foundation.OpcUa105; +using ThingsGateway.Gateway.Application; +using ThingsGateway.NewLife.Json.Extension; +using ThingsGateway.NewLife.Threading; + +using TouchSocket.Core; + +namespace ThingsGateway.Plugin.OpcUa105; + +/// +/// +/// +public class OpcUaMaster : CollectBase +{ + private readonly OpcUaMasterProperty _driverProperties = new(); + + private ThingsGateway.Foundation.OpcUa105.OpcUaMaster _plc; + + private CancellationToken _token; + + private volatile bool connectFirstFail; + private volatile bool connectFirstFailLoged; + private volatile bool success = true; + + /// + public override CollectPropertyBase CollectProperties => _driverProperties; + + /// + public override Type DriverDebugUIType => typeof(ThingsGateway.Debug.OpcUaMaster); + + public override Type DriverPropertyUIType => typeof(OpcUaMasterPropertyRazor); + + + + protected override void InitChannel(IChannel? channel = null) + { + + + //载入配置 + OpcUaProperty config = new() + { + OpcUrl = _driverProperties.OpcUrl, + UpdateRate = _driverProperties.UpdateRate, + DeadBand = _driverProperties.DeadBand, + GroupSize = _driverProperties.GroupSize, + KeepAliveInterval = _driverProperties.KeepAliveInterval, + UseSecurity = _driverProperties.UseSecurity, + ActiveSubscribe = _driverProperties.ActiveSubscribe, + UserName = _driverProperties.UserName, + Password = _driverProperties.Password, + CheckDomain = _driverProperties.CheckDomain, + LoadType = _driverProperties.LoadType, + AutoAcceptUntrustedCertificates = _driverProperties.AutoAcceptUntrustedCertificates, + }; + if (_plc == null) + { + _plc = new(); + _plc.LogEvent += _plc_LogEvent; + _plc.DataChangedHandler += DataChangedHandler; + } + _plc.OpcUaProperty = config; + base.InitChannel(channel); + } + + /// + public override bool IsConnected() => _plc?.Connected == true; + + public override string ToString() + { + return $"{_driverProperties.OpcUrl}"; + } + + /// + protected override void Dispose(bool disposing) + { + if (_plc != null) + { + _plc.DataChangedHandler -= DataChangedHandler; + _plc.LogEvent -= _plc_LogEvent; + + _plc.Disconnect(); + _plc.SafeDispose(); + } + base.Dispose(disposing); + } + + protected override string GetAddressDescription() + { + return _plc?.GetAddressDescription(); + } + + protected override async Task ProtectedStartAsync(CancellationToken cancellationToken) + { + _token = cancellationToken; + try + { + await _plc.ConnectAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogMessage?.LogWarning(ex, "Connect Fail"); + connectFirstFail = true; + } + await base.ProtectedStartAsync(cancellationToken).ConfigureAwait(false); + } + + protected override async ValueTask ProtectedExecuteAsync(CancellationToken cancellationToken) + { + if (connectFirstFail && !IsConnected()) + { + try + { + await _plc.ConnectAsync(cancellationToken).ConfigureAwait(false); + connectFirstFail = false; + } + catch (Exception ex) + { + if (!connectFirstFailLoged) + LogMessage?.LogWarning(ex, "Connect Fail"); + + connectFirstFailLoged = true; + CurrentDevice.SetDeviceStatus(TimerX.Now, true, ex.Message); + await Task.Delay(10000, cancellationToken).ConfigureAwait(false); + } + } + else + { + connectFirstFail = false; + } + if (_driverProperties.ActiveSubscribe) + { + + //获取设备连接状态 + if (IsConnected()) + { + //更新设备活动时间 + CurrentDevice.SetDeviceStatus(TimerX.Now, false); + } + else + { + CurrentDevice.SetDeviceStatus(TimerX.Now, true); + } + + ScriptVariableRun(cancellationToken); + } + else + { + await base.ProtectedExecuteAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + protected override List ProtectedLoadSourceRead(List deviceVariables) + { + if (deviceVariables.Count > 0) + { + var dataLists = deviceVariables.ChunkBetter(_driverProperties.GroupSize); + _plc.Variables = new(); + _plc.Variables.AddRange(dataLists.Select(a => a.Where(a => !a.RegisterAddress.IsNullOrEmpty()).Select(a => a.RegisterAddress!).ToList()).ToList()); + var dataResult = new List(); + foreach (var variable in dataLists) + { + var sourVars = new VariableSourceRead() + { + TimeTick = new(_driverProperties.UpdateRate.ToString()), + RegisterAddress = "", + }; + foreach (var item in variable) + { + sourVars.AddVariable(item); + } + dataResult.Add(sourVars); + } + + return dataResult; + } + else + { + return new(); + } + } + + /// + protected override async ValueTask> ReadSourceAsync(VariableSourceRead deviceVariableSourceRead, CancellationToken cancellationToken) + { + DateTime time = DateTime.Now; + var addresss = deviceVariableSourceRead.VariableRuntimes.Where(a => !a.RegisterAddress.IsNullOrEmpty()).Select(a => a.RegisterAddress!).ToArray(); + try + { + await ReadWriteLock.ReaderLockAsync(cancellationToken).ConfigureAwait(false); + + var result = await _plc.ReadJTokenValueAsync(addresss, cancellationToken).ConfigureAwait(false); + foreach (var data in result) + { + if (!cancellationToken.IsCancellationRequested) + { + var data1 = deviceVariableSourceRead.VariableRuntimes.Where(a => a.RegisterAddress == data.Item1); + + foreach (var item in data1) + { + object value; + if (data.Item3 is JValue jValue) + { + value = jValue.Value; + } + else + { + value = data.Item3; + } + + var isGood = StatusCode.IsGood(data.Item2.StatusCode); + if (_driverProperties.SourceTimestampEnable) + { + time = data.Item2.SourceTimestamp.ToLocalTime(); + } + if (isGood) + { + item.SetValue(value, time); + } + else + { + item.SetValue(null, time, false); + item.VariableSource.LastErrorMessage = data.Item2.StatusCode.ToString(); + } + } + LogMessage.Trace($"{ToString()} Change:{Environment.NewLine}{data.Item1} : {data.Item3}"); + } + } + if (result.Any(a => StatusCode.IsBad(a.Item2.StatusCode))) + { + return new OperResult($"OPC quality bad"); + } + else + { + return OperResult.CreateSuccessResult(null); + } + } + catch (Exception ex) + { + return new OperResult($"ReadSourceAsync {addresss.ToJsonNetString()}:{Environment.NewLine}{ex}"); + } + finally + { + } + } + + /// + protected override async ValueTask> WriteValuesAsync(Dictionary writeInfoLists, CancellationToken cancellationToken) + { + using var writeLock = ReadWriteLock.WriterLock(); + try + { + var result = await _plc.WriteNodeAsync(writeInfoLists.ToDictionary(a => a.Key.RegisterAddress!, a => a.Value), cancellationToken).ConfigureAwait(false); + return result.ToDictionary>, string, OperResult>(a => + { + return writeInfoLists.Keys.FirstOrDefault(b => b.RegisterAddress == a.Key)?.Name!; + } + , a => + { + if (!a.Value.Item1) + return new OperResult(a.Value.Item2); + else + return OperResult.Success; + })!; + } + finally + { + } + } + + private void _plc_LogEvent(byte level, object sender, string message, Exception ex) + { + LogMessage?.Log((LogLevel)level, sender, message, ex); + } + + private void DataChangedHandler((VariableNode variableNode, DataValue dataValue, JToken jToken) data) + { + DateTime time = DateTime.Now; + try + { + if (CurrentDevice.Pause) + return; + if (_token.IsCancellationRequested) + return; + + LogMessage.Trace($"{ToString()} Change: {Environment.NewLine} {data.variableNode.NodeId} : {data.jToken?.ToString()}"); + + if (CurrentDevice.Pause) + { + return; + } + //尝试固定点位的数据类型 + var type = TypeInfo.GetSystemType(TypeInfo.GetBuiltInType(data.variableNode.DataType, _plc.Session.SystemContext.TypeTable), data.variableNode.ValueRank); + + var itemReads = VariableRuntimes.Select(a => a.Value).Where(it => it.RegisterAddress == data.variableNode.NodeId); + + object value; + if (data.jToken is JValue jValue) + { + value = jValue.Value; + } + else + { + value = data.jToken; + } + var isGood = StatusCode.IsGood(data.dataValue.StatusCode); + if (_driverProperties.SourceTimestampEnable) + { + time = data.dataValue.SourceTimestamp.ToLocalTime(); + } + foreach (var item in itemReads) + { + if (CurrentDevice.Pause) + return; + if (_token.IsCancellationRequested) + return; + if (item.DataType == DataTypeEnum.Object) + if (type.Namespace.StartsWith("System")) + { + var enumValues = Enum.GetValues(); + var stringList = enumValues.Select(e => e.ToString()); + if (stringList.Contains(type.Name)) + try { item.DataType = Enum.Parse(type.Name); } catch { } + } + if (isGood) + { + item.SetValue(value, time); + } + else + { + item.SetValue(null, time, false); + item.VariableSource.LastErrorMessage = data.Item2.StatusCode.ToString(); + } + } + success = true; + } + catch (Exception ex) + { + if (success) + LogMessage?.LogWarning(ex); + success = false; + } + } +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaMaster/OpcUaMasterProperty.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaMaster/OpcUaMasterProperty.cs new file mode 100644 index 000000000..6c956398e --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaMaster/OpcUaMasterProperty.cs @@ -0,0 +1,98 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Gateway.Application; + +namespace ThingsGateway.Plugin.OpcUa105; + +/// +/// +/// +public class OpcUaMasterProperty : CollectPropertyBase +{ + /// + /// 连接Url + /// + [DynamicProperty] + public string OpcUrl { get; set; } = "opc.tcp://127.0.0.1:49320"; + + /// + /// 登录账号 + /// + [DynamicProperty] + public string? UserName { get; set; } + + /// + /// 登录密码 + /// + [DynamicProperty] + public string? Password { get; set; } + + /// + /// 检查域 + /// + [DynamicProperty] + public bool CheckDomain { get; set; } + + /// + /// 安全策略 + /// + [DynamicProperty] + public bool UseSecurity { get; set; } = true; + + /// + /// 接受不受信任的证书 + /// + [DynamicProperty()] + public bool AutoAcceptUntrustedCertificates { get; set; } = true; + + /// + /// 是否使用SourceTime + /// + [DynamicProperty] + public bool SourceTimestampEnable { get; set; } = true; + + /// + /// 加载服务端数据类型 + /// + [DynamicProperty] + public bool LoadType { get; set; } = true; + + /// + /// 激活订阅 + /// + [DynamicProperty] + public bool ActiveSubscribe { get; set; } = true; + + /// + /// 更新频率 + /// + [DynamicProperty] + public int UpdateRate { get; set; } = 1000; + + /// + /// 死区 + /// + [DynamicProperty] + public double DeadBand { get; set; } = 0; + + /// + /// 最大组大小 + /// + [DynamicProperty] + public int GroupSize { get; set; } = 500; + + /// + /// 心跳频率 + /// + [DynamicProperty] + public int KeepAliveInterval { get; set; } = 3000; + +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/ApplicationMessageDlg.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/ApplicationMessageDlg.cs new file mode 100644 index 000000000..c5d6dcbec --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/ApplicationMessageDlg.cs @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Opc.Ua.Configuration; + +using TouchSocket.Core; + +namespace ThingsGateway.Plugin.OpcUa105; + +public class ApplicationMessageDlg : IApplicationMessageDlg +{ + private ILog _log; + private string message = string.Empty; + + public ApplicationMessageDlg(ILog log) + { + _log = log; + } + + public override void Message(string text, bool ask) + { + message = text; + } + + public override Task ShowAsync() + { + _log.Warning(message); + return Task.FromResult(true); + } +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/OpcUaLogger.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/OpcUaLogger.cs new file mode 100644 index 000000000..4b4b1f427 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/OpcUaLogger.cs @@ -0,0 +1,65 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +using TouchSocket.Core; + +#pragma warning disable CS8633 // 类型参数的约束中的为 Null 性与隐式实现接口方法中的类型参数的约束不匹配。 + +#pragma warning disable CS8766 // 返回类型中引用类型的为 Null 性与隐式实现的成员不匹配(可能是由于为 Null 性特性)。 + +namespace ThingsGateway.Plugin.OpcUa105; + +internal sealed class OpcUaLogger : ILogger +{ + private ILog _log; + + public OpcUaLogger(ILog log) + { + _log = log; + } + + /// + /// Set the log level + /// + public Microsoft.Extensions.Logging.LogLevel LogLevel { get; set; } = Microsoft.Extensions.Logging.LogLevel.Trace; + + /// + public IDisposable BeginScope(TState state) where TState : notnull + { + return default; + } + + /// + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => logLevel >= LogLevel; + + /// + public void Log( + Microsoft.Extensions.Logging.LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + else + { + var message = formatter(state, exception); + //if (logLevel > Microsoft.Extensions.Logging.LogLevel.Warning) + { + _log.Log((TouchSocket.Core.LogLevel)(byte)logLevel, state, message, exception); + } + } + } +} \ No newline at end of file diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/OpcUaTag.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/OpcUaTag.cs new file mode 100644 index 000000000..7a327fee4 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/OpcUaTag.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Opc.Ua; + +namespace ThingsGateway.Plugin.OpcUa105; + +internal sealed class OpcUaTag : BaseDataVariableState +{ + internal OpcUaTag(NodeState parent) : base(parent) + { + } + + /// + /// 变量Id + /// + internal long Id { get; set; } + + internal bool IsDataTypeInit { get; set; } +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/ThingsGatewayNodeManager.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/ThingsGatewayNodeManager.cs new file mode 100644 index 000000000..091f95f91 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/ThingsGatewayNodeManager.cs @@ -0,0 +1,477 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Newtonsoft.Json.Linq; + +using Opc.Ua; +using Opc.Ua.Server; + +using ThingsGateway.Foundation.OpcUa105; +using ThingsGateway.Gateway.Application; + +using TouchSocket.Core; + +namespace ThingsGateway.Plugin.OpcUa105; + +/// +/// 数据节点 +/// +public class ThingsGatewayNodeManager : CustomNodeManager2 +{ + private const string ReferenceServer = "https://thingsgateway.cn/"; + + //private readonly TypeAdapterConfig _config; + /// + /// OPC和网关对应表 + /// + private readonly Dictionary NodeIdTags = new(); + + private BusinessBase _businessBase; + private volatile bool success = true; + + /// + public ThingsGatewayNodeManager(BusinessBase businessBase, IServerInternal server, ApplicationConfiguration configuration) : base(server, configuration, ReferenceServer) + { + _businessBase = businessBase; + //_config = new TypeAdapterConfig(); + //_config.ForType() + //.Map(dest => dest.WrappedValue, (src) => new Variant(src.Value)) + //.Map(dest => dest.SourceTimestamp, src => DateTime.SpecifyKind(src.CollectTime, DateTimeKind.Utc)) + //.Map(dest => dest.StatusCode, (src) => + //src.IsOnline ? StatusCodes.Good : StatusCodes.Bad); + } + + /// + /// 创建服务目录结构 + /// + /// + public override void CreateAddressSpace(IDictionary> externalReferences) + { + lock (Lock) + { + if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out IList references)) + { + externalReferences[ObjectIds.ObjectsFolder] = references = new List(); + } + //首节点 + FolderState rootFolder = CreateFolder(null, "ThingsGateway", "ThingsGateway"); + rootFolder.AddReference(ReferenceTypes.Organizes, true, ObjectIds.ObjectsFolder); + references.Add(new NodeStateReference(ReferenceTypes.Organizes, false, rootFolder.NodeId)); + rootFolder.EventNotifier = EventNotifiers.SubscribeToEvents; + AddRootNotifier(rootFolder); + + //创建设备树 + var _geviceGroup = _businessBase.VariableRuntimes.Select(a => a.Value) + .GroupBy(a => a.DeviceName); + // 开始寻找设备信息,并计算一些节点信息 + foreach (var item in _geviceGroup) + { + //设备树会有两层 + FolderState fs = CreateFolder(rootFolder, item.Key, item.Key); + fs.AddReference(ReferenceTypes.Organizes, true, ObjectIds.ObjectsFolder); + fs.EventNotifier = EventNotifiers.SubscribeToEvents; + if (item?.Count() > 0) + { + foreach (var item2 in item) + { + CreateVariable(fs, item2); + } + } + } + AddPredefinedNode(SystemContext, rootFolder); + } + } + + ///// + ///// 读取历史数据 + ///// + //public override void HistoryRead(OperationContext context, + // HistoryReadDetails details, + // TimestampsToReturn timestampsToReturn, + // bool releaseContinuationPoints, + // IList nodesToRead, + // IList results, + // IList errors) + //{ + // base.HistoryRead(context, details, timestampsToReturn, releaseContinuationPoints, nodesToRead, results, errors); + // //必须带有时间范围 + // if (details is not ReadRawModifiedDetails readDetail || readDetail.StartTime == DateTime.MinValue || readDetail.EndTime == DateTime.MinValue) + // { + // errors[0] = StatusCodes.BadHistoryOperationUnsupported; + // return; + // } + // var service = BackgroundServiceUtil.GetBackgroundService(); + // if (!service.StatuString.IsSuccess) + // { + // errors[0] = StatusCodes.BadHistoryOperationUnsupported; + // return; + // } + + // var db = service.GetHistoryDbAsync().GetAwaiter().GetResult(); + // if (!db.IsSuccess) + // { + // errors[0] = StatusCodes.BadHistoryOperationUnsupported; + // return; + // } + // var startTime = readDetail.StartTime; + // var endTime = readDetail.EndTime; + + // for (int i = 0; i < nodesToRead.Count; i++) + // { + // var historyRead = nodesToRead[i]; + // if (NodeIdTags.TryGetValue(historyRead.NodeId, out OPCUATag tag)) + // { + // var data = db.Content.Queryable() + // .Where(a => a.Name == tag.SymbolicName) + // .Where(a => a.CollectTime >= startTime) + // .Where(a => a.CollectTime <= endTime) + // .ToList(); + + // if (data.Count > 0) + // { + // var hisDataValue = data.Adapt>(_config); + // HistoryData hisData = new(); + // hisData.DataValues.AddRange(hisDataValue); + // errors[i] = StatusCodes.Good; + // //切记Processed设为true,否则客户端会报错 + // historyRead.Processed = true; + // results[i] = new HistoryReadResult() + // { + // StatusCode = StatusCodes.Good, + // HistoryData = new ExtensionObject(hisData) + // }; + // } + // else + // { + // results[i] = new HistoryReadResult() + // { + // StatusCode = StatusCodes.GoodNoData + // }; + // } + // } + // else + // { + // results[i] = new HistoryReadResult() + // { + // StatusCode = StatusCodes.BadNotFound + // }; + // } + // } + //} + + /// + public override NodeId New(ISystemContext context, NodeState node) + { + if (node is BaseInstanceState instance && instance.Parent != null) + { + string id = instance.Parent.NodeId.Identifier?.ToString(); + if (id != null) + { + //用下划线分割 + return new NodeId(id + "_" + instance.SymbolicName, instance.Parent.NodeId.NamespaceIndex); + } + } + return node.NodeId; + } + + /// + /// 更新变量 + /// + /// + public void UpVariable(VariableData variable) + { + if (!NodeIdTags.TryGetValue(variable.Name, out var uaTag)) + return; + object initialItemValue = null; + initialItemValue = variable.Value; + if (initialItemValue != null) + { + var code = variable.IsOnline ? StatusCodes.Good : StatusCodes.Bad; + if (code == StatusCodes.Good) + { + ChangeNodeData(uaTag, initialItemValue, variable.ChangeTime); + } + + if (uaTag.StatusCode != code) + { + uaTag.StatusCode = code; + } + uaTag.UpdateChangeMasks(NodeStateChangeMasks.Value); + uaTag.ClearChangeMasks(SystemContext, false); + } + } + + /// + /// + /// + /// + protected override void Dispose(bool disposing) + { + NodeIdTags.Clear(); + base.Dispose(disposing); + } + + private static NodeId DataNodeType(Type tp) + { + if (tp == typeof(bool)) + return DataTypeIds.Boolean; + if (tp == typeof(byte)) + return DataTypeIds.Byte; + if (tp == typeof(sbyte)) + return DataTypeIds.SByte; + if (tp == typeof(short)) + return DataTypeIds.Int16; + if (tp == typeof(ushort)) + return DataTypeIds.UInt16; + if (tp == typeof(int)) + return DataTypeIds.Int32; + if (tp == typeof(uint)) + return DataTypeIds.UInt32; + if (tp == typeof(long)) + return DataTypeIds.Int64; + if (tp == typeof(ulong)) + return DataTypeIds.UInt64; + if (tp == typeof(float)) + return DataTypeIds.Float; + if (tp == typeof(double)) + return DataTypeIds.Double; + if (tp == typeof(string)) + return DataTypeIds.String; + if (tp == typeof(DateTime)) + return DataTypeIds.DateTime; + return DataTypeIds.String; + } + + /// + /// 在服务器端直接更改对应数据节点的值 + /// + private void ChangeNodeData(OpcUaTag tag, object value, DateTime dateTime) + { + object newValue; + try + { + if (!tag.IsDataTypeInit) + { + if (tag.DataType == DataTypeIds.String) + { + if (value != null) + { + SetDataType(tag, value); + } + } + else + { + SetRank(tag, value); + } + } + var jToken = JToken.FromObject((tag.DataType == DataTypeIds.String ? value?.ToString() : value)); + var dataValue = JsonUtils.DecoderObject( + Server.MessageContext, + tag.DataType, + TypeInfo.GetBuiltInType(tag.DataType, SystemContext.TypeTable), + jToken.CalculateActualValueRank(), + jToken + ); + newValue = dataValue; + success = true; + } + catch (Exception ex) + { + if (success) + _businessBase.LogMessage.LogWarning(ex, "Conversion value error"); + success = false; + newValue = value; + } + tag.Value = newValue; + tag.Timestamp = dateTime; + + void SetDataType(OpcUaTag tag, object value) + { + tag.IsDataTypeInit = true; + var tp = value.GetType(); + if (tp == typeof(JArray)) + { + try + { + tp = ((JValue)((JArray)value).FirstOrDefault()).Value.GetType(); + tag.ValueRank = ValueRanks.OneOrMoreDimensions; + } + catch + { + } + } + if (tp == typeof(JValue)) + { + tp = ((JValue)value).Value.GetType(); + tag.ValueRank = ValueRanks.Scalar; + } + tag.DataType = DataNodeType(tp); + tag.ClearChangeMasks(SystemContext, false); + } + + void SetRank(OpcUaTag tag, object value) + { + tag.IsDataTypeInit = true; + var tp = value.GetType(); + if (tp == typeof(JArray)) + { + try + { + tp = ((JValue)((JArray)value).FirstOrDefault()).Value.GetType(); + tag.ValueRank = ValueRanks.OneOrMoreDimensions; + } + catch + { + } + } + tag.ClearChangeMasks(SystemContext, false); + } + } + + /// + /// 创建文件夹 + /// + private FolderState CreateFolder(NodeState parent, string name, string description) + { + FolderState folder = new(parent) + { + SymbolicName = name, + ReferenceTypeId = ReferenceTypes.Organizes, + TypeDefinitionId = ObjectTypeIds.FolderType, + Description = description, + NodeId = new NodeId(name, NamespaceIndex), + BrowseName = new QualifiedName(name, NamespaceIndex), + DisplayName = new LocalizedText(name), + WriteMask = AttributeWriteMask.None, + UserWriteMask = AttributeWriteMask.None, + EventNotifier = EventNotifiers.None + }; + + parent?.AddChild(folder); + + return folder; + } + + /// + /// 创建一个值节点,类型需要在创建的时候指定 + /// + private OpcUaTag CreateVariable(NodeState parent, VariableRuntime variableRuntime) + { + OpcUaTag variable = new(parent) + { + SymbolicName = variableRuntime.Name, + ReferenceTypeId = ReferenceTypes.Organizes, + TypeDefinitionId = VariableTypeIds.BaseDataVariableType, + NodeId = new NodeId(variableRuntime.Name, NamespaceIndex), + Description = variableRuntime.Description, + BrowseName = new QualifiedName(variableRuntime.Name, NamespaceIndex), + DisplayName = new LocalizedText(variableRuntime.Name), + WriteMask = AttributeWriteMask.DisplayName | AttributeWriteMask.Description, + UserWriteMask = AttributeWriteMask.DisplayName | AttributeWriteMask.Description, + ValueRank = ValueRanks.Scalar, + Id = variableRuntime.Id, + DataType = DataNodeType(variableRuntime) + }; + var level = ThingsGatewayNodeManager.ProtectTypeTrans(variableRuntime); + variable.AccessLevel = level; + variable.UserAccessLevel = level; + variable.Historizing = false;//历史存储状态 + variable.Value = Opc.Ua.TypeInfo.GetDefaultValue(variable.DataType, ValueRanks.Any, Server.TypeTree); + var code = variableRuntime.IsOnline ? StatusCodes.Good : StatusCodes.Bad; + variable.StatusCode = code; + variable.Timestamp = variableRuntime.CollectTime ?? DateTime.MinValue; + variable.OnWriteValue = OnWriteDataValue; + parent?.AddChild(variable); + NodeIdTags.AddOrUpdate(variable.SymbolicName, variable); + return variable; + } + + /// + /// 网关转OPC数据类型 + /// + /// + /// + private NodeId DataNodeType(VariableRuntime variableRuntime) + { + var str = variableRuntime.GetPropertyValue(_businessBase.DeviceId, nameof(OpcUaServerVariableProperty.DataType)) ?? ""; + Type tp; + if (Enum.TryParse(str, out DataTypeEnum result)) + { + tp = result.GetSystemType(); + } + else + { + tp = variableRuntime.DataType.GetSystemType(); ; + } + + return DataNodeType(tp); + } + + private ServiceResult OnWriteDataValue(ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding, ref object value, ref StatusCode statusCode, ref DateTime timestamp) + { + try + { + var context1 = context as ServerSystemContext; + + //取消注释,插件不限制匿名用户的写入 + //if (context1.UserIdentity.TokenType == UserTokenType.Anonymous) + //{ + // return StatusCodes.BadUserAccessDenied; + //} + OpcUaTag variable = node as OpcUaTag; + if (NodeIdTags.TryGetValue(variable.SymbolicName, out OpcUaTag tag)) + { + if (StatusCode.IsGood(variable.StatusCode)) + { + //仅当指定了值时才将值写入 + if (variable.Value != null) + { + var result = GlobalData.RpcService.InvokeDeviceMethodAsync("OpcUaSlave - " + context1?.OperationContext?.Session?.Identity?.DisplayName, + new() + { + { variable.SymbolicName, value?.ToString() } + } + + ).ConfigureAwait(true).GetAwaiter().GetResult(); + if (result.Values.FirstOrDefault().IsSuccess) + { + return StatusCodes.Good; + } + else + { + return new(StatusCodes.BadWaitingForResponse, result.Values.FirstOrDefault().ErrorMessage); + } + } + } + } + return StatusCodes.BadWaitingForResponse; + } + catch + { + return StatusCodes.BadTypeMismatch; + } + } + + private static byte ProtectTypeTrans(VariableRuntime variableRuntime) + { + byte result = 0; + result = variableRuntime.ProtectType switch + { + ProtectTypeEnum.ReadOnly => (byte)(result | AccessLevels.CurrentRead), + ProtectTypeEnum.ReadWrite => (byte)(result | AccessLevels.CurrentReadOrWrite), + _ => (byte)(result | AccessLevels.CurrentRead), + }; + //if (variableRuntime.HistoryEnable) + //{ + // result = (byte)(result | AccessLevels.HistoryRead); + //} + return result; + } +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/ThingsGatewayServer.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/ThingsGatewayServer.cs new file mode 100644 index 000000000..6194fa5fb --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/Core/ThingsGatewayServer.cs @@ -0,0 +1,328 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Opc.Ua; +using Opc.Ua.Server; + +using System.Security.Cryptography.X509Certificates; + +using ThingsGateway.Admin.Application; +using ThingsGateway.Gateway.Application; + +using TouchSocket.Core; + +namespace ThingsGateway.Plugin.OpcUa105; + +/// +/// UAServer核心实现 +/// +public partial class ThingsGatewayServer : StandardServer +{ + /// + /// 自定义节点 + /// + public ThingsGatewayNodeManager NodeManager; + + private readonly BusinessBase _businessBase; + + private ICertificateValidator m_userCertificateValidator; + + /// + public ThingsGatewayServer(BusinessBase businessBase) + { + _businessBase = businessBase; + } + + /// + public override UserTokenPolicyCollection GetUserTokenPolicies(ApplicationConfiguration configuration, EndpointDescription description) + { + var policies = base.GetUserTokenPolicies(configuration, description); + + // 样品如何修改默认用户令牌的政策 + if (description.SecurityPolicyUri == SecurityPolicies.Aes256_Sha256_RsaPss && + description.SecurityMode == MessageSecurityMode.SignAndEncrypt) + { + policies = new UserTokenPolicyCollection(policies.Where(u => u.TokenType != UserTokenType.Certificate)); + } + else if (description.SecurityPolicyUri == SecurityPolicies.Aes128_Sha256_RsaOaep && + description.SecurityMode == MessageSecurityMode.Sign) + { + policies = new UserTokenPolicyCollection(policies.Where(u => u.TokenType != UserTokenType.Anonymous)); + } + else if (description.SecurityPolicyUri == SecurityPolicies.Aes128_Sha256_RsaOaep && + description.SecurityMode == MessageSecurityMode.SignAndEncrypt) + { + policies = new UserTokenPolicyCollection(policies.Where(u => u.TokenType != UserTokenType.UserName)); + } + return policies; + } + + /// + protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration) + { + List nodeManagers = new List(); + // 创建自定义节点管理器. + NodeManager = new ThingsGatewayNodeManager(_businessBase, server, configuration); + nodeManagers.Add(NodeManager); + // 创建主节点管理器. + var masterNodeManager = new MasterNodeManager(server, configuration, null, nodeManagers.ToArray()); + return masterNodeManager; + } + + /// + protected override ResourceManager CreateResourceManager(IServerInternal server, ApplicationConfiguration configuration) + { + ResourceManager resourceManager = new(server, configuration); + + System.Reflection.FieldInfo[] fields = typeof(StatusCodes).GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + + foreach (System.Reflection.FieldInfo field in fields) + { + uint? id = field.GetValue(typeof(StatusCodes)) as uint?; + + if (id != null) + { + resourceManager.Add(id.Value, "en-US", field.Name); + } + } + + resourceManager.Add("InvalidPassword", "zh-cn", "密码验证失败,'{0}'."); + resourceManager.Add("UnexpectedUserTokenError", "zh-cn", "错误的用户令牌。"); + resourceManager.Add("BadUserAccessDenied", "zh-cn", "当前用户名不存在。"); + + return resourceManager; + } + + /// + protected override void Dispose(bool disposing) + { + NodeManager?.SafeDispose(); + base.Dispose(disposing); + } + + /// + protected override ServerProperties LoadServerProperties() + { + ServerProperties properties = new() + { + ManufacturerName = "Diego", + ProductName = "ThingsGateway OPCUAServer", + ProductUri = "https://gitee.com/diego2098/ThingsGateway", + SoftwareVersion = Utils.GetAssemblySoftwareVersion(), + BuildNumber = Utils.GetAssemblyBuildNumber(), + BuildDate = Utils.GetAssemblyTimestamp() + }; + + return properties; + } + + /// + protected override void OnServerStarted(IServerInternal server) + { + // 当用户身份改变时请求。 + server.SessionManager.ImpersonateUser += SessionManager_ImpersonateUser; + base.OnServerStarted(server); + _businessBase.LogMessage.LogInformation("OPCUAServer Started"); + } + + /// + protected override void OnServerStarting(ApplicationConfiguration configuration) + { + _businessBase.LogMessage.LogInformation("OPCUAServer Starting"); + base.OnServerStarting(configuration); + + // 由应用程序决定如何验证用户身份令牌。 + // 此函数为 X509 身份令牌创建验证器。 + CreateUserIdentityValidators(configuration); + } + + /// + protected override void OnServerStopping() + { + _businessBase.LogMessage.LogInformation("OPCUAServer Stoping"); + base.OnServerStopping(); + } + + private void CreateUserIdentityValidators(ApplicationConfiguration configuration) + { + for (int ii = 0; ii < configuration.ServerConfiguration.UserTokenPolicies.Count; ii++) + { + UserTokenPolicy policy = configuration.ServerConfiguration.UserTokenPolicies[ii]; + + // 为证书令牌策略创建验证器。 + if (policy.TokenType == UserTokenType.Certificate) + { + // check if user certificate trust lists are specified in configuration. + if (configuration.SecurityConfiguration.TrustedUserCertificates != null && + configuration.SecurityConfiguration.UserIssuerCertificates != null) + { + CertificateValidator certificateValidator = new(); + certificateValidator.Update(configuration).GetAwaiter().GetResult(); + certificateValidator.Update(configuration.SecurityConfiguration.UserIssuerCertificates, + configuration.SecurityConfiguration.TrustedUserCertificates, + configuration.SecurityConfiguration.RejectedCertificateStore); + + // set custom validator for user certificates. + m_userCertificateValidator = certificateValidator.GetChannelValidator(); + } + } + } + } + + private void SessionManager_ImpersonateUser(Session session, ImpersonateEventArgs args) + { + // check for a user name cancellationToken. + + if (args.NewIdentity is UserNameIdentityToken userNameToken) + { + args.Identity = VerifyPassword(userNameToken); + + // set AuthenticatedUser role for accepted user/password authentication + args.Identity.GrantedRoleIds.Add(ObjectIds.WellKnownRole_AuthenticatedUser); + + if (args.Identity is SystemConfigurationIdentity) + { + // set ConfigureAdmin role for user with permission to configure server + args.Identity.GrantedRoleIds.Add(ObjectIds.WellKnownRole_ConfigureAdmin); + args.Identity.GrantedRoleIds.Add(ObjectIds.WellKnownRole_SecurityAdmin); + } + + return; + } + + // check for x509 user cancellationToken. + + if (args.NewIdentity is X509IdentityToken x509Token) + { + VerifyUserTokenCertificate(x509Token.Certificate); + args.Identity = new UserIdentity(x509Token); + Utils.LogInfo(Utils.TraceMasks.Security, "X509 Token Accepted: {0}", args.Identity?.DisplayName); + + // set AuthenticatedUser role for accepted certificate authentication + args.Identity.GrantedRoleIds.Add(ObjectIds.WellKnownRole_AuthenticatedUser); + + return; + } + + // check for anonymous cancellationToken. + if (args.NewIdentity is AnonymousIdentityToken || args.NewIdentity == null) + { + // allow anonymous authentication and set Anonymous role for this authentication + args.Identity = new UserIdentity(); + args.Identity.GrantedRoleIds.Add(ObjectIds.WellKnownRole_Anonymous); + + return; + } + + // unsuported identity cancellationToken type. + throw ServiceResultException.Create(StatusCodes.BadIdentityTokenInvalid, + "Not supported user cancellationToken type: {0}.", args.NewIdentity); + } + + /// + /// 从第三方用户中校验 + /// + /// + /// + private IUserIdentity VerifyPassword(UserNameIdentityToken userNameToken) + { + var userName = userNameToken.UserName; + var password = userNameToken.DecryptedPassword; + if (string.IsNullOrEmpty(userName)) + { + // an empty username is not accepted. + throw ServiceResultException.Create(StatusCodes.BadIdentityTokenInvalid, + "Security cancellationToken is not a valid username cancellationToken. An empty username is not accepted."); + } + + if (string.IsNullOrEmpty(password)) + { + // an empty password is not accepted. + throw ServiceResultException.Create(StatusCodes.BadIdentityTokenRejected, + "Security cancellationToken is not a valid username cancellationToken. An empty password is not accepted."); + } + var sysUserService = App.RootServices.GetService(); + var userInfo = sysUserService.GetUserByAccountAsync(userName, null).ConfigureAwait(true).GetAwaiter().GetResult();//获取用户信息 + if (userInfo == null) + { + // construct translation object with default text. + TranslationInfo info = new( + "InvalidPassword", + "en-US", + "Invalid username or password.", + userName); + + // create an exception with a vendor defined sub-code. + throw new ServiceResultException(new ServiceResult( + StatusCodes.BadUserAccessDenied, + "InvalidPassword", + LoadServerProperties().ProductUri, + new LocalizedText(info))); + } + // 有权配置服务器的用户 + if (userName == userInfo.Account && password == userInfo.Password) + { + return new SystemConfigurationIdentity(new UserIdentity(userNameToken)); + } + else + { + return new UserIdentity(userNameToken); + } + } + + private void VerifyUserTokenCertificate(X509Certificate2 certificate) + { + try + { + if (m_userCertificateValidator != null) + { + m_userCertificateValidator.Validate(certificate); + } + else + { + CertificateValidator.Validate(certificate); + } + } + catch (Exception e) + { + TranslationInfo info; + StatusCode result = StatusCodes.BadIdentityTokenRejected; + if (e is ServiceResultException se && se.StatusCode == StatusCodes.BadCertificateUseNotAllowed) + { + info = new TranslationInfo( + "InvalidCertificate", + "en-US", + "'{0}' is an invalid user certificate.", + certificate.Subject); + + result = StatusCodes.BadIdentityTokenInvalid; + } + else + { + // construct translation object with default text. + info = new TranslationInfo( + "UntrustedCertificate", + "en-US", + "'{0}' is not a trusted user certificate.", + certificate.Subject); + } + + // create an exception with a vendor defined sub-code. + throw new ServiceResultException(new ServiceResult( + result, + info.Key, + LoadServerProperties().ProductUri, + new LocalizedText(info))); + } + } +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/OpcUaServer.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/OpcUaServer.cs new file mode 100644 index 000000000..5ac72c8be --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/OpcUaServer.cs @@ -0,0 +1,391 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Mapster; + +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; + +using Opc.Ua; +using Opc.Ua.Configuration; + +using System.Collections.Concurrent; + +using ThingsGateway.Extension; +using ThingsGateway.Gateway.Application; +using ThingsGateway.NewLife.Threading; + +using TouchSocket.Core; + +namespace ThingsGateway.Plugin.OpcUa105; + +/// +/// OPCUA服务端 +/// +public partial class OpcUaServer : BusinessBase +{ + private readonly OpcUaServerProperty _driverPropertys = new(); + private readonly OpcUaServerVariableProperty _variablePropertys = new(); + private ApplicationInstance m_application; + private ApplicationConfiguration m_configuration; + private ThingsGatewayServer m_server; + private volatile bool success = true; + + /// + public override VariablePropertyBase VariablePropertys => _variablePropertys; + + /// + protected override BusinessPropertyBase _businessPropertyBase => _driverPropertys; + + protected IStringLocalizer Localizer { get; private set; } + private ConcurrentQueue CollectVariableRuntimes { get; set; } = new(); + + private static readonly string[] separator = new string[] { ";" }; + + + public override void AfterVariablesChanged() + { + // 如果业务属性指定了全部变量,则设置当前设备的变量运行时列表和采集设备列表 + if (_driverPropertys.IsAllVariable) + { + VariableRuntimes = new(GlobalData.GetEnableVariables()); + + CollectDevices = GlobalData.GetEnableDevices().Where(a => a.Value.IsCollect == true).ToDictionary(); + } + else + { + base.AfterVariablesChanged(); + } + VariableRuntimes.ForEach(a => + { + VariableValueChange(a.Value, a.Value.Adapt()); + }); + } + + + protected override void InitChannel(IChannel? channel = null) + { + ApplicationInstance.MessageDlg = new ApplicationMessageDlg(LogMessage);//默认返回true + + //Utils.SetLogger(new OpcUaLogger(LogMessage)); //调试用途 + m_application = new ApplicationInstance(); + m_configuration = GetDefaultConfiguration(); + m_configuration.Validate(ApplicationType.Server).GetAwaiter().GetResult(); + m_application.ApplicationConfiguration = m_configuration; + if (m_configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates) + { + m_configuration.CertificateValidator.CertificateValidation += (s, e) => + { + e.Accept = (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted); + }; + } + + m_server = new(this); + CollectVariableRuntimes.Clear(); + + + GlobalData.VariableValueChangeEvent += VariableValueChange; + + Localizer = App.CreateLocalizerByType(typeof(OpcUaServer))!; + base.InitChannel(channel); + } + + /// + public override bool IsConnected() + { + try + { + return m_server?.CurrentInstance.CurrentState == Opc.Ua.ServerState.Running; + } + catch (Exception) + { + return false; + } + + } + + /// + protected override void Dispose(bool disposing) + { + GlobalData.VariableValueChangeEvent -= VariableValueChange; + m_application?.Stop(); + m_server?.SafeDispose(); + CollectVariableRuntimes?.Clear(); + base.Dispose(disposing); + } + + protected override async Task ProtectedStartAsync(CancellationToken cancellationToken) + { + // 启动服务器。 + await m_application.CheckApplicationInstanceCertificates(false, 1200, cancellationToken).ConfigureAwait(false); + await m_application.Start(m_server).ConfigureAwait(false); + await base.ProtectedStartAsync(cancellationToken).ConfigureAwait(false); + } + + protected override async ValueTask ProtectedExecuteAsync(CancellationToken cancellationToken) + { + try + { + if (IsConnected()) + { + //更新设备活动时间 + CurrentDevice.SetDeviceStatus(TimerX.Now, false); + } + else + { + CurrentDevice.SetDeviceStatus(TimerX.Now, true); + try + { + await m_application.CheckApplicationInstanceCertificates(false, 1200, cancellationToken).ConfigureAwait(false); + await m_application.Start(m_server).ConfigureAwait(false); + success = true; + } + catch (Exception ex) + { + if (success) + LogMessage.LogWarning(ex, Localizer["CanStartService"]); + success = false; + await Task.Delay(10000, cancellationToken).ConfigureAwait(false); + } + } + var data = CollectVariableRuntimes.ToListWithDequeue(); + data.Reverse(); + ////变化推送 + var varList = data.DistinctBy(a => a.Name).ToList(); + + if (varList?.Count > 0) + { + foreach (var item in varList) + { + try + { + if (!cancellationToken.IsCancellationRequested) + { + m_server?.NodeManager?.UpVariable(item); + } + else + { + break; + } + } + catch (Exception ex) + { + LogMessage.LogWarning(ex); + } + } + } + success = true; + } + catch (Exception ex) + { + if (success) + LogMessage.LogWarning(ex); + success = false; + } + + } + + private ApplicationConfiguration GetDefaultConfiguration() + { + ApplicationConfiguration config = new(); + var urls = _driverPropertys.OpcUaStringUrl.Split(separator, StringSplitOptions.RemoveEmptyEntries); + // 签名及加密验证 + ServerSecurityPolicyCollection policies = new(); + var userTokens = new UserTokenPolicyCollection(); + if (_driverPropertys.SecurityPolicy) + { + policies.Add(new ServerSecurityPolicy() + { + SecurityMode = MessageSecurityMode.Sign, + SecurityPolicyUri = SecurityPolicies.Basic128Rsa15 + }); + policies.Add(new ServerSecurityPolicy() + { + SecurityMode = MessageSecurityMode.Sign, + SecurityPolicyUri = SecurityPolicies.Basic256 + }); + policies.Add(new ServerSecurityPolicy() + { + SecurityMode = MessageSecurityMode.Sign, + SecurityPolicyUri = SecurityPolicies.Basic256Sha256 + }); + policies.Add(new ServerSecurityPolicy() + { + SecurityMode = MessageSecurityMode.Sign, + SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep + }); + policies.Add(new ServerSecurityPolicy() + { + SecurityMode = MessageSecurityMode.Sign, + SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss + }); + policies.Add(new ServerSecurityPolicy() + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Basic128Rsa15 + }); + policies.Add(new ServerSecurityPolicy() + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Basic256 + }); + policies.Add(new ServerSecurityPolicy() + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Basic256Sha256 + }); + policies.Add(new ServerSecurityPolicy() + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep + }); + policies.Add(new ServerSecurityPolicy() + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss + }); + + userTokens.Add(new UserTokenPolicy(UserTokenType.UserName)); + } + else + { + userTokens.Add(new UserTokenPolicy(UserTokenType.UserName)); + policies.Add(new ServerSecurityPolicy() + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None + }); + userTokens.Add(new UserTokenPolicy(UserTokenType.Anonymous)); + } + + config.ApplicationName = "ThingsGateway OPCUAServer"; + config.ApplicationType = ApplicationType.Server; + config.ApplicationUri = _driverPropertys.BigTextApplicationUri; + + config.ServerConfiguration = new ServerConfiguration() + { + // 配置登录的地址 + BaseAddresses = urls, + + SecurityPolicies = policies, + UserTokenPolicies = userTokens, + ShutdownDelay = 1, + + MinRequestThreadCount = 50, + MaxRequestThreadCount = 1000, + MaxQueuedRequestCount = 20000, + + + DiagnosticsEnabled = false, // 是否启用诊断 + MaxSessionCount = 1000, // 最大打开会话数 + MinSessionTimeout = 10000, // 允许该会话在与客户端断开时(单位毫秒)仍然保持连接的最小时间 + MaxSessionTimeout = 60000, // 允许该会话在与客户端断开时(单位毫秒)仍然保持连接的最大时间 + MaxBrowseContinuationPoints = 1000, // 用于Browse / BrowseNext操作的连续点的最大数量。 + MaxQueryContinuationPoints = 1000, // 用于Query / QueryNext操作的连续点的最大数量 + MaxHistoryContinuationPoints = 500, // 用于HistoryRead操作的最大连续点数。 + MaxRequestAge = 1000000, // 传入请求的最大年龄(旧请求被拒绝)。 + MinPublishingInterval = 50, // 服务器支持的最小发布间隔(以毫秒为单位) + MaxPublishingInterval = 3600000, // 服务器支持的最大发布间隔(以毫秒为单位)1小时 + PublishingResolution = 50, // 支持的发布间隔(以毫秒为单位)的最小差异 + MaxSubscriptionLifetime = 3600000, // 订阅将在没有客户端发布的情况下保持打开多长时间 1小时 + MaxMessageQueueSize = 100, // 每个订阅队列中保存的最大消息数 + MaxNotificationQueueSize = 100, // 为每个被监视项目保存在队列中的最大证书数 + MaxNotificationsPerPublish = 1000, // 每次发布的最大通知数 + MinMetadataSamplingInterval = 1000, // 元数据的最小采样间隔 + MaxRegistrationInterval = -1, // 两次注册尝试之间的最大时间(以毫秒为单位)//不提供注册 + + MinSubscriptionLifetime = 1000, + MaxPublishRequestCount = 200, + MaxSubscriptionCount = 1000, + MaxEventQueueSize = 50000, + + + MaxTrustListSize = 0, + MultiCastDnsEnabled = false, + + }; + config.SecurityConfiguration = new SecurityConfiguration() + { + AddAppCertToTrustedStore = true, + SendCertificateChain = true, + AutoAcceptUntrustedCertificates = _driverPropertys.AutoAcceptUntrustedCertificates, + UseValidatedCertificates = true, + RejectSHA1SignedCertificates = false, + RejectUnknownRevocationStatus = true, + MinimumCertificateKeySize = 1024, + SuppressNonceValidationErrors = true, + ApplicationCertificate = new CertificateIdentifier() + { + StoreType = CertificateStoreType.X509Store, + StorePath = "CurrentUser\\UAServer_ThingsGateway", + SubjectName = _driverPropertys.BigTextSubjectName, + //ValidationOptions = CertificateValidationOptions.SuppressHostNameInvalid, + }, + + TrustedPeerCertificates = new CertificateTrustList() + { + StoreType = CertificateStoreType.Directory, + StorePath = AppContext.BaseDirectory + @"OPCUAServerCertificate\pki\trustedPeer", + }, + + TrustedIssuerCertificates = new CertificateTrustList() + { + StoreType = CertificateStoreType.Directory, + StorePath = AppContext.BaseDirectory + @"OPCUAServerCertificate\pki\trustedIssuer", + }, + + RejectedCertificateStore = new CertificateStoreIdentifier() + { + StoreType = CertificateStoreType.Directory, + StorePath = AppContext.BaseDirectory + @"OPCUAServerCertificate\pki\rejected", + }, + UserIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = AppContext.BaseDirectory + @"OPCUAServerCertificate\pki\issuerUser", + }, + TrustedUserCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = AppContext.BaseDirectory + @"OPCUAServerCertificate\pki\trustedUser", + } + }; + + config.TransportConfigurations = new TransportConfigurationCollection(); + config.TransportQuotas = new TransportQuotas + { + OperationTimeout = 6000000, + MaxStringLength = int.MaxValue, + MaxByteStringLength = int.MaxValue, + MaxArrayLength = 65535, + MaxMessageSize = 419430400, + MaxBufferSize = 65535, + ChannelLifetime = 300000, + SecurityTokenLifetime = 3600000 + }; + config.ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }; + config.TraceConfiguration = new TraceConfiguration(); + + config.CertificateValidator = new CertificateValidator(); + config.CertificateValidator.Update(config).GetAwaiter().GetResult(); + config.Extensions = new XmlElementCollection(); + + return config; + } + + private void VariableValueChange(VariableRuntime variableRuntime, VariableData variableData) + { + if (CurrentDevice.Pause) + return; + if (DisposedValue) return; + if (VariableRuntimes.ContainsKey(variableData.Name)) + CollectVariableRuntimes.Enqueue(variableData); + } +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/OpcUaServerProperty.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/OpcUaServerProperty.cs new file mode 100644 index 000000000..ed23d0619 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/OpcUaServerProperty.cs @@ -0,0 +1,56 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Opc.Ua; + +using ThingsGateway.Gateway.Application; + +namespace ThingsGateway.Plugin.OpcUa105; + +/// +public class OpcUaServerProperty : BusinessPropertyBase +{ + [DynamicProperty] + public bool IsAllVariable { get; set; } = false; + + /// + /// 服务地址 + /// + [DynamicProperty(Remark = "分号分割数组,可设置多个url")] + public string OpcUaStringUrl { get; set; } = "opc.tcp://127.0.0.1:49321"; + + /// + /// SubjectName + /// + [DynamicProperty()] + [AutoGenerateColumn(ComponentType = typeof(Textarea), Rows = 1)] + public string BigTextSubjectName { get; set; } = "CN=ThingsGateway OPCUAServer, C=CN, S=GUANGZHOU, O=ThingsGateway, DC=" + System.Net.Dns.GetHostName(); + + /// + /// ApplicationUri + /// + [AutoGenerateColumn(ComponentType = typeof(Textarea), Rows = 1)] + [DynamicProperty()] + public string BigTextApplicationUri { get; set; } = Utils.Format(@"urn:{0}:thingsgatewayopcuaserver", System.Net.Dns.GetHostName()); + + /// + /// 安全策略 + /// + [DynamicProperty()] + public bool SecurityPolicy { get; set; } + + /// + /// 接受不受信任的证书 + /// + [DynamicProperty()] + public bool AutoAcceptUntrustedCertificates { get; set; } = true; +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/OpcUaServerVariableProperty.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/OpcUaServerVariableProperty.cs new file mode 100644 index 000000000..a2dc2a065 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/OpcUaServer/OpcUaServerVariableProperty.cs @@ -0,0 +1,23 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Gateway.Application; + +namespace ThingsGateway.Plugin.OpcUa105; + +/// +public class OpcUaServerVariableProperty : VariablePropertyBase +{ + /// + /// 数据类型 + /// + [DynamicProperty()] + public DataTypeEnum DataType { get; set; } = DataTypeEnum.Object; +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/Pages/OpcUaImportVariable.razor b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Pages/OpcUaImportVariable.razor new file mode 100644 index 000000000..ee1bc8733 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Pages/OpcUaImportVariable.razor @@ -0,0 +1,59 @@ +@namespace ThingsGateway.Debug +@using BootstrapBlazor.Components +@using Microsoft.AspNetCore.Components.Web; +@using System.Reflection; + +@using Opc.Ua +@using ThingsGateway.Foundation.OpcUa105; + +
+ +
+ + { + if(a?.Value?.Tag?.NodeId!=null) + { + ClickItem=a; + NodeAttributes = Plc.ReadNoteAttributes(ClickItem.Value.NodeId.ToString()); + } + await InvokeAsync(StateHasChanged); + }) ShowSkeleton=ShowSkeleton + IsAccordion ClickToggleNode ModelEqualityComparer="OpcUaImportVariable.ModelEqualityComparer" /> +
+
+ + @if (ClickItem?.Value?.Tag?.NodeId != null && NodeAttributes != null) + { + + foreach (var item in NodeAttributes) + { + + } + } +
+ + +
+@{ +#if Plugin +} + +@{ +#endif +} + + + +@code { + RenderFragment RenderTreeItem => item => + @@item.Name + ; + private OPCNodeAttribute[] NodeAttributes = new OPCNodeAttribute[] { }; + private TreeViewItem ClickItem; +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/Pages/OpcUaImportVariable.razor.cs b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Pages/OpcUaImportVariable.razor.cs new file mode 100644 index 000000000..33c8afd14 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Pages/OpcUaImportVariable.razor.cs @@ -0,0 +1,429 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ +#pragma warning disable CA2007 // 考虑对等待的任务调用 ConfigureAwait + +using BootstrapBlazor.Components; + +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +using Opc.Ua; + +using System.Diagnostics.CodeAnalysis; + +using ThingsGateway.Extension; +using ThingsGateway.Foundation.OpcUa105; + +#if Plugin + +using ThingsGateway.Gateway.Application; +using ThingsGateway.Plugin.OpcUa105; + +#endif + +using ThingsGateway.Razor; + +using TouchSocket.Core; + +namespace ThingsGateway.Debug; + +/// +/// 导入变量 +/// +public partial class OpcUaImportVariable +{ + private List> Items = new(); + private IEnumerable Nodes; + private bool ShowSkeleton = true; + + /// + /// Opc对象 + /// + [Parameter] + public ThingsGateway.Foundation.OpcUa105.OpcUaMaster Plc { get; set; } + + [CascadingParameter] + private Func? OnCloseAsync { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer? OpcUaPropertyLocalizer { get; set; } + + [Inject] + [NotNull] + private ToastService? ToastService { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await Task.Run(async () => + { + Items = BuildTreeItemList(await PopulateBranchAsync(ObjectIds.ObjectsFolder), RenderTreeItem).ToList(); + ShowSkeleton = false; + await InvokeAsync(StateHasChanged); + }); + } + await base.OnAfterRenderAsync(firstRender); + } + + /// + /// 构建树节点,传入的列表已经是树结构 + /// + private static IEnumerable> BuildTreeItemList(IEnumerable opcUaTagModels, Microsoft.AspNetCore.Components.RenderFragment render = null, TreeViewItem? parent = null) + { + if (opcUaTagModels == null) return Enumerable.Empty>(); + var trees = new List>(); + foreach (var node in opcUaTagModels) + { + if (node == null) continue; + var item = new TreeViewItem(node) + { + Text = node.Name, + Parent = parent, + IsExpand = false, + Template = render, + HasChildren = node.Children != null, + }; + item.Items = BuildTreeItemList(node.Children, render, item).ToList(); + trees.Add(item); + } + return trees; + } + + private static bool ModelEqualityComparer(OpcUaTagModel x, OpcUaTagModel y) => x.NodeId == y.NodeId; + + private async Task>> OnExpandNodeAsync(TreeViewItem treeViewItem) + { + var data = BuildTreeItemList(await PopulateBranchAsync(treeViewItem.Value.NodeId), RenderTreeItem); + return data; + } + + private Task OnTreeItemChecked(List> items) + { + Nodes = items.Select(a => a.Value); + return Task.CompletedTask; + } + + private async Task PopulateBranch(OpcUaTagModel model) + { + if (model.Children != null) + { + if (model.Children.Count == 0) + { + model.Children = await PopulateBranchAsync((NodeId)model.Tag.NodeId); + } + foreach (var item in model.Children) + { + await PopulateBranch(item); + } + } + } + + private async Task GetReferenceDescriptionCollectionAsync(NodeId sourceId) + { + BrowseDescription nodeToBrowse1 = new() + { + NodeId = sourceId, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.Aggregates, + IncludeSubtypes = true, + NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method | NodeClass.ReferenceType | NodeClass.ObjectType | NodeClass.View | NodeClass.VariableType | NodeClass.DataType), + ResultMask = (uint)BrowseResultMask.All + }; + + BrowseDescription nodeToBrowse2 = new() + { + NodeId = sourceId, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.Organizes, + IncludeSubtypes = true, + NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method | NodeClass.View | NodeClass.ReferenceType | NodeClass.ObjectType | NodeClass.VariableType | NodeClass.DataType), + ResultMask = (uint)BrowseResultMask.All + }; + + BrowseDescriptionCollection nodesToBrowse = new() + { + nodeToBrowse1, + nodeToBrowse2 + }; + + ReferenceDescriptionCollection references = await OpcUaUtils.BrowseAsync(Plc.Session, nodesToBrowse, false); + return references; + } + + [Parameter] + public bool ShowSubvariable { get; set; } + + private async Task> PopulateBranchAsync(NodeId sourceId, bool isAll = false) + { + if (!Plc.Connected) + { + return new() { }; + } + List nodes = new() + { + }; + + ReferenceDescriptionCollection references = await GetReferenceDescriptionCollectionAsync(sourceId); + List list = new(); + if (references != null) + { + for (int ii = 0; ii < references.Count; ii++) + { + ReferenceDescription target = references[ii]; + OpcUaTagModel child = new() + { + Name = Utils.Format("{0}", target), + Tag = target + }; + if (ShowSubvariable || target.NodeClass != NodeClass.Variable) + { + var data = await GetReferenceDescriptionCollectionAsync((NodeId)target.NodeId); + if (data != null && data.Count > 0) + { + if (isAll) + child.Children = await PopulateBranchAsync((NodeId)target.NodeId); + else + child.Children = new(); + } + } + + list.Add(child); + } + } + + nodes.Clear(); + nodes.AddRange(list.ToArray()); + return nodes; + } + +#if Plugin + + private async Task> GetAllTag(IEnumerable opcUaTagModels) + { + List result = new(); + foreach (var item in opcUaTagModels) + { + await PopulateBranch(item); + + result.AddRange(item.GetAllTags().Where(a => a.Children == null).ToList()); + } + + return result; + } + + private async Task OnClickClose() + { + if (OnCloseAsync != null) + await OnCloseAsync(); + } + + private async Task OnClickExport() + { + try + { + if (Nodes == null) return; + var data = await GetImportVariableList((await GetAllTag(Nodes)).DistinctBy(a => a.NodeId)); + if (data.Item3 == null || data.Item3?.Count == 0) + { + await ToastService.Warning(OpcUaPropertyLocalizer["NoVariablesAvailable"], OpcUaPropertyLocalizer["NoVariablesAvailable"]); + return; + } + + await DownChannelExportAsync(data.Item1); + await DownDeviceExportAsync(data.Item2, data.Item1.Name); + await DownDeviceVariableExportAsync(data.Item3.ToList(), data.Item2.Name); + await ToastService.Default(); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + } + } + + private async Task OnClickSave() + { + try + { + if (Nodes == null) return; + var data = await GetImportVariableList((await GetAllTag(Nodes)).DistinctBy(a => a.NodeId)); + if (data.Item3 == null || data.Item3?.Count == 0) + { + await ToastService.Warning(OpcUaPropertyLocalizer["NoVariablesAvailable"], OpcUaPropertyLocalizer["NoVariablesAvailable"]); + return; + } + await App.RootServices.GetRequiredService().SaveChannelAsync(data.Item1, ItemChangedType.Add); + await App.RootServices.GetRequiredService().SaveDeviceAsync(data.Item2, ItemChangedType.Add); + await App.RootServices.GetRequiredService().AddBatchAsync(data.Item3.ToList()); + await ToastService.Default(); + } + catch (Exception ex) + { + await ToastService.Warning(ex.Message); + } + } + + /// + /// 获取设备与变量列表 + /// + /// + private async Task<(Channel, Device, IList)> GetImportVariableList(IEnumerable opcUaTagModels) + { + var channel = GetImportChannel(); + var device = GetImportDevice(channel.Id); + var variables = new ConcurrentList(); + await opcUaTagModels.ParallelForEachAsync(async (b, token) => + { + var a = b.Tag; + var nodeClass = (await Plc.ReadNoteAttributeAsync(a.NodeId.ToString(), Opc.Ua.Attributes.NodeClass, token)).FirstOrDefault().Value.ToString(); + if (nodeClass == nameof(NodeClass.Variable)) + { + ProtectTypeEnum level = ProtectTypeEnum.ReadOnly; + DataTypeEnum dataTypeEnum = DataTypeEnum.Object; + try + { + var userAccessLevel = (AccessLevelType)(await Plc.ReadNoteAttributeAsync(a.NodeId.ToString(), Opc.Ua.Attributes.UserAccessLevel, token)).FirstOrDefault().Value; + level = (userAccessLevel.HasFlag(AccessLevelType.CurrentRead)) ? + userAccessLevel.HasFlag(AccessLevelType.CurrentWrite) ? + ProtectTypeEnum.ReadWrite : ProtectTypeEnum.ReadOnly : ProtectTypeEnum.WriteOnly; + + var dataTypeId = (Opc.Ua.NodeId)(await Plc.ReadNoteAttributeAsync(a.NodeId.ToString(), Opc.Ua.Attributes.DataType, token)).FirstOrDefault().Value; + var dataType = Opc.Ua.TypeInfo.GetSystemType(dataTypeId, Plc.Session.Factory); + var result = dataType != null && Enum.TryParse(dataType.Name, out dataTypeEnum); + if (!result) + { + dataTypeEnum = DataTypeEnum.Object; + } + } + catch + { + } + + var id = Admin.Application.CommonUtils.GetSingleId(); + + variables.Add(new Variable() + { + Name = a.DisplayName.Text + "-" + id, + RegisterAddress = a.NodeId.ToString(), + DeviceId = device.Id, + DataType = dataTypeEnum, + Enable = true, + Id = id, + ProtectType = level, + IntervalTime = "1000", + RpcWriteEnable = true, + }); + } + }, Environment.ProcessorCount / 2); + + return (channel, device, variables); + } + + private Device GetImportDevice(long channelId) + { + var id = Admin.Application.CommonUtils.GetSingleId(); + var data = new Device() + { + Name = Plc.OpcUaProperty.OpcUrl + "-" + id, + Id = id, + ChannelId = channelId, + Enable = true, + DevicePropertys = new(), + }; + data.DevicePropertys.Add(nameof(OpcUaMasterProperty.OpcUrl), Plc.OpcUaProperty.OpcUrl); + data.DevicePropertys.Add(nameof(OpcUaMasterProperty.UserName), Plc.OpcUaProperty.UserName); + data.DevicePropertys.Add(nameof(OpcUaMasterProperty.Password), Plc.OpcUaProperty.Password); + data.DevicePropertys.Add(nameof(OpcUaMasterProperty.CheckDomain), Plc.OpcUaProperty.CheckDomain.ToString()); + data.DevicePropertys.Add(nameof(OpcUaMasterProperty.LoadType), Plc.OpcUaProperty.LoadType.ToString()); + data.DevicePropertys.Add(nameof(OpcUaMasterProperty.UseSecurity), Plc.OpcUaProperty.UseSecurity.ToString()); + data.DevicePropertys.Add(nameof(OpcUaMasterProperty.ActiveSubscribe), true.ToString()); + data.DevicePropertys.Add(nameof(OpcUaMasterProperty.DeadBand), Plc.OpcUaProperty.DeadBand.ToString()); + data.DevicePropertys.Add(nameof(OpcUaMasterProperty.GroupSize), Plc.OpcUaProperty.GroupSize.ToString()); + data.DevicePropertys.Add(nameof(OpcUaMasterProperty.UpdateRate), Plc.OpcUaProperty.UpdateRate.ToString()); + data.DevicePropertys.Add(nameof(OpcUaMasterProperty.KeepAliveInterval), Plc.OpcUaProperty.KeepAliveInterval.ToString()); + return data; + } + + private Channel GetImportChannel() + { + var id = Admin.Application.CommonUtils.GetSingleId(); + var data = new Channel() + { + Name = Plc.OpcUaProperty.OpcUrl + "-" + id, + Id = id, + Enable = true, + ChannelType = ChannelTypeEnum.Other, + PluginName = "ThingsGateway.Plugin.OpcUa105.OpcUaMaster", + }; + return data; + } + + [Inject] + private DownloadService DownloadService { get; set; } + + /// + /// 导出到excel + /// + /// + public async Task DownChannelExportAsync(Channel data) + { + using var memoryStream = await App.RootServices.GetRequiredService().ExportMemoryStream(new List() { data }); + await DownloadService.DownloadFromStreamAsync($"channel{DateTime.Now.ToFileDateTimeFormat()}.xlsx", memoryStream); + } + + /// + /// 导出到excel + /// + /// + public async Task DownDeviceExportAsync(Device data, string channelName) + { + using var memoryStream = await App.RootServices.GetRequiredService().ExportMemoryStream(new List() { data }, channelName); + await DownloadService.DownloadFromStreamAsync($"device{DateTime.Now.ToFileDateTimeFormat()}.xlsx", memoryStream); + } + + /// + /// 导出到excel + /// + /// + public async Task DownDeviceVariableExportAsync(List data, string devName) + { + using var memoryStream = await App.RootServices.GetRequiredService().ExportMemoryStream(data, devName); + await DownloadService.DownloadFromStreamAsync($"variable{DateTime.Now.ToFileDateTimeFormat()}.xlsx", memoryStream); + } + +#endif + + internal sealed class OpcUaTagModel + { + internal List Children { get; set; } + internal string Name { get; set; } + internal string NodeId => (Tag?.NodeId)?.ToString(); + internal ReferenceDescription Tag { get; set; } + + public List GetAllTags() + { + List allTags = new(); + OpcUaTagModel.GetAllTagsRecursive(this, allTags); + return allTags; + } + + private static void GetAllTagsRecursive(OpcUaTagModel parentTag, List allTags) + { + allTags.Add(parentTag); + if (parentTag.Children != null) + foreach (OpcUaTagModel childTag in parentTag.Children) + { + OpcUaTagModel.GetAllTagsRecursive(childTag, allTags); + } + } + } +} diff --git a/src/Plugin/ThingsGateway.Plugin.OpcUa105/Pages/OpcUaMaster.razor b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Pages/OpcUaMaster.razor new file mode 100644 index 000000000..43285e538 --- /dev/null +++ b/src/Plugin/ThingsGateway.Plugin.OpcUa105/Pages/OpcUaMaster.razor @@ -0,0 +1,111 @@ +@page "/OpcUaMaster" +@using BootstrapBlazor.Components +@using ThingsGateway.Extension +@using ThingsGateway.Foundation +@namespace ThingsGateway.Debug +@using ThingsGateway.Foundation.OpcUa105 +@using TouchSocket.Core + + +
+ + @if (_plc?.OpcUaProperty != null) + { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + + + + + + + + +
+ + +
+ + + +
+
+ + + +
+ +
+ + + +
+
+ + + +
+ +
+