Merge pull request '[opcuaclient] OpcUaClient — Method node mirroring + Call passthrough' (#361) from auto/opcuaclient/9 into auto/driver-gaps
This commit was merged in pull request #361.
This commit is contained in:
@@ -60,8 +60,90 @@ public interface IAddressSpaceBuilder
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
void RegisterTypeNode(MirroredTypeNodeInfo info) { /* default: no-op */ }
|
||||
|
||||
/// <summary>
|
||||
/// Register a method node mirrored from an upstream OPC UA server. The method is
|
||||
/// registered as a child of the current builder scope (i.e. the folder representing
|
||||
/// the upstream Object that owns the method). Optional surface — drivers that don't
|
||||
/// mirror methods simply never call it; address-space builders that don't materialise
|
||||
/// method nodes can leave the default no-op in place. Default implementation drops
|
||||
/// the call so adding this method doesn't break existing
|
||||
/// <see cref="IAddressSpaceBuilder"/> implementations.
|
||||
/// </summary>
|
||||
/// <param name="info">Metadata describing the method node, including input/output argument schemas.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The OPC UA Client driver is the primary caller — it picks up
|
||||
/// <c>NodeClass.Method</c> nodes during the <c>HierarchicalReferences</c> browse
|
||||
/// pass, then walks each method's <c>HasProperty</c> references to harvest the
|
||||
/// <c>InputArguments</c> / <c>OutputArguments</c> property values.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The OPC UA server-side <c>DriverNodeManager</c> overrides this to materialize
|
||||
/// a real <c>MethodNode</c> in the local address space and wire its
|
||||
/// <c>OnCallMethod</c> handler to the driver's
|
||||
/// <see cref="IMethodInvoker.CallMethodAsync"/>. Other builders (Galaxy, Modbus,
|
||||
/// FOCAS, S7, TwinCAT, AB-CIP, AB-Legacy) ignore the projection because their
|
||||
/// backends don't expose method nodes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
void RegisterMethodNode(MirroredMethodNodeInfo info) { /* default: no-op */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata describing a single method node mirrored from an upstream OPC UA server.
|
||||
/// Built by the OPC UA Client driver during the discovery browse pass and consumed by
|
||||
/// <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>.
|
||||
/// </summary>
|
||||
/// <param name="BrowseName">OPC UA BrowseName segment from the upstream BrowseName.</param>
|
||||
/// <param name="DisplayName">Human-readable display name; falls back to <paramref name="BrowseName"/>.</param>
|
||||
/// <param name="ObjectNodeId">
|
||||
/// Stringified NodeId of the parent Object that owns this method — the <c>ObjectId</c>
|
||||
/// argument the dispatcher passes back to <see cref="IMethodInvoker.CallMethodAsync"/>.
|
||||
/// </param>
|
||||
/// <param name="MethodNodeId">
|
||||
/// Stringified NodeId of the method node itself — the <c>MethodId</c> argument.
|
||||
/// </param>
|
||||
/// <param name="InputArguments">
|
||||
/// Declaration of the method's input arguments, in order. <c>null</c> or empty when the
|
||||
/// method takes no inputs (or the upstream property couldn't be read).
|
||||
/// </param>
|
||||
/// <param name="OutputArguments">
|
||||
/// Declaration of the method's output arguments, in order. <c>null</c> or empty when the
|
||||
/// method returns no outputs (or the upstream property couldn't be read).
|
||||
/// </param>
|
||||
public sealed record MirroredMethodNodeInfo(
|
||||
string BrowseName,
|
||||
string DisplayName,
|
||||
string ObjectNodeId,
|
||||
string MethodNodeId,
|
||||
IReadOnlyList<MethodArgumentInfo>? InputArguments,
|
||||
IReadOnlyList<MethodArgumentInfo>? OutputArguments);
|
||||
|
||||
/// <summary>
|
||||
/// One row of an OPC UA Argument array — name + data type + array hint. Mirrors the
|
||||
/// <c>Opc.Ua.Argument</c> structure but without the SDK-only types so this DTO can live
|
||||
/// in <c>Core.Abstractions</c>.
|
||||
/// </summary>
|
||||
/// <param name="Name">Argument name from the upstream Argument structure.</param>
|
||||
/// <param name="DriverDataType">
|
||||
/// Mapped local <see cref="DriverDataType"/>. Unknown / structured upstream types fall
|
||||
/// through to <see cref="DriverDataType.String"/> — same convention as variable mirroring.
|
||||
/// </param>
|
||||
/// <param name="ValueRank">
|
||||
/// OPC UA ValueRank: <c>-1</c> = scalar, <c>0</c> = OneOrMoreDimensions, <c>1+</c> = array
|
||||
/// dimensions. Driven directly from the upstream Argument's ValueRank.
|
||||
/// </param>
|
||||
/// <param name="Description">
|
||||
/// Human-readable description from the upstream Argument structure; <c>null</c> when the
|
||||
/// upstream doesn't carry one.
|
||||
/// </param>
|
||||
public sealed record MethodArgumentInfo(
|
||||
string Name,
|
||||
DriverDataType DriverDataType,
|
||||
int ValueRank,
|
||||
string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// Categorises a mirrored type-definition node so the receiving builder can route it into
|
||||
/// the right OPC UA standard subtree (<c>ObjectTypesFolder</c>, <c>VariableTypesFolder</c>,
|
||||
|
||||
82
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs
Normal file
82
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Driver capability for invoking OPC UA Methods on the upstream backend (the OPC UA
|
||||
/// <c>Call</c> service). Optional — only drivers whose backends carry method nodes
|
||||
/// implement it. Currently the OPC UA Client driver is the only implementer; tag-based
|
||||
/// drivers (Modbus, S7, FOCAS, Galaxy, AB-CIP, AB-Legacy, TwinCAT) don't expose method
|
||||
/// nodes so they don't need this surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #4 (composable capability interfaces) — the
|
||||
/// server-side <c>DriverNodeManager</c> discovers method-bearing drivers via an
|
||||
/// <c>is IMethodInvoker</c> check and routes <c>OnCallMethod</c> handlers to
|
||||
/// <see cref="CallMethodAsync"/>. Drivers that don't implement the interface simply
|
||||
/// never have method nodes registered for them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The address-space mirror is driven by <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>
|
||||
/// — drivers register the method node + its <c>InputArguments</c> /
|
||||
/// <c>OutputArguments</c> properties during discovery, then invocations land back on
|
||||
/// <see cref="CallMethodAsync"/> via the server-side dispatcher.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IMethodInvoker
|
||||
{
|
||||
/// <summary>
|
||||
/// Invoke an upstream OPC UA Method. The driver translates input arguments into the
|
||||
/// wire-level <c>CallMethodRequest</c>, dispatches via the active session, and packs
|
||||
/// the response back into a <see cref="MethodCallResult"/>. Per-argument validation
|
||||
/// errors flow through <see cref="MethodCallResult.InputArgumentResults"/>; method-level
|
||||
/// errors (<c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, etc.) flow through
|
||||
/// <see cref="MethodCallResult.StatusCode"/>.
|
||||
/// </summary>
|
||||
/// <param name="objectNodeId">
|
||||
/// Stringified NodeId of the OPC UA Object that owns the method (the <c>ObjectId</c>
|
||||
/// field of <c>CallMethodRequest</c>). Same serialization as <c>IReadable</c>'s
|
||||
/// <c>fullReference</c> — <c>ns=2;s=…</c> / <c>i=…</c> / <c>nsu=…;…</c>.
|
||||
/// </param>
|
||||
/// <param name="methodNodeId">
|
||||
/// Stringified NodeId of the Method node itself (the <c>MethodId</c> field).
|
||||
/// </param>
|
||||
/// <param name="inputs">
|
||||
/// Input arguments in declaration order. The driver wraps each value as a
|
||||
/// <c>Variant</c>; callers pass CLR primitives (plus arrays) — the wire-level
|
||||
/// encoding is the driver's concern.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Per-call cancellation.</param>
|
||||
/// <returns>
|
||||
/// Result of the call — see <see cref="MethodCallResult"/>. Never throws for a
|
||||
/// <c>Bad</c> upstream status; the bad code is surfaced via the result so the caller
|
||||
/// can map it onto an OPC UA service-result for downstream clients.
|
||||
/// </returns>
|
||||
Task<MethodCallResult> CallMethodAsync(
|
||||
string objectNodeId,
|
||||
string methodNodeId,
|
||||
object[] inputs,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single OPC UA <c>Call</c> service invocation.
|
||||
/// </summary>
|
||||
/// <param name="StatusCode">
|
||||
/// Method-level status. <c>0</c> = Good. Bad codes pass through verbatim from the
|
||||
/// upstream so downstream clients see the canonical OPC UA error (e.g.
|
||||
/// <c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, <c>BadArgumentsMissing</c>).
|
||||
/// </param>
|
||||
/// <param name="Outputs">
|
||||
/// Output argument values in declaration order. <c>null</c> when the upstream returned
|
||||
/// no output arguments (or returned a Bad status before producing any).
|
||||
/// </param>
|
||||
/// <param name="InputArgumentResults">
|
||||
/// Per-input-argument status codes. <c>null</c> when the upstream didn't surface
|
||||
/// per-argument validation results (typical for Good calls). Each entry is the OPC UA
|
||||
/// status code for the matching input argument — drivers can use this to surface
|
||||
/// <c>BadTypeMismatch</c>, <c>BadOutOfRange</c>, etc. on a specific argument.
|
||||
/// </param>
|
||||
public sealed record MethodCallResult(
|
||||
uint StatusCode,
|
||||
object[]? Outputs,
|
||||
uint[]? InputArgumentResults);
|
||||
@@ -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,8 +1408,152 @@ 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=<uri>;…</c> string, applying the
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the method-node mirror surface added in PR-9 (Issue #281). Live-call
|
||||
/// coverage of <see cref="OpcUaClientDriver.CallMethodAsync"/> against an upstream
|
||||
/// server lands in integration tests against opc-plc; these unit assertions cover the
|
||||
/// <see cref="IMethodInvoker"/> capability surface, the <see cref="MirroredMethodNodeInfo"/>
|
||||
/// DTO shape, the <see cref="MethodArgumentInfo"/> DTO shape, and the back-compat default
|
||||
/// no-op for <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientMethodInvokerTests
|
||||
{
|
||||
[Fact]
|
||||
public void OpcUaClientDriver_implements_IMethodInvoker()
|
||||
{
|
||||
// The driver is the only built-in IMethodInvoker — tag-based drivers (Modbus, S7,
|
||||
// FOCAS, Galaxy, AB-CIP, AB-Legacy, TwinCAT) intentionally don't expose method nodes.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-method");
|
||||
drv.ShouldBeAssignableTo<IMethodInvoker>(
|
||||
"OPC UA Client driver is the cross-driver method-bearing capability");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallMethodAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
// Same lifecycle invariant as the other capability surfaces — the session has to be
|
||||
// up before a call can land. Surfaces a clean exception rather than NullReference.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.CallMethodAsync(
|
||||
"ns=2;i=1", "ns=2;i=2",
|
||||
Array.Empty<object>(),
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MirroredMethodNodeInfo_carries_browse_identity_owner_and_argument_schemas()
|
||||
{
|
||||
// Exercises the address-space builder's contract for a method node — the DTO fields
|
||||
// line up with the OPC UA Call request shape (ObjectId + MethodId) and carry the
|
||||
// input/output Argument arrays the dispatcher uses to render the local method node's
|
||||
// signature.
|
||||
var inputs = new[]
|
||||
{
|
||||
new MethodArgumentInfo("targetTemperature", DriverDataType.Float64, ValueRank: -1, Description: "deg C"),
|
||||
};
|
||||
var outputs = new[]
|
||||
{
|
||||
new MethodArgumentInfo("ack", DriverDataType.Boolean, ValueRank: -1, Description: null),
|
||||
};
|
||||
var info = new MirroredMethodNodeInfo(
|
||||
BrowseName: "SetTemperature",
|
||||
DisplayName: "Set Temperature",
|
||||
ObjectNodeId: "ns=2;i=1",
|
||||
MethodNodeId: "ns=2;i=42",
|
||||
InputArguments: inputs,
|
||||
OutputArguments: outputs);
|
||||
|
||||
info.BrowseName.ShouldBe("SetTemperature");
|
||||
info.MethodNodeId.ShouldBe("ns=2;i=42");
|
||||
info.ObjectNodeId.ShouldBe("ns=2;i=1");
|
||||
info.InputArguments.ShouldNotBeNull().ShouldHaveSingleItem().Name.ShouldBe("targetTemperature");
|
||||
info.OutputArguments.ShouldNotBeNull().ShouldHaveSingleItem().DriverDataType.ShouldBe(DriverDataType.Boolean);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MirroredMethodNodeInfo_supports_methods_with_no_arguments()
|
||||
{
|
||||
// Argument-less methods (e.g. a Reset / Stop button) are common; null arrays mean
|
||||
// "no inputs / no outputs" — the dispatcher handles that without complaining.
|
||||
var info = new MirroredMethodNodeInfo(
|
||||
BrowseName: "Reset", DisplayName: "Reset",
|
||||
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=99",
|
||||
InputArguments: null, OutputArguments: null);
|
||||
|
||||
info.InputArguments.ShouldBeNull();
|
||||
info.OutputArguments.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MethodCallResult_carries_status_outputs_and_per_input_argument_codes()
|
||||
{
|
||||
// The status-code passthrough contract: Bad codes flow verbatim from the upstream
|
||||
// so downstream OPC UA clients see canonical service-results (BadMethodInvalid,
|
||||
// BadUserAccessDenied, BadArgumentsMissing, …). Outputs + per-arg codes are
|
||||
// optional (null when the upstream didn't surface them).
|
||||
var goodCall = new MethodCallResult(StatusCode: 0, Outputs: new object[] { 42 }, InputArgumentResults: null);
|
||||
goodCall.StatusCode.ShouldBe(0u);
|
||||
goodCall.Outputs.ShouldNotBeNull().Length.ShouldBe(1);
|
||||
goodCall.InputArgumentResults.ShouldBeNull();
|
||||
|
||||
const uint BadMethodInvalid = 0x80540000u;
|
||||
var badCall = new MethodCallResult(BadMethodInvalid, Outputs: null, InputArgumentResults: null);
|
||||
badCall.StatusCode.ShouldBe(BadMethodInvalid);
|
||||
badCall.Outputs.ShouldBeNull();
|
||||
|
||||
const uint BadTypeMismatch = 0x80740000u;
|
||||
var argFailure = new MethodCallResult(
|
||||
StatusCode: BadTypeMismatch, Outputs: null,
|
||||
InputArgumentResults: new[] { 0u, BadTypeMismatch });
|
||||
argFailure.InputArgumentResults.ShouldNotBeNull();
|
||||
argFailure.InputArgumentResults![0].ShouldBe(0u);
|
||||
argFailure.InputArgumentResults![1].ShouldBe(BadTypeMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterMethodNode_default_implementation_is_no_op_for_back_compat()
|
||||
{
|
||||
// Builders that only implement Folder + Variable + AddProperty (the pre-PR-9
|
||||
// contract) keep working — the default no-op on RegisterMethodNode means calling
|
||||
// it on a minimal builder doesn't throw. This protects every non-OPC-UA-Client
|
||||
// builder (Galaxy, Modbus, FOCAS, S7, TwinCAT, AB-CIP, AB-Legacy) from forced
|
||||
// override pressure.
|
||||
IAddressSpaceBuilder builder = new MinimalBuilder();
|
||||
var info = new MirroredMethodNodeInfo(
|
||||
BrowseName: "Reset", DisplayName: "Reset",
|
||||
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=99",
|
||||
InputArguments: null, OutputArguments: null);
|
||||
|
||||
Should.NotThrow(() => builder.RegisterMethodNode(info));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterMethodNode_can_be_overridden_so_OPC_UA_server_builders_record_the_call()
|
||||
{
|
||||
// The OPC UA server-side DriverNodeManager will override RegisterMethodNode to
|
||||
// materialize a real MethodNode + wire its OnCallMethod handler to the driver's
|
||||
// CallMethodAsync. This test models that contract with a recording stub.
|
||||
var recorder = new RecordingMethodBuilder();
|
||||
IAddressSpaceBuilder builder = recorder;
|
||||
|
||||
builder.RegisterMethodNode(new MirroredMethodNodeInfo(
|
||||
BrowseName: "Start", DisplayName: "Start",
|
||||
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=10",
|
||||
InputArguments: null, OutputArguments: null));
|
||||
builder.RegisterMethodNode(new MirroredMethodNodeInfo(
|
||||
BrowseName: "SetSetpoint", DisplayName: "SetSetpoint",
|
||||
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=11",
|
||||
InputArguments: new[] { new MethodArgumentInfo("sp", DriverDataType.Float32, -1, null) },
|
||||
OutputArguments: null));
|
||||
|
||||
recorder.Calls.Count.ShouldBe(2);
|
||||
recorder.Calls[0].BrowseName.ShouldBe("Start");
|
||||
recorder.Calls[1].InputArguments.ShouldNotBeNull().ShouldHaveSingleItem().Name.ShouldBe("sp");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-PR-9 minimum implementation. Verifies the default no-op for
|
||||
/// <c>RegisterMethodNode</c> doesn't break existing builders that don't know about
|
||||
/// the new surface.
|
||||
/// </summary>
|
||||
private sealed class MinimalBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> new StubHandle();
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingMethodBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<MirroredMethodNodeInfo> Calls { get; } = new();
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> throw new NotSupportedException();
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
public void RegisterMethodNode(MirroredMethodNodeInfo info) => Calls.Add(info);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user