From c7661d05100899b3da842b66b7b772c507691ad4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 7 Jun 2026 05:09:53 -0400 Subject: [PATCH] feat(opcua): parse Equipment VirtualTag plans from the deployment artifact --- .../Drivers/DeploymentArtifact.cs | 88 +++++++++++++++++++ .../Drivers/DeploymentArtifactTests.cs | 85 ++++++++++++++++++ 2 files changed, 173 insertions(+) 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 95ad6e9d..e3c6b97b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -193,10 +193,12 @@ public static class DeploymentArtifact var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan); var galaxyTags = BuildGalaxyTagPlans(root, drivers); var equipmentTags = BuildEquipmentTagPlans(root); + var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root); return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags) { EquipmentTags = equipmentTags, + EquipmentVirtualTags = equipmentVirtualTags, }; } catch (JsonException) @@ -227,6 +229,7 @@ public static class DeploymentArtifact full.GalaxyTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray()) { EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), + EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(), }; } @@ -466,6 +469,91 @@ public static class DeploymentArtifact return result; } + /// + /// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one + /// per VirtualTag. The artifact-decode mirror of + /// Phase7Composer.Compose's VirtualTag producer — so the compose-side + artifact-decode + /// plans agree. Expression = the joined Script's SourceCode (empty when the + /// ScriptId is absent); DependencyRefs = the distinct ctx.GetTag("…") literals in + /// that source; FolderPath is always "" (VirtualTag has no FolderPath today). Ordered by + /// EquipmentId then Name to match the composer's deterministic ordering. + /// + private static IReadOnlyList BuildEquipmentVirtualTagPlans(JsonElement root) + { + if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array) + return Array.Empty(); + + // scriptId → SourceCode (the expression source the VirtualTagActor evaluates). + var scriptSourceById = new Dictionary(StringComparer.Ordinal); + if (root.TryGetProperty("Scripts", out var scriptsArr) && scriptsArr.ValueKind == JsonValueKind.Array) + { + foreach (var el in scriptsArr.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + var sid = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null; + if (string.IsNullOrWhiteSpace(sid)) continue; + var src = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String + ? srcEl.GetString() : null; + scriptSourceById[sid!] = src ?? string.Empty; + } + } + + var result = new List(vtArr.GetArrayLength()); + foreach (var el in vtArr.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + var virtualTagId = el.TryGetProperty("VirtualTagId", out var vidEl) ? vidEl.GetString() : null; + var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null; + var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null; + var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null; + var scriptId = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null; + + if (string.IsNullOrWhiteSpace(virtualTagId) || string.IsNullOrWhiteSpace(equipmentId) + || string.IsNullOrWhiteSpace(name)) continue; + + var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src) + ? src : string.Empty; + + result.Add(new EquipmentVirtualTagPlan( + VirtualTagId: virtualTagId!, + EquipmentId: equipmentId!, + FolderPath: string.Empty, + Name: name!, + DataType: dataType ?? "BaseDataType", + Expression: source, + DependencyRefs: ExtractDependencyRefs(source))); + } + + result.Sort((a, b) => + { + var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId); + return byEquipment != 0 ? byEquipment : string.CompareOrdinal(a.Name, b.Name); + }); + return result; + } + + private static readonly System.Text.RegularExpressions.Regex GetTagRefRegex = + new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", System.Text.RegularExpressions.RegexOptions.Compiled); + + /// + /// Distinct ctx.GetTag("ref") string literals in a VirtualTag script source, in + /// first-seen order. The artifact-decode mirror of Phase7Composer.ExtractDependencyRefs + /// — replicated (with the same regex) because Runtime does not reference the OpcUaServer + /// compose assembly; kept in sync with that copy. + /// + private static IReadOnlyList ExtractDependencyRefs(string scriptSource) + { + if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty(); + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(); + foreach (System.Text.RegularExpressions.Match m in GetTagRefRegex.Matches(scriptSource)) + { + var r = m.Groups[1].Value; + if (seen.Add(r)) result.Add(r); + } + 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 / 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 c40986ac..fcc54cda 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 @@ -251,6 +251,50 @@ public sealed class DeploymentArtifactTests c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx"); } + /// + /// Verifies ParseComposition surfaces Equipment-namespace VirtualTags (joined to their Script + /// by ScriptId for the expression source) as EquipmentVirtualTags, with the + /// DependencyRefs extracted from the script's ctx.GetTag("…") literals — the + /// artifact-decode mirror of Phase7Composer.Compose's VirtualTag producer. + /// + [Fact] + public void ParseComposition_reads_EquipmentVirtualTags_from_virtualtags_and_scripts() + { + var blob = JsonSerializer.SerializeToUtf8Bytes(new + { + Scripts = new[] + { + new + { + ScriptId = "scr-1", + SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;", + }, + }, + VirtualTags = new[] + { + new + { + VirtualTagId = "vt-1", + EquipmentId = "eq-1", + Name = "Doubled", + DataType = "Float", + ScriptId = "scr-1", + }, + }, + }); + + var c = DeploymentArtifact.ParseComposition(blob); + + var vt = c.EquipmentVirtualTags.ShouldHaveSingleItem(); + vt.VirtualTagId.ShouldBe("vt-1"); + vt.EquipmentId.ShouldBe("eq-1"); + vt.Name.ShouldBe("Doubled"); + vt.DataType.ShouldBe("Float"); + vt.FolderPath.ShouldBe(""); + vt.Expression.ShouldBe("return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;"); + vt.DependencyRefs.ShouldBe(new[] { "TestMachine_001.TestDouble" }); + } + /// /// Verifies ParseComposition sets the equipment folder DisplayName to the UNS Name /// segment — the source the live rebuild actually uses — not the colloquial MachineCode, so @@ -377,6 +421,47 @@ public sealed class DeploymentArtifactTests comp.DriverInstancePlans.ShouldBeEmpty(); } + /// Verifies the cluster-scoped overload keeps only EquipmentVirtualTags whose EquipmentId + /// belongs to an in-cluster driver (mirroring how EquipmentTags + ScriptedAlarms are filtered). + [Fact] + public void ParseComposition_scoped_keeps_only_my_clusters_virtual_tags() + { + var blob = BlobOf(new + { + Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, + Nodes = new[] + { + new { NodeId = "central-1:4053", ClusterId = "MAIN" }, + new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" }, + }, + DriverInstances = new[] + { + new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, + new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, + }, + Equipment = new[] + { + new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" }, + new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" }, + }, + Scripts = new[] + { + new { ScriptId = "scr", SourceCode = "return 1;" }, + }, + VirtualTags = new[] + { + new { VirtualTagId = "vt-main", EquipmentId = "eq-main", Name = "VM", DataType = "Float", ScriptId = "scr" }, + new { VirtualTagId = "vt-sa", EquipmentId = "eq-sa", Name = "VS", DataType = "Float", ScriptId = "scr" }, + }, + }); + + var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053"); + main.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-main" }); + + var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053"); + siteA.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-sa" }); + } + [Fact] public void ParseComposition_single_cluster_node_id_overload_matches_legacy() {