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 @@
-
+
+
+
+
+