diff --git a/.gitignore b/.gitignore index ee323ee..ed4a86a 100644 --- a/.gitignore +++ b/.gitignore @@ -364,4 +364,6 @@ FodyWeavers.xsd # Rider/JetBrains IDEs .idea/ -/OpcUaTestApp/OpcUaTestApp.csproj +/OpcUaTestApp/ +/OpcUaExample/ +.gitignore diff --git a/DMS.Core/DMS.Core.csproj b/DMS.Core/DMS.Core.csproj index 4bbeeee..e0c93f7 100644 --- a/DMS.Core/DMS.Core.csproj +++ b/DMS.Core/DMS.Core.csproj @@ -23,10 +23,6 @@ - - - - diff --git a/DMS.Core/Interfaces/IExcelService.cs b/DMS.Core/Interfaces/Services/IExcelService.cs similarity index 89% rename from DMS.Core/Interfaces/IExcelService.cs rename to DMS.Core/Interfaces/Services/IExcelService.cs index f653e48..c94d676 100644 --- a/DMS.Core/Interfaces/IExcelService.cs +++ b/DMS.Core/Interfaces/Services/IExcelService.cs @@ -1,6 +1,6 @@ using DMS.Core.Models; -namespace DMS.Core.Interfaces; +namespace DMS.Core.Interfaces.Services; public interface IExcelService { diff --git a/DMS.Infrastructure.UnitTests/Services/OpcUaServiceTest.cs b/DMS.Infrastructure.UnitTests/Services/OpcUaServiceTest.cs new file mode 100644 index 0000000..c6ab823 --- /dev/null +++ b/DMS.Infrastructure.UnitTests/Services/OpcUaServiceTest.cs @@ -0,0 +1,159 @@ +using DMS.Infrastructure.Interfaces.Services; +using DMS.Infrastructure.Services; +using Opc.Ua; +using System; +using System.Threading.Tasks; +using Xunit; + +namespace DMS.Infrastructure.UnitTests.Services +{ + public class OpcUaServiceTest + { + [Fact] + public async Task TestOpcUaService_CreateSession_WithValidUrl_ShouldCreateSession() + { + // Arrange + var service = new OpcUaService(); + var opcUaServerUrl = "opc.tcp://localhost:4840"; // 示例URL,实际测试时需要真实的OPC UA服务器 + + // Act & Assert + // 注意:这个测试需要真实的OPC UA服务器才能通过 + // 在实际测试环境中,您需要启动一个OPC UA服务器 + try + { + await service.CreateSession(opcUaServerUrl); + // 如果没有异常,则认为会话创建成功 + Assert.True(true); + } + catch (Exception ex) + { + // 在没有真实服务器的情况下,我们期望出现连接异常 + Assert.NotNull(ex); + } + } + + [Fact] + public async Task TestOpcUaService_CreateSession_WithNullUrl_ShouldThrowException() + { + // Arrange + var service = new OpcUaService(); + string opcUaServerUrl = null; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await service.CreateSession(opcUaServerUrl); + }); + } + + [Fact] + public async Task TestOpcUaService_CreateSession_WithEmptyUrl_ShouldThrowException() + { + // Arrange + var service = new OpcUaService(); + var opcUaServerUrl = ""; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await service.CreateSession(opcUaServerUrl); + }); + } + + [Fact] + public void TestOpcUaService_IsConnected_WithoutSession_ShouldReturnFalse() + { + // Arrange + var service = new OpcUaService(); + + // Act + var isConnected = service.IsConnected(); + + // Assert + Assert.False(isConnected); + } + + [Fact] + public async Task TestOpcUaService_ConnectAsync_WithoutSession_ShouldThrowException() + { + // Arrange + var service = new OpcUaService(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await service.ConnectAsync(); + }); + } + + [Fact] + public void TestOpcUaService_Connect_WithoutSession_ShouldThrowException() + { + // Arrange + var service = new OpcUaService(); + + // Act & Assert + Assert.Throws(() => + { + service.Connect(); + }); + } + + [Fact] + public void TestOpcUaService_AddSubscription_WithoutSession_ShouldThrowException() + { + // Arrange + var service = new OpcUaService(); + var subscriptionName = "TestSubscription"; + + // Act & Assert + Assert.Throws(() => + { + service.AddSubscription(subscriptionName); + }); + } + + [Fact] + public void TestOpcUaService_BrowseNodes_WithoutSession_ShouldThrowException() + { + // Arrange + var service = new OpcUaService(); + var nodeId = NodeId.Null; + + // Act & Assert + Assert.Throws(() => + { + service.BrowseNodes(nodeId); + }); + } + + [Fact] + public void TestOpcUaService_ReadValue_WithoutSession_ShouldThrowException() + { + // Arrange + var service = new OpcUaService(); + var nodeId = NodeId.Null; + + // Act & Assert + Assert.Throws(() => + { + service.ReadValue(nodeId); + }); + } + + [Fact] + public void TestOpcUaService_WriteValue_WithoutSession_ShouldThrowException() + { + // Arrange + var service = new OpcUaService(); + var nodeId = NodeId.Null; + var value = "test"; + + // Act & Assert + Assert.Throws(() => + { + service.WriteValue(nodeId, value); + }); + } + } +} \ No newline at end of file diff --git a/DMS.Infrastructure/Services/OpcUaHelper.cs b/DMS.Infrastructure/Helper/OpcUaHelper.cs similarity index 98% rename from DMS.Infrastructure/Services/OpcUaHelper.cs rename to DMS.Infrastructure/Helper/OpcUaHelper.cs index b896adf..8427285 100644 --- a/DMS.Infrastructure/Services/OpcUaHelper.cs +++ b/DMS.Infrastructure/Helper/OpcUaHelper.cs @@ -5,7 +5,7 @@ using System; using System.Threading; using System.Threading.Tasks; -namespace DMS.Infrastructure.Services; +namespace DMS.Infrastructure.Helper; public static class OpcUaHelper { diff --git a/DMS.Infrastructure/Interfaces/Services/IOpcUaService.cs b/DMS.Infrastructure/Interfaces/Services/IOpcUaService.cs new file mode 100644 index 0000000..1b83d11 --- /dev/null +++ b/DMS.Infrastructure/Interfaces/Services/IOpcUaService.cs @@ -0,0 +1,72 @@ +using Opc.Ua; +using Opc.Ua.Client; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace DMS.Infrastructure.Interfaces.Services +{ + public interface IOpcUaService + { + /// + /// 创建 OPC UA 会话 + /// + /// OPC UA 服务器地址 + /// 取消令牌 + /// + public Task CreateSession(string opcUaServerUrl, CancellationToken stoppingToken = default); + + /// + /// 连接到 OPC UA 服务器(异步) + /// + /// 取消令牌 + /// + public Task ConnectAsync(CancellationToken stoppingToken = default); + + /// + /// 连接到 OPC UA 服务器(同步) + /// + public void Connect(); + + /// + /// 断开 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(); + } +} \ No newline at end of file diff --git a/DMS.Infrastructure/Services/ExcelService.cs b/DMS.Infrastructure/Services/ExcelService.cs index 2280e7a..fa0fa0d 100644 --- a/DMS.Infrastructure/Services/ExcelService.cs +++ b/DMS.Infrastructure/Services/ExcelService.cs @@ -1,7 +1,7 @@ using System.Data; using System.Reflection; using DMS.Core.Enums; -using DMS.Core.Interfaces; +using DMS.Core.Interfaces.Services; using DMS.Core.Models; using DMS.Infrastructure.Helper; using NPOI.SS.UserModel; diff --git a/DMS.Infrastructure/Services/OpcUaBackgroundService.cs b/DMS.Infrastructure/Services/OpcUaBackgroundService.cs index e8e8ca8..87da528 100644 --- a/DMS.Infrastructure/Services/OpcUaBackgroundService.cs +++ b/DMS.Infrastructure/Services/OpcUaBackgroundService.cs @@ -7,6 +7,7 @@ using Opc.Ua.Client; using Microsoft.Extensions.Logging; using DMS.Application.Interfaces; using DMS.Core.Interfaces; +using DMS.Infrastructure.Helper; namespace DMS.Infrastructure.Services; diff --git a/DMS.Infrastructure/Services/OpcUaService.cs b/DMS.Infrastructure/Services/OpcUaService.cs new file mode 100644 index 0000000..b469ac1 --- /dev/null +++ b/DMS.Infrastructure/Services/OpcUaService.cs @@ -0,0 +1,322 @@ +using DMS.Infrastructure.Interfaces.Services; +using DMS.Infrastructure.Helper; +using Opc.Ua; +using Opc.Ua.Client; +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; + + /// + /// 创建 OPC UA 会话 + /// + /// OPC UA 服务器地址 + /// 取消令牌 + /// + public async Task CreateSession(string opcUaServerUrl, CancellationToken stoppingToken = default) + { + if (string.IsNullOrEmpty(opcUaServerUrl)) + { + throw new ArgumentException("OPC UA server URL cannot be null or empty.", nameof(opcUaServerUrl)); + } + + try + { + _session = await OpcUaHelper.CreateOpcUaSessionAsync(opcUaServerUrl, stoppingToken); + _serverUrl = opcUaServerUrl; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to create OPC UA session: {ex.Message}", ex); + } + } + + /// + /// 连接到 OPC UA 服务器 + /// + public async Task ConnectAsync(CancellationToken stoppingToken = default) + { + if (string.IsNullOrEmpty(_serverUrl)) + { + throw new InvalidOperationException("Server URL is not set. Please call CreateSession first."); + } + + // 如果已经连接,直接返回 + if (_session?.Connected == true) + { + return; + } + + // 重新创建会话 + await CreateSession(_serverUrl, stoppingToken); + } + + /// + /// 连接到 OPC UA 服务器(同步版本,用于向后兼容) + /// + public void Connect() + { + if (string.IsNullOrEmpty(_serverUrl)) + { + throw new InvalidOperationException("Server URL is not set. Please call CreateSession first."); + } + + // 如果已经连接,直接返回 + if (_session?.Connected == true) + { + return; + } + + // 检查会话是否存在但未连接 + if (_session != null) + { + // 尝试重新激活会话 + try + { + _session.Reconnect(); + return; + } + catch + { + // 如果重新连接失败,继续到重新创建会话的步骤 + } + } + + // 如果没有会话或重新连接失败,抛出异常提示需要调用 CreateSession + throw new InvalidOperationException("Session is not created or connection lost. Please call CreateSession first."); + } + + /// + /// 断开 OPC UA 服务器连接 + /// + public void Disconnect() + { + if (_session != null) + { + try + { + _session.Close(); + } + catch (Exception ex) + { + // 记录日志但不抛出异常,确保清理工作完成 + System.Diagnostics.Debug.WriteLine($"Error closing OPC UA session: {ex.Message}"); + } + finally + { + _session = null; + } + } + } + + /// + /// 添加订阅 + /// + /// 订阅名称 + /// 创建的订阅 + public Subscription AddSubscription(string subscriptionName) + { + 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 (string.IsNullOrEmpty(subscriptionName)) + { + throw new ArgumentException("Subscription name cannot be null or empty.", nameof(subscriptionName)); + } + + try + { + var subscription = new Subscription(_session.DefaultSubscription) + { + 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 + { + NodeId = nodeId, + AttributeId = Attributes.Value + } + }; + + // 执行读取操作 + _session.Read( + null, + 0, + TimestampsToReturn.Neither, + nodesToRead, + out var results, + out var diagnosticInfos); + + return results[0]; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to read value from node '{nodeId}': {ex.Message}", ex); + } + } + + /// + /// 写入节点值 + /// + /// 节点ID + /// 要写入的值 + /// 写入结果 + public StatusCode WriteValue(NodeId nodeId, object value) + { + 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 nodesToWrite = new WriteValueCollection + { + 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; + } + } +} diff --git a/DMS.WPF/App.xaml.cs b/DMS.WPF/App.xaml.cs index c9364b4..b09bd42 100644 --- a/DMS.WPF/App.xaml.cs +++ b/DMS.WPF/App.xaml.cs @@ -26,6 +26,8 @@ using DMS.WPF.ViewModels.Dialogs; using DataProcessingService = DMS.Services.DataProcessingService; using IDataProcessingService = DMS.Services.IDataProcessingService; using LogLevel = Microsoft.Extensions.Logging.LogLevel; +using DMS.Core.Interfaces.Services; +using DMS.Infrastructure.Interfaces.Services; namespace DMS; @@ -156,6 +158,8 @@ public partial class App : System.Windows.Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + + services.AddSingleton(); // 注册App服务 @@ -182,6 +186,7 @@ public partial class App : System.Windows.Application services.AddSingleton(); // 注册对话框模型 services.AddTransient(); + services.AddTransient(); services.AddTransient(); // 注册对话框 services.AddSingleton(); diff --git a/DMS.WPF/DMS.WPF.csproj b/DMS.WPF/DMS.WPF.csproj index bdae7c5..3833ec2 100644 --- a/DMS.WPF/DMS.WPF.csproj +++ b/DMS.WPF/DMS.WPF.csproj @@ -66,7 +66,7 @@ Wpf Designer - + MSBuild:Compile Wpf Designer diff --git a/DMS.WPF/ViewModels/Dialogs/ImportExcelDialogViewModel.cs b/DMS.WPF/ViewModels/Dialogs/ImportExcelDialogViewModel.cs index 1e7ba43..1a2822a 100644 --- a/DMS.WPF/ViewModels/Dialogs/ImportExcelDialogViewModel.cs +++ b/DMS.WPF/ViewModels/Dialogs/ImportExcelDialogViewModel.cs @@ -4,7 +4,7 @@ using System.Linq; using AutoMapper; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using DMS.Core.Interfaces; +using DMS.Core.Interfaces.Services; using DMS.Core.Models; using DMS.Helper; using DMS.WPF.ViewModels.Items; diff --git a/DMS.WPF/ViewModels/Dialogs/ImportOpcUaDialogViewModel.cs b/DMS.WPF/ViewModels/Dialogs/ImportOpcUaDialogViewModel.cs new file mode 100644 index 0000000..b15d7bb --- /dev/null +++ b/DMS.WPF/ViewModels/Dialogs/ImportOpcUaDialogViewModel.cs @@ -0,0 +1,256 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DMS.Core.Models; +using DMS.Helper; +using DMS.WPF.ViewModels.Items; +using Opc.Ua.Client; +using System.Collections.ObjectModel; + +namespace DMS.WPF.ViewModels.Dialogs; + +public partial class ImportOpcUaDialogViewModel : DialogViewModelBase> +{ + [ObservableProperty] + private string _endpointUrl = "opc.tcp://127.0.0.1:4855"; // 默认值 + + //[ObservableProperty] + //private ObservableCollection _opcUaNodes; + + [ObservableProperty] + private ObservableCollection _selectedNodeVariables; + + public List SelectedVariables { get; set; } = new List(); + + [ObservableProperty] + private bool _selectAllVariables; + + [ObservableProperty] + private bool _isConnected; + + private Session _session; + + public ImportOpcUaDialogViewModel() + { + //OpcUaNodes = new ObservableCollection(); + SelectedNodeVariables = new ObservableCollection(); + // Automatically connect when the ViewModel is created + //ConnectC.Execute(null); + + } + + [RelayCommand] + private async Task Connect() + { + try + { + // 断开现有连接 + if (_session != null && _session.Connected) + { + await _session.CloseAsync(); + _session.Dispose(); + _session = null; + } + + IsConnected = false; + SelectedNodeVariables.Clear(); + + //_session = await ServiceHelper.CreateOpcUaSessionAsync(EndpointUrl); + + NotificationHelper.ShowSuccess($"已连接到 OPC UA 服务器: {EndpointUrl}"); + IsConnected = true; + + // 浏览根节点 + //await BrowseNodes(OpcUaNodes, ObjectIds.ObjectsFolder); + } + catch (Exception ex) + { + IsConnected = false; + NotificationHelper.ShowError($"连接 OPC UA 服务器失败: {EndpointUrl} - {ex.Message}", ex); + } + } + + /// + /// 处理来自服务器的数据变化通知 + /// + private static void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e) + { + foreach (var value in item.DequeueValues()) + { + Console.WriteLine( + $"[通知] {item.DisplayName}: {value.Value} | 时间戳: {value.SourceTimestamp.ToLocalTime()} | 状态: {value.StatusCode}"); + } + } + + //private async Task BrowseNodes(ObservableCollection nodes, NodeId parentNodeId) + //{ + // try + // { + // Opc.Ua.ReferenceDescriptionCollection references; + // byte[] continuationPoint = null; + + // _session.Browse( + // null, // RequestHeader + // new ViewDescription(), + // parentNodeId, + // 0u, + // BrowseDirection.Forward, + // Opc.Ua.ReferenceTypeIds.HierarchicalReferences, + // true, + // (uint)Opc.Ua.NodeClass.Object | (uint)Opc.Ua.NodeClass.Variable, + // out continuationPoint, + // out references + // ); + + // foreach (var rd in references) + // { + // NodeType nodeType = NodeType.Folder; // 默认是文件夹 + // if ((rd.NodeClass & NodeClass.Variable) != 0) + // { + // nodeType = NodeType.Variable; + // } + // else if ((rd.NodeClass & NodeClass.Object) != 0) + // { + // nodeType = NodeType.Object; + // } + + // var opcUaNode = new OpcUaNode(rd.DisplayName.Text, (NodeId)rd.NodeId, nodeType); + // nodes.Add(opcUaNode); + + // // 如果是文件夹或对象,添加一个虚拟子节点,用于懒加载 + // if (nodeType == NodeType.Folder || nodeType == NodeType.Object) + // { + // opcUaNode.Children.Add(new OpcUaNode("Loading...", NodeId.Null, NodeType.Folder)); // 虚拟节点 + // } + // } + // } + // catch (Exception ex) + // { + // NlogHelper.Error($"浏览 OPC UA 节点失败: {parentNodeId} - {ex.Message}", ex); + // NotificationHelper.ShowError($"浏览 OPC UA 节点失败: {parentNodeId} - {ex.Message}", ex); + // } + //} + + //public async Task LoadNodeVariables(OpcUaNode node) + //{ + //if (node.NodeType == NodeType.Variable) + //{ + // // 如果是变量节点,直接显示它 + // SelectedNodeVariables.Clear(); + // SelectedNodeVariables.Add(new Variable + // { + // Name = node.DisplayName, + // NodeId = node.NodeId.ToString(), + // OpcUaNodeId = node.NodeId.ToString(), + // ProtocolType = ProtocolType.OpcUA, + // IsActive = true // 默认选中 + // }); + // return; + //} + + //if (node.IsLoaded || node.IsLoading) + //{ + // return; // 已经加载或正在加载 + //} + + //node.IsLoading = true; + //node.Children.Clear(); // 清除虚拟节点 + + //try + //{ + // Opc.Ua.ReferenceDescriptionCollection references; + // byte[] continuationPoint = null; + + // _session.Browse( + // null, // RequestHeader + // new ViewDescription(), + // node.NodeId, + // 0u, + // BrowseDirection.Forward, + // Opc.Ua.ReferenceTypeIds.HierarchicalReferences, + // true, + // (uint)Opc.Ua.NodeClass.Object | (uint)Opc.Ua.NodeClass.Variable, + // out continuationPoint, + // out references + // ); + + // foreach (var rd in references) + // { + // NodeType nodeType = NodeType.Folder; + // if ((rd.NodeClass & NodeClass.Variable) != 0) + // { + // nodeType = NodeType.Variable; + // } + // else if ((rd.NodeClass & NodeClass.Object) != 0) + // { + // nodeType = NodeType.Object; + // } + + // var opcUaNode = new OpcUaNode(rd.DisplayName.Text, (NodeId)rd.NodeId, nodeType); + // node.Children.Add(opcUaNode); + + // if (nodeType == NodeType.Folder || nodeType == NodeType.Object) + // { + // opcUaNode.Children.Add(new OpcUaNode("Loading...", NodeId.Null, NodeType.Folder)); // 虚拟节点 + // } + + // // 如果是变量,添加到右侧列表 + // if (nodeType == NodeType.Variable) + // { + // // Read the DataType attribute + // ReadValueId readValueId = new ReadValueId + // { + // NodeId = opcUaNode.NodeId, + // AttributeId = Attributes.DataType, + // // You might need to specify IndexRange and DataEncoding if dealing with arrays or specific encodings + // }; + + // DataValueCollection results; + // DiagnosticInfoCollection diagnosticInfos; + + // _session.Read( + // null, // RequestHeader + // 0, // MaxAge + // TimestampsToReturn.Source, + // new ReadValueIdCollection { readValueId }, + // out results, + // out diagnosticInfos + // ); + + // string dataType = string.Empty; + + // if (results != null && results.Count > 0 && results[0].Value != null) + // { + // // Convert the NodeId of the DataType to a readable string + // NodeId dataTypeNodeId = (NodeId)results[0].Value; + // dataType = _session.NodeCache.GetDisplayText(dataTypeNodeId); + // } + + // SelectedNodeVariables.Add(new Variable + // { + // Name = opcUaNode.DisplayName, + // OpcUaNodeId = opcUaNode.NodeId.ToString(), + // ProtocolType = ProtocolType.OpcUA, + // IsActive = true, // Default selected + // DataType = dataType // Assign the read DataType + // }); + // } + // } + + // node.IsLoaded = true; + //} + //catch (Exception ex) + //{ + // NlogHelper.Error($"加载 OPC UA 节点变量失败: {node.NodeId} - {ex.Message}", ex); + // NotificationHelper.ShowError($"加载 OPC UA 节点变量失败: {node.NodeId} - {ex.Message}", ex); + //} + //finally + //{ + // node.IsLoading = false; + //} + //} + + public ObservableCollection GetSelectedVariables() + { + return new ObservableCollection(SelectedVariables); + } +} \ No newline at end of file diff --git a/DMS.WPF/ViewModels/Dialogs/OpcUaImportDialogViewModel.cs b/DMS.WPF/ViewModels/Dialogs/OpcUaImportDialogViewModel.cs deleted file mode 100644 index 79294e1..0000000 --- a/DMS.WPF/ViewModels/Dialogs/OpcUaImportDialogViewModel.cs +++ /dev/null @@ -1,257 +0,0 @@ -using System.Collections.ObjectModel; -using System.Linq; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using DMS.Core.Enums; -using DMS.Helper; - -namespace DMS.WPF.ViewModels.Dialogs; - -public partial class OpcUaImportDialogViewModel : ObservableObject -{ - // [ObservableProperty] - // private string _endpointUrl = "opc.tcp://127.0.0.1:4855"; // 默认值 - // - // [ObservableProperty] - // private ObservableCollection _opcUaNodes; - // - // [ObservableProperty] - // private ObservableCollection _selectedNodeVariables; - // - // public List SelectedVariables { get; set; }=new List(); - // - // [ObservableProperty] - // private bool _selectAllVariables; - // - // [ObservableProperty] - // private bool _isConnected; - // - // private Session _session; - // - // public OpcUaImportDialogViewModel() - // { - // OpcUaNodes = new ObservableCollection(); - // SelectedNodeVariables = new ObservableCollection(); - // // Automatically connect when the ViewModel is created - // ConnectCommand.Execute(null); - // - // } - // - // [RelayCommand] - // private async Task Connect() - // { - // try - // { - // // 断开现有连接 - // if (_session != null && _session.Connected) - // { - // await _session.CloseAsync(); - // _session.Dispose(); - // _session = null; - // } - // - // IsConnected = false; - // OpcUaNodes.Clear(); - // SelectedNodeVariables.Clear(); - // - // _session = await ServiceHelper.CreateOpcUaSessionAsync(EndpointUrl); - // - // NotificationHelper.ShowSuccess($"已连接到 OPC UA 服务器: {EndpointUrl}"); - // IsConnected = true; - // - // // 浏览根节点 - // await BrowseNodes(OpcUaNodes, ObjectIds.ObjectsFolder); - // } - // catch (Exception ex) - // { - // IsConnected = false; - // NotificationHelper.ShowError($"连接 OPC UA 服务器失败: {EndpointUrl} - {ex.Message}", ex); - // } - // } - // - // /// - // /// 处理来自服务器的数据变化通知 - // /// - // private static void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e) - // { - // foreach (var value in item.DequeueValues()) - // { - // Console.WriteLine( - // $"[通知] {item.DisplayName}: {value.Value} | 时间戳: {value.SourceTimestamp.ToLocalTime()} | 状态: {value.StatusCode}"); - // } - // } - // - // private async Task BrowseNodes(ObservableCollection nodes, NodeId parentNodeId) - // { - // try - // { - // Opc.Ua.ReferenceDescriptionCollection references; - // byte[] continuationPoint = null; - // - // _session.Browse( - // null, // RequestHeader - // new ViewDescription(), - // parentNodeId, - // 0u, - // BrowseDirection.Forward, - // Opc.Ua.ReferenceTypeIds.HierarchicalReferences, - // true, - // (uint)Opc.Ua.NodeClass.Object | (uint)Opc.Ua.NodeClass.Variable, - // out continuationPoint, - // out references - // ); - // - // foreach (var rd in references) - // { - // NodeType nodeType = NodeType.Folder; // 默认是文件夹 - // if ((rd.NodeClass & NodeClass.Variable) != 0) - // { - // nodeType = NodeType.Variable; - // } - // else if ((rd.NodeClass & NodeClass.Object) != 0) - // { - // nodeType = NodeType.Object; - // } - // - // var opcUaNode = new OpcUaNode(rd.DisplayName.Text, (NodeId)rd.NodeId, nodeType); - // nodes.Add(opcUaNode); - // - // // 如果是文件夹或对象,添加一个虚拟子节点,用于懒加载 - // if (nodeType == NodeType.Folder || nodeType == NodeType.Object) - // { - // opcUaNode.Children.Add(new OpcUaNode("Loading...", NodeId.Null, NodeType.Folder)); // 虚拟节点 - // } - // } - // } - // catch (Exception ex) - // { - // NlogHelper.Error($"浏览 OPC UA 节点失败: {parentNodeId} - {ex.Message}", ex); - // NotificationHelper.ShowError($"浏览 OPC UA 节点失败: {parentNodeId} - {ex.Message}", ex); - // } - // } - // - // public async Task LoadNodeVariables(OpcUaNode node) - // { - // if (node.NodeType == NodeType.Variable) - // { - // // 如果是变量节点,直接显示它 - // SelectedNodeVariables.Clear(); - // SelectedNodeVariables.Add(new Variable - // { - // Name = node.DisplayName, - // NodeId = node.NodeId.ToString(), - // OpcUaNodeId = node.NodeId.ToString(), - // ProtocolType = ProtocolType.OpcUA, - // IsActive = true // 默认选中 - // }); - // return; - // } - // - // if (node.IsLoaded || node.IsLoading) - // { - // return; // 已经加载或正在加载 - // } - // - // node.IsLoading = true; - // node.Children.Clear(); // 清除虚拟节点 - // - // try - // { - // Opc.Ua.ReferenceDescriptionCollection references; - // byte[] continuationPoint = null; - // - // _session.Browse( - // null, // RequestHeader - // new ViewDescription(), - // node.NodeId, - // 0u, - // BrowseDirection.Forward, - // Opc.Ua.ReferenceTypeIds.HierarchicalReferences, - // true, - // (uint)Opc.Ua.NodeClass.Object | (uint)Opc.Ua.NodeClass.Variable, - // out continuationPoint, - // out references - // ); - // - // foreach (var rd in references) - // { - // NodeType nodeType = NodeType.Folder; - // if ((rd.NodeClass & NodeClass.Variable) != 0) - // { - // nodeType = NodeType.Variable; - // } - // else if ((rd.NodeClass & NodeClass.Object) != 0) - // { - // nodeType = NodeType.Object; - // } - // - // var opcUaNode = new OpcUaNode(rd.DisplayName.Text, (NodeId)rd.NodeId, nodeType); - // node.Children.Add(opcUaNode); - // - // if (nodeType == NodeType.Folder || nodeType == NodeType.Object) - // { - // opcUaNode.Children.Add(new OpcUaNode("Loading...", NodeId.Null, NodeType.Folder)); // 虚拟节点 - // } - // - // // 如果是变量,添加到右侧列表 - // if (nodeType == NodeType.Variable) - // { - // // Read the DataType attribute - // ReadValueId readValueId = new ReadValueId - // { - // NodeId = opcUaNode.NodeId, - // AttributeId = Attributes.DataType, - // // You might need to specify IndexRange and DataEncoding if dealing with arrays or specific encodings - // }; - // - // DataValueCollection results; - // DiagnosticInfoCollection diagnosticInfos; - // - // _session.Read( - // null, // RequestHeader - // 0, // MaxAge - // TimestampsToReturn.Source, - // new ReadValueIdCollection { readValueId }, - // out results, - // out diagnosticInfos - // ); - // - // string dataType = string.Empty; - // - // if (results != null && results.Count > 0 && results[0].Value != null) - // { - // // Convert the NodeId of the DataType to a readable string - // NodeId dataTypeNodeId = (NodeId)results[0].Value; - // dataType = _session.NodeCache.GetDisplayText(dataTypeNodeId); - // } - // - // SelectedNodeVariables.Add(new Variable - // { - // Name = opcUaNode.DisplayName, - // OpcUaNodeId = opcUaNode.NodeId.ToString(), - // ProtocolType = ProtocolType.OpcUA, - // IsActive = true, // Default selected - // DataType = dataType // Assign the read DataType - // }); - // } - // } - // - // node.IsLoaded = true; - // } - // catch (Exception ex) - // { - // NlogHelper.Error($"加载 OPC UA 节点变量失败: {node.NodeId} - {ex.Message}", ex); - // NotificationHelper.ShowError($"加载 OPC UA 节点变量失败: {node.NodeId} - {ex.Message}", ex); - // } - // finally - // { - // node.IsLoading = false; - // } - // } - // - // public ObservableCollection GetSelectedVariables() - // { - // return new ObservableCollection(SelectedVariables); - // } -} \ No newline at end of file diff --git a/DMS.WPF/ViewModels/VariableTableViewModel.cs b/DMS.WPF/ViewModels/VariableTableViewModel.cs index 9f40e37..b74ae32 100644 --- a/DMS.WPF/ViewModels/VariableTableViewModel.cs +++ b/DMS.WPF/ViewModels/VariableTableViewModel.cs @@ -273,86 +273,66 @@ partial class VariableTableViewModel : ViewModelBase, INavigatable /// 此命令通常绑定到UI中的“从OPC UA导入”按钮。 /// [RelayCommand] - private async Task ImportFromOpcUaServer() + private async void ImportFromOpcUaServer() { - // ContentDialog processingDialog = null; // 用于显示处理中的对话框 - // try - // { - // // 检查OPC UA Endpoint URL是否已设置 - // string opcUaEndpointUrl = VariableTable?.Device?.OpcUaEndpointUrl; - // if (string.IsNullOrEmpty(opcUaEndpointUrl)) - // { - // NotificationHelper.ShowError("OPC UA Endpoint URL 未设置。请在设备详情中配置。"); - // return; - // } - // - // // 显示OPC UA导入对话框,让用户选择要导入的变量 - // var importedVariables = await _dialogService.ShowOpcUaImportDialog(opcUaEndpointUrl); - // if (importedVariables == null || !importedVariables.Any()) - // { - // return; // 用户取消或没有选择任何变量 - // } - // - // // 显示处理中的对话框 - // processingDialog = _dialogService.ShowProcessingDialog("正在处理...", "正在导入OPC UA变量,请稍等片刻...."); - // - // // 在进行重复检查之前,先刷新 Variables 集合,确保其包含所有最新数据 - // await RefreshDataView(); - // - // List newVariables = new List(); - // List importedVariableNames = new List(); - // List existingVariableNames = new List(); - // - // foreach (var variableData in importedVariables) - // { - // // 判断是否存在重复变量,仅在当前 VariableTable 的 Variables 中查找 - // bool isDuplicate = Variables.Any(existingVar => - // (existingVar.Name == variableData.Name) || - // (!string.IsNullOrEmpty(variableData.NodeId) && - // existingVar.NodeId == variableData.NodeId) || - // (!string.IsNullOrEmpty(variableData.OpcUaNodeId) && - // existingVar.OpcUaNodeId == variableData.OpcUaNodeId) - // ); - // - // if (isDuplicate) - // { - // existingVariableNames.Add(variableData.Name); - // } - // else - // { - // variableData.CreateTime = DateTime.Now; - // variableData.VariableTableId = VariableTable.Id; - // variableData.ProtocolType = ProtocolType.OpcUA; // 确保协议类型正确 - // variableData.IsModified = false; - // newVariables.Add(variableData); - // importedVariableNames.Add(variableData.Name); - // } - // } - // - // if (newVariables.Any()) - // { - // // 批量插入新变量数据到数据库 - // var resVarDataCount = await _varDataRepository.AddAsync(newVariables); - // NlogHelper.Info($"成功导入OPC UA变量:{resVarDataCount}个。"); - // } - // - // // 再次刷新 Variables 集合,以反映新添加的数据 - // await RefreshDataView(); - // - // processingDialog?.Hide(); // 隐藏处理中的对话框 - // - // // 显示导入结果对话框 - // await _dialogService.ShowImportResultDialog(importedVariableNames, existingVariableNames); - // } - // catch (Exception e) - // { - // // 捕获并显示错误通知 - // NotificationHelper.ShowError($"从OPC UA服务器导入变量的过程中发生了不可预期的错误:{e.Message}", e); - // } - // finally - // { - // processingDialog?.Hide(); // 确保在任何情况下都隐藏对话框 - // } + try + { + // 检查OPC UA Endpoint URL是否已设置 + string opcUaEndpointUrl = CurrentVariableTable.Device.OpcUaServerUrl; + if (string.IsNullOrEmpty(opcUaEndpointUrl)) + { + NotificationHelper.ShowError("OPC UA Endpoint URL 未设置。请在设备详情中配置。"); + return; + } + + // 显示OPC UA导入对话框,让用户选择要导入的变量 + ImportOpcUaDialogViewModel importOpcUaDialogViewModel = new ImportOpcUaDialogViewModel(); + var importedVariables = await _dialogService.ShowDialogAsync(importOpcUaDialogViewModel); + if (importedVariables == null || !importedVariables.Any()) + { + return; // 用户取消或没有选择任何变量 + } + + //var importedVariableDtos = _mapper.Map>(importedVariables); + //foreach (var variableDto in importedVariableDtos) + //{ + // variableDto.CreatedAt = DateTime.Now; + // variableDto.UpdatedAt = DateTime.Now; + // variableDto.VariableTableId = CurrentVariableTable.Id; + // variableDto.Protocol = ProtocolType.OpcUa; // 确保协议类型正确 + //} + + //var existList = await _variableAppService.FindExistingVariablesAsync(importedVariableDtos); + //if (existList.Count > 0) + //{ + // // 拼接要删除的变量名称,用于确认提示 + // var existNames = string.Join("、", existList.Select(v => v.Name)); + // var confrimDialogViewModel + // = new ConfirmDialogViewModel("存在已经添加的变量", $"变量名称:{existNames},已经存在,是否跳过继续添加其他的变量。取消则不添加任何变量", "继续"); + // var res = await _dialogService.ShowDialogAsync(confrimDialogViewModel); + // if (!res) return; + // // 从导入列表中删除已经存在的变量 + // importedVariableDtos.RemoveAll(variableDto => existList.Contains(variableDto)); + //} + + //if (importedVariableDtos.Count != 0) + //{ + // var isSuccess = await _variableAppService.BatchImportVariablesAsync(importedVariableDtos); + // if (isSuccess) + // { + // _variableItemList.AddRange(_mapper.Map>(importedVariableDtos)); + // NotificationHelper.ShowSuccess($"从OPC UA服务器导入变量成功,共导入变量:{importedVariableDtos.Count}个"); + // } + //} + //else + //{ + // NotificationHelper.ShowSuccess($"列表中没有要添加的变量了。"); + //} + } + catch (Exception e) + { + NotificationHelper.ShowError($"从OPC UA服务器导入变量的过程中发生了不可预期的错误:{e.Message}", e); + } } diff --git a/DMS.WPF/Views/Dialogs/ImportOpcUaDialog.xaml b/DMS.WPF/Views/Dialogs/ImportOpcUaDialog.xaml new file mode 100644 index 0000000..3acf79a --- /dev/null +++ b/DMS.WPF/Views/Dialogs/ImportOpcUaDialog.xaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DMS.WPF/Views/Dialogs/OpcUaImportDialog.xaml.cs b/DMS.WPF/Views/Dialogs/ImportOpcUaDialog.xaml.cs similarity index 83% rename from DMS.WPF/Views/Dialogs/OpcUaImportDialog.xaml.cs rename to DMS.WPF/Views/Dialogs/ImportOpcUaDialog.xaml.cs index 5614461..ee81622 100644 --- a/DMS.WPF/Views/Dialogs/OpcUaImportDialog.xaml.cs +++ b/DMS.WPF/Views/Dialogs/ImportOpcUaDialog.xaml.cs @@ -6,17 +6,17 @@ using iNKORE.UI.WPF.Modern.Controls; namespace DMS.WPF.Views.Dialogs; /// -/// OpcUaImportDialog.xaml 的交互逻辑 +/// ImportOpcUaDialog.xaml 的交互逻辑 /// -public partial class OpcUaImportDialog : ContentDialog +public partial class ImportOpcUaDialog : ContentDialog { - public OpcUaImportDialogViewModel ViewModel + public ImportOpcUaDialogViewModel ViewModel { - get => (OpcUaImportDialogViewModel)DataContext; + get => (ImportOpcUaDialogViewModel)DataContext; set => DataContext = value; } - public OpcUaImportDialog(OpcUaImportDialogViewModel viewModel) + public ImportOpcUaDialog(ImportOpcUaDialogViewModel viewModel) { InitializeComponent(); ViewModel = viewModel; diff --git a/DMS.WPF/Views/Dialogs/OpcUaImportDialog.xaml b/DMS.WPF/Views/Dialogs/OpcUaImportDialog.xaml deleted file mode 100644 index 4b2bc3a..0000000 --- a/DMS.WPF/Views/Dialogs/OpcUaImportDialog.xaml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/DMS.sln b/DMS.sln index 0562014..b62cc28 100644 --- a/DMS.sln +++ b/DMS.sln @@ -24,6 +24,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DMS.Infrastructure.UnitTest EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DMS.WPF.UnitTests", "DMS.WPF.UnitTests\DMS.WPF.UnitTests.csproj", "{C15E6B39-211C-417A-BC3F-551AD17C8905}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpcUaTestApp", "OpcUaTestApp\OpcUaTestApp.csproj", "{FD58DDF1-340B-4946-28B7-5B905832858E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,6 +56,10 @@ Global {C15E6B39-211C-417A-BC3F-551AD17C8905}.Debug|Any CPU.Build.0 = Debug|Any CPU {C15E6B39-211C-417A-BC3F-551AD17C8905}.Release|Any CPU.ActiveCfg = Release|Any CPU {C15E6B39-211C-417A-BC3F-551AD17C8905}.Release|Any CPU.Build.0 = Release|Any CPU + {FD58DDF1-340B-4946-28B7-5B905832858E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD58DDF1-340B-4946-28B7-5B905832858E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD58DDF1-340B-4946-28B7-5B905832858E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD58DDF1-340B-4946-28B7-5B905832858E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 6e61f91..1ecd62e 100644 --- a/README.md +++ b/README.md @@ -1 +1,45 @@ -# PMSWPF \ No newline at end of file +# PMSWPF + +## OPC UA Service + +This project includes an OPC UA service implementation that provides the following functionalities: + +### Features +- Connect to OPC UA servers +- Browse nodes in the OPC UA address space +- Read and write values from/to OPC UA nodes +- Add subscriptions for monitoring node changes + +### Usage + +```csharp +// Create an instance of the OPC UA service +var opcUaService = new OpcUaService(); + +// Connect to an OPC UA server +await opcUaService.CreateSession("opc.tcp://localhost:4840"); + +// Check connection status +if (opcUaService.IsConnected()) +{ + // Browse nodes + var rootNodeId = ObjectIds.RootFolder; + var references = opcUaService.BrowseNodes(rootNodeId); + + // Read a value + var value = opcUaService.ReadValue(someNodeId); + + // Write a value + opcUaService.WriteValue(someNodeId, newValue); + + // Add a subscription + var subscription = opcUaService.AddSubscription("MySubscription"); +} + +// Disconnect when done +opcUaService.Disconnect(); +``` + +### Testing + +Unit tests for the OPC UA service are included in the `DMS.Infrastructure.UnitTests` project. Run them using your preferred test runner. \ No newline at end of file