Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs
T
Joseph Doherty 07abee5f6d Auto: opcuaclient-9 — method node mirroring + Call passthrough
Adds the 9th capability interface (IMethodInvoker) so the OPC UA Client
driver can mirror upstream OPC UA Method nodes into the local address
space and forward Call invocations as Session.CallAsync. Method-bearing
servers (e.g. ProgramStateMachine, Acknowledge / Confirm methods, custom
control surfaces) now show up downstream instead of being silently
filtered out.

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

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

Closes #281

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:52:39 -04:00

232 lines
12 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Streaming builder API a driver uses to register OPC UA nodes during discovery.
/// Core owns the tree; driver streams <c>AddFolder</c> / <c>AddVariable</c> calls
/// as it discovers nodes — no buffering of the whole tree.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #52 — drivers register nodes via this builder
/// rather than returning a tree object. Supports incremental / large address spaces
/// without forcing the driver to buffer the whole tree.
/// </remarks>
public interface IAddressSpaceBuilder
{
/// <summary>
/// Add a folder node. Returns a child builder scoped to inside this folder, so subsequent
/// calls on the child place nodes under it.
/// </summary>
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent).</param>
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
IAddressSpaceBuilder Folder(string browseName, string displayName);
/// <summary>
/// Add a variable node corresponding to a tag. Driver-side full reference + data-type
/// metadata come from the <see cref="DriverAttributeInfo"/> DTO.
/// </summary>
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent folder).</param>
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
/// <param name="attributeInfo">Driver-side metadata for the variable.</param>
IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo);
/// <summary>
/// Add a property to the current node (folder or variable). Properties are static metadata
/// read once at build time (e.g. OPC 40010 Identification fields per the schemas-repo
/// <c>_base</c> equipment-class template).
/// </summary>
void AddProperty(string browseName, DriverDataType dataType, object? value);
/// <summary>
/// Register a type-definition node (ObjectType / VariableType / DataType / ReferenceType)
/// mirrored from an upstream OPC UA server. Optional surface — drivers that don't mirror
/// types simply never call it; address-space builders that don't materialise upstream
/// types 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 type-definition node to mirror.</param>
/// <remarks>
/// <para>
/// The OPC UA Client driver is the primary caller — it walks <c>i=86</c>
/// (TypesFolder) during <c>DiscoverAsync</c> when
/// <c>OpcUaClientDriverOptions.MirrorTypeDefinitions</c> is set so downstream clients
/// see the upstream type system instead of rendering structured-type values as opaque
/// strings.
/// </para>
/// <para>
/// The default no-op is intentional — most builders (Galaxy, Modbus, FOCAS, S7,
/// TwinCAT, AB-CIP) don't have a meaningful type folder to project into and would
/// otherwise need empty-stub overrides.
/// </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>,
/// <c>DataTypesFolder</c>, <c>ReferenceTypesFolder</c>) when projecting upstream types into
/// the local address space.
/// </summary>
public enum MirroredTypeKind
{
ObjectType,
VariableType,
DataType,
ReferenceType,
}
/// <summary>
/// Metadata describing a single type-definition node mirrored from an upstream OPC UA
/// server. Built by the OPC UA Client driver during type-mirror pass and consumed by
/// <see cref="IAddressSpaceBuilder.RegisterTypeNode"/>.
/// </summary>
/// <param name="Kind">Type category — drives which standard sub-folder the node lives under.</param>
/// <param name="UpstreamNodeId">
/// Stringified upstream NodeId (e.g. <c>"ns=2;i=1234"</c>) — preserves the original identity
/// so a builder that wants to project the type with a stable cross-namespace reference can do
/// so. The driver applies any configured namespace remap before stamping this field.
/// </param>
/// <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="SuperTypeNodeId">
/// Stringified upstream NodeId of the super-type (parent type), or <c>null</c> when the node
/// sits directly under the root (e.g. <c>BaseObjectType</c>, <c>BaseVariableType</c>). Lets
/// the builder reconstruct the inheritance chain.
/// </param>
/// <param name="IsAbstract">
/// <c>true</c> when the upstream node has the <c>IsAbstract</c> flag set (Object / Variable /
/// ReferenceType). DataTypes also expose this — the driver passes it through verbatim.
/// </param>
public sealed record MirroredTypeNodeInfo(
MirroredTypeKind Kind,
string UpstreamNodeId,
string BrowseName,
string DisplayName,
string? SuperTypeNodeId,
bool IsAbstract);
/// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary>
public interface IVariableHandle
{
/// <summary>Driver-side full reference for read/write addressing.</summary>
string FullReference { get; }
/// <summary>
/// Annotate this variable with an OPC UA <c>AlarmConditionState</c>. Drivers with
/// <see cref="DriverAttributeInfo.IsAlarm"/> = true call this during discovery so the
/// concrete address-space builder can materialize a sibling condition node. The returned
/// sink receives lifecycle transitions raised through <see cref="IAlarmSource.OnAlarmEvent"/>
/// — the generic node manager wires the subscription; the concrete builder decides how
/// to surface the state (e.g. OPC UA <c>AlarmConditionState.Activate</c>,
/// <c>Acknowledge</c>, <c>Deactivate</c>).
/// </summary>
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
}
/// <summary>
/// Metadata used to materialize an OPC UA <c>AlarmConditionState</c> sibling for a variable.
/// Populated by the driver's discovery step; concrete builders decide how to surface it.
/// </summary>
/// <param name="SourceName">Human-readable alarm name used for the <c>SourceName</c> event field.</param>
/// <param name="InitialSeverity">Severity at address-space build time; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
/// <param name="InitialDescription">Initial description; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
public sealed record AlarmConditionInfo(
string SourceName,
AlarmSeverity InitialSeverity,
string? InitialDescription);
/// <summary>
/// Sink a concrete address-space builder returns from <see cref="IVariableHandle.MarkAsAlarmCondition"/>.
/// The generic node manager routes per-alarm <see cref="IAlarmSource.OnAlarmEvent"/> payloads here —
/// the sink translates the transition into an OPC UA condition state change or whatever the
/// concrete builder's backing address space supports.
/// </summary>
public interface IAlarmConditionSink
{
/// <summary>Push an alarm transition (Active / Acknowledged / Inactive) for this condition.</summary>
void OnTransition(AlarmEventArgs args);
}