diff --git a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json index 0758de6c..76fef22c 100644 --- a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json +++ b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json @@ -7,8 +7,8 @@ {"id": 3, "subject": "Task 3: AbCip opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [4, 5]}, {"id": 4, "subject": "Task 4: TwinCAT opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [3, 5]}, {"id": 5, "subject": "Task 5: FOCAS opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [3, 4]}, - {"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "pending", "blockedBy": [1, 2]}, - {"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "pending", "blockedBy": [6]}, + {"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "completed", "blockedBy": [1, 2]}, + {"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "completed", "blockedBy": [6]}, {"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "pending", "blockedBy": [7]}, {"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "pending", "blockedBy": [8], "parallelWith": [11]}, {"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "pending", "blockedBy": [9], "parallelWith": [12]}, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs new file mode 100644 index 00000000..f2fa8cc2 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs @@ -0,0 +1,49 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// One node captured from a driver's DiscoverAsync stream. Folder ids are +/// path-composed ("/parent/child"); leaf ids are the driver FullName (the commit value). +public sealed class CapturedNode +{ + /// Stable identifier. Folder = path-composed ("/parent/child"); leaf = driver FullName. + public required string Id { get; init; } + + /// Browse name (the path segment under the parent). + public required string Name { get; init; } + + /// Human-readable display name. + public required string DisplayName { get; init; } + + /// True for a variable (terminal); false for a folder. + public required bool IsLeaf { get; init; } + + /// Driver-side attribute metadata; set only on leaves. + public DriverAttributeInfo? Attribute { get; init; } + + /// True when the driver marked this leaf as an alarm condition during discovery. + public bool IsAlarmMarked { get; set; } + + /// Captured child nodes (folders first-or-interleaved in stream order). + public List Children { get; } = []; + + /// Captured static properties attached to this node via AddProperty. + public List<(string Name, DriverDataType Type, object? Value)> Properties { get; } = []; +} + +/// Immutable-after-Build snapshot of a whole discovery capture. May be shared by +/// multiple coalesced browse sessions — sessions drop their reference on dispose, never mutate. +public sealed class CapturedTree +{ + /// Synthetic root; its Children are the top-level captured nodes. + public required CapturedNode Root { get; init; } + + /// True when the node cap was hit and recording stopped short. + public required bool Truncated { get; init; } + + /// Number of nodes actually recorded (bounded by the node cap). + public required int Count { get; init; } + + /// Flat lookup from node id to node (for O(1) Expand/Attributes serving). + public required IReadOnlyDictionary ById { get; init; } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs new file mode 100644 index 00000000..47ac6fdd --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs @@ -0,0 +1,71 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// +/// Serves a captured discovery snapshot through the standard picker session interface. +/// No I/O after open; the tree is point-in-time (picker offers Refresh = close + re-open). +/// The tree may be shared by coalesced sessions — dispose drops only this session's +/// reference. See design §4.3. +/// +public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSession +{ + private CapturedTree? _tree = tree; + + /// + public Guid Token { get; } = Guid.NewGuid(); + + /// + public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; + + /// + public Task> RootAsync(CancellationToken cancellationToken) + { + var t = Tree(); + var nodes = t.Root.Children.Select(Map).ToList(); + if (t.Truncated) + nodes.Add(new BrowseNode("__truncated__", + "⚠ Results truncated — narrow the driver config or use manual entry", + BrowseNodeKind.Folder, HasChildrenHint: false)); + LastUsedUtc = DateTime.UtcNow; + return Task.FromResult>(nodes); + } + + /// + public Task> ExpandAsync(string nodeId, CancellationToken cancellationToken) + { + var t = Tree(); + LastUsedUtc = DateTime.UtcNow; + return Task.FromResult>( + t.ById.TryGetValue(nodeId, out var n) ? n.Children.Select(Map).ToList() : []); + } + + /// + public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken) + { + var t = Tree(); + LastUsedUtc = DateTime.UtcNow; + if (!t.ById.TryGetValue(nodeId, out var n)) + return Task.FromResult>([]); + var attrs = new List(); + if (n.Attribute is { } a) + attrs.Add(new AttributeInfo(n.Name, a.DriverDataType.ToString(), a.IsArray, + a.SecurityClass.ToString(), a.IsAlarm || n.IsAlarmMarked)); + attrs.AddRange(n.Properties.Select(p => + new AttributeInfo(p.Name, p.Type.ToString(), false, "", false))); + return Task.FromResult>(attrs); + } + + /// + public ValueTask DisposeAsync() + { + _tree = null; // drop this session's reference only — tree may be shared (coalescing) + return ValueTask.CompletedTask; + } + + private CapturedTree Tree() => _tree ?? throw new ObjectDisposedException(nameof(CapturedTreeBrowseSession)); + + private static BrowseNode Map(CapturedNode n) => n.IsLeaf + ? new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Leaf, false) + : new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Folder, n.Children.Count > 0); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs new file mode 100644 index 00000000..ceae69df --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs @@ -0,0 +1,112 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// +/// In-memory that records the streamed discovery tree +/// for the universal browser instead of materializing OPC UA nodes. Single-threaded like +/// every builder a driver streams into during DiscoverAsync. Enforces a node cap: once +/// exceeded it stops recording (drivers keep streaming harmlessly) and marks the tree +/// truncated. See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.1. +/// +public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder +{ + private sealed class CaptureState(int nodeCap) + { + public int Count; + public bool Truncated; + public readonly int NodeCap = nodeCap; + public readonly Dictionary ById = new(StringComparer.Ordinal); + public bool TryReserve() + { + if (Count >= NodeCap) { Truncated = true; return false; } + Count++; + return true; + } + } + + private readonly CaptureState _state; + private readonly CapturedNode _scope; + + /// Create a root capturing builder with the given node cap (default 50 000). + /// Maximum number of nodes recorded before the tree is marked truncated. + public CapturingAddressSpaceBuilder(int nodeCap = 50_000) + { + _state = new CaptureState(nodeCap); + _scope = new CapturedNode { Id = "", Name = "", DisplayName = "", IsLeaf = false }; + } + + private CapturingAddressSpaceBuilder(CaptureState state, CapturedNode scope) + { + _state = state; + _scope = scope; + } + + /// + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { + var node = new CapturedNode + { + Id = _scope.Id + "/" + browseName, + Name = browseName, + DisplayName = displayName, + IsLeaf = false, + }; + if (_state.TryReserve()) + { + _scope.Children.Add(node); + _state.ById.TryAdd(node.Id, node); + } + // Over cap: the child builder writes into a detached node — recording stops, streaming doesn't break. + return new CapturingAddressSpaceBuilder(_state, node); + } + + /// + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + var node = new CapturedNode + { + Id = attributeInfo.FullName, + Name = browseName, + DisplayName = displayName, + IsLeaf = true, + Attribute = attributeInfo, + }; + if (_state.TryReserve()) + { + _scope.Children.Add(node); + _state.ById.TryAdd(node.Id, node); + } + return new CapturedVariableHandle(node); + } + + /// + public void AddProperty(string browseName, DriverDataType dataType, object? value) => + _scope.Properties.Add((browseName, dataType, value)); + + /// Snapshot the captured tree. Only valid on the root builder (the one the browser constructed). + /// An immutable-after-build . + public CapturedTree Build() => new() + { + Root = _scope, + Truncated = _state.Truncated, + Count = _state.Count, + ById = _state.ById, + }; + + private sealed class CapturedVariableHandle(CapturedNode node) : IVariableHandle + { + public string FullReference => node.Id; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) + { + node.IsAlarmMarked = true; + return NoopAlarmSink.Instance; + } + } + + private sealed class NoopAlarmSink : IAlarmConditionSink + { + public static readonly NoopAlarmSink Instance = new(); + public void OnTransition(AlarmEventArgs args) { } // browse never delivers live transitions + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs new file mode 100644 index 00000000..9aa3a433 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs @@ -0,0 +1,115 @@ +using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing; + +public class CapturedTreeBrowseSessionTests +{ + private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) => + new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm); + + // A small captured graph: a folder Plc1 with a Speed leaf and an alarm leaf, plus a root-level leaf. + // truncated=true builds with a tiny cap so recording stops short and Truncated flips. + private static CapturedTree SampleTree(bool truncated = false) + { + var b = new CapturingAddressSpaceBuilder(nodeCap: truncated ? 2 : 100); + var plc = b.Folder("Plc1", "PLC 1"); + plc.Variable("Speed", "Speed", Attr("Plc1.Speed")); + plc.Variable("Alarm", "Alarm", Attr("Plc1.Alarm")).MarkAsAlarmCondition( + new AlarmConditionInfo("Alarm", AlarmSeverity.High, null)); + b.Variable("Direct", "Direct", Attr("Root.Direct")); + return b.Build(); + } + + [Fact] + public async Task RootReturnsTopLevelNodes() + { + await using var s = new CapturedTreeBrowseSession(SampleTree()); + var root = await s.RootAsync(CancellationToken.None); + + var folder = root.Single(n => n.NodeId == "/Plc1"); + folder.Kind.ShouldBe(BrowseNodeKind.Folder); + folder.HasChildrenHint.ShouldBeTrue(); + + var leaf = root.Single(n => n.NodeId == "Root.Direct"); + leaf.Kind.ShouldBe(BrowseNodeKind.Leaf); + leaf.NodeId.ShouldBe("Root.Direct"); // leaf NodeId IS the driver FullName + } + + [Fact] + public async Task ExpandReturnsCapturedChildren() + { + await using var s = new CapturedTreeBrowseSession(SampleTree()); + var kids = await s.ExpandAsync("/Plc1", CancellationToken.None); + kids.Select(n => n.NodeId).ShouldBe(new[] { "Plc1.Speed", "Plc1.Alarm" }); + } + + [Fact] + public async Task ExpandUnknownNodeReturnsEmpty() + { + await using var s = new CapturedTreeBrowseSession(SampleTree()); + (await s.ExpandAsync("/nope", CancellationToken.None)).ShouldBeEmpty(); + } + + [Fact] + public async Task AttributesForLeafMapsDriverAttributeInfo() + { + await using var s = new CapturedTreeBrowseSession(SampleTree()); + var attrs = await s.AttributesAsync("Plc1.Speed", CancellationToken.None); + var a = attrs.Single(); + a.Name.ShouldBe("Speed"); + a.DriverDataType.ShouldBe(DriverDataType.Float64.ToString()); + a.IsArray.ShouldBeFalse(); + a.SecurityClass.ShouldBe(SecurityClassification.Operate.ToString()); + a.IsAlarm.ShouldBeFalse(); + + // Alarm marked during discovery surfaces IsAlarm=true even though attr.IsAlarm is false. + var alarmAttrs = await s.AttributesAsync("Plc1.Alarm", CancellationToken.None); + alarmAttrs.Single().IsAlarm.ShouldBeTrue(); + } + + [Fact] + public async Task TruncatedTreeAppendsMarkerFolderAtRoot() + { + await using var s = new CapturedTreeBrowseSession(SampleTree(truncated: true)); + var root = await s.RootAsync(CancellationToken.None); + var marker = root[^1]; + marker.Kind.ShouldBe(BrowseNodeKind.Folder); + marker.HasChildrenHint.ShouldBeFalse(); + marker.DisplayName.ToLowerInvariant().ShouldContain("truncated"); + } + + [Fact] + public async Task CallsRefreshLastUsedUtc() + { + await using var s = new CapturedTreeBrowseSession(SampleTree()); + var before = s.LastUsedUtc; + await Task.Delay(5, TestContext.Current.CancellationToken); + await s.RootAsync(CancellationToken.None); + s.LastUsedUtc.ShouldBeGreaterThan(before); + } + + [Fact] + public async Task DisposedSessionThrowsObjectDisposed() + { + var s = new CapturedTreeBrowseSession(SampleTree()); + await s.DisposeAsync(); + await Should.ThrowAsync(() => s.RootAsync(CancellationToken.None)); + } + + [Fact] + public async Task TwoSessionsOverSameTreeAreIndependent() + { + var tree = SampleTree(); + var s1 = new CapturedTreeBrowseSession(tree); + var s2 = new CapturedTreeBrowseSession(tree); + s1.Token.ShouldNotBe(s2.Token); + + await s1.DisposeAsync(); + // Disposing s1 must not affect s2 — the tree is immutable and shared. + (await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty(); + await s2.DisposeAsync(); + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs new file mode 100644 index 00000000..7c7018e9 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs @@ -0,0 +1,70 @@ +using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing; + +public class CapturingAddressSpaceBuilderTests +{ + private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) => + new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm); + + [Fact] + public void CapturesNestedFoldersAndLeaves() + { + var b = new CapturingAddressSpaceBuilder(nodeCap: 100); + var plc = b.Folder("Plc1", "PLC 1"); + plc.Variable("Speed", "Speed", Attr("Program:Main.Speed")); + var udt = plc.Folder("Motor", "Motor"); + udt.Variable("Amps", "Amps", Attr("Program:Main.Motor.Amps")); + + var tree = b.Build(); + tree.Truncated.ShouldBeFalse(); + tree.Root.Children.Count.ShouldBe(1); + var plcNode = tree.Root.Children[0]; + plcNode.Id.ShouldBe("/Plc1"); + plcNode.IsLeaf.ShouldBeFalse(); + plcNode.Children.Select(c => c.Id).ShouldBe(new[] { "Program:Main.Speed", "/Plc1/Motor" }); + tree.ById["Program:Main.Motor.Amps"].Attribute!.FullName.ShouldBe("Program:Main.Motor.Amps"); + } + + [Fact] + public void LeafNodeIdIsTheDriverFullName() + { + var b = new CapturingAddressSpaceBuilder(100); + var handle = b.Variable("T", "T", Attr("Device.Tag1")); + handle.FullReference.ShouldBe("Device.Tag1"); + b.Build().ById["Device.Tag1"].IsLeaf.ShouldBeTrue(); + } + + [Fact] + public void NodeCapTruncatesInsteadOfGrowing() + { + var b = new CapturingAddressSpaceBuilder(nodeCap: 3); + for (var i = 0; i < 10; i++) b.Variable($"T{i}", $"T{i}", Attr($"Dev.T{i}")); + var tree = b.Build(); + tree.Truncated.ShouldBeTrue(); + tree.Count.ShouldBe(3); + } + + [Fact] + public void MarkAsAlarmConditionRecordsAlarmAndReturnsNoopSink() + { + var b = new CapturingAddressSpaceBuilder(100); + var h = b.Variable("A", "A", Attr("Dev.Alarm1")); + var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("A", AlarmSeverity.High, null)); + sink.ShouldNotBeNull(); + Should.NotThrow(() => sink.OnTransition(null!)); // no-op — browse never delivers transitions + b.Build().ById["Dev.Alarm1"].IsAlarmMarked.ShouldBeTrue(); + } + + [Fact] + public void AddPropertyAttachesToCurrentScope() + { + var b = new CapturingAddressSpaceBuilder(100); + var f = b.Folder("Dev", "Dev"); + f.AddProperty("Model", DriverDataType.String, "1756-L83E"); + b.Build().ById["/Dev"].Properties.ShouldContain(p => p.Name == "Model" && (string?)p.Value == "1756-L83E"); + } +}