diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs new file mode 100644 index 0000000..85a79ec --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs @@ -0,0 +1,173 @@ +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); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs new file mode 100644 index 0000000..71d0203 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs @@ -0,0 +1,221 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa; + +[Trait("Category", "Unit")] +public sealed class EquipmentNodeWalkerTests +{ + [Fact] + public void Walk_EmptyContent_EmitsNothing() + { + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [], [])); + + rec.Children.ShouldBeEmpty(); + } + + [Fact] + public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder() + { + var content = new EquipmentNamespaceContent( + Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")], + Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")], + Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")], + Tags: []); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + rec.Children.Select(c => c.BrowseName).ShouldBe(["berlin", "warsaw"]); // ordered by Name + var warsaw = rec.Children.First(c => c.BrowseName == "warsaw"); + warsaw.Children.Select(c => c.BrowseName).ShouldBe(["oven-line"]); + warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]); + } + + [Fact] + public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid() + { + var uuid = Guid.NewGuid(); + var eq = Eq("eq-1", "line-1", "oven-3"); + eq.EquipmentUuid = uuid; + eq.MachineCode = "MC-42"; + eq.ZTag = null; + eq.SAPID = null; + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var equipmentNode = rec.Children[0].Children[0].Children[0]; + var props = equipmentNode.Properties.Select(p => p.BrowseName).ToList(); + props.ShouldContain("EquipmentId"); + props.ShouldContain("EquipmentUuid"); + props.ShouldContain("MachineCode"); + props.ShouldNotContain("ZTag"); + props.ShouldNotContain("SAPID"); + equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString()); + } + + [Fact] + public void Walk_Adds_ZTag_And_SAPID_When_Present() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + eq.ZTag = "ZT-0042"; + eq.SAPID = "10000042"; + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var equipmentNode = rec.Children[0].Children[0].Children[0]; + equipmentNode.Properties.First(p => p.BrowseName == "ZTag").Value.ShouldBe("ZT-0042"); + equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042"); + } + + [Fact] + public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + eq.Manufacturer = "Trumpf"; + eq.Model = "TruLaser-3030"; + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var equipmentNode = rec.Children[0].Children[0].Children[0]; + var identification = equipmentNode.Children.FirstOrDefault(c => c.BrowseName == "Identification"); + identification.ShouldNotBeNull(); + identification!.Properties.Select(p => p.BrowseName).ShouldContain("Manufacturer"); + identification.Properties.Select(p => p.BrowseName).ShouldContain("Model"); + } + + [Fact] + public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull() + { + var eq = Eq("eq-1", "line-1", "oven-3"); // no identification fields + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var equipmentNode = rec.Children[0].Children[0].Children[0]; + equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification"); + } + + [Fact] + public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + var tag1 = NewTag("tag-1", "Temperature", "Int32", "plcaddr-01", equipmentId: "eq-1"); + var tag2 = NewTag("tag-2", "Setpoint", "Float32", "plcaddr-02", equipmentId: "eq-1"); + var unboundTag = NewTag("tag-3", "Orphan", "Int32", "plcaddr-03", equipmentId: null); // SystemPlatform-style, walker skips + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], + [eq], [tag1, tag2, unboundTag]); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var equipmentNode = rec.Children[0].Children[0].Children[0]; + equipmentNode.Variables.Count.ShouldBe(2); + equipmentNode.Variables.Select(v => v.BrowseName).ShouldBe(["Setpoint", "Temperature"]); + equipmentNode.Variables.First(v => v.BrowseName == "Temperature").AttributeInfo.FullName.ShouldBe("plcaddr-01"); + equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32); + } + + [Fact] + public void Walk_FallsBack_To_String_For_Unparseable_DataType() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + var tag = NewTag("tag-1", "Mystery", "NotARealType", "plcaddr-42", equipmentId: "eq-1"); + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var variable = rec.Children[0].Children[0].Children[0].Variables.Single(); + variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String); + } + + // ----- builders for test seed rows ----- + + private static UnsArea Area(string id, string name) => new() + { + UnsAreaId = id, ClusterId = "c1", Name = name, GenerationId = 1, + }; + + private static UnsLine Line(string id, string areaId, string name) => new() + { + UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1, + }; + + private static Equipment Eq(string equipmentId, string lineId, string name) => new() + { + EquipmentRowId = Guid.NewGuid(), + GenerationId = 1, + EquipmentId = equipmentId, + EquipmentUuid = Guid.NewGuid(), + DriverInstanceId = "drv", + UnsLineId = lineId, + Name = name, + MachineCode = "MC-" + name, + }; + + private static Tag NewTag(string tagId, string name, string dataType, string address, string? equipmentId) => new() + { + TagRowId = Guid.NewGuid(), + GenerationId = 1, + TagId = tagId, + DriverInstanceId = "drv", + EquipmentId = equipmentId, + Name = name, + DataType = dataType, + AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite, + TagConfig = address, + }; + + // ----- recording IAddressSpaceBuilder ----- + + private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder + { + public string BrowseName { get; } = browseName; + public List Children { get; } = new(); + public List Variables { get; } = new(); + public List Properties { get; } = new(); + + public IAddressSpaceBuilder Folder(string name, string _) + { + var child = new RecordingBuilder(name); + Children.Add(child); + return child; + } + + public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr) + { + var v = new RecordingVariable(name, attr); + Variables.Add(v); + return v; + } + + public void AddProperty(string name, DriverDataType _, object? value) => + Properties.Add(new RecordingProperty(name, value)); + } + + private sealed record RecordingProperty(string BrowseName, object? Value); + + private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle + { + public string FullReference => AttributeInfo.FullName; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException(); + } +}