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()
{