From 05c5dd5e2bc92e58019cf3e5d5efeb955d498544 Mon Sep 17 00:00:00 2001 From: MrAlaskan <1922345259@qq.com> Date: Sun, 19 Jul 2026 16:26:57 +0800 Subject: [PATCH] fix(di): accept DeviceType subtypes during device discovery --- src/Opc.Ua.Di.Client/DiDiscoveryClient.cs | 18 +++- .../Opc.Ua.Di.Tests/DiDiscoveryClientTests.cs | 85 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/Opc.Ua.Di.Client/DiDiscoveryClient.cs b/src/Opc.Ua.Di.Client/DiDiscoveryClient.cs index 1560f83fed..01c80c1d1a 100644 --- a/src/Opc.Ua.Di.Client/DiDiscoveryClient.cs +++ b/src/Opc.Ua.Di.Client/DiDiscoveryClient.cs @@ -127,7 +127,11 @@ private static async IAsyncEnumerable BrowseForDevicesAsync( var targetId = ExpandedNodeId.ToNodeId( reference.NodeId, session.NamespaceUris); - if (reference.TypeDefinition == deviceTypeId) + if (await IsSubtypeOfAsync( + session, + reference.TypeDefinition, + deviceTypeId, + ct).ConfigureAwait(false)) { string displayName = reference.DisplayName.Text ?? string.Empty; @@ -154,6 +158,18 @@ private static async IAsyncEnumerable BrowseForDevicesAsync( } } + private static ValueTask IsSubtypeOfAsync( + ISession session, + ExpandedNodeId typeDefinition, + ExpandedNodeId expectedType, + CancellationToken ct) + { + return session.NodeCache.IsTypeOfAsync( + typeDefinition, + expectedType, + ct); + } + private static async ValueTask ReadDeviceClassAsync( ISession session, NodeId deviceNodeId, diff --git a/tests/Opc.Ua.Di.Tests/DiDiscoveryClientTests.cs b/tests/Opc.Ua.Di.Tests/DiDiscoveryClientTests.cs index c57ae2ed0d..922bdc4053 100644 --- a/tests/Opc.Ua.Di.Tests/DiDiscoveryClientTests.cs +++ b/tests/Opc.Ua.Di.Tests/DiDiscoveryClientTests.cs @@ -105,6 +105,8 @@ public async Task EnumerateDevicesAsyncReturnsEmptyWhenNoReferences() public async Task EnumerateDevicesAsyncReturnsDeviceEntryForMatchingTypeDefinition() { Mock sessionMock = CreateSessionMock(); + var nodeCacheMock = new Mock(MockBehavior.Strict); + sessionMock.SetupGet(s => s.NodeCache).Returns(nodeCacheMock.Object); ExpandedNodeId deviceTypeId = global::Opc.Ua.Di.ObjectTypeIds.DeviceType; var deviceNodeId = new NodeId("device-1", 2); @@ -120,6 +122,18 @@ public async Task EnumerateDevicesAsyncReturnsDeviceEntryForMatchingTypeDefiniti SetupBrowseSequential(sessionMock, first: [deviceRef, nonDeviceRef], rest: []); + nodeCacheMock + .Setup(c => c.IsTypeOfAsync( + deviceTypeId, + deviceTypeId, + It.IsAny())) + .Returns(new ValueTask(true)); + nodeCacheMock + .Setup(c => c.IsTypeOfAsync( + new ExpandedNodeId("OtherType", 2), + deviceTypeId, + It.IsAny())) + .Returns(new ValueTask(false)); // Stub the DeviceClass property lookup so it returns // empty (no targets → empty deviceClass). @@ -133,12 +147,60 @@ public async Task EnumerateDevicesAsyncReturnsDeviceEntryForMatchingTypeDefiniti Assert.That(result[0].DeviceId, Is.EqualTo(deviceNodeId)); Assert.That(result[0].DisplayName, Is.EqualTo("Device 1")); Assert.That(result[0].DeviceClass, Is.EqualTo(string.Empty)); + nodeCacheMock.VerifyAll(); + } + + [Test] + public async Task EnumerateDevicesAsyncReturnsDeviceEntryForSubtypeTypeDefinition() + { + Mock sessionMock = CreateSessionMock(); + var nodeCacheMock = new Mock(MockBehavior.Strict); + sessionMock.SetupGet(s => s.NodeCache).Returns(nodeCacheMock.Object); + ExpandedNodeId deviceTypeId = global::Opc.Ua.Di.ObjectTypeIds.DeviceType; + ExpandedNodeId vendorDeviceType = new("VendorDeviceType", 2); + var deviceNodeId = new NodeId("device-subtype-1", 2); + + ReferenceDescription deviceRef = MakeReference( + deviceNodeId, "Vendor Device", vendorDeviceType); + ReferenceDescription nonDeviceRef = MakeReference( + new NodeId("other-1", 2), "Other 1", + new ExpandedNodeId("OtherType", 2)); + + SetupBrowseSequential(sessionMock, + first: [deviceRef, nonDeviceRef], + rest: []); + nodeCacheMock + .Setup(c => c.IsTypeOfAsync( + vendorDeviceType, + deviceTypeId, + It.IsAny())) + .Returns(new ValueTask(true)); + nodeCacheMock + .Setup(c => c.IsTypeOfAsync( + new ExpandedNodeId("OtherType", 2), + deviceTypeId, + It.IsAny())) + .Returns(new ValueTask(false)); + + SetupTranslateBrowsePathsEmpty(sessionMock); + + List result = await ToListAsync( + DiDiscoveryClient.EnumerateDevicesAsync( + sessionMock.Object, NullTelemetry())).ConfigureAwait(false); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].DeviceId, Is.EqualTo(deviceNodeId)); + Assert.That(result[0].DisplayName, Is.EqualTo("Vendor Device")); + Assert.That(result[0].DeviceClass, Is.EqualTo(string.Empty)); + nodeCacheMock.VerifyAll(); } [Test] public async Task EnumerateDevicesAsyncFiltersOutNonDeviceTypes() { Mock sessionMock = CreateSessionMock(); + var nodeCacheMock = new Mock(MockBehavior.Strict); + sessionMock.SetupGet(s => s.NodeCache).Returns(nodeCacheMock.Object); ReferenceDescription nonDeviceRef = MakeReference( new NodeId("folder-1", 2), "Folder 1", new ExpandedNodeId("FolderType", 2)); @@ -146,12 +208,19 @@ public async Task EnumerateDevicesAsyncFiltersOutNonDeviceTypes() SetupBrowseSequential(sessionMock, first: [nonDeviceRef], rest: []); + nodeCacheMock + .Setup(c => c.IsTypeOfAsync( + new ExpandedNodeId("FolderType", 2), + global::Opc.Ua.Di.ObjectTypeIds.DeviceType, + It.IsAny())) + .Returns(new ValueTask(false)); List result = await ToListAsync( DiDiscoveryClient.EnumerateDevicesAsync( sessionMock.Object, NullTelemetry())).ConfigureAwait(false); Assert.That(result, Is.Empty); + nodeCacheMock.VerifyAll(); } [Test] @@ -163,6 +232,8 @@ public async Task EnumerateDevicesAsyncStopsRecursionAtMaxDepth() // bounded number of browse calls (depth 0..3 inclusive // → 4 calls before depth > maxDepth aborts). Mock sessionMock = CreateSessionMock(); + var nodeCacheMock = new Mock(MockBehavior.Strict); + sessionMock.SetupGet(s => s.NodeCache).Returns(nodeCacheMock.Object); ReferenceDescription nonDeviceRef = MakeReference( new NodeId("nested", 2), "Nested", new ExpandedNodeId("FolderType", 2)); @@ -186,6 +257,12 @@ public async Task EnumerateDevicesAsyncStopsRecursionAtMaxDepth() } }.ToArrayOf() }); + nodeCacheMock + .Setup(c => c.IsTypeOfAsync( + new ExpandedNodeId("FolderType", 2), + global::Opc.Ua.Di.ObjectTypeIds.DeviceType, + It.IsAny())) + .Returns(new ValueTask(false)); List result = await ToListAsync( DiDiscoveryClient.EnumerateDevicesAsync( @@ -196,6 +273,12 @@ public async Task EnumerateDevicesAsyncStopsRecursionAtMaxDepth() // depth > 3 guard stops further recursion. Assert.That(browseCalls, Is.EqualTo(4), "Recursion must stop at maxDepth=3 (4 invocations)."); + nodeCacheMock.Verify( + c => c.IsTypeOfAsync( + new ExpandedNodeId("FolderType", 2), + global::Opc.Ua.Di.ObjectTypeIds.DeviceType, + It.IsAny()), + Times.Exactly(4)); } private static async Task> ToListAsync( @@ -228,6 +311,8 @@ private static Mock CreateSessionMock() var nsTable = new NamespaceTable(); nsTable.GetIndexOrAppend(global::Opc.Ua.Di.Namespaces.OpcUaDi); mock.SetupGet(s => s.NamespaceUris).Returns(nsTable); + mock.SetupGet(s => s.NodeCache) + .Returns(new Mock(MockBehavior.Loose).Object); return mock; }