diff --git a/BootstrapBlazor.Extensions.slnx b/BootstrapBlazor.Extensions.slnx index 4d943dad..8e2f9a36 100644 --- a/BootstrapBlazor.Extensions.slnx +++ b/BootstrapBlazor.Extensions.slnx @@ -70,7 +70,6 @@ - @@ -103,6 +102,7 @@ + diff --git a/src/components/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj b/src/components/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj deleted file mode 100644 index 2991c280..00000000 --- a/src/components/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - Bootstrap Blazor WebAssembly wasm UI Components Opc Ua Client - Bootstrap UI components extensions of OpcUa - - - - - - - - - - - - diff --git a/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj b/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj new file mode 100644 index 00000000..40db6f7b --- /dev/null +++ b/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj @@ -0,0 +1,42 @@ + + + + 10.0.1 + + + + Bootstrap Blazor WebAssembly wasm UI Components Opc Ua Client + Bootstrap UI components extensions of OpcUa + + + + 1.5.376.235 + 1.5.378.156 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs b/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs similarity index 50% rename from src/components/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs rename to src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs index 48acecf7..39286d3f 100644 --- a/src/components/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs +++ b/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs @@ -1,21 +1,26 @@ -// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Website: https://www.blazor.zone or https://argozhang.github.io/ namespace Microsoft.Extensions.DependencyInjection; +using BootstrapBlazor.OpcUa; + /// -/// OpcUa 服务扩展类 +/// OpcUa 服务扩展类 +/// OpcUa service extension class /// public static class ServiceCollectionExtensions { /// - /// 增加 OpcUa 数据服务 + /// 增加 OpcUa 数据服务 + /// Add OpcUa data service /// /// /// - public static IServiceCollection AddBootstrapBlazorOpcUaService(this IServiceCollection services) + public static IServiceCollection AddOpcUaServer(this IServiceCollection services) { + services.AddScoped(); return services; } } diff --git a/src/extensions/BootstrapBlazor.OpcUa/Guard.cs b/src/extensions/BootstrapBlazor.OpcUa/Guard.cs new file mode 100644 index 00000000..fb7e4b2e --- /dev/null +++ b/src/extensions/BootstrapBlazor.OpcUa/Guard.cs @@ -0,0 +1,32 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +namespace BootstrapBlazor.OpcUa; + +static class Guard +{ + public static void ThrowIfDisposed(bool disposed, object instance) + { +#if NET6_0 + if (disposed) + { + throw new ObjectDisposedException(instance.GetType().FullName); + } +#else + ObjectDisposedException.ThrowIf(disposed, instance.GetType()); +#endif + } + + public static void ThrowIfNullOrWhiteSpace(string? value, string paramName) + { +#if NET8_0_OR_GREATER + ArgumentException.ThrowIfNullOrWhiteSpace(value, paramName); +#else + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"'{paramName}' cannot be null or whitespace.", paramName); + } +#endif + } +} diff --git a/src/extensions/BootstrapBlazor.OpcUa/IOpcUaServer.cs b/src/extensions/BootstrapBlazor.OpcUa/IOpcUaServer.cs new file mode 100644 index 00000000..471451e2 --- /dev/null +++ b/src/extensions/BootstrapBlazor.OpcUa/IOpcUaServer.cs @@ -0,0 +1,66 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +namespace BootstrapBlazor.OpcUa; + +/// +/// OPC UA 客户端接口 +/// OPC UA client interface +/// +public interface IOpcUaServer : IAsyncDisposable +{ + /// + /// 获得当前是否已连接 + /// Gets whether the client is connected + /// + bool IsConnected { get; } + + /// + /// 获得当前端点地址 + /// Gets the current endpoint URL + /// + string? EndpointUrl { get; } + + /// + /// 连接 OPC UA 服务器 + /// Connects to an OPC UA server + /// + Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default); + + /// + /// 断开当前连接 + /// Disconnects the current session + /// + Task DisconnectAsync(CancellationToken cancellationToken = default); + + /// + /// 读取节点值 + /// Reads node values + /// + Task> ReadAsync(IEnumerable nodeIds, CancellationToken cancellationToken = default); + + /// + /// 写入节点值 + /// Writes node values + /// + Task> WriteAsync(IEnumerable items, CancellationToken cancellationToken = default); + + /// + /// 浏览节点 + /// Browses a node + /// + Task> BrowseAsync(string nodeId, OpcUaBrowseOptions? options = null, CancellationToken cancellationToken = default); + + /// + /// 创建订阅 + /// Creates a subscription + /// + Task CreateSubscriptionAsync(string name, int publishingInterval = 1000, bool active = true, CancellationToken cancellationToken = default); + + /// + /// 取消订阅 + /// Cancels a subscription + /// + Task CancelSubscriptionAsync(IOpcUaSubscription subscription, CancellationToken cancellationToken = default); +} diff --git a/src/extensions/BootstrapBlazor.OpcUa/IOpcUaSubscription.cs b/src/extensions/BootstrapBlazor.OpcUa/IOpcUaSubscription.cs new file mode 100644 index 00000000..1bd362fa --- /dev/null +++ b/src/extensions/BootstrapBlazor.OpcUa/IOpcUaSubscription.cs @@ -0,0 +1,36 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +namespace BootstrapBlazor.OpcUa; + +/// +/// OPC UA 订阅接口 +/// OPC UA subscription interface +/// +public interface IOpcUaSubscription +{ + /// + /// 获得 订阅名称 + /// Gets the subscription name + /// + string Name { get; } + + /// + /// 获得/设置 是否保留上一次的值 + /// Gets or sets whether to retain the previous value + /// + bool KeepLastValue { get; set; } + + /// + /// 获得/设置 数据变化回调 + /// Gets or sets the data change callback + /// + Action>? DataChanged { get; set; } + + /// + /// 增加监控节点 + /// Adds monitored nodes + /// + Task AddItemsAsync(IEnumerable nodeIds, int samplingInterval = -1, CancellationToken cancellationToken = default); +} diff --git a/src/extensions/BootstrapBlazor.OpcUa/OpcUaBrowseElement.cs b/src/extensions/BootstrapBlazor.OpcUa/OpcUaBrowseElement.cs new file mode 100644 index 00000000..52162929 --- /dev/null +++ b/src/extensions/BootstrapBlazor.OpcUa/OpcUaBrowseElement.cs @@ -0,0 +1,19 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +using Opc.Ua; + +namespace BootstrapBlazor.OpcUa; + +/// +/// OPC UA 浏览节点 +/// OPC UA browsed node +/// +public sealed record OpcUaBrowseElement( + string NodeId, + string BrowseName, + string DisplayName, + NodeClass NodeClass, + string ReferenceTypeId, + string? TypeDefinitionId); diff --git a/src/extensions/BootstrapBlazor.OpcUa/OpcUaBrowseOptions.cs b/src/extensions/BootstrapBlazor.OpcUa/OpcUaBrowseOptions.cs new file mode 100644 index 00000000..1d9ad2a3 --- /dev/null +++ b/src/extensions/BootstrapBlazor.OpcUa/OpcUaBrowseOptions.cs @@ -0,0 +1,44 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +using Opc.Ua; + +namespace BootstrapBlazor.OpcUa; + +/// +/// OPC UA 节点浏览配置 +/// OPC UA node browse options +/// +public sealed class OpcUaBrowseOptions +{ + /// + /// 获得/设置 每个节点最多返回的引用数量,零表示由服务器决定 + /// Gets or sets the maximum references returned per node; zero lets the server decide + /// + public uint MaxReferencesReturned { get; set; } + + /// + /// 获得/设置 浏览方向 + /// Gets or sets the browse direction + /// + public BrowseDirection BrowseDirection { get; set; } = BrowseDirection.Forward; + + /// + /// 获得/设置 引用类型 + /// Gets or sets the reference type + /// + public NodeId ReferenceTypeId { get; set; } = ReferenceTypeIds.HierarchicalReferences; + + /// + /// 获得/设置 是否包含引用类型的子类型 + /// Gets or sets whether reference subtypes are included + /// + public bool IncludeSubtypes { get; set; } = true; + + /// + /// 获得/设置 节点类型掩码 + /// Gets or sets the node class mask + /// + public uint NodeClassMask { get; set; } = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method); +} diff --git a/src/extensions/BootstrapBlazor.OpcUa/OpcUaConnectionOptions.cs b/src/extensions/BootstrapBlazor.OpcUa/OpcUaConnectionOptions.cs new file mode 100644 index 00000000..ce4f18f5 --- /dev/null +++ b/src/extensions/BootstrapBlazor.OpcUa/OpcUaConnectionOptions.cs @@ -0,0 +1,62 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +using Opc.Ua; + +namespace BootstrapBlazor.OpcUa; + +/// +/// OPC UA 连接配置 +/// OPC UA connection options +/// +public sealed class OpcUaConnectionOptions +{ + /// + /// 获得/设置 应用名称 + /// Gets or sets the application name + /// + public string ApplicationName { get; set; } = "BootstrapBlazor OpcUa Client"; + + /// + /// 获得/设置 会话名称 + /// Gets or sets the session name + /// + public string SessionName { get; set; } = "BootstrapBlazor.OpcUa"; + + /// + /// 获得/设置 会话超时时间,单位毫秒 + /// Gets or sets the session timeout in milliseconds + /// + public uint SessionTimeout { get; set; } = 60000; + + /// + /// 获得/设置 操作超时时间,单位毫秒 + /// Gets or sets the operation timeout in milliseconds + /// + public int OperationTimeout { get; set; } = 15000; + + /// + /// 获得/设置 是否选择安全端点 + /// Gets or sets whether to select a secure endpoint + /// + public bool UseSecurity { get; set; } + + /// + /// 获得/设置 用户身份,默认使用匿名身份 + /// Gets or sets the user identity; anonymous identity is used by default + /// + public IUserIdentity? Identity { get; set; } + + /// + /// 获得/设置 首选区域 + /// Gets or sets the preferred locales + /// + public IList? PreferredLocales { get; set; } + + /// + /// 获得/设置 应用配置,安全连接应提供包含证书配置的实例 + /// Gets or sets the application configuration; secure connections should provide certificate settings + /// + public ApplicationConfiguration? Configuration { get; set; } +} diff --git a/src/extensions/BootstrapBlazor.OpcUa/OpcUaReadItem.cs b/src/extensions/BootstrapBlazor.OpcUa/OpcUaReadItem.cs new file mode 100644 index 00000000..cc54b659 --- /dev/null +++ b/src/extensions/BootstrapBlazor.OpcUa/OpcUaReadItem.cs @@ -0,0 +1,26 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +using Opc.Ua; + +namespace BootstrapBlazor.OpcUa; + +/// +/// OPC UA 节点读取结果 +/// OPC UA node read result +/// +public sealed record OpcUaReadItem(string NodeId, object? Value, StatusCode StatusCode, DateTime SourceTimestamp, DateTime ServerTimestamp) +{ + /// + /// 获得状态是否正常 + /// Gets whether the status is good + /// + public bool IsGood => Opc.Ua.StatusCode.IsGood(StatusCode); + + /// + /// 获得上一次的值 + /// Gets the previous value + /// + public object? LastValue { get; init; } +} diff --git a/src/extensions/BootstrapBlazor.OpcUa/OpcUaServer.cs b/src/extensions/BootstrapBlazor.OpcUa/OpcUaServer.cs new file mode 100644 index 00000000..c3fd9b8c --- /dev/null +++ b/src/extensions/BootstrapBlazor.OpcUa/OpcUaServer.cs @@ -0,0 +1,450 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +using Opc.Ua; +using Opc.Ua.Client; + +namespace BootstrapBlazor.OpcUa; + +sealed class OpcUaServer : IOpcUaServer +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Dictionary _subscriptions = []; +#if NET8_0_OR_GREATER + private readonly ITelemetryContext _telemetry = DefaultTelemetry.Create(_ => { }); +#endif + private ISession? _session; + private bool _disposed; + + public bool IsConnected => _session?.Connected == true; + + public string? EndpointUrl { get; private set; } + + public async Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default) + { + Guard.ThrowIfDisposed(_disposed, this); + Guard.ThrowIfNullOrWhiteSpace(endpointUrl, nameof(endpointUrl)); + + options ??= new OpcUaConnectionOptions(); + ValidateOptions(options); + var configuration = options.Configuration ?? CreateConfiguration(options); +#if NET8_0_OR_GREATER + await configuration.ValidateAsync(ApplicationType.Client, cancellationToken); + var applicationCertificate = await configuration.SecurityConfiguration.FindApplicationCertificateAsync( + configuration.ApplicationUri, + true, + _telemetry, + cancellationToken); +#else + await configuration.Validate(ApplicationType.Client); + var applicationCertificate = await configuration.SecurityConfiguration.ApplicationCertificate.Find(true, configuration.ApplicationUri); +#endif + + if (options.UseSecurity && applicationCertificate is null) + { + throw new InvalidOperationException("The OPC UA application certificate with a private key was not found."); + } + + await _gate.WaitAsync(cancellationToken); + try + { + Guard.ThrowIfDisposed(_disposed, this); + await DisconnectCoreAsync(cancellationToken); + +#if NET8_0_OR_GREATER + var endpointDescription = await CoreClientUtils.SelectEndpointAsync( + configuration, + endpointUrl, + options.UseSecurity, + options.OperationTimeout, + _telemetry, + cancellationToken); +#else + var endpointDescription = CoreClientUtils.SelectEndpoint(configuration, endpointUrl, options.UseSecurity, options.OperationTimeout); +#endif + var endpointConfiguration = EndpointConfiguration.Create(configuration); + endpointConfiguration.OperationTimeout = options.OperationTimeout; + var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); + +#if NET8_0_OR_GREATER + var sessionFactory = new DefaultSessionFactory(_telemetry); +#else + var sessionFactory = DefaultSessionFactory.Instance; +#endif + _session = await sessionFactory.CreateAsync( + configuration, + endpoint, + false, + options.SessionName, + options.SessionTimeout, + options.Identity, + options.PreferredLocales, + cancellationToken); + + EndpointUrl = endpointUrl; + return _session.Connected; + } + finally + { + _gate.Release(); + } + } + + public async Task DisconnectAsync(CancellationToken cancellationToken = default) + { + Guard.ThrowIfDisposed(_disposed, this); + await _gate.WaitAsync(cancellationToken); + try + { + Guard.ThrowIfDisposed(_disposed, this); + await DisconnectCoreAsync(cancellationToken); + } + finally + { + _gate.Release(); + } + } + + public async Task> ReadAsync(IEnumerable nodeIds, CancellationToken cancellationToken = default) + { + Guard.ThrowIfDisposed(_disposed, this); + ArgumentNullException.ThrowIfNull(nodeIds); + var ids = nodeIds.Select(ParseNodeId).ToArray(); + + await _gate.WaitAsync(cancellationToken); + try + { + Guard.ThrowIfDisposed(_disposed, this); + var session = GetSession(); + var nodesToRead = new ReadValueIdCollection(ids.Select(nodeId => new ReadValueId + { + NodeId = nodeId, + AttributeId = Attributes.Value + })); + + var response = await session.ReadAsync(null, 0, TimestampsToReturn.Both, nodesToRead, cancellationToken); + ClientBase.ValidateResponse(response.Results, nodesToRead); + ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, nodesToRead); + + return ids.Select((nodeId, index) => + { + var value = response.Results[index]; + return new OpcUaReadItem(nodeId.ToString(), value.Value, value.StatusCode, value.SourceTimestamp, value.ServerTimestamp); + }).ToArray(); + } + finally + { + _gate.Release(); + } + } + + public async Task> WriteAsync(IEnumerable items, CancellationToken cancellationToken = default) + { + Guard.ThrowIfDisposed(_disposed, this); + ArgumentNullException.ThrowIfNull(items); + var itemList = items.ToArray(); + var nodesToWrite = new WriteValueCollection(itemList.Select(item => + { + ArgumentNullException.ThrowIfNull(item); + return new WriteValue + { + NodeId = ParseNodeId(item.NodeId), + AttributeId = Attributes.Value, + Value = new DataValue(new Variant(item.Value)) + }; + })); + + await _gate.WaitAsync(cancellationToken); + try + { + Guard.ThrowIfDisposed(_disposed, this); + var session = GetSession(); + var response = await session.WriteAsync(null, nodesToWrite, cancellationToken); + ClientBase.ValidateResponse(response.Results, nodesToWrite); + ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, nodesToWrite); + + return itemList.Select((item, index) => item with { StatusCode = response.Results[index] }).ToArray(); + } + finally + { + _gate.Release(); + } + } + + public async Task> BrowseAsync(string nodeId, OpcUaBrowseOptions? options = null, CancellationToken cancellationToken = default) + { + Guard.ThrowIfDisposed(_disposed, this); + var parsedNodeId = ParseNodeId(nodeId); + options ??= new OpcUaBrowseOptions(); + + await _gate.WaitAsync(cancellationToken); + try + { + Guard.ThrowIfDisposed(_disposed, this); + var session = GetSession(); + var (references, errors) = await session.ManagedBrowseAsync( + null, + null, + [parsedNodeId], + options.MaxReferencesReturned, + options.BrowseDirection, + options.ReferenceTypeId, + options.IncludeSubtypes, + options.NodeClassMask, + cancellationToken); + + if (errors.Count > 0 && ServiceResult.IsBad(errors[0])) + { + throw new ServiceResultException(errors[0]); + } + + return references[0].Select(reference => + { + var resolvedNodeId = ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris); + return new OpcUaBrowseElement( + resolvedNodeId?.ToString() ?? reference.NodeId.ToString(), + reference.BrowseName.ToString(), + reference.DisplayName.Text, + reference.NodeClass, + reference.ReferenceTypeId.ToString(), + reference.TypeDefinition?.ToString()); + }).ToArray(); + } + finally + { + _gate.Release(); + } + } + + public async Task CreateSubscriptionAsync(string name, int publishingInterval = 1000, bool active = true, CancellationToken cancellationToken = default) + { + Guard.ThrowIfDisposed(_disposed, this); + Guard.ThrowIfNullOrWhiteSpace(name, nameof(name)); + if (publishingInterval <= 0) + { + throw new ArgumentOutOfRangeException(nameof(publishingInterval)); + } + + await _gate.WaitAsync(cancellationToken); + try + { + Guard.ThrowIfDisposed(_disposed, this); + var session = GetSession(); + if (_subscriptions.ContainsKey(name)) + { + throw new InvalidOperationException($"An OPC UA subscription named '{name}' already exists."); + } + + var sdkSubscription = new Subscription(session.DefaultSubscription) + { + DisplayName = name, + PublishingInterval = publishingInterval, + PublishingEnabled = active + }; + if (!session.AddSubscription(sdkSubscription)) + { + throw new InvalidOperationException($"Unable to add OPC UA subscription '{name}' to the current session."); + } + + try + { + await sdkSubscription.CreateAsync(cancellationToken); + } + catch (Exception exception) + { + Exception? cleanupException = null; + try + { + if (sdkSubscription.Created) + { + await sdkSubscription.DeleteAsync(true, CancellationToken.None); + } + await session.RemoveSubscriptionAsync(sdkSubscription, CancellationToken.None); + } + catch (Exception ex) + { + cleanupException = ex; + } + finally + { + sdkSubscription.Dispose(); + } + if (cleanupException is not null) + { + throw new AggregateException($"Unable to create or clean up OPC UA subscription '{name}'.", exception, cleanupException); + } + throw; + } + + var subscription = new OpcUaSubscription(sdkSubscription); + _subscriptions.Add(name, subscription); + return subscription; + } + finally + { + _gate.Release(); + } + } + + public async Task CancelSubscriptionAsync(IOpcUaSubscription subscription, CancellationToken cancellationToken = default) + { + Guard.ThrowIfDisposed(_disposed, this); + ArgumentNullException.ThrowIfNull(subscription); + + await _gate.WaitAsync(cancellationToken); + try + { + Guard.ThrowIfDisposed(_disposed, this); + var session = GetSession(); + if (_subscriptions.TryGetValue(subscription.Name, out var registered) && ReferenceEquals(registered, subscription)) + { + await DeleteSubscriptionAsync(session, registered, cancellationToken); + } + } + finally + { + _gate.Release(); + } + } + + private static ApplicationConfiguration CreateConfiguration(OpcUaConnectionOptions options) + { + var pkiRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OPC Foundation", + "CertificateStores"); + + return new ApplicationConfiguration + { + ApplicationName = options.ApplicationName, + ApplicationUri = $"urn:{Utils.GetHostName()}:{options.ApplicationName.Replace(' ', '-')}", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "MachineDefault"), + SubjectName = $"CN={options.ApplicationName}" + }, + TrustedIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "UA Certificate Authorities") + }, + TrustedPeerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "UA Applications") + }, + RejectedCertificateStore = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "RejectedCertificates") + } + }, + TransportConfigurations = [], + TransportQuotas = new TransportQuotas { OperationTimeout = options.OperationTimeout }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = (int)options.SessionTimeout } + }; + } + + private static void ValidateOptions(OpcUaConnectionOptions options) + { + Guard.ThrowIfNullOrWhiteSpace(options.ApplicationName, nameof(options.ApplicationName)); + Guard.ThrowIfNullOrWhiteSpace(options.SessionName, nameof(options.SessionName)); + if (options.OperationTimeout <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options.OperationTimeout)); + } + if (options.SessionTimeout == 0) + { + throw new ArgumentOutOfRangeException(nameof(options.SessionTimeout)); + } + if (options.UseSecurity && options.Configuration is null) + { + throw new ArgumentException("A certificate-enabled application configuration is required for secure OPC UA endpoints.", nameof(options)); + } + } + + private static NodeId ParseNodeId(string nodeId) + { + Guard.ThrowIfNullOrWhiteSpace(nodeId, nameof(nodeId)); + return NodeId.Parse(nodeId); + } + + private ISession GetSession() + { + if (_session is not { Connected: true } session) + { + throw new InvalidOperationException("OPC UA Server is not connected."); + } + return session; + } + + private async Task DeleteSubscriptionAsync(ISession session, OpcUaSubscription subscription, CancellationToken cancellationToken) + { + await subscription.Subscription.DeleteAsync(false, cancellationToken); + try + { + await session.RemoveSubscriptionAsync(subscription.Subscription, cancellationToken); + } + finally + { + _subscriptions.Remove(subscription.Name); + subscription.Dispose(); + } + } + + private async Task DisconnectCoreAsync(CancellationToken cancellationToken) + { + foreach (var subscription in _subscriptions.Values) + { + subscription.Dispose(); + } + _subscriptions.Clear(); + + var session = _session; + _session = null; + EndpointUrl = null; + if (session is not null) + { + try + { + if (session.Connected) + { + await session.CloseAsync(cancellationToken); + } + } + finally + { + session.Dispose(); + } + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + await _gate.WaitAsync(); + try + { + if (_disposed) + { + return; + } + await DisconnectCoreAsync(CancellationToken.None); + _disposed = true; + } + finally + { + _gate.Release(); + } + GC.SuppressFinalize(this); + } +} diff --git a/src/extensions/BootstrapBlazor.OpcUa/OpcUaSubscription.cs b/src/extensions/BootstrapBlazor.OpcUa/OpcUaSubscription.cs new file mode 100644 index 00000000..45194910 --- /dev/null +++ b/src/extensions/BootstrapBlazor.OpcUa/OpcUaSubscription.cs @@ -0,0 +1,122 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +using Opc.Ua; +using Opc.Ua.Client; + +namespace BootstrapBlazor.OpcUa; + +sealed class OpcUaSubscription(Subscription subscription) : IOpcUaSubscription +{ + private readonly object _syncRoot = new(); + private readonly Dictionary _lastValues = []; + private readonly List _items = []; + private bool _disposed; + + public string Name => subscription.DisplayName; + + public bool KeepLastValue { get; set; } + + public Action>? DataChanged { get; set; } + + public async Task AddItemsAsync(IEnumerable nodeIds, int samplingInterval = -1, CancellationToken cancellationToken = default) + { + Guard.ThrowIfDisposed(_disposed, this); + ArgumentNullException.ThrowIfNull(nodeIds); + + var items = nodeIds.Select(nodeId => + { + Guard.ThrowIfNullOrWhiteSpace(nodeId, nameof(nodeIds)); + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = NodeId.Parse(nodeId), + AttributeId = Attributes.Value, + DisplayName = nodeId, + SamplingInterval = samplingInterval + }; + item.Notification += OnNotification; + return item; + }).ToArray(); + + if (items.Length == 0) + { + return; + } + + subscription.AddItems(items); + lock (_syncRoot) + { + _items.AddRange(items); + } + + try + { + await subscription.ApplyChangesAsync(cancellationToken); + } + catch + { + subscription.RemoveItems(items); + lock (_syncRoot) + { + foreach (var item in items) + { + item.Notification -= OnNotification; + _items.Remove(item); + } + } + throw; + } + } + + private void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs args) + { + var values = item.DequeueValues(); + if (values.Count == 0) + { + return; + } + + var nodeId = item.StartNodeId.ToString(); + var results = new List(values.Count); + + lock (_syncRoot) + { + foreach (var value in values) + { + _lastValues.TryGetValue(nodeId, out var lastValue); + results.Add(new OpcUaReadItem(nodeId, value.Value, value.StatusCode, value.SourceTimestamp, value.ServerTimestamp) + { + LastValue = KeepLastValue ? lastValue : null + }); + _lastValues[nodeId] = value.Value; + } + } + + DataChanged?.Invoke(results); + } + + internal Subscription Subscription => subscription; + + internal void Dispose() + { + if (_disposed) + { + return; + } + + lock (_syncRoot) + { + foreach (var item in _items) + { + item.Notification -= OnNotification; + } + _items.Clear(); + _lastValues.Clear(); + } + + DataChanged = null; + subscription.Dispose(); + _disposed = true; + } +} diff --git a/src/extensions/BootstrapBlazor.OpcUa/OpcUaWriteItem.cs b/src/extensions/BootstrapBlazor.OpcUa/OpcUaWriteItem.cs new file mode 100644 index 00000000..74ce01aa --- /dev/null +++ b/src/extensions/BootstrapBlazor.OpcUa/OpcUaWriteItem.cs @@ -0,0 +1,26 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +using Opc.Ua; + +namespace BootstrapBlazor.OpcUa; + +/// +/// OPC UA 节点写入项 +/// OPC UA node write item +/// +public sealed record OpcUaWriteItem(string NodeId, object? Value) +{ + /// + /// 获得写入状态 + /// Gets the write status + /// + public StatusCode StatusCode { get; init; } = StatusCodes.Good; + + /// + /// 获得写入是否成功 + /// Gets whether the write succeeded + /// + public bool Result => Opc.Ua.StatusCode.IsGood(StatusCode); +} diff --git a/src/components/BootstrapBlazor.OpcUa/_Imports.razor b/src/extensions/BootstrapBlazor.OpcUa/_Imports.razor similarity index 100% rename from src/components/BootstrapBlazor.OpcUa/_Imports.razor rename to src/extensions/BootstrapBlazor.OpcUa/_Imports.razor diff --git a/test/UnitTestOpcUa/UnitTest1.cs b/test/UnitTestOpcUa/UnitTest1.cs index 3ae5feb3..8e67a3a6 100644 --- a/test/UnitTestOpcUa/UnitTest1.cs +++ b/test/UnitTestOpcUa/UnitTest1.cs @@ -1,113 +1,94 @@ -// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Website: https://www.blazor.zone or https://argozhang.github.io/ -using Microsoft.Extensions.Configuration; +using BootstrapBlazor.OpcUa; using Opc.Ua; -using Opc.Ua.Client; -using Opc.Ua.Configuration; namespace UnitTestOpcUa; public class UnitTest1 { [Fact] - public async Task Connect_Ok() + public async Task AddOpcUaServer_Ok() { - var config = new ApplicationConfiguration() - { - ApplicationName = "KEPServerEX Client", - ApplicationType = ApplicationType.Client, - ApplicationUri = "urn:" + Utils.GetHostName() + ":KEPServerEXClient", - SecurityConfiguration = new SecurityConfiguration - { - ApplicationCertificate = new CertificateIdentifier { StoreType = "X509Store", StorePath = "CurrentUser\\My" }, - TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = "OPC Foundation/CertificateStores/UA Applications" }, - RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = "OPC Foundation/CertificateStores/RejectedCertificates" }, - AutoAcceptUntrustedCertificates = true // 仅用于测试环境 - }, - TransportConfigurations = new TransportConfigurationCollection(), - TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, - ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 } - }; + var services = new ServiceCollection(); + services.AddOpcUaServer(); + + await using var provider = services.BuildServiceProvider(); + await using var scope1 = provider.CreateAsyncScope(); + await using var scope2 = provider.CreateAsyncScope(); + var server = scope1.ServiceProvider.GetRequiredService(); - //await config.Validate(ApplicationType.Client); - - // 创建端点描述 - var endpointDescription = CoreClientUtils.SelectEndpoint("opc.tcp://127.0.0.1:49320", false); - var endpointConfiguration = EndpointConfiguration.Create(config); - var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); - - // 创建会话 - var identity = new UserIdentity("BB", "123456@163.com"); // 匿名登录,或提供用户名密码 - var session = await Session.Create( - config, - endpoint, - false, - false, - config.ApplicationName, - 60000, - identity, - null); + Assert.Same(server, scope1.ServiceProvider.GetRequiredService()); + Assert.NotSame(server, scope2.ServiceProvider.GetRequiredService()); + Assert.False(server.IsConnected); + Assert.Null(server.EndpointUrl); } [Fact] - public async Task FindServersAsync() + public async Task Operation_NotConnected() { - // 配置与连接过程同前述基本客户端 - var application = new ApplicationInstance - { - ApplicationName = "OPC UA Basic Client", - ApplicationType = ApplicationType.Client - }; - var applicationConfiguration = new ApplicationConfiguration + var services = new ServiceCollection(); + services.AddOpcUaServer(); + + await using var provider = services.BuildServiceProvider(); + var server = provider.GetRequiredService(); + + await Assert.ThrowsAsync(() => server.ReadAsync(["ns=2;s=Tag"])); + await Assert.ThrowsAsync(() => server.WriteAsync([new OpcUaWriteItem("ns=2;s=Tag", 1)])); + await Assert.ThrowsAsync(() => server.BrowseAsync("i=85")); + await Assert.ThrowsAsync(() => server.CreateSubscriptionAsync("Test")); + } + + [Fact] + public void Model_Ok() + { + var timestamp = DateTime.UtcNow; + var readItem = new OpcUaReadItem("ns=2;s=Tag", 10, StatusCodes.Good, timestamp, timestamp) { - ApplicationName = application.ApplicationName, - ApplicationType = application.ApplicationType, - ClientConfiguration = new ClientConfiguration() + LastValue = 9 }; - var endpointURL = "opc.tcp://127.0.0.1:49320"; - var endpointDescription = CoreClientUtils.SelectEndpoint(applicationConfiguration, endpointURL, false); - var endpointConfiguration = EndpointConfiguration.Create(); - var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); - - var session = await Session.Create( - configuration: applicationConfiguration, - endpoint: endpoint, - updateBeforeConnect: false, - sessionName: "Opc.Session.BootstrapBlazor", - sessionTimeout: 60000, - identity: null, - preferredLocales: null); - - // Browser - var browser = new Browser(session) + var writeItem = new OpcUaWriteItem("ns=2;s=Tag", 10) { - BrowseDirection = BrowseDirection.Forward, - NodeClassMask = (int)NodeClass.Variable | (int)NodeClass.Object | (int)NodeClass.Method, - ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, - IncludeSubtypes = true, - MaxReferencesReturned = 1000 + StatusCode = StatusCodes.BadNotWritable }; - // 浏览节点 - var references = browser.Browse(ObjectIds.ObjectsFolder); + Assert.True(readItem.IsGood); + Assert.Equal(9, readItem.LastValue); + Assert.False(writeItem.Result); + } - var readValueId = new ReadValueId - { - NodeId = new NodeId("ns=2;s=Simulation Examples.Functions.Ramp1"), - AttributeId = Attributes.Value - }; + [Fact] + public async Task Dispose_Ok() + { + var services = new ServiceCollection(); + services.AddOpcUaServer(); - var readValues = new ReadValueIdCollection { readValueId }; + await using var provider = services.BuildServiceProvider(); + var server = provider.GetRequiredService(); + await server.DisposeAsync(); - // 读取节点值 - var resp = await session.ReadAsync( - null, - 0, - TimestampsToReturn.Both, - readValues, CancellationToken.None); + await Assert.ThrowsAsync(() => server.DisconnectAsync()); + } - await session.CloseAsync(); + [Fact] + public async Task Connect_Options() + { + var services = new ServiceCollection(); + services.AddOpcUaServer(); + + await using var provider = services.BuildServiceProvider(); + var server = provider.GetRequiredService(); + + await Assert.ThrowsAsync(() => server.ConnectAsync("", new OpcUaConnectionOptions())); + await Assert.ThrowsAsync(() => server.ConnectAsync("opc.tcp://localhost:4840", new OpcUaConnectionOptions + { + UseSecurity = true + })); + await Assert.ThrowsAsync(() => server.ConnectAsync("opc.tcp://localhost:4840", new OpcUaConnectionOptions + { + OperationTimeout = 0 + })); } } diff --git a/test/UnitTestOpcUa/UnitTestOpcUa.csproj b/test/UnitTestOpcUa/UnitTestOpcUa.csproj index 08438495..5c4c57a4 100644 --- a/test/UnitTestOpcUa/UnitTestOpcUa.csproj +++ b/test/UnitTestOpcUa/UnitTestOpcUa.csproj @@ -1,7 +1,11 @@  - + + + + +