Fix OPC 10000-21 onboarding interoperability (#4385)

# Description

Fix the four blockers that prevented the OPC 10000-21 onboarding
registrar and client from working end to end:

- generate every `ModelDesign` target independently, including multiple
models in one directory, while preserving stable identifier CSV matching
and cross-model dependencies;
- ship the generated `Opc.Ua.Onboarding` constants, node states,
encodeables, and typed clients from `Opc.Ua.Gds.Common`;
- correct the standard `EncodedTicket` datatype to derive from
`ByteString`;
- align the new 2.0 `OnboardingClient` API with the generated
`ArrayOf<ByteString>` / `ArrayOf<StatusCode>` contract;
- make `BindToTicketStore` fully asynchronous, cancellation-aware, and
assign the declared output slot instead of appending a second output;
- add generated-model, client fallback, server binding, and in-process
end-to-end regressions.

The change also adds `samples/Gds/OnboardingRegistrar`,
`samples/Gds/OnboardingClient`, and `run-onboarding-demo.ps1`. The demo
loads the generated GDS and Onboarding models, restricts ticket
administration to an ephemeral `RegistrarAdmin` user, proves anonymous
access is denied, and completes a secure register/unregister round trip.

The History CI follow-up rejects negative and non-finite
processed-history intervals before provider dispatch and enforces the
buffered aggregate output cap while calculator results are flushed. This
prevents malformed requests from running until the client timeout,
exhausting memory, delaying server teardown, and causing later
alarm-event tests to fail.

## Related Issues

- Fixes #4378
- Unblocks OPCFoundation/UA-.NETStandard-Samples#819
- Tracks the canonical model correction in OPCFoundation/UA-Nodeset#122

## Validation

- Full `net10.0` test projects:
  - `Opc.Ua.SourceGeneration.Core.Tests`: 3803 passed, 8 skipped
  - `Opc.Ua.SourceGeneration.Tests`: 143 passed
  - `Opc.Ua.Gds.Tests`: 1114 passed, 42 skipped
- `Opc.Ua.History.Tests` with the CI coverage settings: 506 passed, 27
skipped
- `HistorianDispatcher` tests: 88 passed on `net10.0`.
- Invalid processed-history interval regressions passed on `net10.0` and
`net48`.
- Focused onboarding/source-generation regressions passed on `net48`.
- `dotnet build UA.slnx -p:CustomTestTarget=net10.0 --no-restore`
succeeded; the reported analyzer warnings are pre-existing in unchanged
XRegistry/WoT files.
- Packed all three GDS packages and verified the packaged
`Opc.Ua.Gds.Common` assembly contains the generated Onboarding model
surface.
- `pwsh samples/Gds/run-onboarding-demo.ps1` verified anonymous denial
and the authenticated secure ticket lifecycle.

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2d2c52c7-7040-4bd6-98de-aaf82ba7846b
This commit is contained in:
Marc Schier
2026-09-02 11:39:19 +02:00
committed by GitHub
parent 029e4ce7f3
commit b97fd8d4b5
32 changed files with 2191 additions and 517 deletions
+4
View File
@@ -20,6 +20,10 @@
<Folder Name="/samples/DI/">
<Project Path="samples/DI/PumpDeviceIntegrationServer/PumpDeviceIntegrationServer.csproj" />
</Folder>
<Folder Name="/samples/Gds/">
<Project Path="samples/Gds/OnboardingClient/OnboardingClient.csproj" />
<Project Path="samples/Gds/OnboardingRegistrar/OnboardingRegistrar.csproj" />
</Folder>
<Folder Name="/samples/Isa95/">
<Project Path="samples/Isa95/MinimalIsa95Server/MinimalIsa95Server.csproj" />
</Folder>
+47 -4
View File
@@ -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<StatusCode> results = await onboarding.RegisterTicketsAsync(
[
new ByteString(firstTicket),
new ByteString(secondTicket)
]);
```
The public contract follows Part 21: tickets are
`ArrayOf<ByteString>` (`EncodedTicket` is a ByteString subtype), and
the per-ticket results are `ArrayOf<StatusCode>`.
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<DeviceRegistrarState>().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)
+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<AssemblyName>OnboardingClient</AssemblyName>
<RootNamespace>OnboardingClientDemo</RootNamespace>
<Description>OPC 10000-21 onboarding client sample.</Description>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<RestrictForLegacyTfm>true</RestrictForLegacyTfm>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Opc.Ua.Configuration\Opc.Ua.Configuration.csproj" />
<ProjectReference Include="..\..\..\src\Opc.Ua.Client\Opc.Ua.Client.csproj" />
<ProjectReference Include="..\..\..\src\Opc.Ua.Gds.Common\Opc.Ua.Gds.Common.csproj" />
<ProjectReference Include="..\..\..\src\Opc.Ua.Gds.Client.Common\Opc.Ua.Gds.Client.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
</ItemGroup>
</Project>
+225
View File
@@ -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<CancellationToken, Task<ManagedSession>> connect =
host.Services.GetRequiredService<
Func<CancellationToken, Task<ManagedSession>>>();
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<NodeId, CancellationToken, ValueTask<OnboardingClient>>
createOnboardingClient = host.Services.GetRequiredService<
Func<NodeId, CancellationToken, ValueTask<OnboardingClient>>>();
OnboardingClient onboarding = await createOnboardingClient(
registrarId,
CancellationToken.None)
.ConfigureAwait(false);
ArrayOf<ByteString> tickets =
[
new ByteString(Encoding.UTF8.GetBytes("demo-ticket-one")),
new ByteString(Encoding.UTF8.GetBytes("demo-ticket-two"))
];
ArrayOf<StatusCode> 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<StatusCode> removed = await onboarding
.UnregisterTicketsAsync([tickets[0]])
.ConfigureAwait(false);
RequireStatus(removed, 0, StatusCodes.Good, "unregister ticket one");
Console.WriteLine($"UNREGISTER {removed[0]}");
ArrayOf<StatusCode> 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<StatusCode> 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}.");
}
}
@@ -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)]
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<AssemblyName>OnboardingRegistrar</AssemblyName>
<RootNamespace>OnboardingRegistrar</RootNamespace>
<Description>OPC 10000-21 onboarding registrar sample.</Description>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<RestrictForLegacyTfm>true</RestrictForLegacyTfm>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Opc.Ua.Configuration\Opc.Ua.Configuration.csproj" />
<ProjectReference Include="..\..\..\src\Opc.Ua.Server\Opc.Ua.Server.csproj" />
<ProjectReference Include="..\..\..\src\Opc.Ua.Gds.Common\Opc.Ua.Gds.Common.csproj" />
<ProjectReference Include="..\..\..\src\Opc.Ua.Gds.Server.Common\Opc.Ua.Gds.Server.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
</ItemGroup>
</Project>
@@ -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
{
/// <summary>
/// Creates the Part 21 onboarding registrar node manager.
/// </summary>
public sealed class OnboardingRegistrarNodeManagerFactory : IAsyncNodeManagerFactory
{
/// <summary>
/// Initializes a new factory.
/// </summary>
public OnboardingRegistrarNodeManagerFactory(ITicketStore ticketStore)
{
m_ticketStore = ticketStore ??
throw new ArgumentNullException(nameof(ticketStore));
}
/// <inheritdoc/>
public ArrayOf<string> NamespacesUris =>
[
Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding,
Opc.Ua.Gds.Namespaces.OpcUaGds
];
/// <inheritdoc/>
public ValueTask<IAsyncNodeManager> CreateAsync(
IServerInternal server,
ApplicationConfiguration configuration,
CancellationToken cancellationToken = default)
{
#pragma warning disable CA2000 // Ownership is transferred to the server.
return new ValueTask<IAsyncNodeManager>(
new OnboardingRegistrarNodeManager(
server,
configuration,
m_ticketStore));
#pragma warning restore CA2000
}
private readonly ITicketStore m_ticketStore;
}
/// <summary>
/// Loads the generated OPC 10000-21 model and binds its standard
/// registrar administration instance to an injected ticket store.
/// </summary>
public sealed class OnboardingRegistrarNodeManager : AsyncCustomNodeManager
{
/// <summary>
/// Initializes the node manager.
/// </summary>
public OnboardingRegistrarNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
ITicketStore ticketStore)
: base(
server,
configuration,
server.Telemetry.CreateLogger<OnboardingRegistrarNodeManager>())
{
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();
}
/// <inheritdoc/>
protected override ValueTask<NodeStateCollection> LoadPredefinedNodesAsync(
ISystemContext context,
CancellationToken cancellationToken = default)
{
NodeStateCollection nodes = new NodeStateCollection()
.AddOpcUaGds(context)
.AddOpcUaOnboarding(context);
DeviceRegistrarState registrar =
nodes.OfType<DeviceRegistrarState>().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<NodeStateCollection>(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<RolePermissionType> methodPermissions =
[
new RolePermissionType
{
RoleId = roleId,
Permissions = (uint)(PermissionType.Browse | PermissionType.Call)
}
];
administration.RegisterTickets!.RolePermissions = methodPermissions;
administration.UnregisterTickets!.RolePermissions = methodPermissions;
}
private readonly ITicketStore m_ticketStore;
}
/// <summary>
/// Emits the demo readiness marker after the OPC UA server reaches its
/// running state.
/// </summary>
public sealed class OnboardingReadyStartupTask : IServerStartupTask
{
/// <summary>
/// Initializes the startup task.
/// </summary>
public OnboardingReadyStartupTask(string endpoint)
{
m_endpoint = endpoint ??
throw new ArgumentNullException(nameof(endpoint));
}
/// <inheritdoc/>
public ValueTask OnServerStartedAsync(
IServerContext server,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"ONBOARDING_REGISTRAR_READY {m_endpoint}");
return default;
}
private readonly string m_endpoint;
}
/// <summary>
/// Grants the generated RegistrarAdmin role to the one ephemeral demo
/// account after its username/password token has been authenticated.
/// </summary>
public sealed class OnboardingRegistrarAdminAugmenter : IIdentityAugmenter
{
/// <summary>
/// Initializes the augmenter.
/// </summary>
public OnboardingRegistrarAdminAugmenter(string userName)
{
m_userName = userName ??
throw new ArgumentNullException(nameof(userName));
}
/// <inheritdoc/>
public ValueTask<AuthenticationResult> 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>(
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>(
AuthenticationResult.Accept(augmented));
}
private readonly string m_userName;
}
}
+143
View File
@@ -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<ITicketStore>(_ => new MemoryTicketStore());
builder.Services.AddSingleton<IUserDatabase>(userDatabase);
builder.Services.AddSingleton<IUserManagement>(services =>
new UserManagement(services.GetRequiredService<IUserDatabase>()));
builder.Services.AddSingleton<IServerStartupTask>(
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<OnboardingRegistrarNodeManagerFactory>();
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;
}
@@ -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)]
+104
View File
@@ -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<StatusCode> 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.
+149
View File
@@ -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
}
}
+7
View File
@@ -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.
+1 -1
View File
@@ -56432,7 +56432,7 @@
<Category>Onboarding Ticket Reader</Category>
<Documentation>https://reference.opcfoundation.org/v105/Core/docs/Part21/8.2.1</Documentation>
<References>
<Reference ReferenceType="HasSubtype" IsForward="false">i=12</Reference>
<Reference ReferenceType="HasSubtype" IsForward="false">i=15</Reference>
</References>
</UADataType>
<UAObjectType NodeId="i=26871" BrowseName="ProvisionableDeviceType">
@@ -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<ByteString>` ticket inputs and returns `ArrayOf<StatusCode>`.
Register it through `.AddGdsClient().AddOnboardingClient()`.
## Target frameworks
`net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`,
+52 -144
View File
@@ -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
{
/// <summary>
/// Client-side wrapper for the OPC 10000-100 Part 21
/// <c>DeviceRegistrarAdminType</c> facet. Calls
/// <c>RegisterTickets</c> / <c>UnregisterTickets</c> on the
/// server-side registrar and returns the per-ticket status
/// codes.
/// Client-side wrapper for the OPC 10000-21
/// <c>DeviceRegistrarAdminType</c> facet.
/// </summary>
public sealed class OnboardingClient
/// <remarks>
/// The specification-native <c>RegisterTickets</c> and
/// <c>UnregisterTickets</c> methods are inherited from
/// <see cref="DeviceRegistrarAdminTypeClient"/>.
/// </remarks>
public sealed class OnboardingClient : DeviceRegistrarAdminTypeClient
{
/// <summary>
/// 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;
}
/// <summary>
/// The owning session.
/// </summary>
public ISession Session { get; }
public new ISession Session => (ISession)base.Session;
/// <summary>
/// The NodeId of the registrar instance.
/// The NodeId of the registrar administration instance.
/// </summary>
public NodeId RegistrarNodeId { get; }
public NodeId RegistrarNodeId => ObjectId;
/// <summary>
/// Telemetry context.
/// </summary>
public ITelemetryContext Telemetry { get; }
/// <summary>
/// Invokes <c>RegisterTickets</c>. Returns the per-ticket
/// status array reported by the server.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="tickets"/> is <c>null</c>.</exception>
public ValueTask<int[]> 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;
}
/// <summary>
/// Invokes <c>UnregisterTickets</c>. Returns the per-ticket
/// status array.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="tickets"/> is <c>null</c>.</exception>
public ValueTask<int[]> 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<int[]> 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<int> ai => ai.ToArray() ?? [],
_ => []
};
}
private async ValueTask<NodeId> 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;
}
}
}
+10 -3
View File
@@ -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`,
@@ -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`,
@@ -29,16 +29,17 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Opc.Ua.Gds.Server.Onboarding
{
/// <summary>
/// Convenience extensions that wire an
/// <see cref="ITicketStore"/> + <see cref="IManagedApplicationRegistry"/>
/// pair to the OPC 10000-100 Part 21 <c>DeviceRegistrarAdminType</c>
/// Convenience extensions that wire an <see cref="ITicketStore"/>
/// to the OPC 10000-21 <c>DeviceRegistrarAdminType</c>
/// method children — <c>RegisterTickets</c> and
/// <c>UnregisterTickets</c>. Uses
/// <see cref="MethodState.OnCallMethod2"/> so the extension works
/// <see cref="MethodState.OnCallMethod2Async"/> so the extension works
/// against any concrete state derived from
/// <see cref="BaseObjectState"/> 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<ServiceResult> HandleRegisterAsync(
ITicketStore store,
ArrayOf<Variant> inputs,
List<Variant> outputs)
List<Variant> 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<ByteString> 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<ServiceResult> HandleUnregisterAsync(
ITicketStore store,
ArrayOf<Variant> inputs,
List<Variant> outputs)
List<Variant> 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<ByteString> 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<ByteString> 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.
@@ -35,8 +35,8 @@ using System.Threading.Tasks;
namespace Opc.Ua.Gds.Server.Onboarding
{
/// <summary>
/// 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
/// <c>BaseTicketType</c> / <c>DeviceIdentityTicketType</c> /
/// <c>CompositeIdentityTicketType</c> proxies in
/// <c>Opc.Ua.Di</c> remain the wire-level encoding. The store
/// works with the encoded ticket bytes (the spec's
/// <c>EncodedTicket</c> type — a <see cref="byte"/>[] alias).
/// <c>Opc.Ua.Onboarding</c> remain the wire-level encoding. The store
/// persists the bytes carried by the specification's
/// <c>EncodedTicket</c> ByteString subtype.
/// </para>
/// <para>
/// All implementations must be safe for concurrent calls.
@@ -67,8 +67,8 @@ namespace Opc.Ua.Gds.Server.Onboarding
/// ticket).
/// </param>
/// <param name="encodedTicket">
/// Encoded ticket bytes (DataType <c>EncodedTicket</c>, a
/// <see cref="byte"/>[] alias).
/// Encoded ticket bytes carried on the wire as the
/// <c>EncodedTicket</c> ByteString subtype.
/// </param>
/// <param name="metadata">
/// Application-supplied metadata for the ticket (kind,
@@ -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<DataValue> output, bool partial)
private static bool TryFlushCalculator(
IAggregateCalculator calculator,
List<DataValue> 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<List<DataValue>> CollectAllRawAsync(
@@ -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
{
/// <summary>
/// Tests for <see cref="DeviceRegistrarAdminExtensions.BindToTicketStore"/> —
/// wires the RegisterTickets / UnregisterTickets methods of a
/// <c>DeviceRegistrarAdminType</c> instance through an
/// <see cref="ITicketStore"/>.
/// Tests for <see cref="DeviceRegistrarAdminExtensions.BindToTicketStore"/>.
/// </summary>
[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<ServiceResultException>(
() => bare.BindToTicketStore(store));
() => bare.BindToTicketStore(new MemoryTicketStore()));
}
[Test]
public void BindRejectsNullArgs()
{
(BaseObjectState reg, _, _) = CreateRegistrar();
(_, DeviceRegistrarAdminState registrar) = CreateRegistrar();
Assert.Throws<ArgumentNullException>(
() => reg.BindToTicketStore(null!));
() => registrar.BindToTicketStore(null!));
BaseObjectState? nullState = null;
Assert.Throws<ArgumentNullException>(
() => 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<ByteString> 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<Variant> inputs = new[] { new Variant(ticketsBs.ToArrayOf()) }.ToArrayOf();
List<Variant> outputs = [];
ServiceResult result = register.OnCallMethod2!(
context: new SystemContext(telemetry: null!),
method: register,
objectId: reg.NodeId,
inputArguments: inputs,
outputArguments: outputs);
(ServiceResult result, List<ServiceResult> argumentErrors, List<Variant> 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>(ServiceResult.IsGood));
Assert.That(outputs, Has.Count.EqualTo(1));
int[] statuses = ((ArrayOf<int>)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<StatusCode> statuses),
Is.True);
Assert.That(
statuses,
Is.EqualTo(new StatusCode[]
{
StatusCodes.Good,
StatusCodes.Good
}.ToArrayOf()));
var stored = new List<TicketRecord>();
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<ByteString> tickets =
[
new ByteString(new byte[] { 0xAA })
];
byte[] ticket = [0xAA];
ByteString[] ticketsBs = [new ByteString(ticket)];
ArrayOf<Variant> registerInputs = new[]
{ new Variant(ticketsBs.ToArrayOf()) }.ToArrayOf();
List<Variant> 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<Variant> 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<StatusCode> 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<ITicketStore>(MockBehavior.Strict);
store
.Setup(s => s.AddAsync(
It.IsAny<string>(),
It.IsAny<byte[]>(),
It.IsAny<TicketMetadata>(),
It.IsAny<CancellationToken>()))
.Throws(new ServiceResultException(StatusCodes.BadEntryExists));
registrar.BindToTicketStore(store.Object);
(_, _, List<Variant> 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<StatusCode> 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<string> invalidTickets = ["not-a-ticket"];
(ServiceResult result, List<ServiceResult> argumentErrors, List<Variant> 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<ITicketStore>(MockBehavior.Strict);
store
.Setup(s => s.AddAsync(
It.IsAny<string>(),
It.IsAny<byte[]>(),
It.IsAny<TicketMetadata>(),
It.IsAny<CancellationToken>()))
.Returns<string, byte[], TicketMetadata, CancellationToken>(
(_, _, _, ct) =>
{
ct.ThrowIfCancellationRequested();
return default;
});
registrar.BindToTicketStore(store.Object);
using var cts = new CancellationTokenSource();
cts.Cancel();
List<Variant> outputs = [Variant.Null];
Assert.ThrowsAsync<OperationCanceledException>(
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<DeviceRegistrarState>().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<ServiceResult> ArgumentErrors,
List<Variant> Outputs)> CallAsync(
MethodState method,
ISystemContext context,
NodeId objectId,
Variant input)
{
var argumentErrors = new List<ServiceResult>();
var outputs = new List<Variant>();
ServiceResult result = await method.CallAsync(
context,
objectId,
[input],
argumentErrors,
outputs)
.ConfigureAwait(false);
return (result, argumentErrors, outputs);
}
}
internal static class AsyncEnumerableTestExtensions
{
public static async ValueTask<int> CountAsync<T>(
this IAsyncEnumerable<T> 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<Variant> unregisterInputs = new[]
{ new Variant(ticketsBs.ToArrayOf()) }.ToArrayOf();
List<Variant> unregisterOutputs = [];
unregister.OnCallMethod2!(
new SystemContext(telemetry: null!), unregister, reg.NodeId,
unregisterInputs, unregisterOutputs);
int[] statuses = ((ArrayOf<int>)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;
}
}
}
@@ -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<ISession> CreateSessionMock()
{
var mock = new Mock<ISession>();
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<ITelemetryContext>().Object;
}
private static void SetupTranslateToMethod(
Mock<ISession> sessionMock, NodeId methodId)
{
sessionMock
.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
It.IsAny<RequestHeader?>(),
It.IsAny<ArrayOf<BrowsePath>>(),
It.IsAny<CancellationToken>()))
.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<ISession> sessionMock, int[] statuses)
{
sessionMock
.Setup(s => s.CallAsync(
It.IsAny<RequestHeader?>(),
It.IsAny<ArrayOf<CallMethodRequest>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new CallResponse
{
Results = new CallMethodResult[]
{
new() {
StatusCode = StatusCodes.Good,
OutputArguments = new Variant[]
{
new(statuses.ToArrayOf())
}.ToArrayOf()
}
}.ToArrayOf()
});
}
[Test]
public void ConstructorRejectsNullSession()
{
Assert.Throws<ArgumentNullException>(
() => new OnboardingClient(null!, kRegistrarId, NullTelemetry()));
() => new OnboardingClient(null!, kRegistrarId, CreateTelemetry()));
}
[Test]
public void ConstructorRejectsNullRegistrarId()
{
Mock<ISession> s = CreateSessionMock();
Mock<ISession> session = CreateSessionMock(out _);
Assert.Throws<ArgumentException>(
() => new OnboardingClient(s.Object, NodeId.Null, NullTelemetry()));
() => new OnboardingClient(session.Object, NodeId.Null, CreateTelemetry()));
}
[Test]
public void ConstructorRejectsNullTelemetry()
{
Mock<ISession> s = CreateSessionMock();
Mock<ISession> session = CreateSessionMock(out _);
Assert.Throws<ArgumentNullException>(
() => new OnboardingClient(s.Object, kRegistrarId, null!));
() => new OnboardingClient(session.Object, kRegistrarId, null!));
}
[Test]
public async Task RegisterTicketsReturnsStatusArray()
public async Task RegisterTicketsUsesGeneratedTypeMethodAndNativeTypes()
{
Mock<ISession> session = CreateSessionMock();
SetupTranslateToMethod(session, new NodeId("Reg_Register", 2));
SetupCallReturnsStatuses(session,
Mock<ISession> session = CreateSessionMock(out IServiceMessageContext messageContext);
CallMethodRequest? observedRequest = null;
ArrayOf<StatusCode> 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<ByteString> 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<StatusCode> 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<ByteString> observedTickets),
Is.True);
Assert.That(observedTickets, Is.EqualTo(tickets));
session.Verify(s => s.TranslateBrowsePathsToNodeIdsAsync(
It.IsAny<RequestHeader>(),
It.IsAny<ArrayOf<BrowsePath>>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Test]
public async Task UnregisterTicketsReturnsStatusArray()
public async Task RegisterTicketsFallbackUsesOnboardingBrowseNameNamespace()
{
Mock<ISession> session = CreateSessionMock();
SetupTranslateToMethod(session, new NodeId("Reg_Unregister", 2));
SetupCallReturnsStatuses(session,
[
(int)(uint)StatusCodes.Good
]);
Mock<ISession> 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<ISession> 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<NodeId>();
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<RequestHeader?>(),
It.IsAny<RequestHeader>(),
It.IsAny<ArrayOf<BrowsePath>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new TranslateBrowsePathsToNodeIdsResponse
{
Results = new BrowsePathResult[]
.Returns<RequestHeader, ArrayOf<BrowsePath>, CancellationToken>(
(_, paths, _) =>
{
new() {
StatusCode = StatusCodes.BadNotFound,
Targets = []
}
}.ToArrayOf()
});
observedPath = paths[0];
return new ValueTask<TranslateBrowsePathsToNodeIdsResponse>(
new TranslateBrowsePathsToNodeIdsResponse
{
ResponseHeader = new ResponseHeader(),
Results =
[
new BrowsePathResult
{
StatusCode = StatusCodes.Good,
Targets =
[
new BrowsePathTarget
{
TargetId = new ExpandedNodeId(instanceMethodId),
RemainingPathIndex = uint.MaxValue
}
]
}
]
});
});
ArrayOf<StatusCode> statuses = await client
.RegisterTicketsAsync([new ByteString(new byte[] { 1 })])
.ConfigureAwait(false);
var client = new OnboardingClient(
session.Object, kRegistrarId, NullTelemetry());
Assert.ThrowsAsync<ServiceResultException>(
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<ISession> session = CreateSessionMock();
Mock<ISession> 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<ArgumentNullException>(
async () => await client.RegisterTicketsAsync(null!).ConfigureAwait(false));
ArrayOf<StatusCode> statuses = await client
.UnregisterTicketsAsync([new ByteString(new byte[] { 0xAA })])
.ConfigureAwait(false);
Assert.That(
statuses,
Is.EqualTo(new StatusCode[] { StatusCodes.BadNotFound }.ToArrayOf()));
}
private static Mock<ISession> 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<ISession>(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<ISession> session,
Func<CallMethodRequest, CallMethodResult> handler)
{
session
.Setup(s => s.CallAsync(
It.IsAny<RequestHeader>(),
It.IsAny<ArrayOf<CallMethodRequest>>(),
It.IsAny<CancellationToken>()))
.Returns<RequestHeader, ArrayOf<CallMethodRequest>, CancellationToken>(
(_, requests, _) =>
new ValueTask<CallResponse>(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
};
}
}
/// <summary>
/// Exercises the generated Part 21 model, client proxy fallback, method
/// validation and ticket-store bindings as one in-process call chain.
/// </summary>
[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<ISession> session = CreateSessionBridge(context, registrar);
var client = new OnboardingClient(
session.Object,
registrar.NodeId,
context.Telemetry);
ArrayOf<ByteString> tickets =
[
new ByteString("ticket-one"u8.ToArray()),
new ByteString("ticket-two"u8.ToArray())
];
ArrayOf<StatusCode> registered = await client
.RegisterTicketsAsync(tickets)
.ConfigureAwait(false);
ArrayOf<StatusCode> removed = await client
.UnregisterTicketsAsync([tickets[0]])
.ConfigureAwait(false);
ArrayOf<StatusCode> 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<TicketRecord>();
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<DeviceRegistrarState>().Single();
return (context, root.Administration ??
throw new InvalidOperationException(
"The generated DeviceRegistrar has no Administration child."));
}
private static Mock<ISession> CreateSessionBridge(
SystemContext context,
DeviceRegistrarAdminState registrar)
{
var session = new Mock<ISession>(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<RequestHeader>(),
It.IsAny<ArrayOf<BrowsePath>>(),
It.IsAny<CancellationToken>()))
.Returns<RequestHeader, ArrayOf<BrowsePath>, CancellationToken>(
(_, paths, _) =>
new ValueTask<TranslateBrowsePathsToNodeIdsResponse>(
ResolvePaths(context, registrar, paths)));
session
.Setup(s => s.CallAsync(
It.IsAny<RequestHeader>(),
It.IsAny<ArrayOf<CallMethodRequest>>(),
It.IsAny<CancellationToken>()))
.Returns<RequestHeader, ArrayOf<CallMethodRequest>, CancellationToken>(
(_, requests, ct) => InvokeMethodsAsync(
context,
registrar,
requests,
ct));
return session;
}
private static TranslateBrowsePathsToNodeIdsResponse ResolvePaths(
ISystemContext context,
DeviceRegistrarAdminState registrar,
ArrayOf<BrowsePath> 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<CallResponse> InvokeMethodsAsync(
ISystemContext context,
DeviceRegistrarAdminState registrar,
ArrayOf<CallMethodRequest> 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<ServiceResult>();
var outputs = new List<Variant>();
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;
}
}
}
@@ -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.")]
@@ -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<IHistorianProvider>();
Mock<IHistorianProcessedProvider> processedProvider =
provider.As<IHistorianProcessedProvider>();
processedProvider
.Setup(p => p.ReadProcessedAsync(
It.IsAny<HistorianOperationContext>(),
It.IsAny<HistorianProcessedReadRequest>(),
It.IsAny<HistorianResumeToken>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<HistorianPage<DataValue>>(HistorianPage<DataValue>.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<HistorianOperationContext>(),
It.IsAny<HistorianProcessedReadRequest>(),
It.IsAny<HistorianResumeToken>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Test]
public async Task DispatchProcessedReadWithReverseTimeRangeIsNotRejectedAsInvalidArgumentAsync()
{
@@ -128,10 +128,11 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
}
/// <summary>
/// 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.
/// </summary>
[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));
}
}
/// <summary>
@@ -256,10 +261,11 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
}
/// <summary>
/// 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.
/// </summary>
[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<string>
{
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")));
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// Tests that each single-model directory preserves its legacy
/// non-basename identifier file in a multi-directory collection.
/// </summary>
[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<DesignFileCollection> 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));
}
/// <summary>
/// Tests that unrelated identifier files in the same directory are
/// ignored in favour of the target's basename match.
/// </summary>
[Test]
public void GroupMultipleIdentifierFilesInSameDirectoryUsesBasenameMatch()
{
// Arrange
string dir = Path.Combine("C:", "TestDir");
var designFiles = new List<string>
{
Path.Combine(dir, "Design1.xml")
Path.Combine(dir, "Design2.xml")
};
var identifierFiles = new List<string>
{
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")));
}
/// <summary>
@@ -367,8 +436,8 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
};
var identifierFiles = new List<string>
{
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")));
}
/// <summary>
@@ -411,7 +480,7 @@ namespace Opc.Ua.SourceGeneration.Api.Tests
};
var identifierFiles = new List<string>
{
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.
/// </summary>
[Test]
public void Group_DesignFilesWithRelativePaths_GroupsByDirectory()
public void GroupDesignFilesWithRelativePathsEmitsEachTarget()
{
// Arrange
var designFiles = new List<string>
@@ -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));
}
/// <summary>
@@ -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<string, string> 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,
@@ -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<string, string>
{
["build_property.ModelSourceGeneratorVersion"] = "v105",
["build_property.ModelSourceGeneratorExclude"] = "Draft",
["build_property.ModelSourceGeneratorUseAllowSubtypes"] = "true",
["build_property.ModelSourceGeneratorOmitFluentApi"] = "true",
["build_property.ModelSourceGeneratorOmitEventRecords"] = "true"
});
(ImmutableArray<Diagnostic> 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<global::Opc.Ua.ByteString> tickets"));
});
}
[Test]
public void GenerateAndCompileDemoModelWotNativeProjectionTest()
{
@@ -40,6 +40,9 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>Resources\%(Filename)%(Extension)</Link>
</EmbeddedResource>
<EmbeddedResource Include="..\..\src\Opc.Ua.Gds.Common\Design\OpcUaGdsModel.*">
<Link>Resources\%(Filename)%(Extension)</Link>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Compile Include="..\Opc.Ua.SourceGeneration.Core.Tests\CompilerUtils.cs" />
@@ -8665,7 +8665,7 @@
<!-- Part 21 -->
<opc:DataType SymbolicName="EncodedTicket" BaseType="String" PartNo="21" />
<opc:DataType SymbolicName="EncodedTicket" BaseType="ByteString" PartNo="21" />
<opc:Method SymbolicName="RequestTicketsMethodType">
<opc:OutputArguments>
@@ -151,10 +151,10 @@ namespace Opc.Ua.SourceGeneration
internal static class DesignFileExtensions
{
/// <summary>
/// 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 <see cref="DesignFileCollection.Dependencies"/> 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.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="collection"/>
/// is <c>null</c>.</exception>
@@ -166,33 +166,90 @@ namespace Opc.Ua.SourceGeneration
{
throw new ArgumentNullException(nameof(collection));
}
var idFiles = new Dictionary<string, List<string>>();
if (identifierFiles != null)
{
foreach (string idFile in identifierFiles)
{
string dir = Path.GetDirectoryName(idFile);
if (!idFiles.TryGetValue(dir, out List<string> value))
{
value = [];
idFiles[dir] = value;
}
value.Add(idFile);
}
}
IReadOnlyList<string> idFiles = identifierFiles ?? [];
IReadOnlyList<string> dependencies = collection.Dependencies ?? [];
IReadOnlyList<string> 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<string> 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<string> 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<string> 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;
}
/// <summary>
/// Validates the model design files
/// </summary>