using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
///
/// Minimal driver-side view of the deployment artifact emitted by
/// ConfigComposer.SnapshotAndFlattenAsync. The artifact JSON is the full snapshot —
/// for driver spawning we only need the DriverInstances array. Reading just the
/// subset keeps allocations cheap on every deploy.
///
public sealed record DriverInstanceSpec(
Guid DriverInstanceRowId,
string DriverInstanceId,
string Name,
string DriverType,
bool Enabled,
string DriverConfig,
string? ClusterId = null);
/// How a node should scope a deployment artifact to its own ClusterId.
public enum ClusterFilterMode
{
/// Apply everything (single-cluster / legacy deployments).
None,
/// Filter the artifact to the node's own ClusterId.
ScopeTo,
/// Apply nothing (node's cluster row not found in a multi-cluster artifact).
Suppress,
}
/// Resolved scoping decision for a node against an artifact.
///
/// None = apply everything (single-cluster / legacy); ScopeTo = filter to ;
/// Suppress = apply nothing.
///
/// The node's ClusterId when is ScopeTo; otherwise null.
public readonly record struct ClusterScope(ClusterFilterMode Mode, string? ClusterId);
public static class DeploymentArtifact
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
///
/// 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".
///
/// The deployment artifact blob to parse.
public static IReadOnlyList ParseDriverInstances(ReadOnlySpan blob)
{
if (blob.IsEmpty) return Array.Empty();
try
{
using var doc = JsonDocument.Parse(blob.ToArray());
if (!doc.RootElement.TryGetProperty("DriverInstances", out var arr)
|| arr.ValueKind != JsonValueKind.Array)
{
return Array.Empty();
}
var result = new List(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();
}
}
///
/// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster
/// (or legacy) artifacts resolve to so every existing
/// deployment applies unchanged. In a multi-cluster artifact the node's ClusterNode row
/// (matched by ) selects its
/// ClusterId; a missing row resolves to . Empty /
/// malformed blobs resolve to (lenient, matching the
/// other parsers).
///
/// The deployment artifact blob to inspect.
/// The node's identity (e.g. "central-1:4053") to match against Nodes.
/// The resolved decision for this node.
public static ClusterScope ResolveClusterScope(ReadOnlySpan 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.Ordinal)) 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);
}
}
///
/// Parse a deployment artifact blob into the driver-instance specs a specific node should
/// spawn, scoped to its own ClusterId via . 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).
///
/// The deployment artifact blob to parse.
/// The node's identity (e.g. "central-1:4053") used to resolve cluster scope.
/// The driver-instance specs this node should spawn.
public static IReadOnlyList ParseDriverInstances(ReadOnlySpan blob, string nodeId)
{
var scope = ResolveClusterScope(blob, nodeId);
var all = ParseDriverInstances(blob);
return scope.Mode switch
{
ClusterFilterMode.Suppress => Array.Empty(),
ClusterFilterMode.ScopeTo => all.Where(
s => string.Equals(s.ClusterId, scope.ClusterId, StringComparison.Ordinal)).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);
}
///
/// Parse the artifact into the projected used by
/// Phase7Planner + Phase7Applier. 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 ConfigComposer.SnapshotAndFlattenAsync 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.
///
/// The deployment artifact blob to parse.
public static Phase7CompositionResult ParseComposition(ReadOnlySpan 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);
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags)
{
EquipmentTags = equipmentTags,
};
}
catch (JsonException)
{
return Empty();
}
}
private static Phase7CompositionResult Empty() => new(
Array.Empty(),
Array.Empty(),
Array.Empty(),
Array.Empty(),
Array.Empty(),
Array.Empty());
///
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
/// SystemPlatform-namespace tags (Galaxy hierarchy), then emit one
/// per qualifying tag. Mirrors Phase7Composer.Compose's filter so a compose-side
/// plan and an artifact-decode plan agree on the same set of tags.
///
private static IReadOnlyList BuildGalaxyTagPlans(JsonElement root, IReadOnlyList drivers)
{
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
return Array.Empty();
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
return Array.Empty();
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
return Array.Empty();
// 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(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(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(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;
}
///
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
/// Equipment-namespace tags (non-null EquipmentId, owning namespace Kind == Equipment), then
/// emit one per qualifying tag. The artifact-decode mirror of
/// Phase7Composer.Compose's equipment filter — the inverse of
/// — 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).
///
private static IReadOnlyList BuildEquipmentTagPlans(JsonElement root)
{
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
return Array.Empty();
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
return Array.Empty();
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
return Array.Empty();
// 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(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(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(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;
}
///
/// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName"
/// field). The artifact-decode mirror of Phase7Composer.ExtractTagFullName /
/// EquipmentNodeWalker.ExtractFullName — replicated because Runtime does not reference
/// the Core driver assembly. Falls back to the raw blob when absent or non-JSON.
///
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 ReadArray(JsonElement root, string propertyName, Func reader)
where T : class
{
if (!root.TryGetProperty(propertyName, out var arr) || arr.ValueKind != JsonValueKind.Array)
return Array.Empty();
var result = new List(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 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);
}
}