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);
+ }
+}