708 lines
38 KiB
C#
708 lines
38 KiB
C#
using System.Text.Json;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
|
|
/// <summary>
|
|
/// Minimal driver-side view of the deployment artifact emitted by
|
|
/// <c>ConfigComposer.SnapshotAndFlattenAsync</c>. The artifact JSON is the full snapshot —
|
|
/// for driver spawning we only need the <c>DriverInstances</c> array. Reading just the
|
|
/// subset keeps allocations cheap on every deploy.
|
|
/// </summary>
|
|
public sealed record DriverInstanceSpec(
|
|
Guid DriverInstanceRowId,
|
|
string DriverInstanceId,
|
|
string Name,
|
|
string DriverType,
|
|
bool Enabled,
|
|
string DriverConfig,
|
|
string? ClusterId = null);
|
|
|
|
/// <summary>How a node should scope a deployment artifact to its own ClusterId.</summary>
|
|
public enum ClusterFilterMode
|
|
{
|
|
/// <summary>Apply everything (single-cluster / legacy deployments).</summary>
|
|
None,
|
|
|
|
/// <summary>Filter the artifact to the node's own ClusterId.</summary>
|
|
ScopeTo,
|
|
|
|
/// <summary>Apply nothing (node's cluster row not found in a multi-cluster artifact).</summary>
|
|
Suppress,
|
|
}
|
|
|
|
/// <summary>Resolved scoping decision for a node against an artifact.</summary>
|
|
/// <param name="Mode">
|
|
/// None = apply everything (single-cluster / legacy); ScopeTo = filter to <paramref name="ClusterId"/>;
|
|
/// Suppress = apply nothing.
|
|
/// </param>
|
|
/// <param name="ClusterId">The node's ClusterId when <paramref name="Mode"/> is ScopeTo; otherwise null.</param>
|
|
public readonly record struct ClusterScope(ClusterFilterMode Mode, string? ClusterId);
|
|
|
|
public static class DeploymentArtifact
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Parse a deployment artifact blob into the list of driver-instance specs to spawn.
|
|
/// Empty / malformed blobs return an empty list — callers log + treat as "no drivers".
|
|
/// </summary>
|
|
/// <param name="blob">The deployment artifact blob to parse.</param>
|
|
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob)
|
|
{
|
|
if (blob.IsEmpty) return Array.Empty<DriverInstanceSpec>();
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(blob.ToArray());
|
|
if (!doc.RootElement.TryGetProperty("DriverInstances", out var arr)
|
|
|| arr.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return Array.Empty<DriverInstanceSpec>();
|
|
}
|
|
|
|
var result = new List<DriverInstanceSpec>(arr.GetArrayLength());
|
|
foreach (var el in arr.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var spec = TryReadSpec(el);
|
|
if (spec is not null) result.Add(spec);
|
|
}
|
|
return result;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return Array.Empty<DriverInstanceSpec>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster
|
|
/// (or legacy) artifacts resolve to <see cref="ClusterFilterMode.None"/> so every existing
|
|
/// deployment applies unchanged. In a multi-cluster artifact the node's <c>ClusterNode</c> row
|
|
/// (matched by <paramref name="nodeId"/>) selects <see cref="ClusterFilterMode.ScopeTo"/> its
|
|
/// ClusterId; a missing row resolves to <see cref="ClusterFilterMode.Suppress"/>. Empty /
|
|
/// malformed blobs resolve to <see cref="ClusterFilterMode.None"/> (lenient, matching the
|
|
/// other parsers).
|
|
/// </summary>
|
|
/// <param name="blob">The deployment artifact blob to inspect.</param>
|
|
/// <param name="nodeId">The node's identity (e.g. "central-1:4053") to match against <c>Nodes</c>.</param>
|
|
/// <returns>The resolved <see cref="ClusterScope"/> decision for this node.</returns>
|
|
public static ClusterScope ResolveClusterScope(ReadOnlySpan<byte> blob, string nodeId)
|
|
{
|
|
if (blob.IsEmpty) return new ClusterScope(ClusterFilterMode.None, null);
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(blob.ToArray());
|
|
var root = doc.RootElement;
|
|
var clusterCount = root.TryGetProperty("Clusters", out var cl) && cl.ValueKind == JsonValueKind.Array
|
|
? cl.GetArrayLength() : 0;
|
|
if (clusterCount <= 1) return new ClusterScope(ClusterFilterMode.None, null);
|
|
|
|
string? myCluster = null;
|
|
if (root.TryGetProperty("Nodes", out var nodes) && nodes.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var el in nodes.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var nid = el.TryGetProperty("NodeId", out var nEl) ? nEl.GetString() : null;
|
|
if (!string.Equals(nid, nodeId, StringComparison.OrdinalIgnoreCase)) continue;
|
|
myCluster = el.TryGetProperty("ClusterId", out var cEl) ? cEl.GetString() : null;
|
|
break;
|
|
}
|
|
}
|
|
return string.IsNullOrWhiteSpace(myCluster)
|
|
? new ClusterScope(ClusterFilterMode.Suppress, null)
|
|
: new ClusterScope(ClusterFilterMode.ScopeTo, myCluster);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return new ClusterScope(ClusterFilterMode.None, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse a deployment artifact blob into the driver-instance specs a specific node should
|
|
/// spawn, scoped to its own ClusterId via <see cref="ResolveClusterScope"/>. Single-cluster /
|
|
/// legacy artifacts return every spec; a multi-cluster artifact returns only the matching
|
|
/// cluster's specs (or none when the node's row is absent).
|
|
/// </summary>
|
|
/// <param name="blob">The deployment artifact blob to parse.</param>
|
|
/// <param name="nodeId">The node's identity (e.g. "central-1:4053") used to resolve cluster scope.</param>
|
|
/// <returns>The driver-instance specs this node should spawn.</returns>
|
|
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob, string nodeId)
|
|
{
|
|
var scope = ResolveClusterScope(blob, nodeId);
|
|
if (scope.Mode == ClusterFilterMode.Suppress) return Array.Empty<DriverInstanceSpec>();
|
|
var all = ParseDriverInstances(blob);
|
|
return scope.Mode == ClusterFilterMode.ScopeTo
|
|
? all.Where(s => string.Equals(s.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase)).ToArray()
|
|
: all;
|
|
}
|
|
|
|
private static DriverInstanceSpec? TryReadSpec(JsonElement el)
|
|
{
|
|
var rowId = el.TryGetProperty("DriverInstanceRowId", out var rowEl)
|
|
&& rowEl.TryGetGuid(out var rid) ? rid : Guid.Empty;
|
|
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
|
|
var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
|
var type = el.TryGetProperty("DriverType", out var typeEl) ? typeEl.GetString() : null;
|
|
var enabled = !el.TryGetProperty("Enabled", out var enEl) || enEl.GetBoolean();
|
|
var config = el.TryGetProperty("DriverConfig", out var cfgEl) ? cfgEl.GetString() : null;
|
|
var clusterId = el.TryGetProperty("ClusterId", out var clEl) ? clEl.GetString() : null;
|
|
|
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null;
|
|
|
|
return new DriverInstanceSpec(
|
|
DriverInstanceRowId: rowId,
|
|
DriverInstanceId: id!,
|
|
Name: name ?? id!,
|
|
DriverType: type!,
|
|
Enabled: enabled,
|
|
DriverConfig: config ?? "{}",
|
|
ClusterId: clusterId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse the artifact into the projected <see cref="Phase7CompositionResult"/> used by
|
|
/// <c>Phase7Planner</c> + <c>Phase7Applier</c>. Returns an empty composition for empty/
|
|
/// malformed blobs so callers can treat parse failure as a no-op deploy.
|
|
///
|
|
/// The artifact JSON is produced by <c>ConfigComposer.SnapshotAndFlattenAsync</c> in the
|
|
/// ControlPlane — its Pascal-case property names match the EF entities. We only need a
|
|
/// subset of fields per entity class to drive the address-space rebuild on driver-role
|
|
/// nodes.
|
|
/// </summary>
|
|
/// <param name="blob">The deployment artifact blob to parse.</param>
|
|
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob)
|
|
{
|
|
if (blob.IsEmpty) return Empty();
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(blob.ToArray());
|
|
var root = doc.RootElement;
|
|
|
|
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
|
|
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
|
|
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
|
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
|
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)
|
|
{
|
|
return Empty();
|
|
}
|
|
}
|
|
|
|
/// <summary>Cluster-scoped overload: the address-space composition a node should materialise given
|
|
/// its NodeId. Filters every projection to the node's own ClusterId (see <see cref="ResolveClusterScope"/>).
|
|
/// Equipment attribution is dual-path: driver-bound equipment (non-null DriverInstanceId) is kept when
|
|
/// its driver is in-cluster; driver-less equipment (null DriverInstanceId) is kept when its UNS line's
|
|
/// area is in-cluster.
|
|
/// When <paramref name="onInconsistency"/> is supplied it is invoked with a human-readable message for each
|
|
/// kept driver-bound equipment whose owning UNS line is NOT in the node's cluster — a cross-cluster binding
|
|
/// that violates the same-cluster invariant (decision #122) and would orphan the equipment folder. This is
|
|
/// detection only (observability); the equipment is still returned, since the upstream draft validator
|
|
/// is the authority that should prevent the binding in the first place.</summary>
|
|
/// <param name="blob">The deployment artifact blob.</param>
|
|
/// <param name="nodeId">This node's identity in "host:port" form.</param>
|
|
/// <param name="onInconsistency">Optional diagnostic callback for cross-cluster orphan bindings; null disables the check.</param>
|
|
/// <returns>The filtered composition per the node's scoping decision.</returns>
|
|
public static Phase7CompositionResult ParseComposition(
|
|
ReadOnlySpan<byte> blob, string nodeId, Action<string>? onInconsistency = null)
|
|
{
|
|
var scope = ResolveClusterScope(blob, nodeId);
|
|
if (scope.Mode == ClusterFilterMode.None) return ParseComposition(blob);
|
|
if (scope.Mode == ClusterFilterMode.Suppress) return Empty();
|
|
|
|
var full = ParseComposition(blob);
|
|
var sets = BuildClusterSets(blob, scope.ClusterId!);
|
|
|
|
var keptLines = full.UnsLines.Where(l => sets.AreaIds.Contains(l.UnsAreaId)).ToArray();
|
|
var keptEquipment = full.EquipmentNodes.Where(e => sets.EquipmentIds.Contains(e.EquipmentId)).ToArray();
|
|
|
|
if (onInconsistency is not null)
|
|
{
|
|
var keptLineIds = keptLines.Select(l => l.UnsLineId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
// This cross-cluster check only fires for DRIVER-BOUND equipment. Driver-less equipment
|
|
// is attributed by its UNS line's area cluster, so by construction its line is always in
|
|
// keptLines — the condition `!keptLineIds.Contains(e.UnsLineId)` is never true for it.
|
|
foreach (var e in keptEquipment)
|
|
{
|
|
if (!string.IsNullOrEmpty(e.UnsLineId) && !keptLineIds.Contains(e.UnsLineId))
|
|
onInconsistency(
|
|
$"equipment '{e.EquipmentId}' is in cluster '{scope.ClusterId}' (by its driver) but its " +
|
|
$"UNS line '{e.UnsLineId}' is not — a cross-cluster binding violates the same-cluster " +
|
|
"invariant (decision #122) and would orphan the equipment folder.");
|
|
}
|
|
}
|
|
|
|
return new Phase7CompositionResult(
|
|
full.UnsAreas.Where(a => sets.AreaIds.Contains(a.UnsAreaId)).ToArray(),
|
|
keptLines,
|
|
keptEquipment,
|
|
full.DriverInstancePlans.Where(d => sets.DriverIds.Contains(d.DriverInstanceId)).ToArray(),
|
|
full.ScriptedAlarmPlans.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(),
|
|
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(),
|
|
};
|
|
}
|
|
|
|
/// <summary>The in-cluster id sets used to filter a composition.</summary>
|
|
/// <param name="DriverIds">DriverInstanceIds whose row carries the in-scope ClusterId.</param>
|
|
/// <param name="AreaIds">UnsAreaIds whose row carries the in-scope ClusterId.</param>
|
|
/// <param name="EquipmentIds">In-cluster EquipmentIds: driver-bound equipment is attributed by its
|
|
/// driver's cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster.</param>
|
|
private sealed record ClusterSets(HashSet<string> DriverIds, HashSet<string> AreaIds, HashSet<string> EquipmentIds);
|
|
|
|
/// <summary>Build the in-cluster id sets used to filter a composition: DriverInstanceIds + UnsAreaIds
|
|
/// that directly carry the ClusterId, plus in-cluster EquipmentIds — driver-bound equipment attributed
|
|
/// by its driver's cluster, and driver-less equipment (null DriverInstanceId) attributed by its UNS
|
|
/// line's area cluster.</summary>
|
|
/// <param name="blob">The deployment artifact blob.</param>
|
|
/// <param name="clusterId">The node's ClusterId to scope to.</param>
|
|
/// <returns>The resolved in-cluster id sets (empty on parse failure => empty composition).</returns>
|
|
private static ClusterSets BuildClusterSets(ReadOnlySpan<byte> blob, string clusterId)
|
|
{
|
|
var driverIds = new HashSet<string>(StringComparer.Ordinal);
|
|
var areaIds = new HashSet<string>(StringComparer.Ordinal);
|
|
var equipmentIds = new HashSet<string>(StringComparer.Ordinal);
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(blob.ToArray());
|
|
var root = doc.RootElement;
|
|
CollectIdsWhereCluster(root, "DriverInstances", "DriverInstanceId", clusterId, driverIds);
|
|
CollectIdsWhereCluster(root, "UnsAreas", "UnsAreaId", clusterId, areaIds);
|
|
// Map each UnsLine to its owning UnsArea so driver-less equipment can be attributed via
|
|
// its line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId).
|
|
var lineToArea = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
if (root.TryGetProperty("UnsLines", out var lines) && lines.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var el in lines.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var lineId = el.TryGetProperty("UnsLineId", out var lEl) ? lEl.GetString() : null;
|
|
var areaId = el.TryGetProperty("UnsAreaId", out var aEl) ? aEl.GetString() : null;
|
|
if (!string.IsNullOrWhiteSpace(lineId) && !string.IsNullOrWhiteSpace(areaId))
|
|
lineToArea[lineId!] = areaId!;
|
|
}
|
|
}
|
|
// Equipment carries no ClusterId — driver-bound equipment is attributed by its driver's
|
|
// cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster.
|
|
if (root.TryGetProperty("Equipment", out var eq) && eq.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var el in eq.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
|
|
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
|
var lineId = el.TryGetProperty("UnsLineId", out var luEl) ? luEl.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(id)) continue;
|
|
var inByDriver = di is not null && driverIds.Contains(di);
|
|
var inByLine = di is null && lineId is not null
|
|
&& lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine);
|
|
if (inByDriver || inByLine) equipmentIds.Add(id!);
|
|
}
|
|
}
|
|
}
|
|
catch (JsonException) { /* empty sets => nothing matches => empty composition */ }
|
|
return new ClusterSets(driverIds, areaIds, equipmentIds);
|
|
}
|
|
|
|
/// <summary>Collect each row's <paramref name="idField"/> value from <paramref name="arrayName"/> whose
|
|
/// ClusterId equals <paramref name="clusterId"/> (case-insensitive, matching the codebase convention).</summary>
|
|
/// <param name="root">The artifact root element.</param>
|
|
/// <param name="arrayName">The array property name to scan (e.g. "DriverInstances").</param>
|
|
/// <param name="idField">The id field to collect from each in-cluster row.</param>
|
|
/// <param name="clusterId">The ClusterId rows must match.</param>
|
|
/// <param name="into">The set to collect matching ids into.</param>
|
|
private static void CollectIdsWhereCluster(
|
|
JsonElement root, string arrayName, string idField, string clusterId, HashSet<string> into)
|
|
{
|
|
if (!root.TryGetProperty(arrayName, out var arr) || arr.ValueKind != JsonValueKind.Array) return;
|
|
foreach (var el in arr.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var cid = el.TryGetProperty("ClusterId", out var cEl) ? cEl.GetString() : null;
|
|
if (!string.Equals(cid, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
|
|
var id = el.TryGetProperty(idField, out var idEl) ? idEl.GetString() : null;
|
|
if (!string.IsNullOrWhiteSpace(id)) into.Add(id!);
|
|
}
|
|
}
|
|
|
|
private static Phase7CompositionResult Empty() => new(
|
|
Array.Empty<UnsAreaProjection>(),
|
|
Array.Empty<UnsLineProjection>(),
|
|
Array.Empty<EquipmentNode>(),
|
|
Array.Empty<DriverInstancePlan>(),
|
|
Array.Empty<ScriptedAlarmPlan>(),
|
|
Array.Empty<GalaxyTagPlan>());
|
|
|
|
/// <summary>
|
|
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
|
|
/// SystemPlatform-namespace tags (Galaxy hierarchy), then emit one <see cref="GalaxyTagPlan"/>
|
|
/// per qualifying tag. Mirrors <c>Phase7Composer.Compose</c>'s filter so a compose-side
|
|
/// plan and an artifact-decode plan agree on the same set of tags.
|
|
/// </summary>
|
|
private static IReadOnlyList<GalaxyTagPlan> BuildGalaxyTagPlans(JsonElement root, IReadOnlyList<DriverInstancePlan> drivers)
|
|
{
|
|
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
|
|
return Array.Empty<GalaxyTagPlan>();
|
|
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
|
|
return Array.Empty<GalaxyTagPlan>();
|
|
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
|
|
return Array.Empty<GalaxyTagPlan>();
|
|
|
|
// namespaceId → Kind ("SystemPlatform"/"Equipment"/"Simulated") — enum serialises as int by default,
|
|
// but ConfigComposer's snapshot uses default JsonSerializer which writes numbers. Tolerate both.
|
|
var systemPlatformNamespaces = new HashSet<string>(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 isSystemPlatform = kindEl.ValueKind switch
|
|
{
|
|
JsonValueKind.Number => kindEl.GetInt32() == 1, // NamespaceKind.SystemPlatform = 1
|
|
JsonValueKind.String => string.Equals(kindEl.GetString(), "SystemPlatform", StringComparison.Ordinal),
|
|
_ => false,
|
|
};
|
|
if (isSystemPlatform) systemPlatformNamespaces.Add(id!);
|
|
}
|
|
|
|
// driverInstanceId → namespaceId
|
|
var driverToNamespace = new Dictionary<string, string>(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<GalaxyTagPlan>(tagsArr.GetArrayLength());
|
|
foreach (var el in tagsArr.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
// Skip tags with non-null EquipmentId (Equipment-namespace tags belong to a different path).
|
|
if (el.TryGetProperty("EquipmentId", out var eqEl) && eqEl.ValueKind != JsonValueKind.Null) continue;
|
|
|
|
var tagId = el.TryGetProperty("TagId", out var tEl) ? tEl.GetString() : null;
|
|
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;
|
|
|
|
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name))
|
|
continue;
|
|
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
|
|
if (!systemPlatformNamespaces.Contains(nsId)) continue;
|
|
|
|
var folderPath = folder ?? string.Empty;
|
|
var mxRef = string.IsNullOrWhiteSpace(folderPath) ? name! : $"{folderPath}.{name}";
|
|
result.Add(new GalaxyTagPlan(tagId!, di!, folderPath, name!, dataType ?? "BaseDataType", mxRef));
|
|
}
|
|
|
|
result.Sort((a, b) =>
|
|
{
|
|
var byDriver = string.CompareOrdinal(a.DriverInstanceId, b.DriverInstanceId);
|
|
if (byDriver != 0) return byDriver;
|
|
var byFolder = string.CompareOrdinal(a.FolderPath, b.FolderPath);
|
|
if (byFolder != 0) return byFolder;
|
|
return string.CompareOrdinal(a.DisplayName, b.DisplayName);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
|
|
/// Equipment-namespace tags (non-null EquipmentId, owning namespace Kind == Equipment), then
|
|
/// emit one <see cref="EquipmentTagPlan"/> per qualifying tag. The artifact-decode mirror of
|
|
/// <c>Phase7Composer.Compose</c>'s equipment filter — the inverse of <see cref="BuildGalaxyTagPlans"/>
|
|
/// — 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).
|
|
/// </summary>
|
|
private static IReadOnlyList<EquipmentTagPlan> BuildEquipmentTagPlans(JsonElement root)
|
|
{
|
|
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
|
|
return Array.Empty<EquipmentTagPlan>();
|
|
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
|
|
return Array.Empty<EquipmentTagPlan>();
|
|
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
|
|
return Array.Empty<EquipmentTagPlan>();
|
|
|
|
// 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<string>(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<string, string>(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<EquipmentTagPlan>(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 tagId = el.TryGetProperty("TagId", out var tidEl) ? tidEl.GetString() : null;
|
|
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(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
|
|
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
|
|
if (!equipmentNamespaces.Contains(nsId)) continue;
|
|
|
|
result.Add(new EquipmentTagPlan(
|
|
TagId: tagId!,
|
|
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;
|
|
}
|
|
|
|
/// <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>
|
|
/// 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> /
|
|
/// <c>EquipmentNodeWalker.ExtractFullName</c> — replicated because Runtime does not reference
|
|
/// the Core driver assembly. Falls back to the raw blob when absent or non-JSON.
|
|
/// </summary>
|
|
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<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
|
where T : class
|
|
{
|
|
if (!root.TryGetProperty(propertyName, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
|
return Array.Empty<T>();
|
|
|
|
var result = new List<T>(arr.GetArrayLength());
|
|
foreach (var el in arr.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var item = reader(el);
|
|
if (item is not null) result.Add(item);
|
|
}
|
|
// Match Phase7Composer's natural-key sort so plan diffs are deterministic across
|
|
// artifact-decode + composer-compose passes.
|
|
return result.OrderBy(IdentityOf, StringComparer.Ordinal).ToList();
|
|
}
|
|
|
|
private static string IdentityOf<T>(T item) where T : class => item switch
|
|
{
|
|
UnsAreaProjection a => a.UnsAreaId,
|
|
UnsLineProjection l => l.UnsLineId,
|
|
EquipmentNode e => e.EquipmentId,
|
|
DriverInstancePlan d => d.DriverInstanceId,
|
|
ScriptedAlarmPlan a => a.ScriptedAlarmId,
|
|
_ => string.Empty,
|
|
};
|
|
|
|
private static UnsAreaProjection? ReadAreaProjection(JsonElement el)
|
|
{
|
|
var id = el.TryGetProperty("UnsAreaId", out var idEl) ? idEl.GetString() : null;
|
|
var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(id)) return null;
|
|
return new UnsAreaProjection(id!, name ?? id!);
|
|
}
|
|
|
|
private static UnsLineProjection? ReadLineProjection(JsonElement el)
|
|
{
|
|
var id = el.TryGetProperty("UnsLineId", out var idEl) ? idEl.GetString() : null;
|
|
var areaId = el.TryGetProperty("UnsAreaId", out var areaEl) ? areaEl.GetString() : null;
|
|
var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(areaId)) return null;
|
|
return new UnsLineProjection(id!, areaId!, name ?? id!);
|
|
}
|
|
|
|
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
|
|
{
|
|
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
|
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching UnsArea/UnsLine
|
|
// + the live rebuild's source of truth) — NOT the colloquial MachineCode. NodeId stays the
|
|
// logical EquipmentId.
|
|
var displayName = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
|
var lineId = el.TryGetProperty("UnsLineId", out var lineEl) ? lineEl.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(id)) return null;
|
|
return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty);
|
|
}
|
|
|
|
private static DriverInstancePlan? ReadDriverPlan(JsonElement el)
|
|
{
|
|
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
|
|
var type = el.TryGetProperty("DriverType", out var typeEl) ? typeEl.GetString() : null;
|
|
var config = el.TryGetProperty("DriverConfig", out var cfgEl) ? cfgEl.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null;
|
|
return new DriverInstancePlan(id!, type!, config ?? "{}");
|
|
}
|
|
|
|
private static ScriptedAlarmPlan? ReadAlarmPlan(JsonElement el)
|
|
{
|
|
var id = el.TryGetProperty("ScriptedAlarmId", out var idEl) ? idEl.GetString() : null;
|
|
var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null;
|
|
var script = el.TryGetProperty("PredicateScriptId", out var scEl) ? scEl.GetString() : null;
|
|
var template = el.TryGetProperty("MessageTemplate", out var tmEl) ? tmEl.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(id)) return null;
|
|
return new ScriptedAlarmPlan(id!, equipmentId ?? string.Empty, script ?? string.Empty, template ?? string.Empty);
|
|
}
|
|
}
|