using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa; /// /// Materializes the canonical Unified Namespace browse tree for an Equipment-kind /// from the Config DB's /// UnsArea / UnsLine / Equipment / Tag rows. Runs during /// address-space build per whose /// Namespace.Kind = Equipment; SystemPlatform-kind namespaces (Galaxy) are /// exempt per decision #120 and reach this walker only indirectly through /// . /// /// /// /// Composition strategy. ADR-001 (2026-04-20) accepted Option A — Config /// primary. The walker treats the supplied /// snapshot as the authoritative published surface. Every Equipment row becomes a /// folder node at the UNS level-5 segment; every bound to an /// Equipment (non-null ) becomes a variable node under /// it. Driver-discovered tags that have no Config-DB row are not added by this /// walker — the ITagDiscovery path continues to exist for the SystemPlatform case + /// for enrichment, but Equipment-kind composition is fully Tag-row-driven. /// /// /// /// Under each Equipment node. Five identifier properties per decision #121 /// (EquipmentId, EquipmentUuid, MachineCode, ZTag, /// SAPID) are added as OPC UA properties — external systems (ERP, SAP PM) /// resolve equipment by whichever identifier they natively use without a sidecar. /// materializes the OPC 40010 /// Identification sub-folder with the nine decision-#139 fields when at least one /// is non-null; when all nine are null the sub-folder is omitted rather than /// appearing empty. /// /// /// /// Address resolution. Variable nodes carry the driver-side full reference /// in copied from Tag.TagConfig /// (the wire-level address JSON blob whose interpretation is driver-specific). At /// runtime the dispatch layer routes Read/Write calls through the configured /// capability invoker; an unreachable address surfaces as an OPC UA Bad status via /// the natural driver-read failure path, NOT as a build-time reject. The ADR calls /// this "BadNotFound placeholder" behavior — legible to operators via their Admin /// UI + OPC UA client inspection of node status. /// /// /// /// Pure function. This class has no dependency on the OPC UA SDK, no /// Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls /// into the supplied . The server-side wiring /// (load snapshot → invoke walker → per-tag capability probe) lives in the Task B /// PR alongside NodeScopeResolver's Config-DB join. /// /// public static class EquipmentNodeWalker { /// /// Walk into . /// The builder is scoped to the Equipment-kind namespace root; the walker emits /// Area → Line → Equipment folders under it, then identifier properties + the /// Identification sub-folder + variable nodes per bound Tag under each Equipment. /// /// /// The builder scoped to the Equipment-kind namespace root. Caller is responsible for /// creating this (e.g. rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)). /// /// Pre-loaded + pre-filtered rows for a single published generation. public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content) { ArgumentNullException.ThrowIfNull(namespaceBuilder); ArgumentNullException.ThrowIfNull(content); // Group lines by area + equipment by line + tags by equipment up-front. Avoids an // O(N·M) re-scan at each UNS level on large fleets. var linesByArea = content.Lines .GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); var equipmentByLine = content.Equipment .GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); var tagsByEquipment = content.Tags .Where(t => !string.IsNullOrEmpty(t.EquipmentId)) .GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal)) { var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name); if (!linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)) continue; foreach (var line in areaLines) { var lineBuilder = areaBuilder.Folder(line.Name, line.Name); if (!equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)) continue; foreach (var equipment in lineEquipment) { var equipmentBuilder = lineBuilder.Folder(equipment.Name, equipment.Name); AddIdentifierProperties(equipmentBuilder, equipment); IdentificationFolderBuilder.Build(equipmentBuilder, equipment); if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue; foreach (var tag in equipmentTags) AddTagVariable(equipmentBuilder, tag); } } } } /// /// Adds the five operator-facing identifiers from decision #121 as OPC UA properties /// on the Equipment node. EquipmentId + EquipmentUuid are always populated; /// MachineCode is required per ; ZTag + SAPID are nullable in /// the data model so they're skipped when null to avoid empty-string noise in the /// browse tree. /// private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment) { equipmentBuilder.AddProperty("EquipmentId", DriverDataType.String, equipment.EquipmentId); equipmentBuilder.AddProperty("EquipmentUuid", DriverDataType.String, equipment.EquipmentUuid.ToString()); equipmentBuilder.AddProperty("MachineCode", DriverDataType.String, equipment.MachineCode); if (!string.IsNullOrEmpty(equipment.ZTag)) equipmentBuilder.AddProperty("ZTag", DriverDataType.String, equipment.ZTag); if (!string.IsNullOrEmpty(equipment.SAPID)) equipmentBuilder.AddProperty("SAPID", DriverDataType.String, equipment.SAPID); } /// /// Emit a single Tag row as an . The driver /// full reference lives in Tag.TagConfig (wire-level address, driver-specific /// JSON blob); the variable node's data type derives from Tag.DataType. /// Unreachable-address behavior per ADR-001 Option A: the variable is created; the /// driver's natural Read failure surfaces an OPC UA Bad status at runtime. /// private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag) { var attr = new DriverAttributeInfo( FullName: tag.TagConfig, DriverDataType: ParseDriverDataType(tag.DataType), IsArray: false, ArrayDim: null, SecurityClass: SecurityClassification.FreeAccess, IsHistorized: false); equipmentBuilder.Variable(tag.Name, tag.Name, attr); } /// /// Parse (stored as the enum /// name string, decision #138) into the enum value. Unknown names fall back to /// so a one-off driver-specific type doesn't /// abort the whole walk; the underlying driver still sees the original TagConfig /// address + can surface its own typed value via the OPC UA variant at read time. /// private static DriverDataType ParseDriverDataType(string raw) => Enum.TryParse(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String; } /// /// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config /// DB rows. All four collections are scoped to the same /// + the same /// row. The walker assumes this filter /// was applied by the caller + does no cross-generation or cross-namespace validation. /// public sealed record EquipmentNamespaceContent( IReadOnlyList Areas, IReadOnlyList Lines, IReadOnlyList Equipment, IReadOnlyList Tags);