feat(otopcua): capturing address-space builder for driver discovery

This commit is contained in:
Joseph Doherty
2026-06-26 06:39:18 -04:00
parent 74e07225c9
commit da55c6913d
3 changed files with 130 additions and 0 deletions
@@ -0,0 +1,67 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// An <see cref="IAddressSpaceBuilder"/> that RECORDS the streamed tree instead of creating OPC UA
/// nodes — used to capture an <see cref="ITagDiscovery"/> driver's discovered hierarchy so the
/// runtime can graft it under an equipment node. Folder nesting is tracked (each child builder
/// carries its accumulated path), so every variable records its full <see cref="DiscoveredNode.FolderPathSegments"/>.
/// <para>Value nodes only: <see cref="AddProperty"/> is ignored and alarm marking returns a no-op sink
/// (discovered alarms are out of scope — alarms come via the config path).</para>
/// <para>Single-threaded: a driver's <c>DiscoverAsync</c> streams on one caller; the root and its child
/// builders share one <see cref="List{T}"/>. Not thread-safe by design.</para>
/// </summary>
public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
private readonly List<DiscoveredNode> _nodes;
private readonly IReadOnlyList<string> _path;
/// <summary>Create a root capturing builder with an empty folder path and a fresh node list.</summary>
public CapturingAddressSpaceBuilder() : this([], []) { }
private CapturingAddressSpaceBuilder(List<DiscoveredNode> nodes, IReadOnlyList<string> path)
{
_nodes = nodes;
_path = path;
}
/// <summary>All variables captured across the whole tree (shared by the root and every child scope).</summary>
public IReadOnlyList<DiscoveredNode> Nodes => _nodes;
/// <inheritdoc />
public IAddressSpaceBuilder Folder(string browseName, string displayName)
=> new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]);
/// <inheritdoc />
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
_nodes.Add(new DiscoveredNode(
FolderPathSegments: _path,
BrowseName: browseName,
DisplayName: displayName,
FullReference: attributeInfo.FullName,
DataType: attributeInfo.DriverDataType,
IsArray: attributeInfo.IsArray,
ArrayDim: attributeInfo.ArrayDim,
Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly,
IsHistorized: attributeInfo.IsHistorized));
return new NullHandle(attributeInfo.FullName);
}
/// <inheritdoc />
public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ }
/// <summary>A variable handle whose alarm marking is a no-op (discovered alarms are out of scope).</summary>
private sealed class NullHandle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
/// <summary>A null sink that ignores alarm condition transitions.</summary>
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
@@ -0,0 +1,19 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// A flattened variable captured from a driver's <see cref="ITagDiscovery.DiscoverAsync"/> stream
/// by <see cref="CapturingAddressSpaceBuilder"/>. Folder nesting is preserved in
/// <see cref="FolderPathSegments"/> so the injector can re-root the node under an equipment.
/// </summary>
public sealed record DiscoveredNode(
IReadOnlyList<string> FolderPathSegments,
string BrowseName,
string DisplayName,
string FullReference,
DriverDataType DataType,
bool IsArray,
uint? ArrayDim,
bool Writable,
bool IsHistorized);
@@ -0,0 +1,44 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
[Trait("Category", "Unit")]
public sealed class CapturingAddressSpaceBuilderTests
{
[Fact]
public void Records_nested_path_segments_full_reference_and_metadata()
{
var b = new CapturingAddressSpaceBuilder();
var focas = b.Folder("FOCAS", "FOCAS");
var device = focas.Folder("10.0.0.5:8193", "cnc");
var identity = device.Folder("Identity", "Identity");
identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo(
FullName: "10.0.0.5:8193/Identity/SeriesNumber",
DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false));
b.Nodes.Count.ShouldBe(1);
var n = b.Nodes[0];
n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" });
n.BrowseName.ShouldBe("SeriesNumber");
n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber");
n.DataType.ShouldBe(DriverDataType.String);
n.Writable.ShouldBeFalse(); // ViewOnly -> read-only
}
[Fact]
public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink()
{
var b = new CapturingAddressSpaceBuilder();
var f = b.Folder("FOCAS", "FOCAS");
f.AddProperty("Manufacturer", DriverDataType.String, "FANUC"); // ignored, no throw
var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null,
SecurityClassification.ViewOnly, false, IsAlarm: true));
var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null));
sink.ShouldNotBeNull(); // no-op sink, alarms out of scope
b.Nodes.Count.ShouldBe(1);
}
}