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. /// /// Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT /// globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a /// global parent-before-child ordering across the whole list. /// /// Variables to ensure under the (post-collapse) folders. /// Driver FullReference -> equipment NodeId, for live-value routing. 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, IReadOnlySet 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); // Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly // at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the // EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace). var varParent = string.IsNullOrEmpty(varFolderPath) ? equipmentId : 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."), }; }