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 33ec3a59..07137975 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -193,7 +194,7 @@ public static class DeploymentArtifact
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
var galaxyTags = BuildGalaxyTagPlans(root, drivers);
var equipmentTags = BuildEquipmentTagPlans(root);
- var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root);
+ var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags)
{
@@ -525,16 +526,31 @@ public static class DeploymentArtifact
/// 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.
+ /// plans agree. The reserved {{equip}} token in the joined Script's SourceCode is
+ /// substituted with the owning equipment's tag base (derived from '
+ /// FullNames) BEFORE refs are extracted, byte-parity with the composer. Expression = the
+ /// substituted source (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)
+ private static IReadOnlyList BuildEquipmentVirtualTagPlans(
+ JsonElement root, IReadOnlyList equipmentTags)
{
if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array)
return Array.Empty();
+ // Per-equipment tag base = the shared substring-before-first-dot across each equipment's
+ // child-tag FullNames, used to expand the reserved {{equip}} token in shared VirtualTag
+ // scripts (equipment-relative tag paths). Derived from equipmentTags so the artifact-decode
+ // base matches the composer's exactly.
+ var baseByEquip = equipmentTags
+ .GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
+ .ToDictionary(
+ g => g.Key,
+ g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
+ StringComparer.Ordinal);
+
// 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)
@@ -566,14 +582,20 @@ public static class DeploymentArtifact
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src)
? src : string.Empty;
+ // Substitute the {{equip}} token with the owning equipment's tag base BEFORE extracting
+ // refs, so both Expression and DependencyRefs are machine-specific — byte-parity with
+ // Phase7Composer.Compose.
+ var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
+ source, baseByEquip.GetValueOrDefault(equipmentId!));
+
result.Add(new EquipmentVirtualTagPlan(
VirtualTagId: virtualTagId!,
EquipmentId: equipmentId!,
FolderPath: string.Empty,
Name: name!,
DataType: dataType ?? "BaseDataType",
- Expression: source,
- DependencyRefs: ExtractDependencyRefs(source)));
+ Expression: expanded,
+ DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded)));
}
result.Sort((a, b) =>
@@ -584,28 +606,6 @@ public static class DeploymentArtifact
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/DeploymentArtifactEquipTokenTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs
new file mode 100644
index 00000000..3ea4c8c4
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs
@@ -0,0 +1,71 @@
+using System.Text.Json;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
+
+///
+/// Verifies the artifact-decode mirror substitutes the reserved {{equip}} token in a
+/// VirtualTag script's ctx.GetTag("…") literals with the owning equipment's tag base
+/// (derived from its child Equipment-namespace tag's FullName) — byte-parity with
+/// Phase7Composer.Compose's live-edit path, using the same shared
+/// EquipmentScriptPaths helper and the same equipmentTags-derived base.
+///
+public sealed class DeploymentArtifactEquipTokenTests
+{
+ [Fact]
+ public void ParseComposition_substitutes_equip_token_in_virtual_tag_expression()
+ {
+ var blob = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ Namespaces = new[]
+ {
+ new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
+ },
+ DriverInstances = new[]
+ {
+ new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
+ },
+ Tags = new object[]
+ {
+ new
+ {
+ TagId = "tag-source",
+ DriverInstanceId = "drv-modbus",
+ EquipmentId = "TestMachine_001",
+ Name = "Source",
+ FolderPath = (string?)null,
+ DataType = "Float",
+ TagConfig = "{\"FullName\":\"TestMachine_001.Source\"}",
+ },
+ },
+ Scripts = new[]
+ {
+ new
+ {
+ ScriptId = "s-equip",
+ SourceCode = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}.Source\").Value) > 50;",
+ },
+ },
+ VirtualTags = new[]
+ {
+ new
+ {
+ VirtualTagId = "vt-equip",
+ EquipmentId = "TestMachine_001",
+ Name = "OverThreshold",
+ DataType = "Boolean",
+ ScriptId = "s-equip",
+ },
+ },
+ });
+
+ var c = DeploymentArtifact.ParseComposition(blob);
+
+ var vt = c.EquipmentVirtualTags.ShouldHaveSingleItem();
+ vt.Expression.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")");
+ vt.Expression.ShouldNotContain("{{equip}}");
+ vt.DependencyRefs.ShouldBe(new[] { "TestMachine_001.Source" });
+ }
+}