From 392c44010a43d044ac758bf3bd64e82889c0dbfb Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Mon, 25 Aug 2025 14:58:02 +0800 Subject: [PATCH 1/9] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20Ua=20?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BootstrapBlazor.Extensions.sln | 13 ++++++------- .../BootstrapBlazor.OpcUa.csproj | 7 +------ .../Extensions/ServiceCollectionExtensions.cs | 0 .../BootstrapBlazor.OpcUa/_Imports.razor | 0 test/UnitTestOpcUa/UnitTestOpcUa.csproj | 4 ---- 5 files changed, 7 insertions(+), 17 deletions(-) rename src/{components => extensions}/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj (67%) rename src/{components => extensions}/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs (100%) rename src/{components => extensions}/BootstrapBlazor.OpcUa/_Imports.razor (100%) diff --git a/BootstrapBlazor.Extensions.sln b/BootstrapBlazor.Extensions.sln index 7dbcbca8..ff4a0082 100644 --- a/BootstrapBlazor.Extensions.sln +++ b/BootstrapBlazor.Extensions.sln @@ -192,8 +192,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestSvgIcon", "test\Uni EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BootstrapBlazor.FluentSystemIcon", "src\components\BootstrapBlazor.FluentSystemIcon\BootstrapBlazor.FluentSystemIcon.csproj", "{30449D30-0B4E-40FD-85BE-C9BAAC820162}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BootstrapBlazor.OpcUa", "src\components\BootstrapBlazor.OpcUa\BootstrapBlazor.OpcUa.csproj", "{6114A9DF-9DF5-474E-A5B0-25CDF0268B52}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestOpcUa", "test\UnitTestOpcUa\UnitTestOpcUa.csproj", "{98373A64-E224-4715-AE02-A8C6DAFF3338}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BootstrapBlazor.OpcDa", "src\extensions\BootstrapBlazor.OpcDa\BootstrapBlazor.OpcDa.csproj", "{01007B10-7C3C-4136-83FF-981CA39AD3D4}" @@ -202,6 +200,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTestOpcDa", "test\UnitT EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BootstrapBlazor.Tasks.Dashboard", "src\components\BootstrapBlazor.Tasks.Dashboard\BootstrapBlazor.Tasks.Dashboard.csproj", "{30C57119-C564-401C-AE3A-6203E2733E1A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BootstrapBlazor.OpcUa", "src\extensions\BootstrapBlazor.OpcUa\BootstrapBlazor.OpcUa.csproj", "{98054DCC-A9AB-CB11-798F-424112EC8639}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -532,10 +532,6 @@ Global {30449D30-0B4E-40FD-85BE-C9BAAC820162}.Debug|Any CPU.Build.0 = Debug|Any CPU {30449D30-0B4E-40FD-85BE-C9BAAC820162}.Release|Any CPU.ActiveCfg = Release|Any CPU {30449D30-0B4E-40FD-85BE-C9BAAC820162}.Release|Any CPU.Build.0 = Release|Any CPU - {6114A9DF-9DF5-474E-A5B0-25CDF0268B52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6114A9DF-9DF5-474E-A5B0-25CDF0268B52}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6114A9DF-9DF5-474E-A5B0-25CDF0268B52}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6114A9DF-9DF5-474E-A5B0-25CDF0268B52}.Release|Any CPU.Build.0 = Release|Any CPU {98373A64-E224-4715-AE02-A8C6DAFF3338}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {98373A64-E224-4715-AE02-A8C6DAFF3338}.Debug|Any CPU.Build.0 = Debug|Any CPU {98373A64-E224-4715-AE02-A8C6DAFF3338}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -552,6 +548,10 @@ Global {30C57119-C564-401C-AE3A-6203E2733E1A}.Debug|Any CPU.Build.0 = Debug|Any CPU {30C57119-C564-401C-AE3A-6203E2733E1A}.Release|Any CPU.ActiveCfg = Release|Any CPU {30C57119-C564-401C-AE3A-6203E2733E1A}.Release|Any CPU.Build.0 = Release|Any CPU + {98054DCC-A9AB-CB11-798F-424112EC8639}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98054DCC-A9AB-CB11-798F-424112EC8639}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98054DCC-A9AB-CB11-798F-424112EC8639}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98054DCC-A9AB-CB11-798F-424112EC8639}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -642,7 +642,6 @@ Global {10D35EE5-FA31-4C80-B113-CD7A0FB76B4E} = {B6A98ADE-D26A-4D0B-8978-AB7AC915F5AE} {7CAD5915-CE3E-31ED-B1AC-15C61C3ED8C3} = {B6A98ADE-D26A-4D0B-8978-AB7AC915F5AE} {30449D30-0B4E-40FD-85BE-C9BAAC820162} = {FF1089BE-C704-4374-B629-C57C08E1798F} - {6114A9DF-9DF5-474E-A5B0-25CDF0268B52} = {FF1089BE-C704-4374-B629-C57C08E1798F} {98373A64-E224-4715-AE02-A8C6DAFF3338} = {B6A98ADE-D26A-4D0B-8978-AB7AC915F5AE} {01007B10-7C3C-4136-83FF-981CA39AD3D4} = {7B29E81D-92DE-46C8-8EDC-1B48C8F12BC2} {835C8BA9-A9CC-4EA0-9002-34A20F8B2E86} = {B6A98ADE-D26A-4D0B-8978-AB7AC915F5AE} diff --git a/src/components/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj b/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj similarity index 67% rename from src/components/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj rename to src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj index 2d083627..0df36c12 100644 --- a/src/components/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj +++ b/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj @@ -1,4 +1,4 @@ - + 9.0.1 @@ -10,12 +10,7 @@ - - - - - diff --git a/src/components/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs b/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs similarity index 100% rename from src/components/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs rename to src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs 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/UnitTestOpcUa.csproj b/test/UnitTestOpcUa/UnitTestOpcUa.csproj index bcd56af9..d7f0b2e9 100644 --- a/test/UnitTestOpcUa/UnitTestOpcUa.csproj +++ b/test/UnitTestOpcUa/UnitTestOpcUa.csproj @@ -14,10 +14,6 @@ - - - - From a4fb10b9460427528dc24fec68b1207c5f33abb2 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Mon, 25 Aug 2025 14:58:28 +0800 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=96=B0=E6=A0=B9?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapBlazor.OpcDa/BootstrapBlazor.OpcDa.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/extensions/BootstrapBlazor.OpcDa/BootstrapBlazor.OpcDa.csproj b/src/extensions/BootstrapBlazor.OpcDa/BootstrapBlazor.OpcDa.csproj index c2d4edb1..c275823c 100644 --- a/src/extensions/BootstrapBlazor.OpcDa/BootstrapBlazor.OpcDa.csproj +++ b/src/extensions/BootstrapBlazor.OpcDa/BootstrapBlazor.OpcDa.csproj @@ -2,7 +2,6 @@ 9.0.3 - BootstrapBlazor.OpcDa From 56684e988ac143c5d5e5afe6af779b62e3dc72a1 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 24 Feb 2026 12:51:17 +0800 Subject: [PATCH 3/9] =?UTF-8?q?chore:=20=E7=A7=BB=E5=8A=A8=20UA=20?= =?UTF-8?q?=E5=88=B0=20Extensions=20=E6=96=87=E4=BB=B6=E5=A4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BootstrapBlazor.Extensions.slnx | 2 +- .../BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj | 0 .../Extensions/ServiceCollectionExtensions.cs | 0 .../BootstrapBlazor.OpcUa/_Imports.razor | 0 test/UnitTestOpcUa/UnitTestOpcUa.csproj | 8 +------- 5 files changed, 2 insertions(+), 8 deletions(-) rename src/{components => extensions}/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj (100%) rename src/{components => extensions}/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs (100%) rename src/{components => extensions}/BootstrapBlazor.OpcUa/_Imports.razor (100%) diff --git a/BootstrapBlazor.Extensions.slnx b/BootstrapBlazor.Extensions.slnx index 8d607ed9..5358b608 100644 --- a/BootstrapBlazor.Extensions.slnx +++ b/BootstrapBlazor.Extensions.slnx @@ -69,7 +69,6 @@ - @@ -101,6 +100,7 @@ + diff --git a/src/components/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj b/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj similarity index 100% rename from src/components/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj rename to src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj diff --git a/src/components/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs b/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs similarity index 100% rename from src/components/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs rename to src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs 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/UnitTestOpcUa.csproj b/test/UnitTestOpcUa/UnitTestOpcUa.csproj index 08438495..2ef1a363 100644 --- a/test/UnitTestOpcUa/UnitTestOpcUa.csproj +++ b/test/UnitTestOpcUa/UnitTestOpcUa.csproj @@ -1,7 +1 @@ - - - - - - - + From 6a7b0ad110c67e806eaa52c6b2e791cdca7185b1 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 24 Feb 2026 12:55:25 +0800 Subject: [PATCH 4/9] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapBlazor.OpcUa.csproj | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj b/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj index 91699a29..b993ee26 100644 --- a/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj +++ b/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj @@ -5,6 +5,26 @@ Bootstrap UI components extensions of OpcUa + + + + + + + + + + + + + + + + + + + + From 879388c93f5f166ba96553c2a921b398ab153b61 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Sat, 29 Aug 2026 19:43:30 +0800 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=E5=A2=9E=E5=8A=A0=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E6=9C=8D=E5=8A=A1=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/ServiceCollectionExtensions.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs b/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs index 48acecf7..7e71adc4 100644 --- a/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs +++ b/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs @@ -1,20 +1,22 @@ -// 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; /// -/// 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) { return services; } From 35c558dc0fead8149b2ccd519d5fdb3a41e6d9b3 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Sat, 29 Aug 2026 19:43:39 +0800 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20=E5=A2=9E=E5=8A=A0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapBlazor.OpcUa.csproj | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj b/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj index b993ee26..3c3e94c9 100644 --- a/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj +++ b/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj @@ -1,32 +1,38 @@ - + Bootstrap Blazor WebAssembly wasm UI Components Opc Ua Client Bootstrap UI components extensions of OpcUa + + 1.5.376.235 + 1.5.378.156 + + + + + + - - - - + From 0a4dcabf5025b9214c284ae4ccc91ac1ebc29ab3 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Sat, 29 Aug 2026 21:03:25 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20OpcUaServer=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/ServiceCollectionExtensions.cs | 3 + src/extensions/BootstrapBlazor.OpcUa/Guard.cs | 32 ++ .../BootstrapBlazor.OpcUa/IOpcUaServer.cs | 66 +++ .../IOpcUaSubscription.cs | 36 ++ .../OpcUaBrowseElement.cs | 19 + .../OpcUaBrowseOptions.cs | 44 ++ .../OpcUaConnectionOptions.cs | 62 +++ .../BootstrapBlazor.OpcUa/OpcUaReadItem.cs | 26 + .../BootstrapBlazor.OpcUa/OpcUaServer.cs | 450 ++++++++++++++++++ .../OpcUaSubscription.cs | 122 +++++ .../BootstrapBlazor.OpcUa/OpcUaWriteItem.cs | 26 + 11 files changed, 886 insertions(+) create mode 100644 src/extensions/BootstrapBlazor.OpcUa/Guard.cs create mode 100644 src/extensions/BootstrapBlazor.OpcUa/IOpcUaServer.cs create mode 100644 src/extensions/BootstrapBlazor.OpcUa/IOpcUaSubscription.cs create mode 100644 src/extensions/BootstrapBlazor.OpcUa/OpcUaBrowseElement.cs create mode 100644 src/extensions/BootstrapBlazor.OpcUa/OpcUaBrowseOptions.cs create mode 100644 src/extensions/BootstrapBlazor.OpcUa/OpcUaConnectionOptions.cs create mode 100644 src/extensions/BootstrapBlazor.OpcUa/OpcUaReadItem.cs create mode 100644 src/extensions/BootstrapBlazor.OpcUa/OpcUaServer.cs create mode 100644 src/extensions/BootstrapBlazor.OpcUa/OpcUaSubscription.cs create mode 100644 src/extensions/BootstrapBlazor.OpcUa/OpcUaWriteItem.cs diff --git a/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs b/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs index 7e71adc4..39286d3f 100644 --- a/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs +++ b/src/extensions/BootstrapBlazor.OpcUa/Extensions/ServiceCollectionExtensions.cs @@ -4,6 +4,8 @@ namespace Microsoft.Extensions.DependencyInjection; +using BootstrapBlazor.OpcUa; + /// /// OpcUa 服务扩展类 /// OpcUa service extension class @@ -18,6 +20,7 @@ public static class ServiceCollectionExtensions /// 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); +} From 128c1dd5c276be7e09c71588b0f6b5c6648b03fb Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Sat, 29 Aug 2026 21:05:39 +0800 Subject: [PATCH 8/9] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/UnitTestOpcUa/UnitTest1.cs | 153 +++++++++++------------- test/UnitTestOpcUa/UnitTestOpcUa.csproj | 12 +- 2 files changed, 78 insertions(+), 87 deletions(-) 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 2ef1a363..5c4c57a4 100644 --- a/test/UnitTestOpcUa/UnitTestOpcUa.csproj +++ b/test/UnitTestOpcUa/UnitTestOpcUa.csproj @@ -1 +1,11 @@ - + + + + + + + + + + + From 7587a9f20175a2924a0d63dfafcb01774a7cf7b4 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Sat, 29 Aug 2026 21:10:19 +0800 Subject: [PATCH 9/9] chore: bump version 10.0.1 --- .../BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj b/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj index 3c3e94c9..40db6f7b 100644 --- a/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj +++ b/src/extensions/BootstrapBlazor.OpcUa/BootstrapBlazor.OpcUa.csproj @@ -1,5 +1,9 @@  + + 10.0.1 + + Bootstrap Blazor WebAssembly wasm UI Components Opc Ua Client Bootstrap UI components extensions of OpcUa