From 07abee5f6d512a8089028e7ef5230b97fb1dfcf8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 20:52:39 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20opcuaclient-9=20=E2=80=94=20method=20no?= =?UTF-8?q?de=20mirroring=20+=20Call=20passthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../IAddressSpaceBuilder.cs | 82 ++++++ .../IMethodInvoker.cs | 82 ++++++ .../OpcUaClientDriver.cs | 245 +++++++++++++++++- .../OpcUaClientMethodInvokerTests.cs | 181 +++++++++++++ 4 files changed, 588 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMethodInvokerTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs index 7254b8c..cffa031 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs @@ -60,8 +60,90 @@ public interface IAddressSpaceBuilder /// /// void RegisterTypeNode(MirroredTypeNodeInfo info) { /* default: no-op */ } + + /// + /// 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 + /// implementations. + /// + /// Metadata describing the method node, including input/output argument schemas. + /// + /// + /// The OPC UA Client driver is the primary caller — it picks up + /// NodeClass.Method nodes during the HierarchicalReferences browse + /// pass, then walks each method's HasProperty references to harvest the + /// InputArguments / OutputArguments property values. + /// + /// + /// The OPC UA server-side DriverNodeManager overrides this to materialize + /// a real MethodNode in the local address space and wire its + /// OnCallMethod handler to the driver's + /// . Other builders (Galaxy, Modbus, + /// FOCAS, S7, TwinCAT, AB-CIP, AB-Legacy) ignore the projection because their + /// backends don't expose method nodes. + /// + /// + void RegisterMethodNode(MirroredMethodNodeInfo info) { /* default: no-op */ } } +/// +/// 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 +/// . +/// +/// OPC UA BrowseName segment from the upstream BrowseName. +/// Human-readable display name; falls back to . +/// +/// Stringified NodeId of the parent Object that owns this method — the ObjectId +/// argument the dispatcher passes back to . +/// +/// +/// Stringified NodeId of the method node itself — the MethodId argument. +/// +/// +/// Declaration of the method's input arguments, in order. null or empty when the +/// method takes no inputs (or the upstream property couldn't be read). +/// +/// +/// Declaration of the method's output arguments, in order. null or empty when the +/// method returns no outputs (or the upstream property couldn't be read). +/// +public sealed record MirroredMethodNodeInfo( + string BrowseName, + string DisplayName, + string ObjectNodeId, + string MethodNodeId, + IReadOnlyList? InputArguments, + IReadOnlyList? OutputArguments); + +/// +/// One row of an OPC UA Argument array — name + data type + array hint. Mirrors the +/// Opc.Ua.Argument structure but without the SDK-only types so this DTO can live +/// in Core.Abstractions. +/// +/// Argument name from the upstream Argument structure. +/// +/// Mapped local . Unknown / structured upstream types fall +/// through to — same convention as variable mirroring. +/// +/// +/// OPC UA ValueRank: -1 = scalar, 0 = OneOrMoreDimensions, 1+ = array +/// dimensions. Driven directly from the upstream Argument's ValueRank. +/// +/// +/// Human-readable description from the upstream Argument structure; null when the +/// upstream doesn't carry one. +/// +public sealed record MethodArgumentInfo( + string Name, + DriverDataType DriverDataType, + int ValueRank, + string? Description); + /// /// Categorises a mirrored type-definition node so the receiving builder can route it into /// the right OPC UA standard subtree (ObjectTypesFolder, VariableTypesFolder, diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs new file mode 100644 index 0000000..dad5d2d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs @@ -0,0 +1,82 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Driver capability for invoking OPC UA Methods on the upstream backend (the OPC UA +/// Call 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. +/// +/// +/// +/// Per docs/v2/plan.md decision #4 (composable capability interfaces) — the +/// server-side DriverNodeManager discovers method-bearing drivers via an +/// is IMethodInvoker check and routes OnCallMethod handlers to +/// . Drivers that don't implement the interface simply +/// never have method nodes registered for them. +/// +/// +/// The address-space mirror is driven by +/// — drivers register the method node + its InputArguments / +/// OutputArguments properties during discovery, then invocations land back on +/// via the server-side dispatcher. +/// +/// +public interface IMethodInvoker +{ + /// + /// Invoke an upstream OPC UA Method. The driver translates input arguments into the + /// wire-level CallMethodRequest, dispatches via the active session, and packs + /// the response back into a . Per-argument validation + /// errors flow through ; method-level + /// errors (BadMethodInvalid, BadUserAccessDenied, etc.) flow through + /// . + /// + /// + /// Stringified NodeId of the OPC UA Object that owns the method (the ObjectId + /// field of CallMethodRequest). Same serialization as IReadable's + /// fullReferencens=2;s=… / i=… / nsu=…;…. + /// + /// + /// Stringified NodeId of the Method node itself (the MethodId field). + /// + /// + /// Input arguments in declaration order. The driver wraps each value as a + /// Variant; callers pass CLR primitives (plus arrays) — the wire-level + /// encoding is the driver's concern. + /// + /// Per-call cancellation. + /// + /// Result of the call — see . Never throws for a + /// Bad 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. + /// + Task CallMethodAsync( + string objectNodeId, + string methodNodeId, + object[] inputs, + CancellationToken cancellationToken); +} + +/// +/// Result of a single OPC UA Call service invocation. +/// +/// +/// Method-level status. 0 = Good. Bad codes pass through verbatim from the +/// upstream so downstream clients see the canonical OPC UA error (e.g. +/// BadMethodInvalid, BadUserAccessDenied, BadArgumentsMissing). +/// +/// +/// Output argument values in declaration order. null when the upstream returned +/// no output arguments (or returned a Bad status before producing any). +/// +/// +/// Per-input-argument status codes. null 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 +/// BadTypeMismatch, BadOutOfRange, etc. on a specific argument. +/// +public sealed record MethodCallResult( + uint StatusCode, + object[]? Outputs, + uint[]? InputArgumentResults); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 4c365b6..6b00207 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// /// 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(); + } } } + /// + /// Read a method node's InputArguments and OutputArguments properties. + /// Both are standard HasProperty children of any NodeClass.Method node + /// in OPC UA — they carry the array-of-Argument structure the dispatcher needs to + /// surface a callable signature on the local method node. + /// + /// + /// A tuple of (InputArguments, OutputArguments). Either side may be null 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. + /// + private static async Task<(IReadOnlyList?, IReadOnlyList?)> + 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? inputArgs = null; + IReadOnlyList? outputArgs = null; + if (inputPropId is not null) + { + inputArgs = ConvertArguments(values[idx++]); + } + if (outputPropId is not null) + { + outputArgs = ConvertArguments(values[idx]); + } + return (inputArgs, outputArgs); + } + + /// + /// Convert an OPC UA InputArguments/OutputArguments property value + /// (an array of Argument wrapped in ExtensionObject) into the local + /// DTO. Returns null when the value can't be + /// decoded — non-fatal, the method still registers without arg metadata. + /// + private static IReadOnlyList? ConvertArguments(DataValue dv) + { + if (StatusCode.IsBad(dv.StatusCode)) return null; + if (dv.Value is not ExtensionObject[] extensionArray) return null; + var result = new List(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; + } + /// /// Render a NodeId as the canonical nsu=<uri>;… string, applying the /// configured upstream→local namespace-URI remap. Index-namespace nodes (ns=0, @@ -1917,6 +2061,103 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d finally { _gate.Release(); } } + // ---- IMethodInvoker ---- + + /// + /// Forward an OPC UA Call service invocation to the upstream server. The + /// method NodeId + object NodeId come from the address-space mirror set up during + /// discovery (); input values are CLR primitives + /// wrapped into s here so the cross-driver capability surface + /// stays SDK-free. + /// + /// + /// + /// Status-code passthrough: per driver-specs.md §8 (cascading quality), an + /// upstream Bad code is returned verbatim through + /// rather than thrown — downstream OPC UA clients see the canonical service result + /// (BadMethodInvalid, BadUserAccessDenied, BadArgumentsMissing, + /// …). Local-side faults (NodeId parse, lost session) surface the corresponding + /// StatusBad* constants the rest of the driver uses. + /// + /// + /// Per-argument validation results are unpacked into a uint[] so the + /// Core.Abstractions DTO stays SDK-free. null when the upstream + /// didn't surface per-argument codes (typical for Good calls). + /// + /// + public async Task 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()).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 sourceFilter, diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMethodInvokerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMethodInvokerTests.cs new file mode 100644 index 0000000..686b850 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMethodInvokerTests.cs @@ -0,0 +1,181 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Unit tests for the method-node mirror surface added in PR-9 (Issue #281). Live-call +/// coverage of against an upstream +/// server lands in integration tests against opc-plc; these unit assertions cover the +/// capability surface, the +/// DTO shape, the DTO shape, and the back-compat default +/// no-op for . +/// +[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( + "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(async () => + await drv.CallMethodAsync( + "ns=2;i=1", "ns=2;i=2", + Array.Empty(), + 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"); + } + + /// + /// Pre-PR-9 minimum implementation. Verifies the default no-op for + /// RegisterMethodNode doesn't break existing builders that don't know about + /// the new surface. + /// + 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 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); + } +} -- 2.49.1