From d7a0da5ea188fcfe816d42119ddae97696da283d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 06:47:18 -0400 Subject: [PATCH] feat(otopcua): map discovered nodes under an equipment subfolder --- .../DiscoveredInjection.cs | 8 ++ .../Drivers/DiscoveredNodeMapper.cs | 101 ++++++++++++++++++ .../Drivers/DiscoveredNodeMapperTests.cs | 89 +++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs new file mode 100644 index 00000000..90a1abe1 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs @@ -0,0 +1,8 @@ +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; + +/// A folder to ensure during discovered-node injection (NodeId + parent + display). +public sealed record DiscoveredFolder(string NodeId, string? ParentNodeId, string DisplayName); + +/// A read-or-write variable to ensure during discovered-node injection. +public sealed record DiscoveredVariable( + string NodeId, string ParentNodeId, string DisplayName, string DataType, bool Writable, bool IsArray, uint? ArrayLength); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs new file mode 100644 index 00000000..b60e5ff9 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs @@ -0,0 +1,101 @@ +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// The mapped result of grafting discovered nodes under an equipment node. +public sealed record DiscoveredInjectionPlan( + IReadOnlyList Folders, + IReadOnlyList Variables, + IReadOnlyDictionary RoutingByRef); // driver FullReference -> equipment NodeId + +/// +/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping +/// authored Config-DB refs and collapsing the single device-host folder. See the design doc +/// 2026-06-26-otopcua-fixedtree-equipment-injection-design.md. +/// +public static class DiscoveredNodeMapper +{ + /// + /// Maps captured into folders + variables (NodeIds scoped under + /// ) plus a driver-FullReference → equipment-NodeId routing map. + /// + /// The owning equipment's NodeId (root of the grafted subtree). + /// The captured discovery tree (from CapturingAddressSpaceBuilder). + /// + /// Driver FullReferences already authored as Config-DB equipment tags for this driver — + /// skipped so a discovered node never shadows an authored one. + /// + /// The folders, variables, and routing map to apply against the OPC UA address space. + public static DiscoveredInjectionPlan Map( + string equipmentId, IReadOnlyList nodes, ISet authoredRefs) + { + var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList(); + + // Device-folder collapse: when every kept node shares one identical index-1 segment (the single + // device-host folder under the driver root, e.g. "10.0.0.5:8193"), drop it so the path reads + // FOCAS/Identity/... rather than FOCAS/10.0.0.5:8193/Identity/.... With >=2 distinct devices the + // level is retained so identical leaf names across devices don't collide (degrades gracefully). + var collapseIndex1 = kept.Count > 0 + && kept.All(n => n.FolderPathSegments.Count >= 2) + && kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1; + + static IReadOnlyList Effective(IReadOnlyList segs, bool collapse) + => collapse ? [segs[0], .. segs.Skip(2)] : segs; + + var folders = new Dictionary(StringComparer.Ordinal); + var variables = new List(); + var routing = new Dictionary(StringComparer.Ordinal); + + foreach (var n in kept) + { + var segs = Effective(n.FolderPathSegments, collapseIndex1); + + // Ensure every prefix folder, deduped, each parented at its prefix (the first segment's + // parent is the equipment itself). + for (var i = 0; i < segs.Count; i++) + { + var folderPath = string.Join('/', segs.Take(i + 1)); + var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath); + if (folders.ContainsKey(nodeId)) continue; + var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i))); + folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]); + } + + var varFolderPath = string.Join('/', segs); + var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName); + var varParent = EquipmentNodeIds.SubFolder(equipmentId, varFolderPath); + variables.Add(new DiscoveredVariable( + varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim)); + routing[n.FullReference] = varNodeId; + } + + return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing); + } + + /// + /// Maps a to the OPC-UA-built-in type STRING that + /// OtOpcUaNodeManager.EnsureVariable's ResolveBuiltInDataType accepts — so a + /// discovered variable resolves to the same built-in type as an authored equipment tag. Most + /// enum names pass through verbatim; / + /// map to the SDK's "Float"/"Double" names, and (a Galaxy + /// attribute reference) is carried as an OPC UA String per the enum's own contract. + /// + private static string ToBuiltinTypeString(DriverDataType dt) => dt switch + { + DriverDataType.Boolean => "Boolean", + DriverDataType.Int16 => "Int16", + DriverDataType.Int32 => "Int32", + DriverDataType.Int64 => "Int64", + DriverDataType.UInt16 => "UInt16", + DriverDataType.UInt32 => "UInt32", + DriverDataType.UInt64 => "UInt64", + DriverDataType.Float32 => "Float", + DriverDataType.Float64 => "Double", + DriverDataType.String => "String", + DriverDataType.DateTime => "DateTime", + DriverDataType.Reference => "String", + _ => throw new ArgumentOutOfRangeException(nameof(dt), dt, "Unmapped DriverDataType."), + }; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs new file mode 100644 index 00000000..e13abc3d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs @@ -0,0 +1,89 @@ +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 DiscoveredNodeMapperTests +{ + private static DiscoveredNode Node(string[] path, string name, string fullRef, + DriverDataType dt = DriverDataType.Float64, bool writable = false) + => new(path, name, name, fullRef, dt, false, null, writable, false); + + [Fact] + public void Maps_under_equipment_collapsing_single_device_folder() + { + var nodes = new[] + { + Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String), + Node(["FOCAS", "10.0.0.5:8193", "Axes", "X"], "AbsolutePosition", "10.0.0.5:8193/Axes/X/AbsolutePosition"), + }; + + var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); + + result.Variables.Select(v => v.NodeId).ShouldBe(new[] + { + "EQ-1/FOCAS/Identity/SeriesNumber", + "EQ-1/FOCAS/Axes/X/AbsolutePosition", + }, ignoreOrder: true); + result.Folders.Select(f => f.NodeId).ShouldContain("EQ-1/FOCAS/Axes/X"); + result.Folders.First(f => f.NodeId == "EQ-1/FOCAS/Axes/X").ParentNodeId.ShouldBe("EQ-1/FOCAS/Axes"); + result.RoutingByRef["10.0.0.5:8193/Identity/SeriesNumber"].ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber"); + result.Variables.First(v => v.NodeId.EndsWith("SeriesNumber")).Writable.ShouldBeFalse(); + } + + [Fact] + public void Dedups_authored_refs() + { + var nodes = new[] + { + Node(["FOCAS", "10.0.0.5:8193"], "parts-count", "parts-count"), + Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String), + }; + var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet { "parts-count" }); + result.Variables.ShouldHaveSingleItem(); + result.Variables[0].NodeId.ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber"); + } + + [Fact] + public void Does_not_collapse_when_two_devices_present() + { + var nodes = new[] + { + Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "a", DriverDataType.String), + Node(["FOCAS", "10.0.0.6:8193", "Identity"], "SeriesNumber", "b", DriverDataType.String), + }; + var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); + result.Variables.Select(v => v.NodeId).ShouldBe(new[] + { + "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber", + "EQ-1/FOCAS/10.0.0.6:8193/Identity/SeriesNumber", + }, ignoreOrder: true); + } + + [Theory] + // Mirror OtOpcUaNodeManager.ResolveBuiltInDataType's accepted string set: Float32 -> "Float", + // Float64 -> "Double", Reference (Galaxy attr ref encoded as a string) -> "String". The pass-through + // members must keep their enum name so the node manager resolves them to the matching built-in type. + [InlineData(DriverDataType.Float64, "Double")] + [InlineData(DriverDataType.Float32, "Float")] + [InlineData(DriverDataType.Reference, "String")] + [InlineData(DriverDataType.Boolean, "Boolean")] + [InlineData(DriverDataType.Int16, "Int16")] + [InlineData(DriverDataType.Int32, "Int32")] + [InlineData(DriverDataType.Int64, "Int64")] + [InlineData(DriverDataType.UInt16, "UInt16")] + [InlineData(DriverDataType.UInt32, "UInt32")] + [InlineData(DriverDataType.UInt64, "UInt64")] + [InlineData(DriverDataType.String, "String")] + [InlineData(DriverDataType.DateTime, "DateTime")] + public void DataType_maps_to_node_manager_builtin_string(DriverDataType dt, string expected) + { + var nodes = new[] { Node(["FOCAS", "10.0.0.5:8193", "Identity"], "Value", "10.0.0.5:8193/Identity/Value", dt) }; + var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); + result.Variables.ShouldHaveSingleItem(); + result.Variables[0].DataType.ShouldBe(expected); + } +}