feat(opcua): parse Equipment VirtualTag plans from the deployment artifact
This commit is contained in:
@@ -193,10 +193,12 @@ public static class DeploymentArtifact
|
|||||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
||||||
var galaxyTags = BuildGalaxyTagPlans(root, drivers);
|
var galaxyTags = BuildGalaxyTagPlans(root, drivers);
|
||||||
var equipmentTags = BuildEquipmentTagPlans(root);
|
var equipmentTags = BuildEquipmentTagPlans(root);
|
||||||
|
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root);
|
||||||
|
|
||||||
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags)
|
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags)
|
||||||
{
|
{
|
||||||
EquipmentTags = equipmentTags,
|
EquipmentTags = equipmentTags,
|
||||||
|
EquipmentVirtualTags = equipmentVirtualTags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
@@ -227,6 +229,7 @@ public static class DeploymentArtifact
|
|||||||
full.GalaxyTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray())
|
full.GalaxyTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray())
|
||||||
{
|
{
|
||||||
EquipmentTags = full.EquipmentTags.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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
|
||||||
|
/// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of
|
||||||
|
/// <c>Phase7Composer.Compose</c>'s VirtualTag producer — so the compose-side + artifact-decode
|
||||||
|
/// plans agree. <c>Expression</c> = the joined Script's <c>SourceCode</c> (empty when the
|
||||||
|
/// ScriptId is absent); <c>DependencyRefs</c> = the distinct <c>ctx.GetTag("…")</c> literals in
|
||||||
|
/// that source; <c>FolderPath</c> is always "" (VirtualTag has no FolderPath today). Ordered by
|
||||||
|
/// EquipmentId then Name to match the composer's deterministic ordering.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<EquipmentVirtualTagPlan> BuildEquipmentVirtualTagPlans(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array)
|
||||||
|
return Array.Empty<EquipmentVirtualTagPlan>();
|
||||||
|
|
||||||
|
// scriptId → SourceCode (the expression source the VirtualTagActor evaluates).
|
||||||
|
var scriptSourceById = new Dictionary<string, string>(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<EquipmentVirtualTagPlan>(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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Distinct <c>ctx.GetTag("ref")</c> string literals in a VirtualTag script source, in
|
||||||
|
/// first-seen order. The artifact-decode mirror of <c>Phase7Composer.ExtractDependencyRefs</c>
|
||||||
|
/// — replicated (with the same regex) because Runtime does not reference the OpcUaServer
|
||||||
|
/// compose assembly; kept in sync with that copy.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<string> ExtractDependencyRefs(string scriptSource)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty<string>();
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var result = new List<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName"
|
/// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName"
|
||||||
/// field). The artifact-decode mirror of <c>Phase7Composer.ExtractTagFullName</c> /
|
/// field). The artifact-decode mirror of <c>Phase7Composer.ExtractTagFullName</c> /
|
||||||
|
|||||||
@@ -251,6 +251,50 @@ public sealed class DeploymentArtifactTests
|
|||||||
c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx");
|
c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies ParseComposition surfaces Equipment-namespace VirtualTags (joined to their Script
|
||||||
|
/// by ScriptId for the expression source) as <c>EquipmentVirtualTags</c>, with the
|
||||||
|
/// <c>DependencyRefs</c> extracted from the script's <c>ctx.GetTag("…")</c> literals — the
|
||||||
|
/// artifact-decode mirror of <c>Phase7Composer.Compose</c>'s VirtualTag producer.
|
||||||
|
/// </summary>
|
||||||
|
[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" });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies ParseComposition sets the equipment folder DisplayName to the UNS <c>Name</c>
|
/// Verifies ParseComposition sets the equipment folder DisplayName to the UNS <c>Name</c>
|
||||||
/// segment — the source the live rebuild actually uses — not the colloquial MachineCode, so
|
/// segment — the source the live rebuild actually uses — not the colloquial MachineCode, so
|
||||||
@@ -377,6 +421,47 @@ public sealed class DeploymentArtifactTests
|
|||||||
comp.DriverInstancePlans.ShouldBeEmpty();
|
comp.DriverInstancePlans.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies the cluster-scoped overload keeps only EquipmentVirtualTags whose EquipmentId
|
||||||
|
/// belongs to an in-cluster driver (mirroring how EquipmentTags + ScriptedAlarms are filtered).</summary>
|
||||||
|
[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]
|
[Fact]
|
||||||
public void ParseComposition_single_cluster_node_id_overload_matches_legacy()
|
public void ParseComposition_single_cluster_node_id_overload_matches_legacy()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user