diff --git a/DMS.Infrastructure/Interfaces/Services/IOpcUaService.cs b/DMS.Infrastructure/Interfaces/Services/IOpcUaService.cs index b365ba3..05e9f6e 100644 --- a/DMS.Infrastructure/Interfaces/Services/IOpcUaService.cs +++ b/DMS.Infrastructure/Interfaces/Services/IOpcUaService.cs @@ -1,62 +1,24 @@ -using Opc.Ua; -using Opc.Ua.Client; +using DMS.Infrastructure.Models; using System; using System.Collections.Generic; -using System.Threading; using System.Threading.Tasks; namespace DMS.Infrastructure.Interfaces.Services { public interface IOpcUaService { - - /// - /// 连接到 OPC UA 服务器(异步) - /// - /// 取消令牌 - /// - public Task ConnectAsync(string opcUaServerUrl,CancellationToken stoppingToken = default); - - - - /// - /// 断开 OPC UA 服务器连接 - /// - public void Disconnect(); - - /// - /// 添加订阅 - /// - /// 订阅名称 - /// 创建的订阅 - public Subscription AddSubscription(string subscriptionName); - - /// - /// 浏览节点 - /// - /// 起始节点ID - /// 节点引用列表 - public IList BrowseNodes(NodeId nodeId); - - /// - /// 读取节点值 - /// - /// 节点ID - /// 节点值 - public DataValue ReadValue(NodeId nodeId); - - /// - /// 写入节点值 - /// - /// 节点ID - /// 要写入的值 - /// 写入结果 - public StatusCode WriteValue(NodeId nodeId, object value); - - /// - /// 检查是否已连接 - /// - /// - public bool IsConnected(); + bool IsConnected { get; } + Task ConnectAsync(string serviceURL); + Task DisconnectAsync(); + Task> BrowseNode(OpcUaNode? nodeToBrowse); + void SubscribeToNode(OpcUaNode node, Action onDataChange, int publishingInterval = 1000, int samplingInterval = 500); + void SubscribeToNode(List nodes, Action onDataChange, int publishingInterval = 1000, int samplingInterval = 500); + void UnsubscribeFromNode(OpcUaNode node); + void UnsubscribeFromNode(List nodes); + List GetSubscribedNodes(); + Task ReadNodeValueAsync(OpcUaNode node); + Task ReadNodeValuesAsync(List nodes); + Task WriteNodeValueAsync(OpcUaNode node, object value); + Task WriteNodeValuesAsync(Dictionary nodesToWrite); } -} \ No newline at end of file +} diff --git a/DMS.Infrastructure/Models/OpcUaNode.cs b/DMS.Infrastructure/Models/OpcUaNode.cs new file mode 100644 index 0000000..c79079c --- /dev/null +++ b/DMS.Infrastructure/Models/OpcUaNode.cs @@ -0,0 +1,49 @@ +using Opc.Ua; + +namespace DMS.Infrastructure.Models +{ + /// + /// 封装OPC UA节点的基本信息。 + /// + public class OpcUaNode + { + /// + /// 节点的唯一标识符。 + /// + public NodeId? NodeId { get; set; } + + /// + /// 节点的显示名称。 + /// + public string? DisplayName { get; set; } + + /// + /// 节点的类型(如对象、变量等)。 + /// + public NodeClass NodeClass { get; set; } + + /// + /// 节点的值。仅当节点是变量类型时有效。 + /// + public object? Value { get; set; } + + /// + /// 父节点 + /// + public OpcUaNode? ParentNode { get; set; } + + /// + /// 子节点列表 + /// + public List Children { get; set; } = new List(); + + /// + /// 返回节点的字符串表示形式。 + /// + public override string ToString() + { + string valueString = Value != null ? $", Value: {Value}" : ""; + return $"- {DisplayName} ({NodeClass}, {NodeId}{valueString})"; + } + } +} \ No newline at end of file diff --git a/DMS.Infrastructure/Services/OpcUaService.cs b/DMS.Infrastructure/Services/OpcUaService.cs index 8f95a9d..d3f193b 100644 --- a/DMS.Infrastructure/Services/OpcUaService.cs +++ b/DMS.Infrastructure/Services/OpcUaService.cs @@ -1,291 +1,509 @@ -using DMS.Infrastructure.Interfaces.Services; -using DMS.Infrastructure.Helper; +using DMS.Infrastructure.Interfaces.Services; +using DMS.Infrastructure.Models; +using Microsoft.IdentityModel.Tokens; using Opc.Ua; using Opc.Ua.Client; +using Opc.Ua.Configuration; using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading; using System.Threading.Tasks; namespace DMS.Infrastructure.Services { public class OpcUaService : IOpcUaService { - private Session? _session; - private string _serverUrl; + private readonly ApplicationConfiguration _config; + private string _serverUrl; + private Session _session; + private Subscription _subscription; + private readonly Dictionary _subscribedNodes = new Dictionary(); - /// - /// 创建 OPC UA 会话 - /// - /// OPC UA 服务器地址 - /// 取消令牌 - /// - public async Task CreateSession( CancellationToken stoppingToken = default) + public bool IsConnected => _session != null && _session.Connected; + + public OpcUaService() { - + + _config = CreateApplicationConfiguration(); + } + public async Task ConnectAsync(string serverUrl) + { try { - _session = await OpcUaHelper.CreateOpcUaSessionAsync(_serverUrl, stoppingToken); - + _serverUrl = serverUrl.ToUpper(); + if (_serverUrl.IsNullOrEmpty() || !(_serverUrl.StartsWith("OPC.TCP://"))) + { + throw new Exception($"serverUrl服务器地址无效,serverUrl:{_serverUrl}"); + } + // 验证客户端应用程序配置的有效性。 + await _config.Validate(ApplicationType.Client); + + // 如果配置为自动接受不受信任的证书,则设置证书验证回调。 + // 这在开发和测试中很方便,但在生产环境中应谨慎使用。 + if (_config.SecurityConfiguration.AutoAcceptUntrustedCertificates) + { + _config.CertificateValidator.CertificateValidation += (s, e) => { e.Accept = e.Error.StatusCode == StatusCodes.BadCertificateUntrusted; }; + } + + // 创建一个应用程序实例,它代表了客户端应用程序。 + var application = new ApplicationInstance(_config); + + // 检查应用程序实例证书是否存在且有效,如果不存在则会尝试创建。 + bool haveAppCertificate = await application.CheckApplicationInstanceCertificate(false, 2048); + if (!haveAppCertificate) + { + throw new Exception("应用程序实例证书无效!"); + } + + // 从给定的URL发现并选择一个合适的服务器终结点(Endpoint)。 + var selectedEndpoint = CoreClientUtils.SelectEndpoint(_config, _serverUrl, useSecurity: false); + + // 创建到服务器的会话。会话管理客户端和服务器之间的所有通信。 + _session = await Session.Create( + _config, // 应用程序配置 + new ConfiguredEndpoint(null, selectedEndpoint, EndpointConfiguration.Create(_config)), // 要连接的已配置端点 + false, // 不更新服务器端点 + "OpcUaDemo Session", // 会话名称 + 60000, // 会话超时时间(毫秒) + null, // 用户身份验证令牌,此处为匿名 + null // 首选区域设置 + ); } catch (Exception ex) - { - throw new InvalidOperationException($"Failed to create OPC UA session: {ex.Message}", ex); + { + Console.WriteLine($"连接服务器时发生错误: {ex.Message}"); + throw; } } - /// - /// 连接到 OPC UA 服务器 - /// - public async Task ConnectAsync(string opcUaServerUrl, CancellationToken stoppingToken = default) + public async Task DisconnectAsync() { - _serverUrl = opcUaServerUrl; - if (string.IsNullOrEmpty(opcUaServerUrl)) + if (_session != null) { - throw new ArgumentException("OPC UA server URL cannot be null or empty.", nameof(opcUaServerUrl)); + await _session.CloseAsync(); + _session = null; } - if (string.IsNullOrEmpty(_serverUrl)) + } + + public async Task> BrowseNode(OpcUaNode nodeToBrowse) + { + if (!IsConnected) { - throw new InvalidOperationException("Server URL is not set. Please call CreateSession first."); + throw new InvalidOperationException("会话未连接。请在浏览节点前调用ConnectAsync方法。"); } - - // 如果已经连接,直接返回 - if (_session?.Connected == true) + var nodes = new List(); + try + { + // 存放浏览结果的集合 + ReferenceDescriptionCollection references; + // 用于处理分页的延续点,如果一次浏览无法返回所有结果,服务器会返回此值 + byte[] continuationPoint; + + // 调用会话的Browse方法来发现服务器地址空间中的节点 + _session.Browse( + null, // requestHeader: 使用默认值 + null, // view: 不指定视图,即在整个地址空间中浏览 + nodeToBrowse.NodeId, // nodeId: 要浏览的起始节点ID + 0u, // maxResultsToReturn: 0表示返回所有结果(如果服务器支持) + BrowseDirection.Forward, // browseDirection: 向前浏览(子节点) + ReferenceTypeIds.HierarchicalReferences, // referenceTypeId: 只获取层级引用,这是最常用的引用类型 + true, // includeSubtypes: 包含子类型 + (uint)NodeClass.Object | (uint)NodeClass.Variable, // nodeClassMask: 只返回对象和变量类型的节点 + out continuationPoint, // continuationPoint: 输出参数,用于处理分页 + out references); // references: 输出参数,浏览到的节点引用集合 + + if (references != null) + { + // 遍历第一次返回的结果 + foreach (var rd in references) + { + nodes.Add(new OpcUaNode + { + ParentNode = nodeToBrowse, + NodeId = (NodeId)rd.NodeId, + DisplayName = rd.DisplayName.Text, + NodeClass = rd.NodeClass + }); + } + } + + // 如果continuationPoint不为null,说明服务器还有数据未返回,需要循环调用BrowseNext获取 + while (continuationPoint != null) + { + // 调用BrowseNext获取下一批数据 + _session.BrowseNext(null, false, continuationPoint, out continuationPoint, out references); + + if (references != null) + { + // 遍历后续批次返回的结果 + foreach (var rd in references) + { + nodes.Add(new OpcUaNode + { + ParentNode = nodeToBrowse, + NodeId = (NodeId)rd.NodeId, + DisplayName = rd.DisplayName.Text, + NodeClass = rd.NodeClass + }); + } + } + } + // 将找到的子节点列表关联到父节点 + nodeToBrowse.Children = nodes; + } + catch (Exception ex) + { + Console.WriteLine($"浏览节点 '{nodeToBrowse.DisplayName}' 时发生错误: {ex.Message}"); + } + return nodes; + } + + public void SubscribeToNode(OpcUaNode node, Action onDataChange, int publishingInterval = 1000, int samplingInterval = 500) + { + SubscribeToNode(new List { node }, onDataChange, publishingInterval, samplingInterval); + } + + public void SubscribeToNode(List nodes, Action onDataChange, int publishingInterval = 1000, int samplingInterval = 500) + { + // 检查会话是否已连接 + if (!IsConnected) + { + throw new InvalidOperationException("会话未连接。请在订阅节点前调用ConnectAsync方法。"); + } + // 检查节点列表是否有效 + if (nodes == null || !nodes.Any()) + { + return; + } + // 如果还没有订阅对象,则基于会话的默认设置创建一个新的订阅 + if (_subscription == null) + { + _subscription = new Subscription(_session.DefaultSubscription) + { + // 设置服务器向客户端发送通知的速率(毫秒) + PublishingInterval = publishingInterval + }; + // 在会话中添加订阅 + _session.AddSubscription(_subscription); + // 在服务器上创建订阅 + _subscription.Create(); + } + // 如果客户端请求的发布间隔与现有订阅不同,则修改订阅 + else if (_subscription.PublishingInterval != publishingInterval) + { + _subscription.PublishingInterval = publishingInterval; + } + + // 创建一个用于存放待添加监视项的列表 + var itemsToAdd = new List(); + // 遍历所有请求订阅的节点 + foreach (var node in nodes) + { + // 如果节点已经存在于我们的跟踪列表中,则跳过,避免重复订阅 + if (_subscribedNodes.ContainsKey(node.NodeId)) + { + continue; + } + // 为每个节点创建一个监视项 + var monitoredItem = new MonitoredItem(_subscription.DefaultItem) + { + DisplayName = node.DisplayName, + StartNodeId = node.NodeId, + AttributeId = Attributes.Value, // 我们关心的是节点的值属性 + SamplingInterval = samplingInterval // 服务器采样节点值的速率(毫秒) + }; + // 设置数据变化通知的回调函数 + monitoredItem.Notification += (item, e) => + { + // 将通知事件参数转换为MonitoredItemNotification + var notification = e.NotificationValue as MonitoredItemNotification; + if (notification != null) + { + // 通过StartNodeId从我们的跟踪字典中找到对应的OpcUaNode对象 + if (_subscribedNodes.TryGetValue(item.StartNodeId, out var changedNode)) + { + // 更新节点对象的值 + changedNode.Value = notification.Value.Value; + // 调用用户提供的回调函数,并传入更新后的节点 + onDataChange?.Invoke(changedNode); + } + } + }; + // 将创建的监视项添加到待添加列表 + itemsToAdd.Add(monitoredItem); + // 将节点添加到我们的跟踪字典中 + _subscribedNodes[node.NodeId] = node; + } + + // 如果有新的监视项要添加 + if (itemsToAdd.Any()) + { + // 将所有新的监视项批量添加到订阅中 + _subscription.AddItems(itemsToAdd); + // 将所有挂起的更改(包括订阅属性修改和添加新项)应用到服务器 + _subscription.ApplyChanges(); + } + } + + public void UnsubscribeFromNode(OpcUaNode node) + { + UnsubscribeFromNode(new List { node }); + } + + public void UnsubscribeFromNode(List nodes) + { + // 检查订阅对象和节点列表是否有效 + if (_subscription == null || nodes == null || !nodes.Any()) { return; } - // 重新创建会话 - await CreateSession( stoppingToken); + var itemsToRemove = new List(); + // 遍历所有请求取消订阅的节点 + foreach (var node in nodes) + { + // 在当前订阅中查找与节点ID匹配的监视项 + var item = _subscription.MonitoredItems.FirstOrDefault(m => m.StartNodeId.Equals(node.NodeId)); + if (item != null) + { + // 如果找到,则添加到待移除列表 + itemsToRemove.Add(item); + // 从我们的跟踪字典中移除该节点 + _subscribedNodes.Remove(node.NodeId); + } + } + + // 如果有需要移除的监视项 + if (itemsToRemove.Any()) + { + // 从订阅中批量移除监视项 + _subscription.RemoveItems(itemsToRemove); + // 将更改应用到服务器 + _subscription.ApplyChanges(); + } } - - - /// - /// 断开 OPC UA 服务器连接 - /// - public void Disconnect() + public List GetSubscribedNodes() { - if (_session != null) + return _subscribedNodes.Values.ToList(); + } + + public Task ReadNodeValueAsync(OpcUaNode node) + { + return ReadNodeValuesAsync(new List { node }); + } + + public async Task ReadNodeValuesAsync(List nodes) + { + if (!IsConnected || nodes == null || !nodes.Any()) { + return; + } + + // 创建一个用于存放读取请求的集合 + var nodesToRead = new ReadValueIdCollection(); + // 创建一个列表,用于在收到响应后按顺序查找对应的OpcUaNode + var nodeListForLookup = new List(); + // 遍历所有请求读取的节点 + foreach (var node in nodes) + { + // 只处理变量类型的节点,因为只有变量才有值 + if (node.NodeClass == NodeClass.Variable) + { + // 创建一个ReadValueId,指定要读取的节点ID和属性(值) + nodesToRead.Add(new ReadValueId + { + NodeId = node.NodeId, + AttributeId = Attributes.Value + }); + // 将节点添加到查找列表中 + nodeListForLookup.Add(node); + } + } + + // 如果没有需要读取的变量节点,则直接返回 + if (nodesToRead.Count == 0) + { + return; + } + + try + { + // 异步调用Read方法,批量读取所有节点的值 + var response = await _session.ReadAsync( + null, // RequestHeader, 使用默认值 + 0, // maxAge, 0表示直接从设备读取最新值,而不是从缓存读取 + TimestampsToReturn.Neither, // TimestampsToReturn, 表示我们不关心值的时间戳 + nodesToRead, // ReadValueIdCollection, 要读取的节点和属性的集合 + default // CancellationToken + ); + + // 获取响应中的结果和诊断信息 + var results = response.Results; + var diagnosticInfos = response.DiagnosticInfos; + + // 验证响应,确保请求成功 + ClientBase.ValidateResponse(results, nodesToRead); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, nodesToRead); + + // 遍历返回的结果 + for (int i = 0; i < results.Count; i++) + { + // 根据索引找到对应的OpcUaNode + var node = nodeListForLookup[i]; + // 检查状态码,确保读取成功 + if (StatusCode.IsGood(results[i].StatusCode)) + { + // 更新节点的值 + node.Value = results[i].Value; + } + else + { + // 如果读取失败,则将状态码作为值,方便调试 + node.Value = $"({results[i].StatusCode})"; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"读取节点值时发生错误: {ex.Message}"); + } + } + + public Task WriteNodeValueAsync(OpcUaNode node, object value) + { + var nodesToWrite = new Dictionary { { node, value } }; + return WriteNodeValuesAsync(nodesToWrite); + } + + public async Task WriteNodeValuesAsync(Dictionary nodesToWrite) + { + // 检查会话是否连接,以及待写入的节点字典是否有效 + if (!IsConnected || nodesToWrite == null || !nodesToWrite.Any()) + { + return false; + } + + // 创建一个用于存放写入请求的集合 + var writeValues = new WriteValueCollection(); + // 创建一个列表,用于在收到响应后按顺序查找对应的OpcUaNode,以进行错误报告 + var nodeListForLookup = new List(); + + // 遍历所有请求写入的节点和值 + foreach (var entry in nodesToWrite) + { + var node = entry.Key; + var value = entry.Value; + + // 只能向变量类型的节点写入值 + if (node.NodeClass != NodeClass.Variable) + { + Console.WriteLine($"节点 '{node.DisplayName}' 不是变量类型,无法写入。"); + continue; // 跳过非变量节点 + } + try { - _session.Close(); + // 创建一个WriteValue对象,它封装了写入操作的所有信息 + var writeValue = new WriteValue + { + NodeId = node.NodeId, // 指定要写入的节点ID + AttributeId = Attributes.Value, // 指定要写入的是节点的Value属性 + // 将要写入的值封装在DataValue和Variant中 + // Variant可以处理各种数据类型 + Value = new DataValue(new Variant(value)) + }; + writeValues.Add(writeValue); + // 将节点添加到查找列表中,保持与writeValues集合的顺序一致 + nodeListForLookup.Add(node); } catch (Exception ex) { - // 记录日志但不抛出异常,确保清理工作完成 - System.Diagnostics.Debug.WriteLine($"Error closing OPC UA session: {ex.Message}"); - } - finally - { - _session = null; + // 处理在创建写入值时可能发生的异常(例如,值类型不兼容) + Console.WriteLine($"为节点 '{node.DisplayName}' 创建写入值时发生错误: {ex.Message}"); } } - } - /// - /// 添加订阅 - /// - /// 订阅名称 - /// 创建的订阅 - public Subscription AddSubscription(string subscriptionName) - { - if (_session == null) + // 如果没有有效的写入请求,则直接返回 + if (writeValues.Count == 0) { - throw new InvalidOperationException("Session is not created. Please call CreateSession first."); - } - - if (!_session.Connected) - { - throw new InvalidOperationException("Session is not connected. Please call Connect first."); - } - - if (string.IsNullOrEmpty(subscriptionName)) - { - throw new ArgumentException("Subscription name cannot be null or empty.", nameof(subscriptionName)); + return false; } try { - var subscription = new Subscription(_session.DefaultSubscription) + // 异步调用Write方法,将所有写入请求批量发送到服务器 + var response = await _session.WriteAsync( + null, // RequestHeader, 使用默认值 + writeValues, // WriteValueCollection, 要写入的节点和值的集合 + default // CancellationToken + ); + + // 获取响应中的结果和诊断信息 + var results = response.Results; + var diagnosticInfos = response.DiagnosticInfos; + + // 验证响应,确保请求被服务器正确处理 + ClientBase.ValidateResponse(results, writeValues); + ClientBase.ValidateDiagnosticInfos(diagnosticInfos, writeValues); + + bool allSuccess = true; + // 遍历返回的结果状态码 + for (int i = 0; i < results.Count; i++) { - DisplayName = subscriptionName, - PublishingInterval = 1000, - LifetimeCount = 0, - MaxNotificationsPerPublish = 0, - PublishingEnabled = true, - Priority = 0 - }; - - _session.AddSubscription(subscription); - subscription.Create(); - - return subscription; - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to add subscription '{subscriptionName}': {ex.Message}", ex); - } - } - - /// - /// 浏览节点 - /// - /// 起始节点ID - /// 节点引用列表 - public IList BrowseNodes(NodeId nodeId) - { - if (_session == null) - { - throw new InvalidOperationException("Session is not created. Please call CreateSession first."); - } - - if (!_session.Connected) - { - throw new InvalidOperationException("Session is not connected. Please call Connect first."); - } - - if (nodeId == null) - { - throw new ArgumentNullException(nameof(nodeId)); - } - - try - { - // 使用会话的浏览方法 - var response = _session.Browse( - null, - null, - nodeId, - 0u, - BrowseDirection.Forward, - ReferenceTypeIds.HierarchicalReferences, - true, - (uint)NodeClass.Unspecified, - out var continuationPoint, - out var references); - - return references; - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to browse nodes: {ex.Message}", ex); - } - } - - /// - /// 读取节点值 - /// - /// 节点ID - /// 节点值 - public DataValue ReadValue(NodeId nodeId) - { - if (_session == null) - { - throw new InvalidOperationException("Session is not created. Please call CreateSession first."); - } - - if (!_session.Connected) - { - throw new InvalidOperationException("Session is not connected. Please call Connect first."); - } - - if (nodeId == null) - { - throw new ArgumentNullException(nameof(nodeId)); - } - - try - { - // 创建读取值集合 - var nodesToRead = new ReadValueIdCollection - { - new ReadValueId + // 如果返回的状态码表示失败 + if (StatusCode.IsBad(results[i])) { - NodeId = nodeId, - AttributeId = Attributes.Value + allSuccess = false; + // 根据索引找到写入失败的节点 + var failedNode = nodeListForLookup[i]; + Console.WriteLine($"写入节点 '{failedNode.DisplayName}' 失败: {results[i]}"); } - }; + } - // 执行读取操作 - _session.Read( - null, - 0, - TimestampsToReturn.Neither, - nodesToRead, - out var results, - out var diagnosticInfos); - - return results[0]; + return allSuccess; } catch (Exception ex) { - throw new InvalidOperationException($"Failed to read value from node '{nodeId}': {ex.Message}", ex); + Console.WriteLine($"写入节点值时发生错误: {ex.Message}"); + return false; } } - /// - /// 写入节点值 - /// - /// 节点ID - /// 要写入的值 - /// 写入结果 - public StatusCode WriteValue(NodeId nodeId, object value) + private ApplicationConfiguration CreateApplicationConfiguration() { - if (_session == null) + return new ApplicationConfiguration() { - throw new InvalidOperationException("Session is not created. Please call CreateSession first."); - } - - if (!_session.Connected) - { - throw new InvalidOperationException("Session is not connected. Please call Connect first."); - } - - if (nodeId == null) - { - throw new ArgumentNullException(nameof(nodeId)); - } - - try - { - // 创建写入值集合 - var nodesToWrite = new WriteValueCollection + // 应用程序的名称,会显示在服务器端 + ApplicationName = "OpcUaDemoClient", + // 应用程序的唯一标识符URI + ApplicationUri = Utils.Format("urn:{0}:OpcUaDemoClient", System.Net.Dns.GetHostName()), + // 应用程序类型为客户端 + ApplicationType = ApplicationType.Client, + // 安全相关的配置 + SecurityConfiguration = new SecurityConfiguration { - new WriteValue - { - NodeId = nodeId, - AttributeId = Attributes.Value, - Value = new DataValue(new Variant(value)) - } - }; - - // 执行写入操作 - _session.Write( - null, - nodesToWrite, - out var results, - out var diagnosticInfos); - - return results[0]; - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to write value to node '{nodeId}': {ex.Message}", ex); - } - } - - /// - /// 检查是否已连接 - /// - /// - public bool IsConnected() - { - return _session?.Connected == true; + // 应用程序实例证书的存储位置和主题名称 + ApplicationCertificate = new CertificateIdentifier { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\MachineDefault", SubjectName = "OpcUaDemoClient" }, + // 受信任的证书颁发机构的证书存储 + TrustedIssuerCertificates = new CertificateTrustList { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\UA Certificate Authorities" }, + // 受信任的对等(服务器)证书的存储 + TrustedPeerCertificates = new CertificateTrustList { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\UA Applications" }, + // 被拒绝的证书的存储 + RejectedCertificateStore = new CertificateStoreIdentifier { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\RejectedCertificates" }, + // 自动接受不受信任的证书(仅建议在开发环境中使用) + AutoAcceptUntrustedCertificates = true + }, + // 传输配置(例如,缓冲区大小) + TransportConfigurations = new TransportConfigurationCollection(), + // 传输配额(例如,操作超时时间) + TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, + // 客户端特定的配置(例如,默认会话超时) + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, + // 跟踪和日志记录的配置 + TraceConfiguration = new TraceConfiguration() + }; } } } diff --git a/DMS.WPF/App.xaml.cs b/DMS.WPF/App.xaml.cs index b09bd42..21724e5 100644 --- a/DMS.WPF/App.xaml.cs +++ b/DMS.WPF/App.xaml.cs @@ -159,7 +159,7 @@ public partial class App : System.Windows.Application services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddTransient(); // 注册App服务 diff --git a/DMS.WPF/ViewModels/Dialogs/ImportOpcUaDialogViewModel.cs b/DMS.WPF/ViewModels/Dialogs/ImportOpcUaDialogViewModel.cs index 328ca5b..93e44a8 100644 --- a/DMS.WPF/ViewModels/Dialogs/ImportOpcUaDialogViewModel.cs +++ b/DMS.WPF/ViewModels/Dialogs/ImportOpcUaDialogViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input; using DMS.Core.Models; using DMS.Helper; using DMS.Infrastructure.Interfaces.Services; +using DMS.Infrastructure.Models; using DMS.WPF.ViewModels.Items; using Opc.Ua; using Opc.Ua.Client; @@ -15,8 +16,8 @@ public partial class ImportOpcUaDialogViewModel : DialogViewModelBase _opcUaNodes; + [ObservableProperty] + private OpcUaNode _rootOpcUaNode; [ObservableProperty] private ObservableCollection _selectedNodeVariables; @@ -30,7 +31,7 @@ public partial class ImportOpcUaDialogViewModel : DialogViewModelBase(); SelectedNodeVariables = new ObservableCollection(); this._opcUaService = opcUaService; - - _cancellationTokenSource=new CancellationTokenSource(); + RootOpcUaNode = new OpcUaNode() { DisplayName = "根节点", NodeId = Objects.ObjectsFolder }; + _cancellationTokenSource = new CancellationTokenSource(); } @@ -57,13 +58,12 @@ public partial class ImportOpcUaDialogViewModel : DialogViewModelBase Children { get; set; } + + public OpcUaNodeViewModel(string displayName, NodeId nodeId, NodeType nodeType) + { + DisplayName = displayName; + NodeId = nodeId; + NodeType = nodeType; + Children = new ObservableCollection(); + + // 如果是文件夹或对象,添加一个虚拟子节点,用于懒加载 + if (nodeType == NodeType.Folder || nodeType == NodeType.Object) + { + Children.Add(new OpcUaNodeViewModel("Loading...", NodeId.Null, NodeType.Folder)); // 虚拟节点 + } + } + } + + public enum NodeType + { + Folder, + Object, + Variable + } +} \ No newline at end of file diff --git a/DMS.WPF/Views/Dialogs/ImportOpcUaDialog.xaml b/DMS.WPF/Views/Dialogs/ImportOpcUaDialog.xaml index edd265a..330d1f3 100644 --- a/DMS.WPF/Views/Dialogs/ImportOpcUaDialog.xaml +++ b/DMS.WPF/Views/Dialogs/ImportOpcUaDialog.xaml @@ -50,7 +50,7 @@ Grid.Row="1" Grid.Column="0" Margin="0,0,10,0" - ItemsSource="{Binding OpcUaNodes}" + ItemsSource="{Binding RootOpcUaNode.Children}" SelectedItemChanged="TreeView_SelectedItemChanged">