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
+ /// fullReference — ns=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