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:
2026-04-25 20:55:09 -04:00
4 changed files with 588 additions and 2 deletions

View File

@@ -60,8 +60,90 @@ public interface IAddressSpaceBuilder
/// </para> /// </para>
/// </remarks> /// </remarks>
void RegisterTypeNode(MirroredTypeNodeInfo info) { /* default: no-op */ } 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> /// <summary>
/// Categorises a mirrored type-definition node so the receiving builder can route it into /// 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>, /// the right OPC UA standard subtree (<c>ObjectTypesFolder</c>, <c>VariableTypesFolder</c>,

View 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);

View File

@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId) 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 ---- // ---- IAlarmSource state ----
@@ -1349,7 +1349,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
BrowseDirection = BrowseDirection.Forward, BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true, IncludeSubtypes = true,
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable), NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method),
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
| BrowseResultMask.NodeClass | BrowseResultMask.TypeDefinition), | 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)); pendingVariables.Add(new PendingVariable(folder, browseName, displayName, childId, fullName));
increment(); 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> /// <summary>
/// Render a NodeId as the canonical <c>nsu=&lt;uri&gt;;…</c> string, applying the /// Render a NodeId as the canonical <c>nsu=&lt;uri&gt;;…</c> string, applying the
@@ -1917,6 +2061,103 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
finally { _gate.Release(); } 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( private void OnEventNotification(
OpcUaAlarmSubscriptionHandle handle, OpcUaAlarmSubscriptionHandle handle,
HashSet<string> sourceFilter, HashSet<string> sourceFilter,

View File

@@ -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);
}
}