diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs new file mode 100644 index 00000000..8ba73869 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs @@ -0,0 +1,67 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// +/// An that RECORDS the streamed tree instead of creating OPC UA +/// nodes — used to capture an 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 . +/// Value nodes only: is ignored and alarm marking returns a no-op sink +/// (discovered alarms are out of scope — alarms come via the config path). +/// Single-threaded: a driver's DiscoverAsync streams on one caller; the root and its child +/// builders share one . Not thread-safe by design. +/// +public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder +{ + private readonly List _nodes; + private readonly IReadOnlyList _path; + + /// Create a root capturing builder with an empty folder path and a fresh node list. + public CapturingAddressSpaceBuilder() : this([], []) { } + + private CapturingAddressSpaceBuilder(List nodes, IReadOnlyList path) + { + _nodes = nodes; + _path = path; + } + + /// All variables captured across the whole tree (shared by the root and every child scope). + public IReadOnlyList Nodes => _nodes; + + /// + public IAddressSpaceBuilder Folder(string browseName, string displayName) + => new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]); + + /// + 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); + } + + /// + public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ } + + /// A variable handle whose alarm marking is a no-op (discovered alarms are out of scope). + private sealed class NullHandle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + + /// A null sink that ignores alarm condition transitions. + private sealed class NullSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs new file mode 100644 index 00000000..7ff7d43e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs @@ -0,0 +1,19 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// +/// A flattened variable captured from a driver's stream +/// by . Folder nesting is preserved in +/// so the injector can re-root the node under an equipment. +/// +public sealed record DiscoveredNode( + IReadOnlyList FolderPathSegments, + string BrowseName, + string DisplayName, + string FullReference, + DriverDataType DataType, + bool IsArray, + uint? ArrayDim, + bool Writable, + bool IsHistorized); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs new file mode 100644 index 00000000..f1da0dfd --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs @@ -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); + } +}