Files
Marc Schier 4756e7c245 DI (OPC 10000-100) with samples (Pump, SW Update), fluent API gap closures and model dependency implementation (#3808)
This PR implements the **OPC UA Device Integration (DI, OPC 10000-100)**
companion-spec library trio (`Opc.Ua.Di` / `Opc.Ua.Di.Server` /
`Opc.Ua.Di.Client`) plus a worked example
(`Applications/MinimalPumpServer`) demonstrating the OPC 40223 Pumps
companion specification end-to-end with full simulation. It also closes
11 fluent-API gaps in `Opc.Ua.Server.Fluent`, three source-generator
bugs, integrates the DI layer into the unified `services.AddOpcUa()`
Microsoft.Extensions DI hosting pattern, and adds 250+ new tests.

**Worked example — `Applications/MinimalPumpServer/`**
- AOT-compatible console host using `Host.CreateApplicationBuilder` +
`AddOpcUa()`.
- `PumpNodeManager` inherits from `DiNodeManager`; multi-namespace (DI +
Machinery + Pumps) via runtime NodeSet2 loading and
`ModelLoaderBuilder`.
- Demonstrates declarative second-pump creation via
`.ConfigureDevicesFor<PumpNodeManager>(...)` and software-package
seeding from the same configurator.
- Full simulation: pressure, temperatures, power, flow, efficiency,
level, cavitation/overheat fault injection, alarm setup, NAMUR-style
supervision.

**Documentation**
- `Docs/Opc.Ua.Di/` — README, DeviceBuilder, Hosting, LockService,
SoftwareUpdate, ClientHelpers (6 new developer guides).
- `Docs/Opc.Ua.Di/ComplianceMatrix.md` — every DI declaration mapped to
In-v1 / Generated / Deferred / Excluded.
- `Docs/CompanionSpecLibraries.md` — packaging guide for new
companion-spec libraries.
- `Docs/FluentApiGaps.md` — historical record of G1–G11.
- `Docs/SourceGeneratedNodeManagers.md` updated for richer node
managers.
- `Docs/plans/StateMachineBuilder.md` — deferred state-machine builder
design.
2026-06-04 05:57:57 +02:00

560 lines
21 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 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.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Opc.Ua.Client.Roles
{
/// <summary>
/// Async client over the OPC UA Part 18 §4 role-management methods.
/// Wraps an <see cref="ISession"/> and dispatches against the standard
/// <c>Server.ServerCapabilities.RoleSet</c> object.
/// </summary>
public sealed class RoleManagementClient : IRoleManagementClient
{
/// <summary>
/// Creates a new client rooted at the server's
/// <c>Server.ServerCapabilities.RoleSet</c> (NodeId i=15606).
/// </summary>
public RoleManagementClient(ISession session)
{
Session = session ?? throw new ArgumentNullException(nameof(session));
}
/// <summary>
/// The session used for all service calls.
/// </summary>
public ISession Session { get; }
private static NodeId RoleSetId => ObjectIds.Server_ServerCapabilities_RoleSet;
private static NodeId AddRoleMethodId
=> MethodIds.Server_ServerCapabilities_RoleSet_AddRole;
private static NodeId RemoveRoleMethodId
=> MethodIds.Server_ServerCapabilities_RoleSet_RemoveRole;
/// <inheritdoc/>
public async ValueTask<IReadOnlyList<RoleInfo>> ListRolesAsync(
CancellationToken cancellationToken = default)
{
// Browse the RoleSet for its HasComponent role children (RoleType
// instances). Each child's NodeId + browse name is returned;
// properties are then read in a single call per role to keep the
// round-trip count proportional to the number of roles.
BrowseDescription[] browseDescriptions =
[
new BrowseDescription
{
NodeId = RoleSetId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)NodeClass.Object,
ResultMask = (uint)BrowseResultMask.All
}
];
BrowseResponse browseResponse = await Session.BrowseAsync(
null,
null,
requestedMaxReferencesPerNode: 0,
ArrayOf.Wrapped(browseDescriptions),
cancellationToken).ConfigureAwait(false);
ClientBase.ValidateResponse<BrowseDescription, BrowseResult>(
browseResponse.Results, browseDescriptions);
ArrayOf<ReferenceDescription> references = browseResponse.Results[0].References;
var roles = new List<RoleInfo>(references.Count);
// Materialize references to a regular list so the async loop below
// does not capture a span enumerator across await boundaries.
var materializedRefs = new List<ReferenceDescription>(references.Count);
foreach (ReferenceDescription reference in references)
{
materializedRefs.Add(reference);
}
foreach (ReferenceDescription reference in materializedRefs)
{
var roleId = ExpandedNodeId.ToNodeId(reference.NodeId, Session.NamespaceUris);
if (roleId.IsNull)
{
continue;
}
if (!reference.TypeDefinition.IsNull &&
ExpandedNodeId.ToNodeId(reference.TypeDefinition, Session.NamespaceUris) != ObjectTypeIds.RoleType)
{
continue;
}
RoleInfo info = await ReadRoleAsync(roleId, cancellationToken).ConfigureAwait(false);
roles.Add(info);
}
return roles;
}
/// <inheritdoc/>
public async ValueTask<RoleInfo> ReadRoleAsync(
NodeId roleId,
CancellationToken cancellationToken = default)
{
if (roleId.IsNull)
{
throw new ArgumentNullException(nameof(roleId));
}
// Resolve the property NodeIds via a single TranslateBrowsePath batch.
string[] propertyNames =
[
BrowseNames.Identities,
BrowseNames.Applications,
BrowseNames.ApplicationsExclude,
BrowseNames.Endpoints,
BrowseNames.EndpointsExclude,
BrowseNames.CustomConfiguration
];
var browsePaths = new List<BrowsePath>(propertyNames.Length);
foreach (string name in propertyNames)
{
browsePaths.Add(new BrowsePath
{
StartingNode = roleId,
RelativePath = new RelativePath(new QualifiedName(name))
});
}
TranslateBrowsePathsToNodeIdsResponse pathResponse = await Session
.TranslateBrowsePathsToNodeIdsAsync(
null,
ArrayOf.Wrapped(browsePaths.ToArray()),
cancellationToken).ConfigureAwait(false);
ClientBase.ValidateResponse<BrowsePath, BrowsePathResult>(pathResponse.Results, browsePaths);
// Build the Read batch — BrowseName plus each resolved property
// NodeId. Properties that don't exist (optional) are skipped.
var nodesToRead = new List<ReadValueId>
{
new() { NodeId = roleId, AttributeId = Attributes.BrowseName }
};
var resolved = new NodeId[propertyNames.Length];
for (int i = 0; i < propertyNames.Length; i++)
{
BrowsePathResult pathResult = pathResponse.Results[i];
if (StatusCode.IsGood(pathResult.StatusCode) &&
pathResult.Targets.Count > 0 &&
!pathResult.Targets[0].TargetId.IsNull)
{
resolved[i] = ExpandedNodeId.ToNodeId(
pathResult.Targets[0].TargetId, Session.NamespaceUris);
nodesToRead.Add(new ReadValueId
{
NodeId = resolved[i],
AttributeId = Attributes.Value
});
}
}
ReadResponse readResponse = await Session.ReadAsync(
null,
0,
TimestampsToReturn.Neither,
ArrayOf.Wrapped(nodesToRead.ToArray()),
cancellationToken).ConfigureAwait(false);
ClientBase.ValidateResponse<ReadValueId, DataValue>(readResponse.Results, nodesToRead);
ArrayOf<DataValue> results = readResponse.Results;
QualifiedName browseName = results[0].WrappedValue.TryGetValue(out QualifiedName qn)
? qn
: new QualifiedName(string.Empty);
int readIndex = 1;
IReadOnlyList<IdentityMappingRuleType> identities = [];
IReadOnlyList<string> applications = [];
bool applicationsExclude = true;
IReadOnlyList<EndpointType> endpoints = [];
bool endpointsExclude = true;
bool customConfiguration = false;
for (int i = 0; i < propertyNames.Length; i++)
{
if (resolved[i].IsNull)
{
continue;
}
DataValue dv = results[readIndex++];
switch (propertyNames[i])
{
case BrowseNames.Identities:
identities = ReadStructureArray<IdentityMappingRuleType>(dv);
break;
case BrowseNames.Applications:
applications = ReadStringArray(dv);
break;
case BrowseNames.ApplicationsExclude:
applicationsExclude = ReadOptionalBool(dv, defaultValue: true);
break;
case BrowseNames.Endpoints:
endpoints = ReadStructureArray<EndpointType>(dv);
break;
case BrowseNames.EndpointsExclude:
endpointsExclude = ReadOptionalBool(dv, defaultValue: true);
break;
case BrowseNames.CustomConfiguration:
customConfiguration = ReadOptionalBool(dv, defaultValue: false);
break;
}
}
return new RoleInfo(
roleId,
browseName,
identities,
applications,
applicationsExclude,
endpoints,
endpointsExclude,
customConfiguration);
}
/// <inheritdoc/>
public async ValueTask<NodeId> AddRoleAsync(
string roleName,
string? namespaceUri = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(roleName))
{
throw new ArgumentException("RoleName is required.", nameof(roleName));
}
ArrayOf<Variant> output = await Session.CallAsync(
RoleSetId,
AddRoleMethodId,
cancellationToken,
Variant.From(roleName),
Variant.From(namespaceUri ?? string.Empty)).ConfigureAwait(false);
if (output.Count == 0 || !output[0].TryGetValue(out NodeId newRoleId))
{
throw new ServiceResultException(StatusCodes.BadDecodingError,
"AddRole did not return a NodeId.");
}
return newRoleId;
}
/// <inheritdoc/>
public async ValueTask RemoveRoleAsync(NodeId roleId, CancellationToken cancellationToken = default)
{
if (roleId.IsNull)
{
throw new ArgumentNullException(nameof(roleId));
}
await Session.CallAsync(
RoleSetId,
RemoveRoleMethodId,
cancellationToken,
Variant.From(roleId)).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask AddIdentityAsync(
NodeId roleId,
IdentityMappingRuleType rule,
CancellationToken cancellationToken = default)
{
if (rule == null)
{
throw new ArgumentNullException(nameof(rule));
}
NodeId methodId = await ResolveMethodIdAsync(roleId, BrowseNames.AddIdentity, cancellationToken)
.ConfigureAwait(false);
await Session.CallAsync(
roleId,
methodId,
cancellationToken,
Variant.FromStructure(rule)).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask RemoveIdentityAsync(
NodeId roleId,
IdentityMappingRuleType rule,
CancellationToken cancellationToken = default)
{
if (rule == null)
{
throw new ArgumentNullException(nameof(rule));
}
NodeId methodId = await ResolveMethodIdAsync(roleId, BrowseNames.RemoveIdentity, cancellationToken)
.ConfigureAwait(false);
await Session.CallAsync(
roleId,
methodId,
cancellationToken,
Variant.FromStructure(rule)).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask AddApplicationAsync(
NodeId roleId,
string applicationUri,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(applicationUri))
{
throw new ArgumentException("ApplicationUri is required.", nameof(applicationUri));
}
NodeId methodId = await ResolveMethodIdAsync(roleId, BrowseNames.AddApplication, cancellationToken)
.ConfigureAwait(false);
await Session.CallAsync(
roleId,
methodId,
cancellationToken,
Variant.From(applicationUri)).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask RemoveApplicationAsync(
NodeId roleId,
string applicationUri,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(applicationUri))
{
throw new ArgumentException("ApplicationUri is required.", nameof(applicationUri));
}
NodeId methodId = await ResolveMethodIdAsync(roleId, BrowseNames.RemoveApplication, cancellationToken)
.ConfigureAwait(false);
await Session.CallAsync(
roleId,
methodId,
cancellationToken,
Variant.From(applicationUri)).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask AddEndpointAsync(
NodeId roleId,
EndpointType endpoint,
CancellationToken cancellationToken = default)
{
if (endpoint == null)
{
throw new ArgumentNullException(nameof(endpoint));
}
NodeId methodId = await ResolveMethodIdAsync(roleId, BrowseNames.AddEndpoint, cancellationToken)
.ConfigureAwait(false);
await Session.CallAsync(
roleId,
methodId,
cancellationToken,
Variant.FromStructure(endpoint)).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask RemoveEndpointAsync(
NodeId roleId,
EndpointType endpoint,
CancellationToken cancellationToken = default)
{
if (endpoint == null)
{
throw new ArgumentNullException(nameof(endpoint));
}
NodeId methodId = await ResolveMethodIdAsync(roleId, BrowseNames.RemoveEndpoint, cancellationToken)
.ConfigureAwait(false);
await Session.CallAsync(
roleId,
methodId,
cancellationToken,
Variant.FromStructure(endpoint)).ConfigureAwait(false);
}
/// <inheritdoc/>
public ValueTask SetApplicationsExcludeAsync(
NodeId roleId,
bool value,
CancellationToken cancellationToken = default)
{
return WritePropertyAsync(roleId, BrowseNames.ApplicationsExclude, Variant.From(value), cancellationToken);
}
/// <inheritdoc/>
public ValueTask SetEndpointsExcludeAsync(
NodeId roleId,
bool value,
CancellationToken cancellationToken = default)
{
return WritePropertyAsync(roleId, BrowseNames.EndpointsExclude, Variant.From(value), cancellationToken);
}
/// <inheritdoc/>
public ValueTask SetCustomConfigurationAsync(
NodeId roleId,
bool value,
CancellationToken cancellationToken = default)
{
return WritePropertyAsync(roleId, BrowseNames.CustomConfiguration, Variant.From(value), cancellationToken);
}
private ValueTask<NodeId> ResolveMethodIdAsync(
NodeId roleId,
string browseName,
CancellationToken cancellationToken)
{
return ResolveChildAsync(roleId, browseName, NodeClass.Method, cancellationToken);
}
private async ValueTask<NodeId> ResolveChildAsync(
NodeId parentId,
string browseName,
NodeClass expectedClass,
CancellationToken cancellationToken)
{
BrowsePath[] browsePaths =
[
new BrowsePath
{
StartingNode = parentId,
RelativePath = new RelativePath(new QualifiedName(browseName))
}
];
TranslateBrowsePathsToNodeIdsResponse response = await Session
.TranslateBrowsePathsToNodeIdsAsync(
null,
ArrayOf.Wrapped(browsePaths),
cancellationToken).ConfigureAwait(false);
ClientBase.ValidateResponse<BrowsePath, BrowsePathResult>(response.Results, browsePaths);
BrowsePathResult result = response.Results[0];
if (StatusCode.IsBad(result.StatusCode))
{
throw new ServiceResultException(result.StatusCode,
$"Cannot resolve {browseName} on {parentId}: {result.StatusCode}");
}
if (result.Targets.Count == 0 || result.Targets[0].TargetId.IsNull)
{
throw new ServiceResultException(StatusCodes.BadNotFound,
$"Child '{browseName}' not found on {parentId}.");
}
return ExpandedNodeId.ToNodeId(result.Targets[0].TargetId, Session.NamespaceUris);
}
private async ValueTask WritePropertyAsync(
NodeId parentId,
string browseName,
Variant value,
CancellationToken cancellationToken)
{
NodeId propertyId = await ResolveChildAsync(parentId, browseName, NodeClass.Variable, cancellationToken)
.ConfigureAwait(false);
WriteValue[] writes =
[
new WriteValue
{
NodeId = propertyId,
AttributeId = Attributes.Value,
Value = new DataValue(value)
}
];
WriteResponse response = await Session.WriteAsync(
null,
ArrayOf.Wrapped(writes),
cancellationToken).ConfigureAwait(false);
ClientBase.ValidateResponse<WriteValue, StatusCode>(response.Results, writes);
if (StatusCode.IsBad(response.Results[0]))
{
throw new ServiceResultException(response.Results[0],
$"Write to {browseName} failed: {response.Results[0]}");
}
}
private static List<T> ReadStructureArray<T>(DataValue dv) where T : class, IEncodeable, new()
{
if (StatusCode.IsBad(dv.StatusCode))
{
return [];
}
if (dv.WrappedValue.TryGetStructure(out ArrayOf<T> typed))
{
if (typed.Count == 0)
{
return [];
}
var list = new List<T>(typed.Count);
foreach (T item in typed)
{
list.Add(item);
}
return list;
}
if (dv.WrappedValue.TryGetValue(out ArrayOf<ExtensionObject> extArr))
{
var list = new List<T>(extArr.Count);
foreach (ExtensionObject ext in extArr)
{
if (ext.TryGetValue(out T? item) && item != null)
{
list.Add(item);
}
}
return list;
}
return [];
}
private static List<string> ReadStringArray(DataValue dv)
{
if (StatusCode.IsBad(dv.StatusCode))
{
return [];
}
if (!dv.WrappedValue.TryGetValue(out ArrayOf<string> arr) || arr.Count == 0)
{
return [];
}
var list = new List<string>(arr.Count);
foreach (string s in arr)
{
list.Add(s);
}
return list;
}
private static bool ReadOptionalBool(DataValue dv, bool defaultValue)
{
if (StatusCode.IsBad(dv.StatusCode))
{
return defaultValue;
}
return dv.WrappedValue.TryGetValue(out bool b) ? b : defaultValue;
}
}
}