diff --git a/UA.slnx b/UA.slnx
index 5f8b02fbd5..05d586755a 100644
--- a/UA.slnx
+++ b/UA.slnx
@@ -20,6 +20,10 @@
+
+
+
+
diff --git a/docs/GDS.md b/docs/GDS.md
index 2763330342..e7cd1f3848 100644
--- a/docs/GDS.md
+++ b/docs/GDS.md
@@ -18,9 +18,9 @@ control.
| NuGet Package | Contents |
|---------------|----------|
-| `Opc.Ua.Gds.Common` | Model types, encodeable types, design files |
-| `Opc.Ua.Gds.Server.Common` | Server-side node managers, providers, authorization |
-| `Opc.Ua.Gds.Client.Common` | Client proxies for GDS, push, KeyCredential, AuthorizationService |
+| `Opc.Ua.Gds.Common` | Part 12 and Part 21 model types, node states, identifiers, encodeable types |
+| `Opc.Ua.Gds.Server.Common` | Server-side node managers, providers, authorization, onboarding ticket-store binding |
+| `Opc.Ua.Gds.Client.Common` | Client proxies for GDS, push, KeyCredential, AuthorizationService, onboarding |
---
@@ -96,6 +96,49 @@ await pushClient.CreateSelfSignedCertificateAsync(
"CN=NewSubject", domainNames);
```
+### OPC 10000-21 OnboardingClient
+
+`Opc.Ua.Gds.Common` ships the generated `Opc.Ua.Onboarding` model,
+including the well-known `DeviceRegistrar` instance and the typed
+`DeviceRegistrarAdminTypeClient`. `OnboardingClient` is the DI-friendly
+facade for loading and removing encoded tickets:
+
+```csharp
+NodeId registrarId = ExpandedNodeId.ToNodeId(
+ Opc.Ua.Onboarding.ObjectIds.DeviceRegistrar_Administration,
+ session.NamespaceUris);
+
+var onboarding = new OnboardingClient(session, registrarId, telemetry);
+ArrayOf results = await onboarding.RegisterTicketsAsync(
+[
+ new ByteString(firstTicket),
+ new ByteString(secondTicket)
+]);
+```
+
+The public contract follows Part 21: tickets are
+`ArrayOf` (`EncodedTicket` is a ByteString subtype), and
+the per-ticket results are `ArrayOf`.
+
+On the server, load the generated model and bind the standard
+administration object to an injected ticket store:
+
+```csharp
+NodeStateCollection nodes = new NodeStateCollection()
+ .AddOpcUaGds(context)
+ .AddOpcUaOnboarding(context);
+DeviceRegistrarState registrar =
+ nodes.OfType().Single();
+
+registrar.Administration!.BindToTicketStore(ticketStore);
+```
+
+The handlers are asynchronous, pass request cancellation to
+`ITicketStore`, and populate the single declared `Results` output.
+See the runnable
+[`samples/Gds` onboarding demo](../samples/Gds/README.md) for a secure
+registrar/client round trip.
+
---
## 2. Server-Side: Building a GDS
@@ -638,7 +681,7 @@ The following optional Part 12 members are **not** implemented (or only partiall
| `ManagedApplications` / `ApplicationConfigurationType` proxy (§7.10.14–§7.10.16) | ⚠️ partial | `DefaultManagedApplicationsNodeManager` creates the nodes with an `IConfigurationDataStore`; the full GDS-as-proxy update/confirm flow to *remote* managed applications is not complete. |
| KeyCredential authentication `ProfileUris` — AMQP SASL Plain / MQTT UserName / UA transport UserName (§8, optional CUs) | 🔲 provider-defined | The push binding is implemented; the concrete ProfileUri set advertised is defined by the credential provider, not fixed by the stack. |
| A & C CertificateExpiration optional CUs — *Comment* / *Confirm* / *Shelving* | 🔲 inherited | Available through the standard OPC 10000-9 condition model on the alarm instances; not separately exercised by the certificate-alarm tests. |
-| Onboarding / OPC 10000-21 device setup interplay with `ResetToServerDefaults` / `InApplicationSetup` (§7.10.13, Annex G) | 🔲 provider-gated | The stack exposes `InApplicationSetup` and defers the reset to `IServerConfigurationResetProvider`; it does not itself implement an OPC 10000-21 onboarding state machine. |
+| Onboarding / OPC 10000-21 device setup interplay with `ResetToServerDefaults` / `InApplicationSetup` (§7.10.13, Annex G) | 🔲 provider-gated | Registrar ticket administration is implemented through `OnboardingClient`, `ITicketStore`, and `BindToTicketStore`; the stack does not itself implement the complete device-facing onboarding state machine. |
## LDS / LDS-ME (§4–5)
diff --git a/docs/README.md b/docs/README.md
index 8355b7979b..2384f64a63 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -84,7 +84,7 @@ Starting with version 1.5.375.XX the Windows Forms reference client & reference
## Global Discovery Server (GDS)
-* [GDS Developer Guide](GDS.md) — Application registration, certificate management (pull & push models), roles and authorization, provider implementation, end-to-end examples, and a per-requirement [OPC UA Part 12 conformance matrix](GDS.md#conformance-matrix) that links every implemented ServerConfiguration/PushManagement, TrustList, certificate-alarm, KeyCredentialService and AuthorizationService requirement to its source and automated tests (with applicable profiles/conformance units and honest complete/partial/optional/unsupported marks).
+* [GDS Developer Guide](GDS.md) — Application registration, certificate management (pull & push models), roles and authorization, OPC 10000-21 registrar ticket administration, provider implementation, the runnable [`samples/Gds`](../samples/Gds/README.md) onboarding demo, and a per-requirement [OPC UA Part 12 conformance matrix](GDS.md#conformance-matrix).
* [KeyCredentialService](KeyCredentialService.md) — Credential issuance for non-OPC UA services (MQTT, REST), IKeyCredentialRequestStore provider guide, ISecretStore integration.
* [AuthorizationService](AuthorizationService.md) — OAuth2-style access token issuance, IAccessTokenProvider implementation guide.
* [Role-Based Security](RoleBasedUserManagement.md) — Part 18 roles and claim-based identity-mapping rules.
diff --git a/docs/WhatsNewIn2.0.md b/docs/WhatsNewIn2.0.md
index 2fcf34bb43..49aa4f0b96 100644
--- a/docs/WhatsNewIn2.0.md
+++ b/docs/WhatsNewIn2.0.md
@@ -31,7 +31,8 @@ If you are migrating an existing application, the companion
`IEncodeable` data types from model design XML**, removing hand-written
boilerplate while staying AOT-clean.
- **GDS is now Part 12 full-compliance**, with arbitrary certificate groups,
- custom group support, and modernised Push/Pull APIs.
+ custom group support, modernised Push/Pull APIs, and OPC 10000-21
+ registrar ticket administration backed by the generated Onboarding model.
- **OPC UA Part 4 §6.6 redundancy plus opt-in distributed high availability.**
Server and client redundancy (`Server.ServerRedundancy` / `ServiceLevel`,
`ManagedSession.WithServerRedundancy()`) ship in the box, and new
diff --git a/samples/Gds/OnboardingClient/OnboardingClient.csproj b/samples/Gds/OnboardingClient/OnboardingClient.csproj
new file mode 100644
index 0000000000..a79865a587
--- /dev/null
+++ b/samples/Gds/OnboardingClient/OnboardingClient.csproj
@@ -0,0 +1,23 @@
+
+
+ net10.0
+ Exe
+ false
+ OnboardingClient
+ OnboardingClientDemo
+ OPC 10000-21 onboarding client sample.
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Gds/OnboardingClient/Program.cs b/samples/Gds/OnboardingClient/Program.cs
new file mode 100644
index 0000000000..1677e39675
--- /dev/null
+++ b/samples/Gds/OnboardingClient/Program.cs
@@ -0,0 +1,225 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Client;
+using Opc.Ua.Gds.Client;
+using Opc.Ua.Identity;
+
+try
+{
+ HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
+ builder.Logging.ClearProviders();
+ builder.Logging.AddConsole();
+ builder.Logging.SetMinimumLevel(LogLevel.Error);
+
+ string endpoint = builder.Configuration["endpoint"] ??
+ "opc.tcp://localhost:62560/OnboardingRegistrar";
+ string pkiRoot = builder.Configuration["pkiRoot"] ??
+ Path.Combine(
+ Path.GetTempPath(),
+ "opcua-onboarding-demo",
+ "client-pki");
+ bool useAnonymous = bool.TryParse(
+ builder.Configuration["anonymous"],
+ out bool anonymous) && anonymous;
+ IClientIdentityProvider? identityProvider = null;
+ if (!useAnonymous)
+ {
+ string userName = GetRequiredEnvironmentVariable("ONBOARDING_DEMO_USER");
+ string password = GetRequiredEnvironmentVariable("ONBOARDING_DEMO_PASSWORD");
+ var passwordStore = new InMemorySecretStore();
+ var passwordId = new SecretIdentifier(
+ "onboarding-demo-password",
+ passwordStore.StoreType);
+ byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
+ try
+ {
+ await passwordStore.SetAsync(
+ passwordId,
+ passwordBytes,
+ CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(passwordBytes);
+ }
+ identityProvider = new UserNamePasswordIdentityProvider(
+ userName,
+ new SecretRegistry(passwordStore),
+ passwordId);
+ }
+
+ IOpcUaClientBuilder clientBuilder = builder.Services
+ .AddOpcUa()
+ .AddClient(options =>
+ {
+ options.ApplicationName = "OnboardingClient";
+ options.ApplicationUri =
+ "urn:localhost:OPCFoundation:OnboardingClient";
+ options.ProductUri =
+ "uri:opcfoundation.org:UA-.NETStandard:OnboardingClient";
+ options.PkiRoot = pkiRoot;
+ options.AutoAcceptUntrustedCertificates = true;
+ options.RejectSHA1SignedCertificates = true;
+ options.MinimumCertificateKeySize = 2048;
+ options.Session = new ManagedSessionOptions
+ {
+ SessionName = "OnboardingClient",
+ SessionTimeout = TimeSpan.FromSeconds(60)
+ };
+ })
+ .AddDiscoveryAndConnect(options =>
+ {
+ options.DiscoveryUrl = endpoint;
+ options.SecurityMode = MessageSecurityMode.SignAndEncrypt;
+ options.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
+ });
+ if (identityProvider != null)
+ {
+ clientBuilder.AddIdentityProvider(identity => identity.Add(identityProvider));
+ }
+ clientBuilder
+ .AddGdsClient()
+ .AddOnboardingClient();
+
+ using IHost host = builder.Build();
+ await host.StartAsync(CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ Func> connect =
+ host.Services.GetRequiredService<
+ Func>>();
+ ManagedSession session = await connect(CancellationToken.None)
+ .ConfigureAwait(false);
+ await using (session.ConfigureAwait(false))
+ {
+ Console.WriteLine("Server namespaces:");
+ for (int i = 0; i < session.NamespaceUris.Count; i++)
+ {
+ Console.WriteLine($" ns={i}: {session.NamespaceUris.GetString((uint)i)}");
+ }
+ NodeId registrarId = ExpandedNodeId.ToNodeId(
+ Opc.Ua.Onboarding.ObjectIds.DeviceRegistrar_Administration,
+ session.NamespaceUris);
+ if (registrarId.IsNull)
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadNodeIdUnknown,
+ "The server did not publish the OPC 10000-21 namespace.");
+ }
+
+ Func>
+ createOnboardingClient = host.Services.GetRequiredService<
+ Func>>();
+ OnboardingClient onboarding = await createOnboardingClient(
+ registrarId,
+ CancellationToken.None)
+ .ConfigureAwait(false);
+ ArrayOf tickets =
+ [
+ new ByteString(Encoding.UTF8.GetBytes("demo-ticket-one")),
+ new ByteString(Encoding.UTF8.GetBytes("demo-ticket-two"))
+ ];
+
+ ArrayOf registered = await onboarding
+ .RegisterTicketsAsync(tickets)
+ .ConfigureAwait(false);
+ RequireStatus(registered, 0, StatusCodes.Good, "register ticket one");
+ RequireStatus(registered, 1, StatusCodes.Good, "register ticket two");
+ Console.WriteLine(
+ $"REGISTER {registered[0]} {registered[1]}");
+
+ ArrayOf removed = await onboarding
+ .UnregisterTicketsAsync([tickets[0]])
+ .ConfigureAwait(false);
+ RequireStatus(removed, 0, StatusCodes.Good, "unregister ticket one");
+ Console.WriteLine($"UNREGISTER {removed[0]}");
+
+ ArrayOf removedAgain = await onboarding
+ .UnregisterTicketsAsync([tickets[0]])
+ .ConfigureAwait(false);
+ RequireStatus(
+ removedAgain,
+ 0,
+ StatusCodes.BadNotFound,
+ "unregister ticket one again");
+ Console.WriteLine($"UNREGISTER_AGAIN {removedAgain[0]}");
+ Console.WriteLine("ONBOARDING_DEMO_OK");
+ }
+ }
+ finally
+ {
+ await host.StopAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+ return 0;
+}
+catch (Exception ex)
+{
+ Console.Error.WriteLine(ex);
+ return 1;
+}
+
+static string GetRequiredEnvironmentVariable(string name)
+{
+ string? value = Environment.GetEnvironmentVariable(name);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new InvalidOperationException(
+ $"Set the {name} environment variable before starting the demo.");
+ }
+ return value;
+}
+
+static void RequireStatus(
+ ArrayOf statuses,
+ int index,
+ StatusCode expected,
+ string operation)
+{
+ if (statuses.Count <= index || statuses[index] != expected)
+ {
+ StatusCode actual = statuses.Count > index
+ ? statuses[index]
+ : StatusCodes.BadNoData;
+ throw new ServiceResultException(
+ StatusCodes.BadUnexpectedError,
+ $"{operation} returned {actual}; expected {expected}.");
+ }
+}
diff --git a/samples/Gds/OnboardingClient/Properties/AssemblyInfo.cs b/samples/Gds/OnboardingClient/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..7798c9bd57
--- /dev/null
+++ b/samples/Gds/OnboardingClient/Properties/AssemblyInfo.cs
@@ -0,0 +1,32 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+
+[assembly: CLSCompliant(false)]
diff --git a/samples/Gds/OnboardingRegistrar/OnboardingRegistrar.csproj b/samples/Gds/OnboardingRegistrar/OnboardingRegistrar.csproj
new file mode 100644
index 0000000000..784a10c9bb
--- /dev/null
+++ b/samples/Gds/OnboardingRegistrar/OnboardingRegistrar.csproj
@@ -0,0 +1,23 @@
+
+
+ net10.0
+ Exe
+ false
+ OnboardingRegistrar
+ OnboardingRegistrar
+ OPC 10000-21 onboarding registrar sample.
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Gds/OnboardingRegistrar/OnboardingRegistrarNodeManager.cs b/samples/Gds/OnboardingRegistrar/OnboardingRegistrarNodeManager.cs
new file mode 100644
index 0000000000..248ec6327a
--- /dev/null
+++ b/samples/Gds/OnboardingRegistrar/OnboardingRegistrarNodeManager.cs
@@ -0,0 +1,238 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Gds;
+using Opc.Ua.Gds.Server.Onboarding;
+using Opc.Ua.Identity;
+using Opc.Ua.Onboarding;
+using Opc.Ua.Server;
+using Opc.Ua.Server.Hosting;
+
+namespace OnboardingRegistrar
+{
+ ///
+ /// Creates the Part 21 onboarding registrar node manager.
+ ///
+ public sealed class OnboardingRegistrarNodeManagerFactory : IAsyncNodeManagerFactory
+ {
+ ///
+ /// Initializes a new factory.
+ ///
+ public OnboardingRegistrarNodeManagerFactory(ITicketStore ticketStore)
+ {
+ m_ticketStore = ticketStore ??
+ throw new ArgumentNullException(nameof(ticketStore));
+ }
+
+ ///
+ public ArrayOf NamespacesUris =>
+ [
+ Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding,
+ Opc.Ua.Gds.Namespaces.OpcUaGds
+ ];
+
+ ///
+ public ValueTask CreateAsync(
+ IServerInternal server,
+ ApplicationConfiguration configuration,
+ CancellationToken cancellationToken = default)
+ {
+#pragma warning disable CA2000 // Ownership is transferred to the server.
+ return new ValueTask(
+ new OnboardingRegistrarNodeManager(
+ server,
+ configuration,
+ m_ticketStore));
+#pragma warning restore CA2000
+ }
+
+ private readonly ITicketStore m_ticketStore;
+ }
+
+ ///
+ /// Loads the generated OPC 10000-21 model and binds its standard
+ /// registrar administration instance to an injected ticket store.
+ ///
+ public sealed class OnboardingRegistrarNodeManager : AsyncCustomNodeManager
+ {
+ ///
+ /// Initializes the node manager.
+ ///
+ public OnboardingRegistrarNodeManager(
+ IServerInternal server,
+ ApplicationConfiguration configuration,
+ ITicketStore ticketStore)
+ : base(
+ server,
+ configuration,
+ server.Telemetry.CreateLogger())
+ {
+ m_ticketStore = ticketStore ??
+ throw new ArgumentNullException(nameof(ticketStore));
+ NamespaceUris =
+ [
+ Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding,
+ Opc.Ua.Gds.Namespaces.OpcUaGds
+ ];
+
+ Server.Factory.Builder
+ .AddOpcUaGds()
+ .AddOpcUaOnboarding()
+ .Commit();
+ Server.MessageContext.Factory.Builder
+ .AddOpcUaGds()
+ .AddOpcUaOnboarding()
+ .Commit();
+ }
+
+ ///
+ protected override ValueTask LoadPredefinedNodesAsync(
+ ISystemContext context,
+ CancellationToken cancellationToken = default)
+ {
+ NodeStateCollection nodes = new NodeStateCollection()
+ .AddOpcUaGds(context)
+ .AddOpcUaOnboarding(context);
+ DeviceRegistrarState registrar =
+ nodes.OfType().Single();
+ DeviceRegistrarAdminState administration = registrar.Administration ??
+ throw new InvalidOperationException(
+ "The generated DeviceRegistrar has no Administration child.");
+ ConfigurePermissions(context, administration);
+ administration.BindToTicketStore(m_ticketStore);
+ return new ValueTask(nodes);
+ }
+
+ private static void ConfigurePermissions(
+ ISystemContext context,
+ DeviceRegistrarAdminState administration)
+ {
+ NodeId roleId = ExpandedNodeId.ToNodeId(
+ Opc.Ua.Onboarding.ObjectIds.WellKnownRole_RegistrarAdmin,
+ context.NamespaceUris);
+ administration.RolePermissions =
+ [
+ new RolePermissionType
+ {
+ RoleId = roleId,
+ Permissions = (uint)PermissionType.Browse
+ }
+ ];
+ ArrayOf methodPermissions =
+ [
+ new RolePermissionType
+ {
+ RoleId = roleId,
+ Permissions = (uint)(PermissionType.Browse | PermissionType.Call)
+ }
+ ];
+ administration.RegisterTickets!.RolePermissions = methodPermissions;
+ administration.UnregisterTickets!.RolePermissions = methodPermissions;
+ }
+
+ private readonly ITicketStore m_ticketStore;
+ }
+
+ ///
+ /// Emits the demo readiness marker after the OPC UA server reaches its
+ /// running state.
+ ///
+ public sealed class OnboardingReadyStartupTask : IServerStartupTask
+ {
+ ///
+ /// Initializes the startup task.
+ ///
+ public OnboardingReadyStartupTask(string endpoint)
+ {
+ m_endpoint = endpoint ??
+ throw new ArgumentNullException(nameof(endpoint));
+ }
+
+ ///
+ public ValueTask OnServerStartedAsync(
+ IServerContext server,
+ CancellationToken cancellationToken = default)
+ {
+ Console.WriteLine($"ONBOARDING_REGISTRAR_READY {m_endpoint}");
+ return default;
+ }
+
+ private readonly string m_endpoint;
+ }
+
+ ///
+ /// Grants the generated RegistrarAdmin role to the one ephemeral demo
+ /// account after its username/password token has been authenticated.
+ ///
+ public sealed class OnboardingRegistrarAdminAugmenter : IIdentityAugmenter
+ {
+ ///
+ /// Initializes the augmenter.
+ ///
+ public OnboardingRegistrarAdminAugmenter(string userName)
+ {
+ m_userName = userName ??
+ throw new ArgumentNullException(nameof(userName));
+ }
+
+ ///
+ public ValueTask AugmentAsync(
+ IUserIdentity identity,
+ AuthenticationContext context,
+ CancellationToken ct = default)
+ {
+ if (context.TokenHandler is not UserNameIdentityTokenHandler userNameToken ||
+ !string.Equals(
+ userNameToken.UserName,
+ m_userName,
+ StringComparison.Ordinal))
+ {
+ return new ValueTask(
+ AuthenticationResult.NotHandled);
+ }
+ var registrarAdmin = new Role(
+ Opc.Ua.Onboarding.ObjectIds.WellKnownRole_RegistrarAdmin,
+ Opc.Ua.Onboarding.BrowseNames.WellKnownRole_RegistrarAdmin);
+ var augmented = new RoleBasedIdentity(
+ identity,
+ [registrarAdmin],
+ context.MessageContext.NamespaceUris);
+ return new ValueTask(
+ AuthenticationResult.Accept(augmented));
+ }
+
+ private readonly string m_userName;
+ }
+}
diff --git a/samples/Gds/OnboardingRegistrar/Program.cs b/samples/Gds/OnboardingRegistrar/Program.cs
new file mode 100644
index 0000000000..739efe6b31
--- /dev/null
+++ b/samples/Gds/OnboardingRegistrar/Program.cs
@@ -0,0 +1,143 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Gds.Server.Onboarding;
+using Opc.Ua.Server;
+using Opc.Ua.Server.Hosting;
+using Opc.Ua.Server.UserDatabase;
+using Opc.Ua.Server.UserManagement;
+using OnboardingRegistrar;
+
+try
+{
+ HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
+ builder.Logging.ClearProviders();
+ builder.Logging.AddConsole();
+
+ int port = int.TryParse(builder.Configuration["port"], out int configuredPort)
+ ? configuredPort
+ : 62560;
+ string pkiRoot = builder.Configuration["pkiRoot"] ??
+ Path.Combine(
+ Path.GetTempPath(),
+ "opcua-onboarding-demo",
+ "registrar-pki");
+ string endpoint = $"opc.tcp://localhost:{port}/OnboardingRegistrar";
+ string userName = GetRequiredEnvironmentVariable("ONBOARDING_DEMO_USER");
+ string password = GetRequiredEnvironmentVariable("ONBOARDING_DEMO_PASSWORD");
+ var userDatabase = new LinqUserDatabase();
+ byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
+ try
+ {
+ if (!userDatabase.CreateUser(
+ userName,
+ passwordBytes,
+ [Role.AuthenticatedUser]))
+ {
+ throw new InvalidOperationException(
+ "Could not create the onboarding demo administrator.");
+ }
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(passwordBytes);
+ }
+ builder.Services.AddSingleton(_ => new MemoryTicketStore());
+ builder.Services.AddSingleton(userDatabase);
+ builder.Services.AddSingleton(services =>
+ new UserManagement(services.GetRequiredService()));
+ builder.Services.AddSingleton(
+ new OnboardingReadyStartupTask(endpoint));
+ builder.Services
+ .AddOpcUa()
+ .AddServer(options =>
+ {
+ options.ApplicationName = "OnboardingRegistrar";
+ options.ApplicationUri =
+ "urn:localhost:OPCFoundation:OnboardingRegistrar";
+ options.ProductUri =
+ "uri:opcfoundation.org:UA-.NETStandard:OnboardingRegistrar";
+ options.PkiRoot = pkiRoot;
+ options.AutoAcceptUntrustedCertificates = true;
+ options.RejectSHA1Certificates = true;
+ options.MinCertificateKeySize = 2048;
+ options.IncludeSignAndEncryptPolicies = true;
+ options.IncludeUnsecurePolicyNone = false;
+ options.IncludeEccPolicies = false;
+ options.UserTokenPolicies.Add(new OpcUaUserTokenPolicy
+ {
+ TokenType = UserTokenType.Anonymous
+ });
+ options.UserTokenPolicies.Add(new OpcUaUserTokenPolicy
+ {
+ TokenType = UserTokenType.UserName
+ });
+ options.EndpointUrls.Add(endpoint);
+ })
+ .AddDefaultIdentityAuthenticators(options =>
+ {
+ options.EnableAnonymous = true;
+ options.EnableUserNamePassword = true;
+ options.EnableX509 = false;
+ options.EnableJwt = false;
+ })
+ .AddIdentityAugmenter(_ =>
+ new OnboardingRegistrarAdminAugmenter(userName))
+ .AddNodeManager();
+
+ using IHost host = builder.Build();
+ await host.StartAsync(CancellationToken.None).ConfigureAwait(false);
+ await host.WaitForShutdownAsync(CancellationToken.None).ConfigureAwait(false);
+ return 0;
+}
+catch (Exception ex)
+{
+ Console.Error.WriteLine(ex);
+ return 1;
+}
+
+static string GetRequiredEnvironmentVariable(string name)
+{
+ string? value = Environment.GetEnvironmentVariable(name);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new InvalidOperationException(
+ $"Set the {name} environment variable before starting the demo.");
+ }
+ return value;
+}
diff --git a/samples/Gds/OnboardingRegistrar/Properties/AssemblyInfo.cs b/samples/Gds/OnboardingRegistrar/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..7798c9bd57
--- /dev/null
+++ b/samples/Gds/OnboardingRegistrar/Properties/AssemblyInfo.cs
@@ -0,0 +1,32 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+
+[assembly: CLSCompliant(false)]
diff --git a/samples/Gds/README.md b/samples/Gds/README.md
new file mode 100644
index 0000000000..a39aa7c279
--- /dev/null
+++ b/samples/Gds/README.md
@@ -0,0 +1,104 @@
+# OPC 10000-21 Onboarding Demo
+
+This sample demonstrates the registrar-administration part of OPC
+10000-21 end to end over a real OPC UA connection. It uses only the
+model and runtime APIs shipped by this repository:
+
+- `OnboardingRegistrar` loads the generated `Opc.Ua.Onboarding` model,
+ exposes the standard `DeviceRegistrar_Administration` object, and binds
+ `RegisterTickets` / `UnregisterTickets` to an injected
+ `MemoryTicketStore`.
+- `OnboardingClient` connects with a managed session, resolves the
+ well-known registrar NodeId, and uses the DI-provided
+ `Opc.Ua.Gds.Client.OnboardingClient`.
+- `run-onboarding-demo.ps1` builds both applications, waits until the
+ OPC UA server reaches its running state, proves an anonymous caller is
+ denied, runs the authenticated client, and stops the exact registrar
+ process in a `finally` block.
+
+The demo covers ticket administration. It does not implement the
+device-facing `ProvideIdentities` flow or a complete production GDS.
+
+## Run the complete demo
+
+From the repository root:
+
+```powershell
+pwsh samples/Gds/run-onboarding-demo.ps1
+```
+
+The script uses
+`opc.tcp://localhost:62560/OnboardingRegistrar` by default. Select a
+different port with:
+
+```powershell
+pwsh samples/Gds/run-onboarding-demo.ps1 -Port 62660
+```
+
+Successful output ends with:
+
+```text
+REGISTER Good Good
+UNREGISTER Good
+UNREGISTER_AGAIN BadNotFound
+ONBOARDING_DEMO_OK
+```
+
+Pass `-Keep` to retain the temporary PKI stores and registrar logs for
+diagnostics.
+
+## Run the applications manually
+
+Start the registrar:
+
+```powershell
+dotnet run --project samples/Gds/OnboardingRegistrar -f net10.0 -- `
+ --port 62560 `
+ --pkiRoot "$env:TEMP\opcua-onboarding-registrar"
+```
+
+After `ONBOARDING_REGISTRAR_READY` is printed, run the client:
+
+```powershell
+dotnet run --project samples/Gds/OnboardingClient -f net10.0 -- `
+ --endpoint "opc.tcp://localhost:62560/OnboardingRegistrar" `
+ --pkiRoot "$env:TEMP\opcua-onboarding-client"
+```
+
+## Wire contract
+
+The client API uses the Part 21 types directly:
+
+```csharp
+ArrayOf results = await onboarding.RegisterTicketsAsync(
+[
+ new ByteString(firstTicket),
+ new ByteString(secondTicket)
+]);
+```
+
+`EncodedTicket` is a subtype of `ByteString`. The server returns one
+`StatusCode` for every supplied ticket.
+
+## Security
+
+The demo selects `SignAndEncrypt` with `Basic256Sha256`. For a
+self-contained local run, both applications automatically accept the
+other application's newly created certificate and store it under an
+isolated temporary PKI root. The script also generates a one-run random
+username and password, grants that user the generated `RegistrarAdmin`
+role, and restricts the two ticket methods to that role. The endpoint
+also permits an anonymous bootstrap session because `ManagedSession`
+activates its configured identity provider after creating the session;
+anonymous users have no permission to call the registrar methods.
+
+Automatic certificate acceptance is a sample-only convenience. A
+production registrar must use managed trust lists, authenticate and
+authorize registrar administrators, persist tickets in a protected
+store, and apply the audit/redaction requirements described in the
+[GDS developer guide](../../docs/GDS.md).
+
+For a manual run, set `ONBOARDING_DEMO_USER` and
+`ONBOARDING_DEMO_PASSWORD` to the same non-empty values in both
+terminals before starting either application. Do not reuse demo
+credentials in a production deployment.
diff --git a/samples/Gds/run-onboarding-demo.ps1 b/samples/Gds/run-onboarding-demo.ps1
new file mode 100644
index 0000000000..b4150c0514
--- /dev/null
+++ b/samples/Gds/run-onboarding-demo.ps1
@@ -0,0 +1,149 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Builds and runs the OPC 10000-21 onboarding registrar/client demo.
+
+.PARAMETER Port
+ Local OPC UA TCP port used by the registrar.
+
+.PARAMETER Keep
+ Keeps the isolated PKI and captured server logs after the run.
+
+.EXAMPLE
+ pwsh samples/Gds/run-onboarding-demo.ps1
+#>
+[CmdletBinding()]
+param(
+ [int]$Port = 62560,
+ [switch]$Keep
+)
+
+$ErrorActionPreference = 'Stop'
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$serverProject = Join-Path $here 'OnboardingRegistrar\OnboardingRegistrar.csproj'
+$clientProject = Join-Path $here 'OnboardingClient\OnboardingClient.csproj'
+$runRoot = Join-Path ([IO.Path]::GetTempPath()) "opcua-onboarding-demo-$PID"
+$serverPki = Join-Path $runRoot 'registrar-pki'
+$clientPki = Join-Path $runRoot 'client-pki'
+$serverOut = Join-Path $runRoot 'registrar.out.log'
+$serverErr = Join-Path $runRoot 'registrar.err.log'
+$endpoint = "opc.tcp://localhost:$Port/OnboardingRegistrar"
+$serverDll = Join-Path $here 'OnboardingRegistrar\bin\Debug\net10.0\OnboardingRegistrar.dll'
+$clientDll = Join-Path $here 'OnboardingClient\bin\Debug\net10.0\OnboardingClient.dll'
+$server = $null
+$previousUser = $env:ONBOARDING_DEMO_USER
+$previousPassword = $env:ONBOARDING_DEMO_PASSWORD
+
+function Step([string]$message) {
+ Write-Host ''
+ Write-Host "==> $message" -ForegroundColor Cyan
+}
+
+try {
+ New-Item -ItemType Directory -Force -Path $runRoot | Out-Null
+ $env:ONBOARDING_DEMO_USER = "onboarding-admin-$PID"
+ $env:ONBOARDING_DEMO_PASSWORD = [Convert]::ToBase64String(
+ [Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
+
+ Step 'Building the onboarding registrar and client'
+ dotnet build $serverProject -f net10.0 --verbosity quiet
+ if ($LASTEXITCODE -ne 0) {
+ throw 'The onboarding registrar build failed.'
+ }
+ dotnet build $clientProject -f net10.0 --verbosity quiet
+ if ($LASTEXITCODE -ne 0) {
+ throw 'The onboarding client build failed.'
+ }
+
+ Step "Starting the registrar at $endpoint"
+ $server = Start-Process dotnet `
+ -ArgumentList @(
+ "`"$serverDll`"",
+ '--port', $Port,
+ '--pkiRoot', $serverPki
+ ) `
+ -RedirectStandardOutput $serverOut `
+ -RedirectStandardError $serverErr `
+ -PassThru
+
+ $deadline = [DateTime]::UtcNow.AddSeconds(60)
+ $ready = $false
+ while ([DateTime]::UtcNow -lt $deadline) {
+ if ($server.HasExited) {
+ break
+ }
+ if ((Test-Path $serverOut) -and
+ (Get-Content -Raw $serverOut) -match 'ONBOARDING_REGISTRAR_READY') {
+ $ready = $true
+ break
+ }
+ Start-Sleep -Milliseconds 250
+ }
+ if (-not $ready) {
+ throw 'The onboarding registrar did not become ready within 60 seconds.'
+ }
+
+ Step 'Verifying anonymous ticket administration is denied'
+ $anonymousOutput = & dotnet $clientDll `
+ --endpoint $endpoint `
+ --pkiRoot $clientPki `
+ --anonymous true 2>&1
+ $anonymousExitCode = $LASTEXITCODE
+ Write-Host ($anonymousOutput | Out-String)
+ if ($anonymousExitCode -eq 0 -or
+ ($anonymousOutput | Out-String) -notmatch 'BadUserAccessDenied') {
+ throw 'Anonymous ticket administration was not rejected.'
+ }
+
+ Step 'Running the onboarding client'
+ & dotnet $clientDll `
+ --endpoint $endpoint `
+ --pkiRoot $clientPki 2>&1 |
+ Tee-Object -Variable capturedClientOutput
+ if ($LASTEXITCODE -ne 0) {
+ throw 'The onboarding client failed.'
+ }
+ if (($capturedClientOutput | Out-String) -notmatch 'ONBOARDING_DEMO_OK') {
+ throw 'The onboarding client did not report a successful round trip.'
+ }
+
+ Write-Host ''
+ Write-Host 'Onboarding registrar/client round trip succeeded.' -ForegroundColor Green
+}
+catch {
+ if (Test-Path $serverOut) {
+ Write-Host ''
+ Write-Host 'Registrar output:' -ForegroundColor Yellow
+ Get-Content $serverOut
+ }
+ if (Test-Path $serverErr) {
+ Write-Host ''
+ Write-Host 'Registrar errors:' -ForegroundColor Yellow
+ Get-Content $serverErr
+ }
+ throw
+}
+finally {
+ if ($server -ne $null -and -not $server.HasExited) {
+ Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue
+ $server.WaitForExit()
+ }
+ if ($previousUser -eq $null) {
+ Remove-Item Env:\ONBOARDING_DEMO_USER -ErrorAction SilentlyContinue
+ }
+ else {
+ $env:ONBOARDING_DEMO_USER = $previousUser
+ }
+ if ($previousPassword -eq $null) {
+ Remove-Item Env:\ONBOARDING_DEMO_PASSWORD -ErrorAction SilentlyContinue
+ }
+ else {
+ $env:ONBOARDING_DEMO_PASSWORD = $previousPassword
+ }
+ if ($Keep) {
+ Write-Host "Demo artifacts kept at $runRoot" -ForegroundColor Yellow
+ }
+ elseif (Test-Path $runRoot) {
+ Remove-Item -LiteralPath $runRoot -Recurse -Force
+ }
+}
diff --git a/samples/README.md b/samples/README.md
index cf63f26d3a..478f6cd09a 100644
--- a/samples/README.md
+++ b/samples/README.md
@@ -1,5 +1,12 @@
# OPC Foundation UA .NET Standard Reference Server
+## Additional samples
+
+- [OPC 10000-21 onboarding registrar/client](Gds/README.md) — loads the
+ generated Onboarding companion model, binds the standard registrar
+ administration methods to an injected ticket store, and drives a secure
+ register/unregister round trip with a PowerShell script.
+
## Introduction
This document is referring to the CTT version 1.04.9.398.
diff --git a/src/Opc.Ua.Core/Schema/Opc.Ua.NodeSet.xml b/src/Opc.Ua.Core/Schema/Opc.Ua.NodeSet.xml
index 24e98a20c9..bdfc3667c1 100644
--- a/src/Opc.Ua.Core/Schema/Opc.Ua.NodeSet.xml
+++ b/src/Opc.Ua.Core/Schema/Opc.Ua.NodeSet.xml
@@ -56432,7 +56432,7 @@
Onboarding Ticket Reader
https://reference.opcfoundation.org/v105/Core/docs/Part21/8.2.1
- i=12
+ i=15
diff --git a/src/Opc.Ua.Gds.Client.Common/NugetREADME.md b/src/Opc.Ua.Gds.Client.Common/NugetREADME.md
index f96c4d17cc..9200765a78 100644
--- a/src/Opc.Ua.Gds.Client.Common/NugetREADME.md
+++ b/src/Opc.Ua.Gds.Client.Common/NugetREADME.md
@@ -14,6 +14,11 @@ Reference this package from any application or tool that needs to
talk to a GDS — typically a provisioning utility, a certificate
authority bridge, or a server that uses GDS-driven push management.
+For OPC 10000-21 registrar administration, `OnboardingClient` calls the
+standard `DeviceRegistrarAdminType` instance with specification-native
+`ArrayOf` ticket inputs and returns `ArrayOf`.
+Register it through `.AddGdsClient().AddOnboardingClient()`.
+
## Target frameworks
`net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`,
diff --git a/src/Opc.Ua.Gds.Client.Common/OnboardingClient.cs b/src/Opc.Ua.Gds.Client.Common/OnboardingClient.cs
index 2d4651fa3c..a4cc648dbe 100644
--- a/src/Opc.Ua.Gds.Client.Common/OnboardingClient.cs
+++ b/src/Opc.Ua.Gds.Client.Common/OnboardingClient.cs
@@ -1,5 +1,5 @@
/* ========================================================================
- * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
@@ -28,20 +28,21 @@
* ======================================================================*/
using System;
-using System.Threading;
-using System.Threading.Tasks;
using Opc.Ua.Client;
+using Opc.Ua.Onboarding;
namespace Opc.Ua.Gds.Client
{
///
- /// Client-side wrapper for the OPC 10000-100 Part 21
- /// DeviceRegistrarAdminType facet. Calls
- /// RegisterTickets / UnregisterTickets on the
- /// server-side registrar and returns the per-ticket status
- /// codes.
+ /// Client-side wrapper for the OPC 10000-21
+ /// DeviceRegistrarAdminType facet.
///
- public sealed class OnboardingClient
+ ///
+ /// The specification-native RegisterTickets and
+ /// UnregisterTickets methods are inherited from
+ /// .
+ ///
+ public sealed class OnboardingClient : DeviceRegistrarAdminTypeClient
{
///
/// Creates a new onboarding client rooted at the supplied
@@ -51,166 +52,73 @@ namespace Opc.Ua.Gds.Client
ISession session,
NodeId registrarNodeId,
ITelemetryContext telemetry)
+ : base(
+ PrepareSession(session),
+ ValidateRegistrarNodeId(registrarNodeId),
+ telemetry ?? throw new ArgumentNullException(nameof(telemetry)))
{
- if (session is null)
- {
- throw new ArgumentNullException(nameof(session));
- }
- if (registrarNodeId.IsNull)
- {
- throw new ArgumentException(
- "Registrar NodeId is required.", nameof(registrarNodeId));
- }
- if (telemetry is null)
- {
- throw new ArgumentNullException(nameof(telemetry));
- }
- Session = session;
- RegistrarNodeId = registrarNodeId;
- Telemetry = telemetry;
}
///
/// The owning session.
///
- public ISession Session { get; }
+ public new ISession Session => (ISession)base.Session;
///
- /// The NodeId of the registrar instance.
+ /// The NodeId of the registrar administration instance.
///
- public NodeId RegistrarNodeId { get; }
+ public NodeId RegistrarNodeId => ObjectId;
- ///
- /// Telemetry context.
- ///
- public ITelemetryContext Telemetry { get; }
-
- ///
- /// Invokes RegisterTickets. Returns the per-ticket
- /// status array reported by the server.
- ///
- /// is null.
- public ValueTask RegisterTicketsAsync(
- byte[][] tickets, CancellationToken ct = default)
+ private static ISession PrepareSession(ISession session)
{
- if (tickets == null)
+ if (session is null)
{
- throw new ArgumentNullException(nameof(tickets));
+ throw new ArgumentNullException(nameof(session));
}
- return CallTicketArrayMethodAsync("RegisterTickets", tickets, ct);
+ NamespaceTable messageContextNamespaces =
+ session.MessageContext.NamespaceUris;
+ NamespaceTable sessionNamespaces = session.NamespaceUris;
+ if (!NamespaceTablesMatch(messageContextNamespaces, sessionNamespaces))
+ {
+ messageContextNamespaces.Update(sessionNamespaces.ToArray());
+ }
+ return session;
}
- ///
- /// Invokes UnregisterTickets. Returns the per-ticket
- /// status array.
- ///
- /// is null.
- public ValueTask UnregisterTicketsAsync(
- byte[][] tickets, CancellationToken ct = default)
+ private static bool NamespaceTablesMatch(
+ NamespaceTable first,
+ NamespaceTable second)
{
- if (tickets == null)
+ if (ReferenceEquals(first, second))
{
- throw new ArgumentNullException(nameof(tickets));
+ return true;
}
- return CallTicketArrayMethodAsync("UnregisterTickets", tickets, ct);
- }
-
- private async ValueTask CallTicketArrayMethodAsync(
- string methodBrowseName,
- byte[][] tickets,
- CancellationToken ct)
- {
- NodeId methodId = await ResolveMethodAsync(methodBrowseName, ct)
- .ConfigureAwait(false);
-
- var bs = new ByteString[tickets.Length];
- for (int i = 0; i < tickets.Length; i++)
+ if (first.Count != second.Count)
{
- bs[i] = new ByteString(tickets[i] ?? []);
+ return false;
}
-
- var request = new CallMethodRequest
+ for (int i = 0; i < first.Count; i++)
{
- ObjectId = RegistrarNodeId,
- MethodId = methodId,
- InputArguments =
- new Variant[] { new(bs.ToArrayOf()) }.ToArrayOf()
- };
-
- CallResponse response = await Session
- .CallAsync(
- requestHeader: null,
- methodsToCall: new[] { request }.ToArrayOf(),
- ct: ct)
- .ConfigureAwait(false);
-
- if (response.Results.Count == 0)
- {
- throw new ServiceResultException(
- StatusCodes.BadUnexpectedError,
- "Registrar call returned no results.");
- }
-
- CallMethodResult result = response.Results[0];
- if (StatusCode.IsBad(result.StatusCode))
- {
- throw new ServiceResultException(
- result.StatusCode,
- $"Registrar call returned bad status {result.StatusCode}.");
- }
-
- if (result.OutputArguments.Count == 0)
-
- {
- return [];
- }
- object? boxed = result.OutputArguments[0].AsBoxedObject();
- return boxed switch
- {
- int[] arr => arr,
- ArrayOf ai => ai.ToArray() ?? [],
- _ => []
- };
- }
-
- private async ValueTask ResolveMethodAsync(
- string browseName, CancellationToken ct)
- {
- var path = new BrowsePath
- {
- StartingNode = RegistrarNodeId,
- RelativePath = new RelativePath
+ if (!string.Equals(
+ first.GetString((uint)i),
+ second.GetString((uint)i),
+ StringComparison.Ordinal))
{
- Elements = new[]
- {
- new RelativePathElement
- {
- ReferenceTypeId = ReferenceTypeIds.HasComponent,
- IsInverse = false,
- IncludeSubtypes = true,
- TargetName = new QualifiedName(browseName)
- }
- }
+ return false;
}
- };
-
- TranslateBrowsePathsToNodeIdsResponse translate = await Session
- .TranslateBrowsePathsToNodeIdsAsync(
- null, new[] { path }.ToArrayOf(), ct)
- .ConfigureAwait(false);
-
- if (translate.Results.Count == 0 ||
- StatusCode.IsBad(translate.Results[0].StatusCode) ||
- translate.Results[0].Targets.Count == 0)
- {
- throw new ServiceResultException(
- StatusCodes.BadNotFound,
- $"Could not resolve method '{browseName}' under registrar.");
}
+ return true;
+ }
- return ExpandedNodeId.ToNodeId(
- translate.Results[0].Targets[0].TargetId,
- Session.NamespaceUris);
+ private static NodeId ValidateRegistrarNodeId(NodeId registrarNodeId)
+ {
+ if (registrarNodeId.IsNull)
+ {
+ throw new ArgumentException(
+ "Registrar NodeId is required.",
+ nameof(registrarNodeId));
+ }
+ return registrarNodeId;
}
}
}
diff --git a/src/Opc.Ua.Gds.Common/NugetREADME.md b/src/Opc.Ua.Gds.Common/NugetREADME.md
index dc7f55e637..c4942db8a1 100644
--- a/src/Opc.Ua.Gds.Common/NugetREADME.md
+++ b/src/Opc.Ua.Gds.Common/NugetREADME.md
@@ -2,9 +2,10 @@
`OPCFoundation.NetStandard.Opc.Ua.Gds.Common` contains the shared
information model and DataType definitions used by the OPC UA Global
-Discovery Server (GDS) — OPC 10000-12 (Part 12). It is the type-only
-contract that the client (`Opc.Ua.Gds.Client.Common`) and server
-(`Opc.Ua.Gds.Server.Common`) GDS packages depend on.
+Discovery Server (GDS) — OPC 10000-12 (Part 12) — and the OPC 10000-21
+Onboarding companion model. It is the type-only contract that the client
+(`Opc.Ua.Gds.Client.Common`) and server (`Opc.Ua.Gds.Server.Common`) GDS
+packages depend on.
## Overview
@@ -12,6 +13,12 @@ Reference this package directly only when you need the GDS proxies
without the client / server-side helpers — typically for a tool that
inspects or transcodes GDS payloads outside a full GDS pipeline.
+The package ships generated `Opc.Ua.Onboarding` constants, node states,
+encodeable types, and typed ObjectType clients. This includes
+`DeviceRegistrarState`, `DeviceRegistrarAdminState`,
+`DeviceRegistrarAdminTypeClient`, and the well-known
+`ObjectIds.DeviceRegistrar_Administration` NodeId.
+
## Target frameworks
`net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`,
diff --git a/src/Opc.Ua.Gds.Server.Common/NugetREADME.md b/src/Opc.Ua.Gds.Server.Common/NugetREADME.md
index 5d6df88727..35f93847c4 100644
--- a/src/Opc.Ua.Gds.Server.Common/NugetREADME.md
+++ b/src/Opc.Ua.Gds.Server.Common/NugetREADME.md
@@ -15,6 +15,13 @@ applications-database provider ships an in-process LINQ
implementation; production deployments usually plug a real database
back-end behind the same `IApplicationsDatabase` surface.
+The package also provides the OPC 10000-21 registrar administration
+surface. Load the generated `Opc.Ua.Onboarding` nodes from
+`Opc.Ua.Gds.Common`, then bind `DeviceRegistrarAdminState` to an
+`ITicketStore` with `BindToTicketStore`. `MemoryTicketStore` is provided
+for samples and tests; production hosts should inject a persistent,
+concurrency-safe implementation.
+
## Target frameworks
`net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`,
diff --git a/src/Opc.Ua.Gds.Server.Common/Onboarding/DeviceRegistrarAdminExtensions.cs b/src/Opc.Ua.Gds.Server.Common/Onboarding/DeviceRegistrarAdminExtensions.cs
index 1ee634d431..3b277fd069 100644
--- a/src/Opc.Ua.Gds.Server.Common/Onboarding/DeviceRegistrarAdminExtensions.cs
+++ b/src/Opc.Ua.Gds.Server.Common/Onboarding/DeviceRegistrarAdminExtensions.cs
@@ -29,16 +29,17 @@
using System;
using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
namespace Opc.Ua.Gds.Server.Onboarding
{
///
- /// Convenience extensions that wire an
- /// +
- /// pair to the OPC 10000-100 Part 21 DeviceRegistrarAdminType
+ /// Convenience extensions that wire an
+ /// to the OPC 10000-21 DeviceRegistrarAdminType
/// method children — RegisterTickets and
/// UnregisterTickets. Uses
- /// so the extension works
+ /// so the extension works
/// against any concrete state derived from
/// that carries the expected
/// method children.
@@ -88,99 +89,115 @@ namespace Opc.Ua.Gds.Server.Onboarding
ITicketStore capturedStore = store;
- register.OnCallMethod2 = (ctx, m, objectId, inputs, outputs) =>
- HandleRegister(ctx, capturedStore, inputs, outputs);
+ register.OnCallMethod2Async = (ctx, m, objectId, inputs, outputs, ct) =>
+ HandleRegisterAsync(capturedStore, inputs, outputs, ct);
- unregister.OnCallMethod2 = (ctx, m, objectId, inputs, outputs) =>
- HandleUnregister(ctx, capturedStore, inputs, outputs);
+ unregister.OnCallMethod2Async = (ctx, m, objectId, inputs, outputs, ct) =>
+ HandleUnregisterAsync(capturedStore, inputs, outputs, ct);
}
- private static ServiceResult HandleRegister(
- ISystemContext context,
+ private static async ValueTask HandleRegisterAsync(
ITicketStore store,
ArrayOf inputs,
- List outputs)
+ List outputs,
+ CancellationToken cancellationToken)
{
if (inputs.Count < 1)
{
return StatusCodes.BadArgumentsMissing;
}
- byte[][] tickets = ExtractTicketArray(inputs[0]);
- int[] results = new int[tickets.Length];
-
- for (int i = 0; i < tickets.Length; i++)
+ if (!inputs[0].TryGetValue(out ArrayOf tickets))
{
- string ticketId = ComputeTicketId(tickets[i]);
+ return StatusCodes.BadTypeMismatch;
+ }
+ if (outputs.Count < 1)
+ {
+ return StatusCodes.BadUnexpectedError;
+ }
+ var results = new StatusCode[tickets.Count];
+
+ for (int i = 0; i < tickets.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ byte[] ticket = tickets[i].ToArray();
+ string ticketId = ComputeTicketId(ticket);
try
{
- store.AddAsync(
- ticketId,
- tickets[i],
- new TicketMetadata(
- Kind: TicketKind.Unspecified,
- ManufacturerName: string.Empty,
- ModelName: string.Empty,
- SerialNumber: string.Empty,
- ProductInstanceUri: string.Empty))
- .AsTask().GetAwaiter().GetResult();
- results[i] = (int)(uint)StatusCodes.Good;
+ await store.AddAsync(
+ ticketId,
+ ticket,
+ new TicketMetadata(
+ Kind: TicketKind.Unspecified,
+ ManufacturerName: string.Empty,
+ ModelName: string.Empty,
+ SerialNumber: string.Empty,
+ ProductInstanceUri: string.Empty),
+ cancellationToken)
+ .ConfigureAwait(false);
+ results[i] = StatusCodes.Good;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
}
catch (Exception ex)
{
- results[i] = (int)new ServiceResult(ex).StatusCode.Code;
+ results[i] = new ServiceResult(ex).StatusCode;
}
}
- outputs.Add(new Variant(results.ToArrayOf()));
+ outputs[0] = Variant.From(results.ToArrayOf());
return ServiceResult.Good;
}
- private static ServiceResult HandleUnregister(
- ISystemContext context,
+ private static async ValueTask HandleUnregisterAsync(
ITicketStore store,
ArrayOf inputs,
- List outputs)
+ List outputs,
+ CancellationToken cancellationToken)
{
if (inputs.Count < 1)
{
return StatusCodes.BadArgumentsMissing;
}
- byte[][] tickets = ExtractTicketArray(inputs[0]);
- int[] results = new int[tickets.Length];
-
- for (int i = 0; i < tickets.Length; i++)
+ if (!inputs[0].TryGetValue(out ArrayOf tickets))
{
- string ticketId = ComputeTicketId(tickets[i]);
+ return StatusCodes.BadTypeMismatch;
+ }
+ if (outputs.Count < 1)
+ {
+ return StatusCodes.BadUnexpectedError;
+ }
+ var results = new StatusCode[tickets.Count];
+
+ for (int i = 0; i < tickets.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ byte[] ticket = tickets[i].ToArray();
+ string ticketId = ComputeTicketId(ticket);
try
{
- bool removed = store.RemoveAsync(ticketId).AsTask().GetAwaiter().GetResult();
+ bool removed = await store
+ .RemoveAsync(ticketId, cancellationToken)
+ .ConfigureAwait(false);
results[i] = removed
- ? (int)(uint)StatusCodes.Good
- : (int)(uint)StatusCodes.BadNotFound;
+ ? StatusCodes.Good
+ : StatusCodes.BadNotFound;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
}
catch (Exception ex)
{
- results[i] = (int)new ServiceResult(ex).StatusCode.Code;
+ results[i] = new ServiceResult(ex).StatusCode;
}
}
- outputs.Add(new Variant(results.ToArrayOf()));
+ outputs[0] = Variant.From(results.ToArrayOf());
return ServiceResult.Good;
}
- private static byte[][] ExtractTicketArray(Variant variant)
- {
- object? boxed = variant.AsBoxedObject();
- return boxed switch
- {
- byte[][] arr => arr,
- ByteString[] bs => Array.ConvertAll(bs, b => b.ToArray()),
- ArrayOf abs => abs.ToArray()
- is { } abu ? Array.ConvertAll(abu, b => b.ToArray()) : [],
- _ => []
- };
- }
-
private static string ComputeTicketId(byte[] ticket)
{
// Stable identifier — SHA-256 hash of the encoded ticket.
diff --git a/src/Opc.Ua.Gds.Server.Common/Onboarding/ITicketStore.cs b/src/Opc.Ua.Gds.Server.Common/Onboarding/ITicketStore.cs
index 715d4a8869..5a1686e612 100644
--- a/src/Opc.Ua.Gds.Server.Common/Onboarding/ITicketStore.cs
+++ b/src/Opc.Ua.Gds.Server.Common/Onboarding/ITicketStore.cs
@@ -35,8 +35,8 @@ using System.Threading.Tasks;
namespace Opc.Ua.Gds.Server.Onboarding
{
///
- /// Storage abstraction for OPC 10000-100 Onboarding tickets
- /// (Part 21, OpcUaOnboardingModel.xml). A ticket is the encoded
+ /// Storage abstraction for OPC 10000-21 Onboarding tickets.
+ /// A ticket is the encoded
/// identifier-and-vendor data block a device presents during
/// provisioning; the registrar stores them and matches incoming
/// device certificates against the bundle.
@@ -46,9 +46,9 @@ namespace Opc.Ua.Gds.Server.Onboarding
/// This is the application-facing facade; the source-generated
/// BaseTicketType / DeviceIdentityTicketType /
/// CompositeIdentityTicketType proxies in
- /// Opc.Ua.Di remain the wire-level encoding. The store
- /// works with the encoded ticket bytes (the spec's
- /// EncodedTicket type — a [] alias).
+ /// Opc.Ua.Onboarding remain the wire-level encoding. The store
+ /// persists the bytes carried by the specification's
+ /// EncodedTicket ByteString subtype.
///
///
/// All implementations must be safe for concurrent calls.
@@ -67,8 +67,8 @@ namespace Opc.Ua.Gds.Server.Onboarding
/// ticket).
///
///
- /// Encoded ticket bytes (DataType EncodedTicket, a
- /// [] alias).
+ /// Encoded ticket bytes carried on the wire as the
+ /// EncodedTicket ByteString subtype.
///
///
/// Application-supplied metadata for the ticket (kind,
diff --git a/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs b/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs
index 7414b7a942..aa724d5963 100644
--- a/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs
+++ b/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs
@@ -397,10 +397,14 @@ namespace Opc.Ua.Server.Historian
}
// Part 11 v1.05.07 §6.5.4.2: the request domain is defined by StartTime, EndTime and
- // ProcessingInterval, all of which shall be specified. If StartTime equals EndTime there
- // is no meaningful way to interpret the (zero-width) time domain, so the Server shall
- // return Bad_InvalidArgument.
- if (details.StartTime == details.EndTime)
+ // ProcessingInterval, all of which shall be specified. A zero ProcessingInterval is
+ // valid and requests one aggregate over the entire range; negative or non-finite
+ // durations are invalid. If StartTime equals EndTime there is no meaningful way to
+ // interpret the zero-width time domain.
+ if (details.StartTime == details.EndTime ||
+ details.ProcessingInterval < 0 ||
+ double.IsNaN(details.ProcessingInterval) ||
+ double.IsInfinity(details.ProcessingInterval))
{
result.StatusCode = StatusCodes.BadInvalidArgument;
return StatusCodes.BadInvalidArgument;
@@ -545,13 +549,10 @@ namespace Opc.Ua.Server.Historian
foreach (HistoricalDataValue sample in page.Values)
{
- if (!calculator.QueueRawValue(sample.Value))
+ if (!calculator.QueueRawValue(sample.Value) &&
+ !TryFlushCalculator(calculator, values, partial: false))
{
- FlushCalculator(calculator, values, partial: false);
- if (values.Count > kMaxProcessedBufferedOutputs)
- {
- return StatusCodes.BadTooManyOperations;
- }
+ return StatusCodes.BadTooManyOperations;
}
}
@@ -562,8 +563,7 @@ namespace Opc.Ua.Server.Historian
token2 = page.NextToken;
}
- FlushCalculator(calculator, values, partial: true);
- if (values.Count > kMaxProcessedBufferedOutputs)
+ if (!TryFlushCalculator(calculator, values, partial: true))
{
return StatusCodes.BadTooManyOperations;
}
@@ -2018,12 +2018,22 @@ namespace Opc.Ua.Server.Historian
_ = nodeId;
}
- private static void FlushCalculator(IAggregateCalculator calculator, List output, bool partial)
+ private static bool TryFlushCalculator(
+ IAggregateCalculator calculator,
+ List output,
+ bool partial)
{
while (calculator.TryGetProcessedValue(partial, out DataValue computed))
{
+ if (output.Count >= kMaxProcessedBufferedOutputs)
+ {
+ return false;
+ }
+
output.Add(computed);
}
+
+ return true;
}
private static async ValueTask> CollectAllRawAsync(
diff --git a/tests/Opc.Ua.Gds.Tests/Onboarding/DeviceRegistrarAdminExtensionsTests.cs b/tests/Opc.Ua.Gds.Tests/Onboarding/DeviceRegistrarAdminExtensionsTests.cs
index 5cb6c72043..eb9e634c86 100644
--- a/tests/Opc.Ua.Gds.Tests/Onboarding/DeviceRegistrarAdminExtensionsTests.cs
+++ b/tests/Opc.Ua.Gds.Tests/Onboarding/DeviceRegistrarAdminExtensionsTests.cs
@@ -1,5 +1,5 @@
/* ========================================================================
- * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
@@ -29,150 +29,281 @@
using System;
using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
+using Moq;
using NUnit.Framework;
using Opc.Ua.Gds.Server.Onboarding;
-
-#nullable enable
-#pragma warning disable CA2000
+using Opc.Ua.Onboarding;
+using Opc.Ua.Tests;
namespace Opc.Ua.Gds.Tests.Onboarding
{
///
- /// Tests for —
- /// wires the RegisterTickets / UnregisterTickets methods of a
- /// DeviceRegistrarAdminType instance through an
- /// .
+ /// Tests for .
///
[TestFixture]
[Category("DI")]
[Category("Onboarding")]
public sealed class DeviceRegistrarAdminExtensionsTests
{
- private const ushort kNs = 2;
-
- private static (BaseObjectState Registrar, MethodState Register, MethodState Unregister)
- CreateRegistrar()
- {
- var registrar = new BaseObjectState(parent: null)
- {
- NodeId = new NodeId("Registrar", kNs),
- BrowseName = new QualifiedName("DeviceRegistrar", kNs),
- DisplayName = new LocalizedText("DeviceRegistrar")
- };
- var register = new MethodState(registrar)
- {
- NodeId = new NodeId("Register", kNs),
- BrowseName = new QualifiedName("RegisterTickets", kNs),
- DisplayName = new LocalizedText("RegisterTickets")
- };
- var unregister = new MethodState(registrar)
- {
- NodeId = new NodeId("Unregister", kNs),
- BrowseName = new QualifiedName("UnregisterTickets", kNs),
- DisplayName = new LocalizedText("UnregisterTickets")
- };
- registrar.AddChild(register);
- registrar.AddChild(unregister);
- return (registrar, register, unregister);
- }
-
[Test]
public void BindRejectsNodeMissingMethods()
{
var bare = new BaseObjectState(parent: null)
{
- NodeId = new NodeId("Bare", kNs),
- BrowseName = new QualifiedName("Bare", kNs)
+ NodeId = new NodeId("Bare", 2),
+ BrowseName = new QualifiedName("Bare", 2)
};
- var store = new MemoryTicketStore();
Assert.Throws(
- () => bare.BindToTicketStore(store));
+ () => bare.BindToTicketStore(new MemoryTicketStore()));
}
[Test]
public void BindRejectsNullArgs()
{
- (BaseObjectState reg, _, _) = CreateRegistrar();
+ (_, DeviceRegistrarAdminState registrar) = CreateRegistrar();
Assert.Throws(
- () => reg.BindToTicketStore(null!));
+ () => registrar.BindToTicketStore(null!));
BaseObjectState? nullState = null;
Assert.Throws(
() => nullState!.BindToTicketStore(new MemoryTicketStore()));
}
[Test]
- public void RegisterTicketsCallStoresTickets()
+ public async Task RegisterTicketsStoresTicketsAndReplacesSeededOutput()
{
- (BaseObjectState reg, MethodState register, _) = CreateRegistrar();
+ (SystemContext context, DeviceRegistrarAdminState registrar) = CreateRegistrar();
var store = new MemoryTicketStore();
- reg.BindToTicketStore(store);
-
- byte[][] tickets =
+ registrar.BindToTicketStore(store);
+ ArrayOf tickets =
[
- [1, 2, 3],
- [4, 5, 6]
+ new ByteString(new byte[] { 1, 2, 3 }),
+ new ByteString(new byte[] { 4, 5, 6 })
];
- ByteString[] ticketsBs = Array.ConvertAll(tickets, b => new ByteString(b));
- ArrayOf inputs = new[] { new Variant(ticketsBs.ToArrayOf()) }.ToArrayOf();
- List outputs = [];
- ServiceResult result = register.OnCallMethod2!(
- context: new SystemContext(telemetry: null!),
- method: register,
- objectId: reg.NodeId,
- inputArguments: inputs,
- outputArguments: outputs);
+ (ServiceResult result, List argumentErrors, List outputs) =
+ await CallAsync(
+ registrar.RegisterTickets!,
+ context,
+ registrar.NodeId,
+ Variant.From(tickets))
+ .ConfigureAwait(false);
- Assert.That(ServiceResult.IsGood(result));
+ Assert.That(ServiceResult.IsGood(result), Is.True);
+ Assert.That(argumentErrors, Has.All.Matches(ServiceResult.IsGood));
Assert.That(outputs, Has.Count.EqualTo(1));
- int[] statuses = ((ArrayOf)outputs[0].AsBoxedObject()!).ToArray()!;
- Assert.That(statuses, Has.Length.EqualTo(2));
- Assert.That(statuses[0], Is.EqualTo((int)(uint)StatusCodes.Good));
- Assert.That(statuses[1], Is.EqualTo((int)(uint)StatusCodes.Good));
+ Assert.That(
+ outputs[0].TryGetValue(out ArrayOf statuses),
+ Is.True);
+ Assert.That(
+ statuses,
+ Is.EqualTo(new StatusCode[]
+ {
+ StatusCodes.Good,
+ StatusCodes.Good
+ }.ToArrayOf()));
+
+ var stored = new List();
+ await foreach (TicketRecord ticket in store.ListAsync().ConfigureAwait(false))
+ {
+ stored.Add(ticket);
+ }
+ Assert.That(stored, Has.Count.EqualTo(2));
}
[Test]
public async Task UnregisterTicketsRemovesPreviouslyStored()
{
- (BaseObjectState reg, MethodState register, MethodState unregister) = CreateRegistrar();
+ (SystemContext context, DeviceRegistrarAdminState registrar) = CreateRegistrar();
var store = new MemoryTicketStore();
- reg.BindToTicketStore(store);
+ registrar.BindToTicketStore(store);
+ ArrayOf tickets =
+ [
+ new ByteString(new byte[] { 0xAA })
+ ];
- byte[] ticket = [0xAA];
- ByteString[] ticketsBs = [new ByteString(ticket)];
- ArrayOf registerInputs = new[]
- { new Variant(ticketsBs.ToArrayOf()) }.ToArrayOf();
- List registerOutputs = [];
- register.OnCallMethod2!(
- new SystemContext(telemetry: null!), register, reg.NodeId,
- registerInputs, registerOutputs);
+ _ = await CallAsync(
+ registrar.RegisterTickets!,
+ context,
+ registrar.NodeId,
+ Variant.From(tickets))
+ .ConfigureAwait(false);
+ (_, _, List outputs) = await CallAsync(
+ registrar.UnregisterTickets!,
+ context,
+ registrar.NodeId,
+ Variant.From(tickets))
+ .ConfigureAwait(false);
+ Assert.That(outputs, Has.Count.EqualTo(1));
+ Assert.That(
+ outputs[0].TryGetValue(out ArrayOf statuses),
+ Is.True);
+ Assert.That(
+ statuses,
+ Is.EqualTo(new StatusCode[] { StatusCodes.Good }.ToArrayOf()));
+ Assert.That(
+ await store.ListAsync().CountAsync().ConfigureAwait(false),
+ Is.Zero);
+ }
+
+ [Test]
+ public async Task RegisterTicketsReportsPerTicketStoreFailure()
+ {
+ (SystemContext context, DeviceRegistrarAdminState registrar) = CreateRegistrar();
+ var store = new Mock(MockBehavior.Strict);
+ store
+ .Setup(s => s.AddAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Throws(new ServiceResultException(StatusCodes.BadEntryExists));
+ registrar.BindToTicketStore(store.Object);
+
+ (_, _, List outputs) = await CallAsync(
+ registrar.RegisterTickets!,
+ context,
+ registrar.NodeId,
+ Variant.From(
+ new ByteString[]
+ {
+ new(new byte[] { 1 })
+ }.ToArrayOf()))
+ .ConfigureAwait(false);
+
+ Assert.That(
+ outputs[0].TryGetValue(out ArrayOf statuses),
+ Is.True);
+ Assert.That(
+ statuses,
+ Is.EqualTo(new StatusCode[] { StatusCodes.BadEntryExists }.ToArrayOf()));
+ }
+
+ [Test]
+ public async Task RegisterTicketsRejectsNonByteStringWireType()
+ {
+ (SystemContext context, DeviceRegistrarAdminState registrar) = CreateRegistrar();
+ registrar.BindToTicketStore(new MemoryTicketStore());
+ ArrayOf invalidTickets = ["not-a-ticket"];
+
+ (ServiceResult result, List argumentErrors, List outputs) =
+ await CallAsync(
+ registrar.RegisterTickets!,
+ context,
+ registrar.NodeId,
+ Variant.From(invalidTickets))
+ .ConfigureAwait(false);
+
+ Assert.That(ServiceResult.IsGood(result), Is.True);
+ Assert.That(argumentErrors, Has.Count.EqualTo(1));
+ Assert.That(
+ argumentErrors[0].StatusCode,
+ Is.EqualTo(StatusCodes.BadTypeMismatch));
+ Assert.That(outputs, Is.Empty);
+ }
+
+ [Test]
+ public void RegisterTicketsPropagatesCancellationToTicketStore()
+ {
+ (_, DeviceRegistrarAdminState registrar) = CreateRegistrar();
+ var store = new Mock(MockBehavior.Strict);
+ store
+ .Setup(s => s.AddAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(
+ (_, _, _, ct) =>
+ {
+ ct.ThrowIfCancellationRequested();
+ return default;
+ });
+ registrar.BindToTicketStore(store.Object);
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+ List outputs = [Variant.Null];
+
+ Assert.ThrowsAsync(
+ async () => await registrar.RegisterTickets!.OnCallMethod2Async!(
+ new SystemContext(NUnitTelemetryContext.Create()),
+ registrar.RegisterTickets,
+ registrar.NodeId,
+ [Variant.From(
+ new ByteString[]
+ {
+ new(new byte[] { 1 })
+ }.ToArrayOf())],
+ outputs,
+ cts.Token).ConfigureAwait(false));
+ }
+
+ private static (SystemContext Context, DeviceRegistrarAdminState Registrar)
+ CreateRegistrar()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+ ServiceMessageContext messageContext = ServiceMessageContext.Create(telemetry);
+ messageContext.NamespaceUris.GetIndexOrAppend(
+ Opc.Ua.Gds.Namespaces.OpcUaGds);
+ messageContext.NamespaceUris.GetIndexOrAppend(
+ Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding);
+ var typeTable = new TypeTable(messageContext.NamespaceUris);
+ typeTable.AddSubtype(Opc.Ua.DataTypeIds.ByteString, NodeId.Null);
+ typeTable.AddSubtype(
+ Opc.Ua.DataTypeIds.EncodedTicket,
+ Opc.Ua.DataTypeIds.ByteString);
+ var context = new SystemContext(telemetry)
+ {
+ NamespaceUris = messageContext.NamespaceUris,
+ ServerUris = messageContext.ServerUris,
+ EncodeableFactory = messageContext.Factory,
+ TypeTable = typeTable
+ };
+ NodeStateCollection nodes = new NodeStateCollection().AddOpcUaOnboarding(context);
+ DeviceRegistrarState root = nodes.OfType().Single();
+ DeviceRegistrarAdminState registrar = root.Administration ??
+ throw new InvalidOperationException(
+ "The generated DeviceRegistrar has no Administration child.");
+ return (context, registrar);
+ }
+
+ private static async ValueTask<(
+ ServiceResult Result,
+ List ArgumentErrors,
+ List Outputs)> CallAsync(
+ MethodState method,
+ ISystemContext context,
+ NodeId objectId,
+ Variant input)
+ {
+ var argumentErrors = new List();
+ var outputs = new List();
+ ServiceResult result = await method.CallAsync(
+ context,
+ objectId,
+ [input],
+ argumentErrors,
+ outputs)
+ .ConfigureAwait(false);
+ return (result, argumentErrors, outputs);
+ }
+ }
+
+ internal static class AsyncEnumerableTestExtensions
+ {
+ public static async ValueTask CountAsync(
+ this IAsyncEnumerable values)
+ {
int count = 0;
- await foreach (TicketRecord _ in store.ListAsync().ConfigureAwait(false))
+ await foreach (T _ in values.ConfigureAwait(false))
{
count++;
}
- Assert.That(count, Is.EqualTo(1));
-
- ArrayOf unregisterInputs = new[]
- { new Variant(ticketsBs.ToArrayOf()) }.ToArrayOf();
- List unregisterOutputs = [];
- unregister.OnCallMethod2!(
- new SystemContext(telemetry: null!), unregister, reg.NodeId,
- unregisterInputs, unregisterOutputs);
-
- int[] statuses = ((ArrayOf)unregisterOutputs[0].AsBoxedObject()!).ToArray()!;
- Assert.That(statuses, Has.Length.EqualTo(1));
- Assert.That(statuses[0], Is.EqualTo((int)(uint)StatusCodes.Good));
-
- count = 0;
- await foreach (TicketRecord _ in store.ListAsync().ConfigureAwait(false))
- {
- count++;
- }
- Assert.That(count, Is.Zero);
+ return count;
}
}
}
diff --git a/tests/Opc.Ua.Gds.Tests/Onboarding/OnboardingClientTests.cs b/tests/Opc.Ua.Gds.Tests/Onboarding/OnboardingClientTests.cs
index f7d418d6b2..4cf4d3c138 100644
--- a/tests/Opc.Ua.Gds.Tests/Onboarding/OnboardingClientTests.cs
+++ b/tests/Opc.Ua.Gds.Tests/Onboarding/OnboardingClientTests.cs
@@ -1,5 +1,5 @@
/* ========================================================================
- * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
@@ -28,14 +28,17 @@
* ======================================================================*/
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Gds.Client;
-
-#nullable enable
+using Opc.Ua.Gds.Server.Onboarding;
+using Opc.Ua.Onboarding;
+using Opc.Ua.Tests;
namespace Opc.Ua.Gds.Tests.Onboarding
{
@@ -49,182 +52,465 @@ namespace Opc.Ua.Gds.Tests.Onboarding
{
private static readonly NodeId kRegistrarId = new("Reg", 2);
- private static Mock CreateSessionMock()
- {
- var mock = new Mock();
- var nsTable = new NamespaceTable();
- // Seed with the GDS namespace (which carries the Onboarding
- // ObjectTypes after the model relocation). OnboardingClient
- // resolves method NodeIds via browse-path translation, not
- // by namespace-constant lookup, so this is only here to
- // give the mocked session a non-empty namespace table.
- nsTable.GetIndexOrAppend(Namespaces.OpcUaGds);
- mock.SetupGet(s => s.NamespaceUris).Returns(nsTable);
- return mock;
- }
-
- private static ITelemetryContext NullTelemetry()
- {
- return new Mock().Object;
- }
-
- private static void SetupTranslateToMethod(
- Mock sessionMock, NodeId methodId)
- {
- sessionMock
- .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
- It.IsAny(),
- It.IsAny>(),
- It.IsAny()))
- .ReturnsAsync(new TranslateBrowsePathsToNodeIdsResponse
- {
- Results = new BrowsePathResult[]
- {
- new() {
- StatusCode = StatusCodes.Good,
- Targets = new BrowsePathTarget[]
- {
- new() {
- TargetId = new ExpandedNodeId(methodId)
- }
- }.ToArrayOf()
- }
- }.ToArrayOf()
- });
- }
-
- private static void SetupCallReturnsStatuses(
- Mock sessionMock, int[] statuses)
- {
- sessionMock
- .Setup(s => s.CallAsync(
- It.IsAny(),
- It.IsAny>(),
- It.IsAny()))
- .ReturnsAsync(new CallResponse
- {
- Results = new CallMethodResult[]
- {
- new() {
- StatusCode = StatusCodes.Good,
- OutputArguments = new Variant[]
- {
- new(statuses.ToArrayOf())
- }.ToArrayOf()
- }
- }.ToArrayOf()
- });
- }
-
[Test]
public void ConstructorRejectsNullSession()
{
Assert.Throws(
- () => new OnboardingClient(null!, kRegistrarId, NullTelemetry()));
+ () => new OnboardingClient(null!, kRegistrarId, CreateTelemetry()));
}
[Test]
public void ConstructorRejectsNullRegistrarId()
{
- Mock s = CreateSessionMock();
+ Mock session = CreateSessionMock(out _);
Assert.Throws(
- () => new OnboardingClient(s.Object, NodeId.Null, NullTelemetry()));
+ () => new OnboardingClient(session.Object, NodeId.Null, CreateTelemetry()));
}
[Test]
public void ConstructorRejectsNullTelemetry()
{
- Mock s = CreateSessionMock();
+ Mock session = CreateSessionMock(out _);
Assert.Throws(
- () => new OnboardingClient(s.Object, kRegistrarId, null!));
+ () => new OnboardingClient(session.Object, kRegistrarId, null!));
}
[Test]
- public async Task RegisterTicketsReturnsStatusArray()
+ public async Task RegisterTicketsUsesGeneratedTypeMethodAndNativeTypes()
{
- Mock session = CreateSessionMock();
- SetupTranslateToMethod(session, new NodeId("Reg_Register", 2));
- SetupCallReturnsStatuses(session,
+ Mock session = CreateSessionMock(out IServiceMessageContext messageContext);
+ CallMethodRequest? observedRequest = null;
+ ArrayOf expected =
[
- (int)(uint)StatusCodes.Good,
- (int)(uint)StatusCodes.BadEntryExists
- ]);
-
+ StatusCodes.Good,
+ StatusCodes.BadEntryExists
+ ];
+ SetupCall(session, request =>
+ {
+ observedRequest = request;
+ return Good(Variant.From(expected));
+ });
var client = new OnboardingClient(
- session.Object, kRegistrarId, NullTelemetry());
-
- int[] statuses = await client.RegisterTicketsAsync(
+ session.Object,
+ kRegistrarId,
+ CreateTelemetry());
+ ArrayOf tickets =
[
- [1, 2],
- [3, 4]
- ]).ConfigureAwait(false);
+ new ByteString(new byte[] { 1, 2 }),
+ new ByteString(new byte[] { 3, 4 })
+ ];
- Assert.That(statuses, Has.Length.EqualTo(2));
- Assert.That(statuses[0], Is.EqualTo((int)(uint)StatusCodes.Good));
- Assert.That(statuses[1], Is.EqualTo((int)(uint)StatusCodes.BadEntryExists));
+ ArrayOf statuses = await client
+ .RegisterTicketsAsync(tickets)
+ .ConfigureAwait(false);
+
+ Assert.That(statuses, Is.EqualTo(expected));
+ Assert.That(observedRequest, Is.Not.Null);
+ NodeId expectedMethodId = ExpandedNodeId.ToNodeId(
+ Opc.Ua.Onboarding.MethodIds.DeviceRegistrarAdminType_RegisterTickets,
+ messageContext.NamespaceUris);
+ Assert.That(observedRequest!.MethodId, Is.EqualTo(expectedMethodId));
+ Assert.That(observedRequest.ObjectId, Is.EqualTo(kRegistrarId));
+ Assert.That(
+ observedRequest.InputArguments[0].TryGetValue(
+ out ArrayOf observedTickets),
+ Is.True);
+ Assert.That(observedTickets, Is.EqualTo(tickets));
+ session.Verify(s => s.TranslateBrowsePathsToNodeIdsAsync(
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()), Times.Never);
}
[Test]
- public async Task UnregisterTicketsReturnsStatusArray()
+ public async Task RegisterTicketsFallbackUsesOnboardingBrowseNameNamespace()
{
- Mock session = CreateSessionMock();
- SetupTranslateToMethod(session, new NodeId("Reg_Unregister", 2));
- SetupCallReturnsStatuses(session,
- [
- (int)(uint)StatusCodes.Good
- ]);
-
+ Mock session = CreateSessionMock(out IServiceMessageContext messageContext);
var client = new OnboardingClient(
- session.Object, kRegistrarId, NullTelemetry());
-
- int[] statuses = await client.UnregisterTicketsAsync(
- [
- "\t"u8.ToArray()
- ]).ConfigureAwait(false);
-
- Assert.That(statuses, Has.Length.EqualTo(1));
- Assert.That(statuses[0], Is.EqualTo((int)(uint)StatusCodes.Good));
- }
-
- [Test]
- public void RegisterTicketsThrowsOnUnresolvedMethod()
- {
- Mock session = CreateSessionMock();
+ session.Object,
+ kRegistrarId,
+ CreateTelemetry());
+ NodeId typeMethodId = ExpandedNodeId.ToNodeId(
+ Opc.Ua.Onboarding.MethodIds.DeviceRegistrarAdminType_RegisterTickets,
+ messageContext.NamespaceUris);
+ var instanceMethodId = new NodeId("Reg_Register", kRegistrarId.NamespaceIndex);
+ var calls = new List();
+ BrowsePath? observedPath = null;
+ SetupCall(session, request =>
+ {
+ calls.Add(request.MethodId);
+ return request.MethodId.Equals(typeMethodId)
+ ? BadMethodInvalid()
+ : Good(Variant.From(new StatusCode[] { StatusCodes.Good }.ToArrayOf()));
+ });
session
.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
- It.IsAny(),
+ It.IsAny(),
It.IsAny>(),
It.IsAny()))
- .ReturnsAsync(new TranslateBrowsePathsToNodeIdsResponse
- {
- Results = new BrowsePathResult[]
+ .Returns, CancellationToken>(
+ (_, paths, _) =>
{
- new() {
- StatusCode = StatusCodes.BadNotFound,
- Targets = []
- }
- }.ToArrayOf()
- });
+ observedPath = paths[0];
+ return new ValueTask(
+ new TranslateBrowsePathsToNodeIdsResponse
+ {
+ ResponseHeader = new ResponseHeader(),
+ Results =
+ [
+ new BrowsePathResult
+ {
+ StatusCode = StatusCodes.Good,
+ Targets =
+ [
+ new BrowsePathTarget
+ {
+ TargetId = new ExpandedNodeId(instanceMethodId),
+ RemainingPathIndex = uint.MaxValue
+ }
+ ]
+ }
+ ]
+ });
+ });
+ ArrayOf statuses = await client
+ .RegisterTicketsAsync([new ByteString(new byte[] { 1 })])
+ .ConfigureAwait(false);
- var client = new OnboardingClient(
- session.Object, kRegistrarId, NullTelemetry());
-
- Assert.ThrowsAsync(
- async () => await client.RegisterTicketsAsync(
- [
- [1]
- ]).ConfigureAwait(false));
+ Assert.That(
+ statuses,
+ Is.EqualTo(new StatusCode[] { StatusCodes.Good }.ToArrayOf()));
+ Assert.That(calls, Is.EqualTo(new[] { typeMethodId, instanceMethodId }));
+ Assert.That(observedPath, Is.Not.Null);
+ RelativePathElement element = observedPath!.RelativePath.Elements[0];
+ Assert.That(
+ element.TargetName.Name,
+ Is.EqualTo(Opc.Ua.Onboarding.BrowseNames.RegisterTickets));
+ Assert.That(
+ element.TargetName.NamespaceIndex,
+ Is.EqualTo((ushort)messageContext.NamespaceUris.GetIndex(
+ Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding)));
}
[Test]
- public void RegisterTicketsRejectsNullArray()
+ public async Task UnregisterTicketsReturnsStatusCodes()
{
- Mock session = CreateSessionMock();
+ Mock session = CreateSessionMock(out _);
+ SetupCall(
+ session,
+ _ => Good(Variant.From(
+ new StatusCode[] { StatusCodes.BadNotFound }.ToArrayOf())));
var client = new OnboardingClient(
- session.Object, kRegistrarId, NullTelemetry());
+ session.Object,
+ kRegistrarId,
+ CreateTelemetry());
- Assert.ThrowsAsync(
- async () => await client.RegisterTicketsAsync(null!).ConfigureAwait(false));
+ ArrayOf statuses = await client
+ .UnregisterTicketsAsync([new ByteString(new byte[] { 0xAA })])
+ .ConfigureAwait(false);
+
+ Assert.That(
+ statuses,
+ Is.EqualTo(new StatusCode[] { StatusCodes.BadNotFound }.ToArrayOf()));
+ }
+
+ private static Mock CreateSessionMock(
+ out IServiceMessageContext messageContext)
+ {
+ ITelemetryContext telemetry = CreateTelemetry();
+ ServiceMessageContext context = ServiceMessageContext.Create(telemetry);
+ var namespaceUris = new NamespaceTable();
+ namespaceUris.GetIndexOrAppend(
+ Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding);
+ messageContext = context;
+
+ var mock = new Mock(MockBehavior.Strict);
+ mock.SetupGet(s => s.MessageContext).Returns(context);
+ mock.SetupGet(s => s.NamespaceUris).Returns(namespaceUris);
+ return mock;
+ }
+
+ private static ITelemetryContext CreateTelemetry()
+ {
+ return NUnitTelemetryContext.Create();
+ }
+
+ private static void SetupCall(
+ Mock session,
+ Func handler)
+ {
+ session
+ .Setup(s => s.CallAsync(
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns, CancellationToken>(
+ (_, requests, _) =>
+ new ValueTask(new CallResponse
+ {
+ ResponseHeader = new ResponseHeader(),
+ Results = [handler(requests[0])]
+ }));
+ }
+
+ private static CallMethodResult Good(params Variant[] outputs)
+ {
+ return new CallMethodResult
+ {
+ StatusCode = StatusCodes.Good,
+ OutputArguments = outputs
+ };
+ }
+
+ private static CallMethodResult BadMethodInvalid()
+ {
+ return new CallMethodResult
+ {
+ StatusCode = StatusCodes.BadMethodInvalid
+ };
+ }
+ }
+
+ ///
+ /// Exercises the generated Part 21 model, client proxy fallback, method
+ /// validation and ticket-store bindings as one in-process call chain.
+ ///
+ [TestFixture]
+ [Category("Onboarding")]
+ public sealed class OnboardingEndToEndTests
+ {
+ [Test]
+ public async Task GeneratedRegistrarAndClientRoundTripTickets()
+ {
+ (SystemContext context, DeviceRegistrarAdminState registrar) =
+ CreateGeneratedRegistrar();
+ var store = new MemoryTicketStore();
+ registrar.BindToTicketStore(store);
+ Mock session = CreateSessionBridge(context, registrar);
+ var client = new OnboardingClient(
+ session.Object,
+ registrar.NodeId,
+ context.Telemetry);
+ ArrayOf tickets =
+ [
+ new ByteString("ticket-one"u8.ToArray()),
+ new ByteString("ticket-two"u8.ToArray())
+ ];
+
+ ArrayOf registered = await client
+ .RegisterTicketsAsync(tickets)
+ .ConfigureAwait(false);
+ ArrayOf removed = await client
+ .UnregisterTicketsAsync([tickets[0]])
+ .ConfigureAwait(false);
+ ArrayOf removedAgain = await client
+ .UnregisterTicketsAsync([tickets[0]])
+ .ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ registered,
+ Is.EqualTo(new StatusCode[]
+ {
+ StatusCodes.Good,
+ StatusCodes.Good
+ }.ToArrayOf()));
+ Assert.That(
+ removed,
+ Is.EqualTo(new StatusCode[] { StatusCodes.Good }.ToArrayOf()));
+ Assert.That(
+ removedAgain,
+ Is.EqualTo(new StatusCode[] { StatusCodes.BadNotFound }.ToArrayOf()));
+ });
+
+ var remaining = new List();
+ await foreach (TicketRecord record in store.ListAsync().ConfigureAwait(false))
+ {
+ remaining.Add(record);
+ }
+ Assert.That(remaining, Has.Count.EqualTo(1));
+ Assert.That(
+ remaining[0].EncodedTicket,
+ Is.EqualTo(tickets[1].ToArray()));
+ }
+
+ private static (SystemContext Context, DeviceRegistrarAdminState Registrar)
+ CreateGeneratedRegistrar()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+ ServiceMessageContext messageContext = ServiceMessageContext.Create(telemetry);
+ messageContext.NamespaceUris.GetIndexOrAppend(
+ Opc.Ua.Gds.Namespaces.OpcUaGds);
+ messageContext.NamespaceUris.GetIndexOrAppend(
+ Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding);
+ var typeTable = new TypeTable(messageContext.NamespaceUris);
+ typeTable.AddSubtype(Opc.Ua.DataTypeIds.ByteString, NodeId.Null);
+ typeTable.AddSubtype(
+ Opc.Ua.DataTypeIds.EncodedTicket,
+ Opc.Ua.DataTypeIds.ByteString);
+ var context = new SystemContext(telemetry)
+ {
+ NamespaceUris = messageContext.NamespaceUris,
+ ServerUris = messageContext.ServerUris,
+ EncodeableFactory = messageContext.Factory,
+ TypeTable = typeTable
+ };
+ NodeStateCollection nodes = new NodeStateCollection()
+ .AddOpcUaGds(context)
+ .AddOpcUaOnboarding(context);
+ Assert.That(
+ nodes.Any(node => node.NodeId.Equals(ExpandedNodeId.ToNodeId(
+ Opc.Ua.Gds.DataTypeIds.ApplicationRecordDataType,
+ context.NamespaceUris))),
+ Is.True,
+ "The Onboarding model's GDS datatype dependency must be loaded.");
+ DeviceRegistrarState root = nodes.OfType().Single();
+ return (context, root.Administration ??
+ throw new InvalidOperationException(
+ "The generated DeviceRegistrar has no Administration child."));
+ }
+
+ private static Mock CreateSessionBridge(
+ SystemContext context,
+ DeviceRegistrarAdminState registrar)
+ {
+ var session = new Mock(MockBehavior.Strict);
+ ServiceMessageContext messageContext = ServiceMessageContext.Create(context.Telemetry);
+ messageContext.NamespaceUris = context.NamespaceUris;
+ messageContext.ServerUris = context.ServerUris;
+ session.SetupGet(s => s.MessageContext).Returns(messageContext);
+ session.SetupGet(s => s.NamespaceUris).Returns(context.NamespaceUris);
+ session
+ .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns, CancellationToken>(
+ (_, paths, _) =>
+ new ValueTask(
+ ResolvePaths(context, registrar, paths)));
+ session
+ .Setup(s => s.CallAsync(
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns, CancellationToken>(
+ (_, requests, ct) => InvokeMethodsAsync(
+ context,
+ registrar,
+ requests,
+ ct));
+ return session;
+ }
+
+ private static TranslateBrowsePathsToNodeIdsResponse ResolvePaths(
+ ISystemContext context,
+ DeviceRegistrarAdminState registrar,
+ ArrayOf paths)
+ {
+ var results = new BrowsePathResult[paths.Count];
+ for (int i = 0; i < paths.Count; i++)
+ {
+ BrowsePath path = paths[i];
+ NodeState? current = path.StartingNode.Equals(registrar.NodeId)
+ ? registrar
+ : null;
+ foreach (RelativePathElement element in path.RelativePath.Elements)
+ {
+ current = current?.FindChild(context, element.TargetName);
+ if (current == null)
+ {
+ break;
+ }
+ }
+ results[i] = current == null
+ ? new BrowsePathResult
+ {
+ StatusCode = StatusCodes.BadNoMatch,
+ Targets = []
+ }
+ : new BrowsePathResult
+ {
+ StatusCode = StatusCodes.Good,
+ Targets =
+ [
+ new BrowsePathTarget
+ {
+ TargetId = new ExpandedNodeId(current.NodeId),
+ RemainingPathIndex = uint.MaxValue
+ }
+ ]
+ };
+ }
+ return new TranslateBrowsePathsToNodeIdsResponse
+ {
+ ResponseHeader = new ResponseHeader(),
+ Results = results
+ };
+ }
+
+ private static async ValueTask InvokeMethodsAsync(
+ ISystemContext context,
+ DeviceRegistrarAdminState registrar,
+ ArrayOf requests,
+ CancellationToken cancellationToken)
+ {
+ var results = new CallMethodResult[requests.Count];
+ for (int i = 0; i < requests.Count; i++)
+ {
+ CallMethodRequest request = requests[i];
+ MethodState? method = FindInstanceMethod(registrar, request.MethodId);
+ if (method == null)
+ {
+ results[i] = new CallMethodResult
+ {
+ StatusCode = StatusCodes.BadMethodInvalid,
+ InputArgumentResults = [],
+ OutputArguments = []
+ };
+ continue;
+ }
+
+ var argumentErrors = new List();
+ var outputs = new List();
+ ServiceResult result = await method.CallAsync(
+ context,
+ request.ObjectId,
+ request.InputArguments,
+ argumentErrors,
+ outputs,
+ cancellationToken)
+ .ConfigureAwait(false);
+ results[i] = new CallMethodResult
+ {
+ StatusCode = result.StatusCode,
+ InputArgumentResults = argumentErrors
+ .Select(error => error.StatusCode)
+ .ToArrayOf(),
+ OutputArguments = outputs.ToArrayOf()
+ };
+ }
+ return new CallResponse
+ {
+ ResponseHeader = new ResponseHeader(),
+ Results = results
+ };
+ }
+
+ private static MethodState? FindInstanceMethod(
+ DeviceRegistrarAdminState registrar,
+ NodeId methodId)
+ {
+ if (registrar.RegisterTickets?.NodeId.Equals(methodId) == true)
+ {
+ return registrar.RegisterTickets;
+ }
+ if (registrar.UnregisterTickets?.NodeId.Equals(methodId) == true)
+ {
+ return registrar.UnregisterTickets;
+ }
+ return null;
}
}
}
diff --git a/tests/Opc.Ua.History.Tests/AggregateBaseTests.cs b/tests/Opc.Ua.History.Tests/AggregateBaseTests.cs
index 8112d254f6..c96a4c255a 100644
--- a/tests/Opc.Ua.History.Tests/AggregateBaseTests.cs
+++ b/tests/Opc.Ua.History.Tests/AggregateBaseTests.cs
@@ -440,13 +440,17 @@ namespace Opc.Ua.History.Tests
DateTime endTime = DateTime.UtcNow;
DateTime startTime = endTime.AddMinutes(-10);
- await AssertProcessedReadFailsAsync(
+ HistoryReadResponse response = await ExecuteHistoryReadProcessedAsync(
nodeId,
startTime,
endTime,
aggregateId: ObjectIds.AggregateFunction_Average,
- processingInterval: -1,
- allowEmptyResults: false).ConfigureAwait(false);
+ processingInterval: -1).ConfigureAwait(false);
+
+ Assert.That(response.Results.Count, Is.EqualTo(1));
+ Assert.That(
+ response.Results[0].StatusCode,
+ Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Description("AggregateType list is empty; expect a Bad operation status or service-level rejection.")]
diff --git a/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs b/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs
index 9cfaedc2ba..0e6abe3967 100644
--- a/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs
+++ b/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs
@@ -544,6 +544,67 @@ namespace Opc.Ua.Server.Tests.Historian
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
+ [TestCase(-1d)]
+ [TestCase(double.NaN)]
+ [TestCase(double.NegativeInfinity)]
+ [TestCase(double.PositiveInfinity)]
+ public async Task DispatchProcessedReadWithInvalidProcessingIntervalReturnsBadInvalidArgumentAsync(
+ double processingInterval)
+ {
+ HarnessFixture h = CreateHarness();
+ var nodeId = new NodeId($"negative-interval-{Guid.NewGuid():N}", 1);
+ BaseDataVariableState node = CreateVariable(nodeId);
+ var provider = new Mock();
+ Mock processedProvider =
+ provider.As();
+ processedProvider
+ .Setup(p => p.ReadProcessedAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(new ValueTask>(HistorianPage.Empty));
+
+ var details = new ReadProcessedDetails
+ {
+ StartTime = HarnessFixture.BaseTime,
+ EndTime = HarnessFixture.BaseTime.AddMinutes(1),
+ ProcessingInterval = processingInterval,
+ AggregateConfiguration = new AggregateConfiguration
+ {
+ PercentDataBad = 100,
+ PercentDataGood = 100
+ }
+ };
+ var nodeToRead = new HistoryReadValueId
+ {
+ NodeId = nodeId,
+ ContinuationPoint = ByteString.Empty
+ };
+
+ var result = new HistoryReadResult();
+ ServiceResult error = await HistorianDispatcher.DispatchProcessedReadAsync(
+ h.SystemContext,
+ provider.Object,
+ node,
+ nodeToRead,
+ details,
+ ObjectIds.AggregateFunction_Average,
+ TimestampsToReturn.Source,
+ result,
+ CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
+ processedProvider.Verify(
+ p => p.ReadProcessedAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ }
+
[Test]
public async Task DispatchProcessedReadWithReverseTimeRangeIsNotRejectedAsInvalidArgumentAsync()
{
diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/DesignFileTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/DesignFileTests.cs
index c90b6f4e16..54fdd0cd93 100644
--- a/tests/Opc.Ua.SourceGeneration.Core.Tests/DesignFileTests.cs
+++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/DesignFileTests.cs
@@ -128,10 +128,11 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
}
///
- /// Tests that Group returns single group when multiple design files are in the same directory.
+ /// Tests that Group returns one generation unit per design file when
+ /// multiple targets share a directory.
///
[Test]
- public void Group_MultipleFilesInSameDirectory_ReturnsSingleGroup()
+ public void GroupMultipleFilesInSameDirectoryReturnsOneGroupPerTarget()
{
// Arrange
string dir = Path.Combine("C:", "TestDir");
@@ -155,12 +156,16 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
var result = collection.Group().ToList();
// Assert
Assert.That(result, Is.Not.Null);
- Assert.That(result, Has.Count.EqualTo(1));
- Assert.That(result[0].Targets, Is.Not.Null);
- Assert.That(result[0].Targets, Has.Count.EqualTo(3));
- Assert.That(result[0].Targets, Is.EquivalentTo(designFiles));
- Assert.That(result[0].IdentifierFilePath, Is.EqualTo("fallback.csv"));
- Assert.That(result[0].Options, Is.EqualTo(options));
+ Assert.That(result, Has.Count.EqualTo(3));
+ Assert.That(result.SelectMany(group => group.Targets), Is.EquivalentTo(designFiles));
+ foreach (DesignFileCollection group in result)
+ {
+ Assert.That(group.Targets, Has.Count.EqualTo(1));
+ Assert.That(group.Dependencies, Has.Count.EqualTo(2));
+ Assert.That(group.Dependencies, Does.Not.Contain(group.Targets[0]));
+ Assert.That(group.IdentifierFilePath, Is.EqualTo("fallback.csv"));
+ Assert.That(group.Options, Is.EqualTo(options));
+ }
}
///
@@ -256,10 +261,11 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
}
///
- /// Tests that Group uses identifier file from matching directory when provided.
+ /// Tests that Group assigns an identifier file by matching the target
+ /// base name, even when several models share a directory.
///
[Test]
- public void Group_IdentifierFileInMatchingDirectory_UsesIdentifierFile()
+ public void GroupBasenameIdentifierFilesAssignsEachTarget()
{
// Arrange
string dir = Path.Combine("C:", "TestDir");
@@ -270,7 +276,8 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
};
var identifierFiles = new List
{
- Path.Combine(dir, "identifiers.csv")
+ Path.Combine(dir, "Design1.csv"),
+ Path.Combine(dir, "Design2.CSV")
};
var collection = new DesignFileCollection
{
@@ -282,26 +289,88 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
var result = collection.Group(identifierFiles).ToList();
// Assert
Assert.That(result, Is.Not.Null);
- Assert.That(result, Has.Count.EqualTo(1));
- Assert.That(result[0].IdentifierFilePath, Is.EqualTo(Path.Combine(dir, "identifiers.csv")));
+ Assert.That(result, Has.Count.EqualTo(2));
+ Assert.That(
+ result.Single(group => group.Targets[0].EndsWith("Design1.xml", StringComparison.Ordinal))
+ .IdentifierFilePath,
+ Is.EqualTo(Path.Combine(dir, "Design1.csv")));
+ Assert.That(
+ result.Single(group => group.Targets[0].EndsWith("Design2.xml", StringComparison.Ordinal))
+ .IdentifierFilePath,
+ Is.EqualTo(Path.Combine(dir, "Design2.CSV")));
}
///
- /// Tests that Group uses first identifier file when multiple identifier files exist in same directory.
+ /// Tests that the legacy single-model case continues to use the only
+ /// adjacent identifier file even when its basename differs.
///
[Test]
- public void Group_MultipleIdentifierFilesInSameDirectory_UsesFirstIdentifierFile()
+ public void GroupSingleTargetWithNonBasenameIdentifierUsesAdjacentFile()
+ {
+ string dir = Path.Combine("C:", "TestDir");
+ string target = Path.Combine(dir, "Design.xml");
+ string identifierFile = Path.Combine(dir, "LegacyIdentifiers.csv");
+ var collection = new DesignFileCollection
+ {
+ Targets = [target],
+ IdentifierFilePath = "fallback.csv"
+ };
+
+ DesignFileCollection result = collection
+ .Group([identifierFile])
+ .Single();
+
+ Assert.That(result.IdentifierFilePath, Is.EqualTo(identifierFile));
+ }
+
+ ///
+ /// Tests that each single-model directory preserves its legacy
+ /// non-basename identifier file in a multi-directory collection.
+ ///
+ [Test]
+ public void GroupMultipleSingleTargetDirectoriesUseAdjacentLegacyFiles()
+ {
+ string dir1 = Path.Combine("C:", "Dir1");
+ string dir2 = Path.Combine("C:", "Dir2");
+ string target1 = Path.Combine(dir1, "Model1.xml");
+ string target2 = Path.Combine(dir2, "Model2.xml");
+ string identifier1 = Path.Combine(dir1, "Identifiers.csv");
+ string identifier2 = Path.Combine(dir2, "Identifiers.csv");
+ var collection = new DesignFileCollection
+ {
+ Targets = [target1, target2],
+ IdentifierFilePath = "fallback.csv"
+ };
+
+ List result = collection
+ .Group([identifier1, identifier2])
+ .ToList();
+
+ Assert.That(
+ result.Single(group => group.Targets[0] == target1).IdentifierFilePath,
+ Is.EqualTo(identifier1));
+ Assert.That(
+ result.Single(group => group.Targets[0] == target2).IdentifierFilePath,
+ Is.EqualTo(identifier2));
+ }
+
+ ///
+ /// Tests that unrelated identifier files in the same directory are
+ /// ignored in favour of the target's basename match.
+ ///
+ [Test]
+ public void GroupMultipleIdentifierFilesInSameDirectoryUsesBasenameMatch()
{
// Arrange
string dir = Path.Combine("C:", "TestDir");
var designFiles = new List
{
- Path.Combine(dir, "Design1.xml")
+ Path.Combine(dir, "Design2.xml")
};
var identifierFiles = new List
{
Path.Combine(dir, "identifiers1.csv"),
- Path.Combine(dir, "identifiers2.csv"),
+ Path.Combine(dir, "Design2.csv"),
Path.Combine(dir, "identifiers3.csv")
};
var collection = new DesignFileCollection
@@ -315,7 +384,7 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
// Assert
Assert.That(result, Is.Not.Null);
Assert.That(result, Has.Count.EqualTo(1));
- Assert.That(result[0].IdentifierFilePath, Is.EqualTo(Path.Combine(dir, "identifiers1.csv")));
+ Assert.That(result[0].IdentifierFilePath, Is.EqualTo(Path.Combine(dir, "Design2.csv")));
}
///
@@ -367,8 +436,8 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
};
var identifierFiles = new List
{
- Path.Combine(dir1, "id1.csv"),
- Path.Combine(dir3, "id3.csv")
+ Path.Combine(dir1, "Design1.csv"),
+ Path.Combine(dir3, "Design3.csv")
};
var collection = new DesignFileCollection
{
@@ -383,13 +452,13 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
Assert.That(result, Has.Count.EqualTo(3));
DesignFileCollection group1 = result.FirstOrDefault(g => g.Targets.Contains(Path.Combine(dir1, "Design1.xml")));
Assert.That(group1, Is.Not.Null);
- Assert.That(group1.IdentifierFilePath, Is.EqualTo(Path.Combine(dir1, "id1.csv")));
+ Assert.That(group1.IdentifierFilePath, Is.EqualTo(Path.Combine(dir1, "Design1.csv")));
DesignFileCollection group2 = result.FirstOrDefault(g => g.Targets.Contains(Path.Combine(dir2, "Design2.xml")));
Assert.That(group2, Is.Not.Null);
Assert.That(group2.IdentifierFilePath, Is.EqualTo("fallback.csv"));
DesignFileCollection group3 = result.FirstOrDefault(g => g.Targets.Contains(Path.Combine(dir3, "Design3.xml")));
Assert.That(group3, Is.Not.Null);
- Assert.That(group3.IdentifierFilePath, Is.EqualTo(Path.Combine(dir3, "id3.csv")));
+ Assert.That(group3.IdentifierFilePath, Is.EqualTo(Path.Combine(dir3, "Design3.csv")));
}
///
@@ -411,7 +480,7 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
};
var identifierFiles = new List
{
- Path.Combine(dir2, "identifiers_dir2.csv")
+ Path.Combine(dir2, "DesignC.csv")
};
var options = new DesignFileOptions
{
@@ -429,16 +498,16 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
var result = collection.Group(identifierFiles).ToList();
// Assert
Assert.That(result, Is.Not.Null);
- Assert.That(result, Has.Count.EqualTo(2));
+ Assert.That(result, Has.Count.EqualTo(5));
DesignFileCollection group1 = result.FirstOrDefault(g => g.Targets.Contains(Path.Combine(dir1, "DesignA.xml")));
Assert.That(group1, Is.Not.Null);
- Assert.That(group1.Targets, Has.Count.EqualTo(2));
+ Assert.That(group1.Targets, Has.Count.EqualTo(1));
Assert.That(group1.IdentifierFilePath, Is.EqualTo("global_fallback.csv"));
Assert.That(group1.Options, Is.EqualTo(options));
DesignFileCollection group2 = result.FirstOrDefault(g => g.Targets.Contains(Path.Combine(dir2, "DesignC.xml")));
Assert.That(group2, Is.Not.Null);
- Assert.That(group2.Targets, Has.Count.EqualTo(3));
- Assert.That(group2.IdentifierFilePath, Is.EqualTo(Path.Combine(dir2, "identifiers_dir2.csv")));
+ Assert.That(group2.Targets, Has.Count.EqualTo(1));
+ Assert.That(group2.IdentifierFilePath, Is.EqualTo(Path.Combine(dir2, "DesignC.csv")));
Assert.That(group2.Options, Is.EqualTo(options));
}
@@ -498,7 +567,7 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
/// Tests that Group handles design files with relative paths correctly.
///
[Test]
- public void Group_DesignFilesWithRelativePaths_GroupsByDirectory()
+ public void GroupDesignFilesWithRelativePathsEmitsEachTarget()
{
// Arrange
var designFiles = new List
@@ -517,7 +586,7 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
var result = collection.Group().ToList();
// Assert
Assert.That(result, Is.Not.Null);
- Assert.That(result, Has.Count.EqualTo(2));
+ Assert.That(result, Has.Count.EqualTo(3));
}
///
diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/GenerateStackTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/GenerateStackTests.cs
index 85c7d5fa2c..e133f873d2 100644
--- a/tests/Opc.Ua.SourceGeneration.Core.Tests/GenerateStackTests.cs
+++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/GenerateStackTests.cs
@@ -75,6 +75,35 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
GenerateStack(generationType, telemetry, out _);
}
+ [Test]
+ public void EncodedTicketUsesByteStringSupertype()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
+ Dictionary generatedText = GenerateStack(
+ StackGenerationType.Models,
+ telemetry,
+ out _);
+ string nodeStates = generatedText.Single(pair =>
+ pair.Key.EndsWith(
+ "Opc.Ua.NodeStates.ex.g.cs",
+ System.StringComparison.Ordinal)).Value;
+ const string declaration =
+ "internal static global::Opc.Ua.DataTypeState CreateEncodedTicket(";
+ int declarationIndex = nodeStates.IndexOf(
+ declaration,
+ System.StringComparison.Ordinal);
+
+ Assert.That(declarationIndex, Is.GreaterThanOrEqualTo(0));
+ int initializerEnd = System.Math.Min(
+ declarationIndex + 1500,
+ nodeStates.Length);
+ string encodedTicketInitializer =
+ nodeStates[declarationIndex..initializerEnd];
+ Assert.That(
+ encodedTicketInitializer,
+ Does.Contain("state.SuperTypeId = global::Opc.Ua.NodeId.Create(15u,"));
+ }
+
[Theory]
public async Task GenerateAndCompileStackTestAsync(
OptimizationLevel optimizationLevel,
diff --git a/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs b/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs
index 3473def2d3..41cbcbe75f 100644
--- a/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs
+++ b/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs
@@ -141,6 +141,62 @@ namespace Opc.Ua.SourceGeneration
Assert.That(generatorResult.GeneratedSources, Has.Length.EqualTo(19));
}
+ [Test]
+ public void SameDirectoryModelDesignFilesGenerateEveryModelTest()
+ {
+ var options = new AnalyzerOptionsProvider(
+ new Dictionary
+ {
+ ["build_property.ModelSourceGeneratorVersion"] = "v105",
+ ["build_property.ModelSourceGeneratorExclude"] = "Draft",
+ ["build_property.ModelSourceGeneratorUseAllowSubtypes"] = "true",
+ ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true",
+ ["build_property.ModelSourceGeneratorOmitEventRecords"] = "true"
+ });
+
+ (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) =
+ RunGeneratorLeniently(
+ options,
+ [
+ EmbeddedText.From("OpcUaGdsModel.xml"),
+ EmbeddedText.From("OpcUaGdsModel.csv"),
+ EmbeddedText.From("OpcUaOnboardingModel.xml"),
+ EmbeddedText.From("OpcUaOnboardingModel.csv")
+ ]);
+
+ Assert.That(
+ diagnostics,
+ Is.Empty,
+ string.Join("\n", diagnostics.Select(diagnostic => diagnostic.ToString())));
+ GeneratorRunResult result = runResult.Results[0];
+ Assert.That(result.Exception, Is.Null);
+ string generated = string.Join(
+ "\n",
+ result.GeneratedSources.Select(source => source.SourceText.ToString()));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ result.GeneratedSources.Any(source =>
+ source.HintName == "Opc.Ua.Gds.Identifiers.g.cs"),
+ Is.True);
+ Assert.That(
+ result.GeneratedSources.Any(source =>
+ source.HintName == "Opc.Ua.Onboarding.Identifiers.g.cs"),
+ Is.True);
+ Assert.That(
+ generated,
+ Does.Contain("public const uint ApplicationRecordDataType = 1;"));
+ Assert.That(
+ generated,
+ Does.Contain("public const uint DeviceRegistrar = 1344;"));
+ Assert.That(
+ generated,
+ Does.Contain(
+ "global::Opc.Ua.ArrayOf tickets"));
+ });
+ }
+
[Test]
public void GenerateAndCompileDemoModelWotNativeProjectionTest()
{
diff --git a/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj b/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj
index a6a684c90f..757dd7a9d8 100644
--- a/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj
+++ b/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj
@@ -40,6 +40,9 @@
PreserveNewest
Resources\%(Filename)%(Extension)
+
+ Resources\%(Filename)%(Extension)
+
diff --git a/tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml b/tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml
index 8332aaed11..8e49c93e53 100644
--- a/tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml
+++ b/tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml
@@ -8665,7 +8665,7 @@
-
+
diff --git a/tools/Opc.Ua.SourceGeneration.Core/DesignFile.cs b/tools/Opc.Ua.SourceGeneration.Core/DesignFile.cs
index 11fe02a46d..156e6cba0b 100644
--- a/tools/Opc.Ua.SourceGeneration.Core/DesignFile.cs
+++ b/tools/Opc.Ua.SourceGeneration.Core/DesignFile.cs
@@ -151,10 +151,10 @@ namespace Opc.Ua.SourceGeneration
internal static class DesignFileExtensions
{
///
- /// Get design file groups for processing. A group is a set of design files
- /// in the same common folder with an optional csv file included. Each group
- /// carries the same so that
- /// cross-model references resolve for every target.
+ /// Gets design file groups for processing. Each target is emitted as an
+ /// independent model and is paired with an adjacent identifier file that
+ /// has the same base name. Every other target remains available as a
+ /// dependency so cross-model references resolve in either direction.
///
///
/// is null.
@@ -166,33 +166,90 @@ namespace Opc.Ua.SourceGeneration
{
throw new ArgumentNullException(nameof(collection));
}
- var idFiles = new Dictionary>();
- if (identifierFiles != null)
- {
- foreach (string idFile in identifierFiles)
- {
- string dir = Path.GetDirectoryName(idFile);
- if (!idFiles.TryGetValue(dir, out List value))
- {
- value = [];
- idFiles[dir] = value;
- }
- value.Add(idFile);
- }
- }
+ IReadOnlyList idFiles = identifierFiles ?? [];
IReadOnlyList dependencies = collection.Dependencies ?? [];
+ IReadOnlyList targets = collection.Targets;
return collection.Targets
- .GroupBy(Path.GetDirectoryName)
- .Select(g => new DesignFileCollection
+ .Select(target => new DesignFileCollection
{
- Targets = [.. g],
- Dependencies = dependencies,
- IdentifierFilePath = idFiles.TryGetValue(g.Key, out List files) ?
- files.FirstOrDefault() : collection.IdentifierFilePath,
+ Targets = [target],
+ Dependencies = [.. dependencies
+ .Concat(targets)
+ .Where(path => !string.Equals(path, target, StringComparison.Ordinal))
+ .Distinct(StringComparer.Ordinal)],
+ IdentifierFilePath = ResolveIdentifierFile(
+ target,
+ idFiles,
+ collection.IdentifierFilePath,
+ IsSoleTargetInDirectory(target, targets)),
Options = collection.Options
});
}
+ private static string ResolveIdentifierFile(
+ string target,
+ IReadOnlyList identifierFiles,
+ string fallback,
+ bool useSoleAdjacentFallback)
+ {
+ string targetDirectory = Path.GetDirectoryName(target) ?? string.Empty;
+ string targetName = Path.GetFileNameWithoutExtension(target);
+ string basenameMatch = identifierFiles.FirstOrDefault(path =>
+ string.Equals(
+ Path.GetDirectoryName(path) ?? string.Empty,
+ targetDirectory,
+ StringComparison.Ordinal) &&
+ string.Equals(
+ Path.GetFileNameWithoutExtension(path),
+ targetName,
+ StringComparison.Ordinal) &&
+ string.Equals(
+ Path.GetExtension(path),
+ ".csv",
+ StringComparison.OrdinalIgnoreCase));
+ if (basenameMatch != null)
+ {
+ return basenameMatch;
+ }
+ if (useSoleAdjacentFallback)
+ {
+ string[] adjacentFiles = [.. identifierFiles.Where(path =>
+ string.Equals(
+ Path.GetDirectoryName(path) ?? string.Empty,
+ targetDirectory,
+ StringComparison.Ordinal))];
+ if (adjacentFiles.Length == 1)
+ {
+ return adjacentFiles[0];
+ }
+ }
+ return fallback;
+ }
+
+ private static bool IsSoleTargetInDirectory(
+ string target,
+ IReadOnlyList targets)
+ {
+ string targetDirectory = Path.GetDirectoryName(target) ?? string.Empty;
+ bool foundTarget = false;
+ foreach (string path in targets)
+ {
+ if (!string.Equals(
+ Path.GetDirectoryName(path) ?? string.Empty,
+ targetDirectory,
+ StringComparison.Ordinal))
+ {
+ continue;
+ }
+ if (foundTarget)
+ {
+ return false;
+ }
+ foundTarget = true;
+ }
+ return foundTarget;
+ }
+
///
/// Validates the model design files
///