diff --git a/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json b/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json index 9bd094a1..17476ca4 100644 --- a/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json +++ b/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json @@ -5,7 +5,7 @@ "tasks": [ {"id": 0, "nativeTaskId": 86, "subject": "Task 0: Confirm signatures + record architecture decisions", "status": "completed", "blockedBy": []}, {"id": 1, "nativeTaskId": 87, "subject": "Task 1: Carry equipment signals in the composition + artifact", "status": "completed", "blockedBy": [86]}, - {"id": 2, "nativeTaskId": 88, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "pending", "blockedBy": [87]}, + {"id": 2, "nativeTaskId": 88, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "completed", "blockedBy": [87]}, {"id": 3, "nativeTaskId": 89, "subject": "Task 3: Friendly browse names for UNS folders", "status": "pending", "blockedBy": [87]}, {"id": 4, "nativeTaskId": 90, "subject": "Task 4: Idempotency + restart-safety", "status": "pending", "blockedBy": [88]}, {"id": 5, "nativeTaskId": 91, "subject": "Task 5: docker-dev integration verification + tool support", "status": "pending", "blockedBy": [88, 89]} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index 58278d67..70ff35d4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -172,6 +172,67 @@ public sealed class Phase7Applier composition.GalaxyTags.Count, foldersCreated.Count); } + /// + /// Materialise Equipment-namespace tags from a composition snapshot — the equipment-signal + /// analogue of . For each , + /// ensure its optional FolderPath sub-folder under the existing equipment folder, then + /// ensure a Variable (NodeId = FullName, the driver-side ref) inside it. Variables + /// start BadWaitingForInitialData; the driver fills live values in a later milestone. + /// Idempotent. + /// + /// Task 0 architecture decisions (recorded per the equipment-namespace-structure + /// plan). Decision #1 = A — a sink-based pass, NOT a reuse of + /// EquipmentNodeWalker: no sink-backed IAddressSpaceBuilder adapter exists + /// (GenericDriverNodeManager.CapturingBuilder decorates another builder, not the + /// sink), and the walker re-creates the whole Area/Line/Equipment tree with browse-path + /// NodeIds — incompatible with this path's logical-Id NodeIds (decision #3) and the + /// already-materialised equipment folders (decision #4). Decision #4 = this pass adds + /// ONLY variables (and any per-tag sub-folder); owns + /// the equipment folders and this pass never re-creates them. The sink's + /// EnsureVariable takes a plain string dataType (not a DriverAttributeInfo). + /// + /// + /// The composition result containing the equipment tags to materialise. + public void MaterialiseEquipmentTags(Phase7CompositionResult composition) + { + ArgumentNullException.ThrowIfNull(composition); + if (composition.EquipmentTags.Count == 0) return; + + // Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder + // (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the + // equipment folder, which MaterialiseHierarchy already created (decision #4: never re-create + // the equipment folder here). + var foldersCreated = new HashSet(StringComparer.Ordinal); + foreach (var tag in composition.EquipmentTags) + { + if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; + var folderNodeId = EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath); + if (!foldersCreated.Add(folderNodeId)) continue; + SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath); + } + + // Variables: NodeId = FullName (the driver-side reference → read/write routing key). Parent + // is the FolderPath sub-folder when set, else the equipment folder directly. Like the Galaxy + // pass, per-variable idempotency relies on the sink's own EnsureVariable idempotency. + foreach (var tag in composition.EquipmentTags) + { + var parent = string.IsNullOrWhiteSpace(tag.FolderPath) + ? tag.EquipmentId + : EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath); + SafeEnsureVariable(tag.FullName, parent, tag.Name, tag.DataType); + } + + _logger.LogInformation( + "Phase7Applier: equipment tags materialised (tags={Tags}, equipment={Equipment})", + composition.EquipmentTags.Count, + composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count()); + } + + /// Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment + /// folder so two equipments' identically-named sub-folders never collide. + private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) => + $"{equipmentId}/{folderPath}"; + private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName) { try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index ff8e5ecc..9f005cc1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -230,6 +230,11 @@ public sealed class OpcUaPublishActor : ReceiveActor // + Variable node exist so clients can browse them. The Galaxy driver fills values // on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData. _applier.MaterialiseGalaxyTags(composition); + // Equipment-namespace tags get their own pass: ensures each signal's Variable (and any + // FolderPath sub-folder) exists under its already-materialised equipment folder so + // clients can browse them. Live values arrive in a later milestone; until then the + // variables show BadWaitingForInitialData. + _applier.MaterialiseEquipmentTags(composition); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "rebuild")); _log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})", diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs index 7cbae453..c9dabda1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -177,6 +177,58 @@ public sealed class Phase7ApplierTests sink.VariableCalls.ShouldContain(("line.cell.Torque", "line.cell", "Torque", "Float")); } + /// Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly + /// under its existing equipment folder (NodeId == FullName, parent == EquipmentId, + /// displayName == Name) and does NOT re-create the equipment folder (decision #4). + [Fact] + public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + UnsAreas: Array.Empty(), + UnsLines: Array.Empty(), + EquipmentNodes: Array.Empty(), + DriverInstancePlans: Array.Empty(), + ScriptedAlarmPlans: Array.Empty(), + GalaxyTags: Array.Empty()) + { + EquipmentTags = new[] + { + new EquipmentTagPlan("eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"), + }, + }; + + applier.MaterialiseEquipmentTags(composition); + + sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed + sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("40001", "eq-1", "Speed", "Float")); + } + + /// Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment + /// folder (not the namespace root), with the variable parented to that sub-folder. + [Fact] + public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentTags = new[] + { + new EquipmentTagPlan("eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"), + }, + }; + + applier.MaterialiseEquipmentTags(composition); + + sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); + sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("40002", "eq-1/Diagnostics", "Temp", "Float")); + } + /// Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild. [Fact] public void Added_galaxy_tags_trigger_rebuild()