diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs
index c414f1c1..b07efa97 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs
@@ -115,22 +115,6 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return (nextCp, nextRefs ?? []);
}
- ///
- public async Task HasChildrenAsync(NodeId nodeId, CancellationToken ct)
- {
- var (_, _, references) = await _session.BrowseAsync(
- null,
- null,
- nodeId,
- 1u,
- BrowseDirection.Forward,
- ReferenceTypeIds.HierarchicalReferences,
- true,
- 0u);
-
- return references != null && references.Count > 0;
- }
-
///
public async Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct)
{
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs
index f3d81e64..8734c50b 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs
@@ -90,14 +90,6 @@ internal interface ISessionAdapter : IDisposable
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct = default);
- ///
- /// Checks whether a node has any forward hierarchical child references.
- ///
- /// The node to inspect for child objects or variables.
- /// The cancellation token that aborts the child lookup.
- /// true when the node has at least one forward hierarchical child reference; otherwise false.
- Task HasChildrenAsync(NodeId nodeId, CancellationToken ct = default);
-
///
/// Reads raw historical data.
///
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/BrowseResult.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/BrowseResult.cs
index 4e3658f8..d0b06838 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/BrowseResult.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/BrowseResult.cs
@@ -34,7 +34,9 @@ public sealed class BrowseResult
public string NodeClass { get; }
///
- /// Whether the node has child references.
+ /// Whether the node MAY have child references: for every Object, confirmed lazily when the
+ /// caller browses it (an expanded empty folder simply yields no children). Variables and Methods report
+ /// .
///
public bool HasChildren { get; }
}
\ No newline at end of file
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
index c1d87129..fd3f1971 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
@@ -224,18 +224,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
while (references.Count > 0)
{
+ // Child detection is lazy: an Object MAY have children and is confirmed when the caller expands or
+ // recurses into it. Probing each Object child with its own Browse made one level of N folders cost N + 1
+ // round trips, and a recursive subscribe over a 50k-node tree ~50k extra Browse calls.
foreach (var reference in references)
- {
- var childNodeId = ExpandedNodeId.ToNodeId(reference.NodeId, _session.NamespaceUris);
- var hasChildren = reference.NodeClass == NodeClass.Object &&
- await _session.HasChildrenAsync(childNodeId, ct);
-
results.Add(new BrowseResult(
reference.NodeId.ToString(),
reference.DisplayName?.Text ?? string.Empty,
reference.NodeClass.ToString(),
- hasChildren));
- }
+ reference.NodeClass == NodeClass.Object));
if (continuationPoint is not { Length: > 0 } pending) break;
(continuationPoint, references) = await _session.BrowseNextAsync(pending, ct);
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs
index 381719a4..18d2bee4 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
+using System.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Opc.Ua;
@@ -19,6 +20,11 @@ public partial class SubscriptionsViewModel : ObservableObject
private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service;
+ // NodeId -> its rows, kept in step with ActiveSubscriptions by OnActiveSubscriptionsChanged, so a notification is
+ // one lookup instead of a scan of every row on the UI thread (2 000 items at 1 Hz was 4M string compares/s).
+ // Touched only on the UI thread, like the collection itself.
+ private readonly Dictionary> _rowsByNodeId = new(StringComparer.Ordinal);
+
///
/// Last user-visible status message — set when a subscribe/unsubscribe operation fails so the
/// shell can surface the diagnostic instead of silently dropping the error. Cleared on success.
@@ -49,9 +55,13 @@ public partial class SubscriptionsViewModel : ObservableObject
{
_service = service;
_dispatcher = dispatcher;
+ ActiveSubscriptions.CollectionChanged += OnActiveSubscriptionsChanged;
_service.DataChanged += OnDataChanged;
}
+ /// The number of distinct NodeIds in the notification index (test seam).
+ internal int IndexedNodeCount => _rowsByNodeId.Count;
+
/// Currently active subscriptions.
public ObservableCollection ActiveSubscriptions { get; } = [];
@@ -62,16 +72,42 @@ public partial class SubscriptionsViewModel : ObservableObject
{
_dispatcher.Post(() =>
{
- foreach (var item in ActiveSubscriptions)
- if (item.NodeId == e.NodeId)
- {
- item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
- item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
- item.Timestamp = e.Value.SourceTimestamp.ToString("O");
- }
+ if (!_rowsByNodeId.TryGetValue(e.NodeId, out var rows)) return;
+ foreach (var item in rows)
+ {
+ item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
+ item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
+ item.Timestamp = e.Value.SourceTimestamp.ToString("O");
+ }
});
}
+ private void OnActiveSubscriptionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (e.Action == NotifyCollectionChangedAction.Reset)
+ {
+ _rowsByNodeId.Clear();
+ foreach (var item in ActiveSubscriptions) Index(item);
+ return;
+ }
+
+ if (e.OldItems != null)
+ foreach (SubscriptionItemViewModel item in e.OldItems)
+ if (_rowsByNodeId.TryGetValue(item.NodeId, out var rows) && rows.Remove(item) && rows.Count == 0)
+ _rowsByNodeId.Remove(item.NodeId);
+
+ if (e.NewItems != null)
+ foreach (SubscriptionItemViewModel item in e.NewItems)
+ Index(item);
+ }
+
+ private void Index(SubscriptionItemViewModel item)
+ {
+ if (!_rowsByNodeId.TryGetValue(item.NodeId, out var rows))
+ _rowsByNodeId[item.NodeId] = rows = [];
+ rows.Add(item);
+ }
+
private bool CanAddSubscription()
{
return IsConnected && !string.IsNullOrWhiteSpace(NewNodeIdText);
@@ -149,7 +185,7 @@ public partial class SubscriptionsViewModel : ObservableObject
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
// Skip if already subscribed
- if (ActiveSubscriptions.Any(s => s.NodeId == nodeIdStr)) return;
+ if (_rowsByNodeId.ContainsKey(nodeIdStr)) return;
try
{
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs
index a73a9f65..14f1df52 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs
@@ -28,8 +28,6 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
public int BrowseCount { get; private set; }
/// Gets the number of times BrowseNextAsync has been called.
public int BrowseNextCount { get; private set; }
- /// Gets the number of times HasChildrenAsync has been called.
- public int HasChildrenCount { get; private set; }
/// Gets the number of times HistoryReadRawAsync has been called.
public int HistoryReadRawCount { get; private set; }
/// Gets the number of times HistoryReadAggregateAsync has been called.
@@ -57,8 +55,6 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
/// Gets or sets the continuation point for browse-next operations.
public byte[]? BrowseNextContinuationPoint { get; set; }
- /// Gets or sets the result returned by has-children operations.
- public bool HasChildrenResponse { get; set; } = false;
/// Gets or sets the historical values returned by raw history-read operations.
public List HistoryReadRawResponse { get; set; } = [];
@@ -150,13 +146,6 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse));
}
- ///
- public Task HasChildrenAsync(NodeId nodeId, CancellationToken ct)
- {
- HasChildrenCount++;
- return Task.FromResult(HasChildrenResponse);
- }
-
///
public Task> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs
index a604fb08..f1035075 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs
@@ -384,10 +384,12 @@ public class OpcUaClientServiceTests : IDisposable
}
///
- /// Verifies that object nodes trigger child-detection checks so the client can mark expandable branches.
+ /// R08 3.6: browsing a level issues ONE Browse (plus BrowseNext pages) however many Object children it holds. Child
+ /// detection is lazy: an Object may have children and is confirmed when it is expanded/recursed, instead of one
+ /// extra Browse per Object child (~50 000 calls for a recursive subscribe over a 50k-node raw tree).
///
[Fact]
- public async Task BrowseAsync_ObjectNode_ChecksHasChildren()
+ public async Task BrowseAsync_ObjectChildren_MayHaveChildren_WithoutAPerChildBrowse()
{
var session = new FakeSessionAdapter
{
@@ -398,17 +400,29 @@ public class OpcUaClientServiceTests : IDisposable
NodeId = new ExpandedNodeId("ns=2;s=Folder1"),
DisplayName = new LocalizedText("Folder1"),
NodeClass = NodeClass.Object
+ },
+ new ReferenceDescription
+ {
+ NodeId = new ExpandedNodeId("ns=2;s=Folder2"),
+ DisplayName = new LocalizedText("Folder2"),
+ NodeClass = NodeClass.Object
+ },
+ new ReferenceDescription
+ {
+ NodeId = new ExpandedNodeId("ns=2;s=Tag"),
+ DisplayName = new LocalizedText("Tag"),
+ NodeClass = NodeClass.Variable
}
- ],
- HasChildrenResponse = true
+ ]
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var results = await _service.BrowseAsync();
- results[0].HasChildren.ShouldBeTrue();
- session.HasChildrenCount.ShouldBe(1);
+ results.Select(r => r.HasChildren).ShouldBe([true, true, false]);
+ session.BrowseCount.ShouldBe(1);
+ session.BrowseNextCount.ShouldBe(0);
}
///
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/SubscriptionsViewModelTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/SubscriptionsViewModelTests.cs
index 8c4fd3f5..44ca937a 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/SubscriptionsViewModelTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/SubscriptionsViewModelTests.cs
@@ -111,6 +111,70 @@ public class SubscriptionsViewModelTests
_vm.ActiveSubscriptions[0].Value.ShouldBeNull();
}
+ ///
+ /// R08 3.6: notifications are routed through a NodeId index, not a scan of every row. The index follows every
+ /// mutation of (add, remove, replace, clear).
+ ///
+ [Fact]
+ public void NodeId_index_follows_every_collection_mutation()
+ {
+ var a = new SubscriptionItemViewModel("ns=2;s=A", 1000);
+ var b = new SubscriptionItemViewModel("ns=2;s=B", 1000);
+ var c = new SubscriptionItemViewModel("ns=2;s=C", 1000);
+ _vm.ActiveSubscriptions.Add(a);
+ _vm.ActiveSubscriptions.Add(b);
+ _vm.ActiveSubscriptions.Add(c);
+ _vm.IndexedNodeCount.ShouldBe(3);
+
+ _vm.ActiveSubscriptions.Remove(b);
+ _vm.IndexedNodeCount.ShouldBe(2);
+
+ _vm.ActiveSubscriptions[0] = new SubscriptionItemViewModel("ns=2;s=D", 1000); // replaces A
+ Raise("ns=2;s=A", 1);
+ Raise("ns=2;s=D", 2);
+ a.Value.ShouldBeNull();
+ _vm.ActiveSubscriptions[0].Value.ShouldBe("2");
+
+ _vm.ActiveSubscriptions.Clear();
+ _vm.IndexedNodeCount.ShouldBe(0);
+ }
+
+ /// A removed row stops receiving values while its sibling (the positive control) still does.
+ [Fact]
+ public void DataChanged_after_remove_updates_only_the_remaining_rows()
+ {
+ var a = new SubscriptionItemViewModel("ns=2;s=A", 1000);
+ var b = new SubscriptionItemViewModel("ns=2;s=B", 1000);
+ _vm.ActiveSubscriptions.Add(a);
+ _vm.ActiveSubscriptions.Add(b);
+ _vm.ActiveSubscriptions.Remove(a);
+
+ Raise("ns=2;s=A", 7);
+ Raise("ns=2;s=B", 42);
+
+ a.Value.ShouldBeNull();
+ b.Value.ShouldBe("42");
+ }
+
+ /// Two rows for the same node (no dedup on the manual-add path) both update, as before.
+ [Fact]
+ public void DataChanged_updates_every_row_for_the_node()
+ {
+ var first = new SubscriptionItemViewModel("ns=2;s=A", 1000);
+ var second = new SubscriptionItemViewModel("ns=2;s=A", 500);
+ _vm.ActiveSubscriptions.Add(first);
+ _vm.ActiveSubscriptions.Add(second);
+
+ Raise("ns=2;s=A", 5);
+
+ first.Value.ShouldBe("5");
+ second.Value.ShouldBe("5");
+ }
+
+ private void Raise(string nodeId, int value) =>
+ _service.RaiseDataChanged(new DataChangedEventArgs(nodeId,
+ new DataValue(new Variant(value), StatusCodes.Good, DateTime.UtcNow)));
+
/// Verifies that Clear removes all subscriptions.
[Fact]
public void Clear_RemovesAllSubscriptions()