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); } }