From febe462750fb21fd4ea854af09ea02d63dc64dd0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 6 Jun 2026 14:42:38 -0400 Subject: [PATCH] feat(opcua): carry Equipment-namespace tags through the deployment composition Add EquipmentTagPlan + an init-only EquipmentTags member on Phase7CompositionResult (mirror of GalaxyTags). Populate it compose-side (Tag.EquipmentId != null AND owning namespace Kind == Equipment) and artifact-decode-side via BuildEquipmentTagPlans, with FullName extracted from Tag.TagConfig. Init-only member (not a 7th positional param) so existing convenience constructors + call sites are untouched. --- ...amespace-structure-milestone.md.tasks.json | 14 ++- .../Phase7Composer.cs | 80 +++++++++++- .../Drivers/DeploymentArtifact.cs | 116 +++++++++++++++++- .../Drivers/DeploymentArtifactTests.cs | 60 +++++++++ 4 files changed, 262 insertions(+), 8 deletions(-) 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 c1351ee5..9bd094a1 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 @@ -1,12 +1,14 @@ { "planPath": "docs/plans/2026-06-06-equipment-namespace-structure-milestone.md", + "scopeDoc": "docs/plans/2026-06-06-equipment-namespace-materialization-scope.md", + "branch": "feat/equipment-namespace-structure", "tasks": [ - {"id": 103, "subject": "Task 0: Confirm signatures + record architecture decisions", "status": "pending"}, - {"id": 104, "subject": "Task 1: Carry equipment signals in the composition + artifact", "status": "pending", "blockedBy": [103]}, - {"id": 105, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "pending", "blockedBy": [104]}, - {"id": 106, "subject": "Task 3: Friendly browse names for UNS folders", "status": "pending", "blockedBy": [104]}, - {"id": 107, "subject": "Task 4: Idempotency + restart-safety", "status": "pending", "blockedBy": [105]}, - {"id": 108, "subject": "Task 5: docker-dev integration verification + tool support", "status": "pending", "blockedBy": [105, 106]} + {"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": 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]} ], "lastUpdated": "2026-06-06" } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index 9c62fb93..e998fc88 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; @@ -45,6 +46,16 @@ public sealed record Phase7CompositionResult( : this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty()) { } + + /// + /// Equipment-namespace tags — a with non-null + /// in an Equipment-kind namespace. Mirror of for the UNS + /// equipment-signal path: Phase7Applier.MaterialiseEquipmentTags materialises each as + /// a Variable under its existing equipment folder. Declared as an init-only member defaulting + /// to empty (rather than a 7th positional parameter) so every existing convenience + /// constructor + call site keeps compiling unchanged; new producers set it via initializer. + /// + public IReadOnlyList EquipmentTags { get; init; } = Array.Empty(); } public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName); @@ -68,6 +79,23 @@ public sealed record GalaxyTagPlan( string DataType, string MxAccessRef); +/// +/// One Equipment-namespace tag from a row whose +/// is non-null and whose owning driver's namespace is Equipment-kind. Carries the parent +/// folder (already materialised by Phase7Applier.MaterialiseHierarchy) +/// the variable hangs under, the optional sub-folder, the leaf +/// display, the OPC UA , and the driver-side +/// reference (extracted from Tag.TagConfig) used as the variable +/// NodeId + read/write routing key. The equipment-signal analogue of . +/// +public sealed record EquipmentTagPlan( + string EquipmentId, + string DriverInstanceId, + string FolderPath, + string Name, + string DataType, + string FullName); + /// /// Pure composer that flattens the live-edit DB tables into the address-space build plan a /// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role @@ -178,6 +206,56 @@ public static class Phase7Composer MxAccessRef: string.IsNullOrWhiteSpace(t.FolderPath) ? t.Name : $"{t.FolderPath}.{t.Name}")) .ToList(); - return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags); + // Equipment tags = the inverse filter: a Tag bound to an Equipment (non-null EquipmentId) + // whose driver's namespace is Equipment-kind. FullName is the driver-side wire reference + // pulled from TagConfig — it becomes the variable's NodeId + read/write routing key. + var equipmentTags = tags + .Where(t => t.EquipmentId is not null) + .Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di) + && namespacesById.TryGetValue(di.NamespaceId, out var ns) + && ns.Kind == NamespaceKind.Equipment) + .OrderBy(t => t.EquipmentId, StringComparer.Ordinal) + .ThenBy(t => t.FolderPath, StringComparer.Ordinal) + .ThenBy(t => t.Name, StringComparer.Ordinal) + .Select(t => new EquipmentTagPlan( + EquipmentId: t.EquipmentId!, + DriverInstanceId: t.DriverInstanceId, + FolderPath: t.FolderPath ?? string.Empty, + Name: t.Name, + DataType: t.DataType, + FullName: ExtractTagFullName(t.TagConfig))) + .ToList(); + + return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags) + { + EquipmentTags = equipmentTags, + }; + } + + /// + /// Extract the driver-side full reference from a JSON blob: the + /// CK_Tag_TagConfig_IsJson constraint guarantees a JSON object, and every shipped + /// driver stores the wire-level address in a top-level FullName field. Replicated from + /// EquipmentNodeWalker.ExtractFullName because OpcUaServer does not reference the Core + /// driver assembly (kept in sync with the artifact-decode copy in DeploymentArtifact). + /// Falls back to the raw blob when it is not a JSON object with a string FullName. + /// + /// The tag's wire-level address JSON. + /// The extracted full reference, or the raw blob when no FullName is present. + private static string ExtractTagFullName(string tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig; + try + { + using var doc = JsonDocument.Parse(tagConfig); + if (doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty("FullName", out var fullName) + && fullName.ValueKind == JsonValueKind.String) + { + return fullName.GetString() ?? tagConfig; + } + } + catch (JsonException) { /* fall through to raw blob */ } + return tagConfig; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index 93368eeb..31f48120 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -107,8 +107,12 @@ public static class DeploymentArtifact var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan); var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan); var galaxyTags = BuildGalaxyTagPlans(root, drivers); + var equipmentTags = BuildEquipmentTagPlans(root); - return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags); + return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags) + { + EquipmentTags = equipmentTags, + }; } catch (JsonException) { @@ -203,6 +207,116 @@ public static class DeploymentArtifact return result; } + /// + /// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find + /// Equipment-namespace tags (non-null EquipmentId, owning namespace Kind == Equipment), then + /// emit one per qualifying tag. The artifact-decode mirror of + /// Phase7Composer.Compose's equipment filter — the inverse of + /// — so the compose-side + artifact-decode plans agree on the same set of tags. FullName is + /// read from each tag's TagConfig blob (top-level "FullName" field). + /// + private static IReadOnlyList BuildEquipmentTagPlans(JsonElement root) + { + if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array) + return Array.Empty(); + if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array) + return Array.Empty(); + if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array) + return Array.Empty(); + + // namespaceId → Equipment-kind. Kind serialises as a number by default (Equipment = 0); + // tolerate the string form too (matches BuildGalaxyTagPlans's number/string handling). + var equipmentNamespaces = new HashSet(StringComparer.Ordinal); + foreach (var el in nsArr.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + var id = el.TryGetProperty("NamespaceId", out var idEl) ? idEl.GetString() : null; + if (string.IsNullOrWhiteSpace(id)) continue; + if (!el.TryGetProperty("Kind", out var kindEl)) continue; + var isEquipment = kindEl.ValueKind switch + { + JsonValueKind.Number => kindEl.GetInt32() == 0, // NamespaceKind.Equipment = 0 + JsonValueKind.String => string.Equals(kindEl.GetString(), "Equipment", StringComparison.Ordinal), + _ => false, + }; + if (isEquipment) equipmentNamespaces.Add(id!); + } + + // driverInstanceId → namespaceId + var driverToNamespace = new Dictionary(StringComparer.Ordinal); + foreach (var el in diArr.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null; + var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null; + if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns)) + driverToNamespace[id!] = ns!; + } + + var result = new List(tagsArr.GetArrayLength()); + foreach (var el in tagsArr.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + // Equipment tags REQUIRE a non-null EquipmentId (the inverse of the Galaxy filter). + if (!el.TryGetProperty("EquipmentId", out var eqEl) || eqEl.ValueKind == JsonValueKind.Null) continue; + var equipmentId = eqEl.GetString(); + if (string.IsNullOrWhiteSpace(equipmentId)) continue; + + var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null; + var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null; + var folder = el.TryGetProperty("FolderPath", out var fpEl) && fpEl.ValueKind != JsonValueKind.Null + ? fpEl.GetString() : null; + var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null; + var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String + ? tcEl.GetString() : null; + + if (string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue; + if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue; + if (!equipmentNamespaces.Contains(nsId)) continue; + + result.Add(new EquipmentTagPlan( + EquipmentId: equipmentId!, + DriverInstanceId: di!, + FolderPath: folder ?? string.Empty, + Name: name!, + DataType: dataType ?? "BaseDataType", + FullName: ExtractTagFullName(tagConfig))); + } + + result.Sort((a, b) => + { + var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId); + if (byEquipment != 0) return byEquipment; + var byFolder = string.CompareOrdinal(a.FolderPath, b.FolderPath); + if (byFolder != 0) return byFolder; + return string.CompareOrdinal(a.Name, b.Name); + }); + return result; + } + + /// + /// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName" + /// field). The artifact-decode mirror of Phase7Composer.ExtractTagFullName / + /// EquipmentNodeWalker.ExtractFullName — replicated because Runtime does not reference + /// the Core driver assembly. Falls back to the raw blob when absent or non-JSON. + /// + private static string ExtractTagFullName(string? tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig ?? string.Empty; + try + { + using var doc = JsonDocument.Parse(tagConfig); + if (doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty("FullName", out var fullName) + && fullName.ValueKind == JsonValueKind.String) + { + return fullName.GetString() ?? tagConfig; + } + } + catch (JsonException) { /* fall through to raw blob */ } + return tagConfig; + } + private static IReadOnlyList ReadArray(JsonElement root, string propertyName, Func reader) where T : class { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs index b8447ab5..22f5ffb1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs @@ -117,6 +117,66 @@ public sealed class DeploymentArtifactTests c.ScriptedAlarmPlans.Single().ScriptedAlarmId.ShouldBe("alarm-1"); } + /// + /// Verifies ParseComposition surfaces Equipment-namespace tags (non-null EquipmentId in an + /// Equipment-kind namespace) as EquipmentTags, with FullName extracted + /// from the tag's TagConfig blob — the equipment-signal mirror of the Galaxy-tag path. A + /// SystemPlatform (Galaxy) tag in the same blob must NOT leak into EquipmentTags and must + /// still route to GalaxyTags. + /// + [Fact] + public void ParseComposition_reads_EquipmentTags_from_equipment_namespace() + { + var blob = JsonSerializer.SerializeToUtf8Bytes(new + { + Namespaces = new[] + { + new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment + new { NamespaceId = "ns-sp", Kind = 1 }, // NamespaceKind.SystemPlatform + }, + DriverInstances = new[] + { + new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" }, + new { DriverInstanceId = "drv-galaxy", DriverType = "Galaxy", DriverConfig = "{}", NamespaceId = "ns-sp" }, + }, + Tags = new object[] + { + new + { + TagId = "tag-eq", + DriverInstanceId = "drv-modbus", + EquipmentId = "eq-1", + Name = "Speed", + FolderPath = (string?)null, + DataType = "Float", + TagConfig = "{\"FullName\":\"40001\"}", + }, + new + { + TagId = "tag-gx", + DriverInstanceId = "drv-galaxy", + EquipmentId = (string?)null, + Name = "Temp", + FolderPath = "area", + DataType = "Float", + TagConfig = "{\"FullName\":\"area.Temp\"}", + }, + }, + }); + + var c = DeploymentArtifact.ParseComposition(blob); + + var tag = c.EquipmentTags.ShouldHaveSingleItem(); + tag.EquipmentId.ShouldBe("eq-1"); + tag.DriverInstanceId.ShouldBe("drv-modbus"); + tag.Name.ShouldBe("Speed"); + tag.DataType.ShouldBe("Float"); + tag.FullName.ShouldBe("40001"); // extracted from TagConfig, not the raw blob + + // The Galaxy tag still routes to GalaxyTags and does NOT leak into EquipmentTags. + c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx"); + } + /// Verifies that specs missing required fields are dropped. [Fact] public void Spec_missing_required_fields_is_dropped()