Auto: opcuaclient-9 — method node mirroring + Call passthrough

Adds the 9th capability interface (IMethodInvoker) so the OPC UA Client
driver can mirror upstream OPC UA Method nodes into the local address
space and forward Call invocations as Session.CallAsync. Method-bearing
servers (e.g. ProgramStateMachine, Acknowledge / Confirm methods, custom
control surfaces) now show up downstream instead of being silently
filtered out.

- Core.Abstractions: IMethodInvoker + MethodCallResult; default no-op
  IAddressSpaceBuilder.RegisterMethodNode + MirroredMethodNodeInfo +
  MethodArgumentInfo. Default impls keep tag-based drivers and existing
  builders compiling without forced overrides.
- OpcUaClientDriver: BrowseRecursiveAsync now lifts the Method node-class
  filter; for each method it walks HasProperty to pick up InputArguments
  + OutputArguments and decodes the Argument extension objects into
  MethodArgumentInfo. Status-codes pass through verbatim (cascading
  quality), local NodeId-parse + lost-session faults surface as
  BadNodeIdInvalid / BadCommunicationError.
- 7 new unit tests cover the capability surface, the DTO shapes, and the
  back-compat default no-op for RegisterMethodNode. Suite green at
  160/160.

Server-side OnCallMethod dispatch (wiring local MethodNode handlers to
IMethodInvoker.CallMethodAsync inside DriverNodeManager) is deferred to
a follow-up — the driver-side surface + browse mirror ship cleanly here.

Closes #281

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-25 20:52:39 -04:00
parent 0f3abed4c7
commit 07abee5f6d
4 changed files with 588 additions and 2 deletions

View File

@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// </para>
/// </remarks>
public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId)
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider, IDisposable, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider, IMethodInvoker, IDisposable, IAsyncDisposable
{
// ---- IAlarmSource state ----
@@ -1349,7 +1349,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method),
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
| BrowseResultMask.NodeClass | BrowseResultMask.TypeDefinition),
}
@@ -1408,9 +1408,153 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
pendingVariables.Add(new PendingVariable(folder, browseName, displayName, childId, fullName));
increment();
}
else if (rf.NodeClass == NodeClass.Method)
{
// Methods hang off Objects (the parent of this browse step). Walk HasProperty
// to harvest InputArguments / OutputArguments — both are standard properties
// on Method nodes — then project to the address-space builder. Best-effort:
// arguments that fail to read fall through to null so the method still
// registers (the dispatcher returns BadArgumentsMissing if a client tries
// to invoke it without the argument schema).
var (inputArgs, outputArgs) = await ReadMethodArgumentsAsync(session, childId, ct)
.ConfigureAwait(false);
var methodId = BuildRemappedFullName(childId, session.NamespaceUris,
_options.Curation.NamespaceRemap);
var ownerId = BuildRemappedFullName(node, session.NamespaceUris,
_options.Curation.NamespaceRemap);
folder.RegisterMethodNode(new MirroredMethodNodeInfo(
BrowseName: browseName,
DisplayName: displayName,
ObjectNodeId: ownerId,
MethodNodeId: methodId,
InputArguments: inputArgs,
OutputArguments: outputArgs));
increment();
}
}
}
/// <summary>
/// Read a method node's <c>InputArguments</c> and <c>OutputArguments</c> properties.
/// Both are standard <c>HasProperty</c> children of any <c>NodeClass.Method</c> node
/// in OPC UA — they carry the array-of-Argument structure the dispatcher needs to
/// surface a callable signature on the local method node.
/// </summary>
/// <returns>
/// A tuple of (InputArguments, OutputArguments). Either side may be <c>null</c> when
/// the method has no arguments of that kind (the property simply isn't present on the
/// upstream method) or when the read failed — both paths are non-fatal.
/// </returns>
private static async Task<(IReadOnlyList<MethodArgumentInfo>?, IReadOnlyList<MethodArgumentInfo>?)>
ReadMethodArgumentsAsync(ISession session, NodeId methodNodeId, CancellationToken ct)
{
// Browse the method's HasProperty children to find the InputArguments /
// OutputArguments property NodeIds. Standard browse-name-based lookup would also
// work but the property NodeIds aren't stable across servers, so we walk the
// references — the SDK gives us BrowseName + NodeId in the same response.
var browseDescriptions = new BrowseDescriptionCollection
{
new()
{
NodeId = methodNodeId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HasProperty,
IncludeSubtypes = true,
NodeClassMask = (uint)NodeClass.Variable,
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.NodeClass),
}
};
NodeId? inputPropId = null;
NodeId? outputPropId = null;
try
{
var resp = await session.BrowseAsync(
requestHeader: null,
view: null,
requestedMaxReferencesPerNode: 0,
nodesToBrowse: browseDescriptions,
ct: ct).ConfigureAwait(false);
if (resp.Results.Count == 0) return (null, null);
foreach (var rf in resp.Results[0].References)
{
var name = rf.BrowseName?.Name;
if (string.IsNullOrEmpty(name)) continue;
var propId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris);
if (NodeId.IsNull(propId)) continue;
if (string.Equals(name, BrowseNames.InputArguments, StringComparison.Ordinal))
inputPropId = propId;
else if (string.Equals(name, BrowseNames.OutputArguments, StringComparison.Ordinal))
outputPropId = propId;
}
}
catch
{
return (null, null);
}
if (inputPropId is null && outputPropId is null) return (null, null);
var nodesToRead = new ReadValueIdCollection();
if (inputPropId is not null)
nodesToRead.Add(new ReadValueId { NodeId = inputPropId, AttributeId = Attributes.Value });
if (outputPropId is not null)
nodesToRead.Add(new ReadValueId { NodeId = outputPropId, AttributeId = Attributes.Value });
DataValueCollection values;
try
{
var readResp = await session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Neither,
nodesToRead: nodesToRead,
ct: ct).ConfigureAwait(false);
values = readResp.Results;
}
catch
{
return (null, null);
}
var idx = 0;
IReadOnlyList<MethodArgumentInfo>? inputArgs = null;
IReadOnlyList<MethodArgumentInfo>? outputArgs = null;
if (inputPropId is not null)
{
inputArgs = ConvertArguments(values[idx++]);
}
if (outputPropId is not null)
{
outputArgs = ConvertArguments(values[idx]);
}
return (inputArgs, outputArgs);
}
/// <summary>
/// Convert an OPC UA <c>InputArguments</c>/<c>OutputArguments</c> property value
/// (an array of <c>Argument</c> wrapped in <c>ExtensionObject</c>) into the local
/// <see cref="MethodArgumentInfo"/> DTO. Returns <c>null</c> when the value can't be
/// decoded — non-fatal, the method still registers without arg metadata.
/// </summary>
private static IReadOnlyList<MethodArgumentInfo>? ConvertArguments(DataValue dv)
{
if (StatusCode.IsBad(dv.StatusCode)) return null;
if (dv.Value is not ExtensionObject[] extensionArray) return null;
var result = new List<MethodArgumentInfo>(extensionArray.Length);
foreach (var ext in extensionArray)
{
if (ext?.Body is not Argument arg) continue;
result.Add(new MethodArgumentInfo(
Name: arg.Name ?? string.Empty,
DriverDataType: MapUpstreamDataType(arg.DataType),
ValueRank: arg.ValueRank,
Description: arg.Description?.Text));
}
return result;
}
/// <summary>
/// Render a NodeId as the canonical <c>nsu=&lt;uri&gt;;…</c> string, applying the
/// configured upstream→local namespace-URI remap. Index-namespace nodes (<c>ns=0</c>,
@@ -1917,6 +2061,103 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
finally { _gate.Release(); }
}
// ---- IMethodInvoker ----
/// <summary>
/// Forward an OPC UA <c>Call</c> service invocation to the upstream server. The
/// method NodeId + object NodeId come from the address-space mirror set up during
/// discovery (<see cref="MirroredMethodNodeInfo"/>); input values are CLR primitives
/// wrapped into <see cref="Variant"/>s here so the cross-driver capability surface
/// stays SDK-free.
/// </summary>
/// <remarks>
/// <para>
/// Status-code passthrough: per <c>driver-specs.md</c> §8 (cascading quality), an
/// upstream Bad code is returned verbatim through <see cref="MethodCallResult.StatusCode"/>
/// rather than thrown — downstream OPC UA clients see the canonical service result
/// (<c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, <c>BadArgumentsMissing</c>,
/// …). Local-side faults (NodeId parse, lost session) surface the corresponding
/// <c>StatusBad*</c> constants the rest of the driver uses.
/// </para>
/// <para>
/// Per-argument validation results are unpacked into a <c>uint[]</c> so the
/// <c>Core.Abstractions</c> DTO stays SDK-free. <c>null</c> when the upstream
/// didn't surface per-argument codes (typical for Good calls).
/// </para>
/// </remarks>
public async Task<MethodCallResult> CallMethodAsync(
string objectNodeId, string methodNodeId, object[] inputs, CancellationToken cancellationToken)
{
var session = RequireSession();
if (!TryParseNodeId(session, objectNodeId, out var objId))
return new MethodCallResult(StatusBadNodeIdInvalid, null, null);
if (!TryParseNodeId(session, methodNodeId, out var methodId))
return new MethodCallResult(StatusBadNodeIdInvalid, null, null);
var inputVariants = new VariantCollection(
(inputs ?? Array.Empty<object>()).Select(v => new Variant(v)));
var callRequests = new CallMethodRequestCollection
{
new CallMethodRequest
{
ObjectId = objId,
MethodId = methodId,
InputArguments = inputVariants,
},
};
CallMethodResultCollection results;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
var resp = await session.CallAsync(
requestHeader: null,
methodsToCall: callRequests,
ct: cancellationToken).ConfigureAwait(false);
results = resp.Results;
}
catch
{
// Lost session / decode failure / cancellation. Surface a local
// BadCommunicationError so downstream clients can distinguish 'wire failed'
// from 'upstream rejected the call'.
return new MethodCallResult(StatusBadCommunicationError, null, null);
}
}
finally { _gate.Release(); }
if (results.Count == 0)
return new MethodCallResult(StatusBadInternalError, null, null);
var r = results[0];
// Unwrap output Variants into a CLR object[] so callers don't need an SDK dep.
object[]? outputs = null;
if (r.OutputArguments is { Count: > 0 })
{
outputs = new object[r.OutputArguments.Count];
for (var i = 0; i < r.OutputArguments.Count; i++)
outputs[i] = r.OutputArguments[i].Value!;
}
// Per-input-argument validation results. Most servers return an empty list on
// success; only populate the DTO field when the server actually surfaced per-arg
// codes so callers can use null as 'no per-argument feedback'.
uint[]? inputArgResults = null;
if (r.InputArgumentResults is { Count: > 0 })
{
inputArgResults = new uint[r.InputArgumentResults.Count];
for (var i = 0; i < r.InputArgumentResults.Count; i++)
inputArgResults[i] = r.InputArgumentResults[i].Code;
}
return new MethodCallResult(r.StatusCode.Code, outputs, inputArgResults);
}
private void OnEventNotification(
OpcUaAlarmSubscriptionHandle handle,
HashSet<string> sourceFilter,