Merge pull request '[opcuaclient] OpcUaClient — Type definition mirroring' (#360) from auto/opcuaclient/8 into auto/driver-gaps
This commit was merged in pull request #360.
This commit is contained in:
@@ -35,8 +35,77 @@ public interface IAddressSpaceBuilder
|
||||
/// <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>
|
||||
/// 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
|
||||
{
|
||||
|
||||
@@ -1038,10 +1038,237 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
// still a couple of hundred ms total since the SDK chunks ReadAsync automatically.
|
||||
await EnrichAndRegisterVariablesAsync(session, pendingVariables, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Pass 3 (opt-in): mirror upstream type definitions under the four standard type
|
||||
// sub-folders (ObjectTypes / VariableTypes / DataTypes / ReferenceTypes). Off by
|
||||
// default so existing deployments don't suddenly see a flood of type nodes; enable
|
||||
// via OpcUaClientDriverOptions.MirrorTypeDefinitions when downstream clients need
|
||||
// the upstream type system to render structured values or decode custom events.
|
||||
if (_options.MirrorTypeDefinitions)
|
||||
{
|
||||
await MirrorTypeDefinitionsAsync(session, builder, includeRegex, excludeRegex,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pass 3 of discovery: walk the upstream <c>TypesFolder</c> (<c>i=86</c>) and project
|
||||
/// the four standard type sub-folders into the local address space via
|
||||
/// <see cref="IAddressSpaceBuilder.RegisterTypeNode"/>. Honours the same curation rules
|
||||
/// as pass-1 — paths are slash-joined under each type-folder root (e.g.
|
||||
/// <c>"ObjectTypes/BaseObjectType/MyType"</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Uses <c>Session.FetchTypeTreeAsync</c> on each of the four root type nodes so the
|
||||
/// SDK's TypeTree cache is populated in one batched call per root rather than
|
||||
/// per-node round trips during the recursion. This PR ships the structural mirror
|
||||
/// only — binary-encoding priming via <c>LoadDataTypeSystem</c> is tracked as a
|
||||
/// follow-up because the public SDK surface for that helper was removed in
|
||||
/// OPCFoundation.NetStandard 1.5.378+.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>RegisterTypeNode</c> has a default no-op implementation on the interface so
|
||||
/// most builders (Galaxy, Modbus, FOCAS, S7, TwinCAT, AB-CIP) ignore the projection
|
||||
/// entirely — only the OPC UA server-side <c>DriverNodeManager</c> needs to override
|
||||
/// it for the client driver's mirror pass to surface in the OPC UA server's address
|
||||
/// space.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task MirrorTypeDefinitionsAsync(
|
||||
ISession session, IAddressSpaceBuilder builder,
|
||||
Regex? includeRegex, Regex? excludeRegex, CancellationToken ct)
|
||||
{
|
||||
// FetchTypeTreeAsync populates the SDK-side TypeTree cache rooted at the four standard
|
||||
// type folders. This isn't free (it's a hierarchical browse) but it's the canonical way
|
||||
// to prime the cache so subsequent NodeCache.FetchNode calls hit memory rather than the
|
||||
// wire on every type. Failures are caught + logged-via-health-surface — the structural
|
||||
// mirror still proceeds with an empty cache.
|
||||
try
|
||||
{
|
||||
var typeRoots = new ExpandedNodeIdCollection
|
||||
{
|
||||
new ExpandedNodeId(ObjectIds.ObjectTypesFolder),
|
||||
new ExpandedNodeId(ObjectIds.VariableTypesFolder),
|
||||
new ExpandedNodeId(ObjectIds.DataTypesFolder),
|
||||
new ExpandedNodeId(ObjectIds.ReferenceTypesFolder),
|
||||
};
|
||||
await session.FetchTypeTreeAsync(typeRoots, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-fatal — the structural mirror still works without a primed TypeTree cache;
|
||||
// we just don't get the in-memory super-type chain shortcuts.
|
||||
}
|
||||
|
||||
// Note: this PR ships the structural mirror only. A previous SDK version exposed
|
||||
// ISession.LoadDataTypeSystem(NodeId, CancellationToken) for priming structured-type
|
||||
// encodings; that method was removed from the public surface in OPCFoundation.NetStandard
|
||||
// 1.5.378+. Loading the binary type system now requires per-node walks of the encoding
|
||||
// dictionaries via NodeCache helpers, which is significant additional scope. Tracked as
|
||||
// a follow-up; existing deployments that need structured-type decoding can mirror the
|
||||
// raw type tree today and consume Variant<ExtensionObject> on the client side.
|
||||
|
||||
await MirrorTypeBranchAsync(session, builder, ObjectIds.ObjectTypesFolder,
|
||||
MirroredTypeKind.ObjectType, "ObjectTypes", includeRegex, excludeRegex, ct)
|
||||
.ConfigureAwait(false);
|
||||
await MirrorTypeBranchAsync(session, builder, ObjectIds.VariableTypesFolder,
|
||||
MirroredTypeKind.VariableType, "VariableTypes", includeRegex, excludeRegex, ct)
|
||||
.ConfigureAwait(false);
|
||||
await MirrorTypeBranchAsync(session, builder, ObjectIds.DataTypesFolder,
|
||||
MirroredTypeKind.DataType, "DataTypes", includeRegex, excludeRegex, ct)
|
||||
.ConfigureAwait(false);
|
||||
await MirrorTypeBranchAsync(session, builder, ObjectIds.ReferenceTypesFolder,
|
||||
MirroredTypeKind.ReferenceType, "ReferenceTypes", includeRegex, excludeRegex, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursive walk of a single type-folder branch (ObjectType / VariableType / DataType /
|
||||
/// ReferenceType). Uses HasSubtype reference walking (the canonical OPC UA way to
|
||||
/// enumerate type hierarchies) — IncludeSubtypes=false so the recursion controls depth
|
||||
/// itself rather than the server bulk-returning the full subtree at the root.
|
||||
/// </summary>
|
||||
private async Task MirrorTypeBranchAsync(
|
||||
ISession session, IAddressSpaceBuilder builder,
|
||||
NodeId rootNode, MirroredTypeKind kind, string rootSegmentName,
|
||||
Regex? includeRegex, Regex? excludeRegex, CancellationToken ct)
|
||||
{
|
||||
var visited = new HashSet<NodeId>();
|
||||
var discovered = 0;
|
||||
await WalkTypeNodeAsync(session, builder, rootNode, kind, rootSegmentName,
|
||||
superTypeNodeId: null, depth: 0, visited, includeRegex, excludeRegex,
|
||||
() => discovered, () => discovered++, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task WalkTypeNodeAsync(
|
||||
ISession session, IAddressSpaceBuilder builder,
|
||||
NodeId node, MirroredTypeKind kind, string pathPrefix,
|
||||
string? superTypeNodeId, int depth,
|
||||
HashSet<NodeId> visited, Regex? includeRegex, Regex? excludeRegex,
|
||||
Func<int> discovered, Action increment, CancellationToken ct)
|
||||
{
|
||||
if (depth >= _options.MaxBrowseDepth) return;
|
||||
if (discovered() >= _options.MaxDiscoveredNodes) return;
|
||||
if (!visited.Add(node)) return;
|
||||
|
||||
// Browse subtypes only (HasSubtype): for an Object/Variable/ReferenceType the children
|
||||
// we care about are subtypes. We don't need to enumerate property nodes / instance
|
||||
// children of types at this layer — RegisterTypeNode is purely for the type identity.
|
||||
var browseDescriptions = new BrowseDescriptionCollection
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = node,
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
|
||||
IncludeSubtypes = false,
|
||||
NodeClassMask = (uint)(NodeClass.ObjectType | NodeClass.VariableType
|
||||
| NodeClass.DataType | NodeClass.ReferenceType),
|
||||
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
|
||||
| BrowseResultMask.NodeClass),
|
||||
}
|
||||
};
|
||||
|
||||
BrowseResponse resp;
|
||||
try
|
||||
{
|
||||
resp = await session.BrowseAsync(
|
||||
requestHeader: null,
|
||||
view: null,
|
||||
requestedMaxReferencesPerNode: 0,
|
||||
nodesToBrowse: browseDescriptions,
|
||||
ct: ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Transient browse failure — skip this branch, keep the rest of the mirror going.
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp.Results.Count == 0) return;
|
||||
var refs = resp.Results[0].References;
|
||||
|
||||
foreach (var rf in refs)
|
||||
{
|
||||
if (discovered() >= _options.MaxDiscoveredNodes) break;
|
||||
|
||||
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris);
|
||||
if (NodeId.IsNull(childId)) continue;
|
||||
|
||||
var browseName = rf.BrowseName?.Name ?? childId.ToString();
|
||||
var displayName = rf.DisplayName?.Text ?? browseName;
|
||||
var childPath = pathPrefix + "/" + browseName;
|
||||
|
||||
// Curation rules apply to the type walk too — operators with very tight servers
|
||||
// can scope the type mirror via "ObjectTypes/MyVendor/*" globs.
|
||||
if (!ShouldInclude(childPath, includeRegex, excludeRegex))
|
||||
continue;
|
||||
|
||||
// Read IsAbstract for the type. Treated as best-effort — if the upstream returns
|
||||
// Bad we default to false so the mirror still ships rather than dropping the node.
|
||||
var isAbstract = await TryReadIsAbstractAsync(session, childId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var upstreamId = BuildRemappedFullName(childId, session.NamespaceUris,
|
||||
_options.Curation.NamespaceRemap);
|
||||
var parentId = BuildRemappedFullName(node, session.NamespaceUris,
|
||||
_options.Curation.NamespaceRemap);
|
||||
|
||||
builder.RegisterTypeNode(new MirroredTypeNodeInfo(
|
||||
Kind: kind,
|
||||
UpstreamNodeId: upstreamId,
|
||||
BrowseName: browseName,
|
||||
DisplayName: displayName,
|
||||
SuperTypeNodeId: superTypeNodeId is null ? null : parentId,
|
||||
IsAbstract: isAbstract));
|
||||
increment();
|
||||
|
||||
// Recurse — depth+1 because each level of HasSubtype is real depth in the type tree.
|
||||
// Pass childId-as-supertype string so descendants can record their super-type chain.
|
||||
await WalkTypeNodeAsync(session, builder, childId, kind, childPath,
|
||||
superTypeNodeId: upstreamId, depth + 1, visited,
|
||||
includeRegex, excludeRegex, discovered, increment, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort read of the <c>IsAbstract</c> attribute for a type node. Falls back to
|
||||
/// <c>false</c> on any read failure so a single bad upstream attribute doesn't drop the
|
||||
/// entire type from the mirror.
|
||||
/// </summary>
|
||||
private static async Task<bool> TryReadIsAbstractAsync(
|
||||
ISession session, NodeId node, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nodesToRead = new ReadValueIdCollection
|
||||
{
|
||||
new ReadValueId { NodeId = node, AttributeId = Attributes.IsAbstract },
|
||||
};
|
||||
var resp = await session.ReadAsync(
|
||||
requestHeader: null,
|
||||
maxAge: 0,
|
||||
timestampsToReturn: TimestampsToReturn.Neither,
|
||||
nodesToRead: nodesToRead,
|
||||
ct: ct).ConfigureAwait(false);
|
||||
if (resp.Results.Count > 0
|
||||
&& StatusCode.IsGood(resp.Results[0].StatusCode)
|
||||
&& resp.Results[0].Value is bool b)
|
||||
{
|
||||
return b;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translate the curation glob list into a single regex that matches if any pattern
|
||||
/// matches. Returns null for null/empty input so the call site can short-circuit
|
||||
|
||||
@@ -191,6 +191,40 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// pre-curation behaviour exactly — empty include = include all.
|
||||
/// </summary>
|
||||
public OpcUaClientCurationOptions Curation { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <c>DiscoverAsync</c> runs an additional pass that walks the upstream
|
||||
/// <c>TypesFolder</c> (<c>i=86</c>) — ObjectTypes (<c>i=88</c>), VariableTypes
|
||||
/// (<c>i=89</c>), DataTypes (<c>i=90</c>), ReferenceTypes (<c>i=91</c>) — and projects the
|
||||
/// discovered type-definition nodes into the local address space via
|
||||
/// <c>IAddressSpaceBuilder.RegisterTypeNode</c>. Default <c>false</c> — opt-in so
|
||||
/// existing deployments don't suddenly see a flood of type nodes after upgrade. Enable
|
||||
/// when downstream clients need the upstream type system to render structured values or
|
||||
/// decode custom event fields.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The type-mirror pass uses <c>Session.FetchTypeTreeAsync</c> on each of the four
|
||||
/// root type nodes so the SDK's local TypeTree cache is populated efficiently (one
|
||||
/// batched browse per root rather than per-node round trips). This PR ships the
|
||||
/// <i>structural</i> mirror only — every type node is registered with its identity,
|
||||
/// super-type chain, and IsAbstract flag, but structured-type binary encodings are
|
||||
/// NOT primed. (The OPCFoundation SDK removed
|
||||
/// <c>ISession.LoadDataTypeSystem(NodeId, CancellationToken)</c> from the public
|
||||
/// surface in 1.5.378+; loading binary encodings now requires per-node walks of
|
||||
/// <c>HasEncoding</c> + dictionary nodes which is tracked as a follow-up.) Clients
|
||||
/// that need structured-type decoding can still consume
|
||||
/// <c>Variant<ExtensionObject></c> on the wire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="OpcUaClientCurationOptions.IncludePaths"/> +
|
||||
/// <see cref="OpcUaClientCurationOptions.ExcludePaths"/> still apply to the type
|
||||
/// walk; paths are slash-joined under their root (e.g.
|
||||
/// <c>"ObjectTypes/BaseObjectType/SomeType"</c>). Most operators want all types so
|
||||
/// empty include = include all is the right default.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool MirrorTypeDefinitions { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the type-mirror surface added in PR-8 (Issue #280). Live-browse
|
||||
/// coverage of the Pass-3 walk against an upstream server lands in integration tests
|
||||
/// against opc-plc; these unit assertions cover the option default, the new
|
||||
/// <see cref="MirroredTypeNodeInfo"/> shape, and the back-compat default no-op for
|
||||
/// <see cref="IAddressSpaceBuilder.RegisterTypeNode"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientTypeMirrorTests
|
||||
{
|
||||
[Fact]
|
||||
public void MirrorTypeDefinitions_default_is_false_so_existing_deployments_are_unchanged()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.MirrorTypeDefinitions.ShouldBeFalse(
|
||||
"opt-in flag — existing deployments shouldn't suddenly see a flood of type nodes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MirrorTypeDefinitions_can_be_enabled_via_init()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { MirrorTypeDefinitions = true };
|
||||
opts.MirrorTypeDefinitions.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MirroredTypeNodeInfo_carries_kind_identity_supertype_and_abstract_flag()
|
||||
{
|
||||
var info = new MirroredTypeNodeInfo(
|
||||
Kind: MirroredTypeKind.ObjectType,
|
||||
UpstreamNodeId: "ns=2;i=1234",
|
||||
BrowseName: "MyMachine",
|
||||
DisplayName: "My Machine",
|
||||
SuperTypeNodeId: "i=58", // BaseObjectType
|
||||
IsAbstract: false);
|
||||
|
||||
info.Kind.ShouldBe(MirroredTypeKind.ObjectType);
|
||||
info.UpstreamNodeId.ShouldBe("ns=2;i=1234");
|
||||
info.BrowseName.ShouldBe("MyMachine");
|
||||
info.DisplayName.ShouldBe("My Machine");
|
||||
info.SuperTypeNodeId.ShouldBe("i=58");
|
||||
info.IsAbstract.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MirroredTypeKind_covers_all_four_OPC_UA_type_categories()
|
||||
{
|
||||
// The four standard OPC UA type-folder children — ObjectTypes (i=88),
|
||||
// VariableTypes (i=89), DataTypes (i=90), ReferenceTypes (i=91). Asserts the enum
|
||||
// shape rather than the values so the test catches accidental category renames.
|
||||
Enum.GetValues<MirroredTypeKind>().Length.ShouldBe(4);
|
||||
Enum.IsDefined(MirroredTypeKind.ObjectType).ShouldBeTrue();
|
||||
Enum.IsDefined(MirroredTypeKind.VariableType).ShouldBeTrue();
|
||||
Enum.IsDefined(MirroredTypeKind.DataType).ShouldBeTrue();
|
||||
Enum.IsDefined(MirroredTypeKind.ReferenceType).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterTypeNode_default_implementation_is_no_op_so_existing_builders_dont_break()
|
||||
{
|
||||
// Builders that only implement Folder + Variable + AddProperty (i.e. the pre-PR-8
|
||||
// contract) should continue to work — the default no-op on RegisterTypeNode means
|
||||
// calling it on a minimal builder doesn't throw.
|
||||
IAddressSpaceBuilder builder = new MinimalBuilder();
|
||||
var info = new MirroredTypeNodeInfo(MirroredTypeKind.DataType,
|
||||
UpstreamNodeId: "i=290", BrowseName: "Duration", DisplayName: "Duration",
|
||||
SuperTypeNodeId: "i=290", IsAbstract: false);
|
||||
|
||||
Should.NotThrow(() => builder.RegisterTypeNode(info));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterTypeNode_can_be_overridden_so_OPC_UA_server_builders_record_the_call()
|
||||
{
|
||||
// The OPC UA server-side DriverNodeManager will override RegisterTypeNode to actually
|
||||
// project the type into the local address space. This test models that contract with
|
||||
// a recording stub.
|
||||
var recorder = new RecordingTypeBuilder();
|
||||
IAddressSpaceBuilder builder = recorder;
|
||||
|
||||
builder.RegisterTypeNode(new MirroredTypeNodeInfo(
|
||||
MirroredTypeKind.ObjectType, "ns=2;i=1", "T1", "T1", null, false));
|
||||
builder.RegisterTypeNode(new MirroredTypeNodeInfo(
|
||||
MirroredTypeKind.VariableType, "ns=2;i=2", "V1", "V1", "i=63", true));
|
||||
|
||||
recorder.Calls.Count.ShouldBe(2);
|
||||
recorder.Calls[0].Kind.ShouldBe(MirroredTypeKind.ObjectType);
|
||||
recorder.Calls[1].IsAbstract.ShouldBeTrue();
|
||||
recorder.Calls[1].SuperTypeNodeId.ShouldBe("i=63");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum back-compat implementation — only the pre-PR-8 surface is overridden.
|
||||
/// Verifies the default-implementation no-op for <c>RegisterTypeNode</c> doesn't break
|
||||
/// existing builders.
|
||||
/// </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 RecordingTypeBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<MirroredTypeNodeInfo> 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 RegisterTypeNode(MirroredTypeNodeInfo info) => Calls.Add(info);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user