using System.Text.Json; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; 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.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); } } /// /// 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); if (scope.Mode == ClusterFilterMode.Suppress) return Array.Empty(); 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); } /// /// 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 equipmentTags = BuildEquipmentTagPlans(root); var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags); var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root); return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms) { EquipmentTags = equipmentTags, EquipmentVirtualTags = equipmentVirtualTags, EquipmentScriptedAlarms = equipmentScriptedAlarms, }; } catch (JsonException) { return Empty(); } } /// Cluster-scoped overload: the address-space composition a node should materialise given /// its NodeId. Filters every projection to the node's own ClusterId (see ). /// 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 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. /// The deployment artifact blob. /// This node's identity in "host:port" form. /// Optional diagnostic callback for cross-cluster orphan bindings; null disables the check. /// The filtered composition per the node's scoping decision. public static Phase7CompositionResult ParseComposition( ReadOnlySpan blob, string nodeId, Action? 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()) { EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(), EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(), }; } /// The in-cluster id sets used to filter a composition. /// DriverInstanceIds whose row carries the in-scope ClusterId. /// UnsAreaIds whose row carries the in-scope ClusterId. /// 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. private sealed record ClusterSets(HashSet DriverIds, HashSet AreaIds, HashSet EquipmentIds); /// 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. /// The deployment artifact blob. /// The node's ClusterId to scope to. /// The resolved in-cluster id sets (empty on parse failure => empty composition). private static ClusterSets BuildClusterSets(ReadOnlySpan blob, string clusterId) { var driverIds = new HashSet(StringComparer.Ordinal); var areaIds = new HashSet(StringComparer.Ordinal); var equipmentIds = new HashSet(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(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); } /// Collect each row's value from whose /// ClusterId equals (case-insensitive, matching the codebase convention). /// The artifact root element. /// The array property name to scan (e.g. "DriverInstances"). /// The id field to collect from each in-cluster row. /// The ClusterId rows must match. /// The set to collect matching ids into. private static void CollectIdsWhereCluster( JsonElement root, string arrayName, string idField, string clusterId, HashSet 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(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty()); /// /// 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 — 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. 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; // AccessLevel → Writable. ConfigComposer serialises the TagAccessLevel enum WITHOUT a // string converter, so it lands as a number (Read = 0, ReadWrite = 1); tolerate the string // form ("ReadWrite") too — same defensive both-forms parse as the Kind gate above. MUST match // Phase7Composer's `AccessLevel == TagAccessLevel.ReadWrite` exactly (byte-parity). A missing // field defaults to non-writable (read-only). var writable = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind switch { JsonValueKind.Number => alEl.GetInt32() == (int)TagAccessLevel.ReadWrite, JsonValueKind.String => string.Equals(alEl.GetString(), nameof(TagAccessLevel.ReadWrite), StringComparison.Ordinal), _ => false, }; if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue; if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue; // Equipment-kind namespace only — byte-parity with the composer's pure // `ns.Kind == NamespaceKind.Equipment` predicate (no Galaxy exception). Galaxy points are // ordinary equipment tags now (GalaxyMxGateway is a standard Equipment-kind driver). if (!equipmentNamespaces.Contains(nsId)) continue; var (isHistorized, historianTagname) = ExtractTagHistorize(tagConfig); var (isArray, arrayLength) = ExtractTagArray(tagConfig); result.Add(new EquipmentTagPlan( TagId: tagId!, EquipmentId: equipmentId!, DriverInstanceId: di!, FolderPath: folder ?? string.Empty, Name: name!, DataType: dataType ?? "BaseDataType", FullName: ExtractTagFullName(tagConfig), Writable: writable, Alarm: ExtractTagAlarm(tagConfig), IsHistorized: isHistorized, HistorianTagname: historianTagname, IsArray: isArray, ArrayLength: arrayLength)); } 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; } /// /// 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. 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, 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) { 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(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; // Historize: the artifact carries a Pascal-case "Historize" bool (ConfigComposer serialises // the whole VirtualTag entity with DefaultIgnoreCondition.Never). Robust parse — default // false; only honoured when the JSON value is an actual boolean — so absent/non-bool ⇒ false, // byte-parity with Phase7Composer's entity-default-false behaviour. var historize = el.TryGetProperty("Historize", out var hEl) && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False) && hEl.GetBoolean(); // 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: expanded, DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded), Historize: historize)); } result.Sort((a, b) => { var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId); return byEquipment != 0 ? byEquipment : string.CompareOrdinal(a.Name, b.Name); }); return result; } /// /// Join the artifact's ScriptedAlarms array to its Scripts array (by PredicateScriptId) to emit /// one per alarm. The artifact-decode mirror of /// Phase7Composer.Compose's scripted-alarm producer — so the compose-side + artifact-decode /// plans agree byte-for-byte. An alarm whose PredicateScriptId has no matching Script row is /// SKIPPED (matching the composer's skip behaviour) to preserve parity. PredicateSource = the /// joined script source ("" when missing — but such alarms are skipped above); DependencyRefs /// = the shared merge of the predicate's /// distinct ctx.GetTag("…") reads UNION the message template's {TagPath} tokens. Scripted /// alarms do NOT use {{equip}} substitution (only virtual tags do) — the predicate source is /// used as-is. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order. /// private static IReadOnlyList BuildEquipmentScriptedAlarmPlans(JsonElement root) { if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array) return Array.Empty(); // scriptId → SourceCode (the predicate source the alarm host evaluates). Same join the // VirtualTag builder uses; an alarm whose PredicateScriptId is absent here is skipped below. var scriptSourceById = new Dictionary(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(alarmsArr.GetArrayLength()); foreach (var el in alarmsArr.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var scriptedAlarmId = el.TryGetProperty("ScriptedAlarmId", out var idEl) ? idEl.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 alarmType = el.TryGetProperty("AlarmType", out var atEl) ? atEl.GetString() : null; var severity = el.TryGetProperty("Severity", out var svEl) && svEl.TryGetInt32(out var sv) ? sv : 0; var messageTemplate = el.TryGetProperty("MessageTemplate", out var mtEl) ? mtEl.GetString() : null; var predicateScriptId = el.TryGetProperty("PredicateScriptId", out var psEl) ? psEl.GetString() : null; // Booleans default to the entity defaults (true) when absent / null / non-boolean, so a // partial blob decodes the same as the composer's view of a default-constructed // ScriptedAlarm — preserving byte-parity. GetBoolean only runs for a genuine true/false // token (a non-bool token would otherwise throw InvalidOperationException, uncaught here). bool ReadBool(string prop, bool dflt) => el.TryGetProperty(prop, out var b) && b.ValueKind is JsonValueKind.True or JsonValueKind.False ? b.GetBoolean() : dflt; var historize = ReadBool("HistorizeToAveva", true); var retain = ReadBool("Retain", true); var enabled = ReadBool("Enabled", true); if (string.IsNullOrWhiteSpace(scriptedAlarmId)) continue; // Skip alarms whose predicate script is missing — matching Phase7Composer's skip behaviour // so both sides emit the same set (byte-parity). if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var source)) continue; result.Add(new EquipmentScriptedAlarmPlan( ScriptedAlarmId: scriptedAlarmId!, EquipmentId: equipmentId ?? string.Empty, Name: name ?? string.Empty, AlarmType: alarmType ?? string.Empty, Severity: severity, MessageTemplate: messageTemplate ?? string.Empty, PredicateScriptId: predicateScriptId, PredicateSource: source, DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, messageTemplate), HistorizeToAveva: historize, Retain: retain, Enabled: enabled)); } result.Sort((a, b) => { var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId); return byEquipment != 0 ? byEquipment : string.CompareOrdinal(a.ScriptedAlarmId, b.ScriptedAlarmId); }); 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; } /// Parses the optional alarm object from a tag's TagConfig JSON. Returns null /// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The /// live-edit side (Phase7Composer.ExtractTagAlarm) MUST parse identically (byte-parity). private static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig) { if (string.IsNullOrWhiteSpace(tagConfig)) return null; try { using var doc = JsonDocument.Parse(tagConfig); if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null; var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String ? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition"; var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number && sEl.TryGetInt32(out var sv) ? sv : 500; // historizeToAveva (bool?, absent ⇒ null ⇒ historize): byte-parity with // Phase7Composer.ExtractTagAlarm — only an explicit false suppresses the durable AVEVA write. bool? historize = a.TryGetProperty("historizeToAveva", out var hEl) && hEl.ValueKind is JsonValueKind.True or JsonValueKind.False ? hEl.GetBoolean() : null; return new EquipmentTagAlarmInfo(type, sev, historize); } catch (JsonException) { return null; } } /// Parses the optional server-side HistoryRead intent from a tag's TagConfig JSON: /// the isHistorized bool (absent / not a bool / non-object root / blank / malformed ⇒ /// false) and the optional historianTagname string override (absent / not a string / /// whitespace-or-empty ⇒ null, meaning the historian tagname defaults to the tag's FullName, /// resolved later). The raw string value is used — not trimmed — matching ExtractTagFullName / /// ExtractTagAlarm. Never throws. The live-edit composer side /// (Phase7Composer.ExtractTagHistorize) MUST parse identically (byte-parity). private static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig) { if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null); try { using var doc = JsonDocument.Parse(tagConfig); if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null); var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl) && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False) && hEl.GetBoolean(); string? tagname = null; if (doc.RootElement.TryGetProperty("historianTagname", out var nEl) && nEl.ValueKind == JsonValueKind.String) { var raw = nEl.GetString(); if (!string.IsNullOrWhiteSpace(raw)) tagname = raw; } return (isHistorized, tagname); } catch (JsonException) { return (false, null); } } /// Parses the optional array intent from a tag's TagConfig JSON: the isArray /// bool (absent / not a bool / non-object root / blank / malformed ⇒ false) and the optional /// arrayLength uint (honoured ONLY when isArray is true AND the prop is a JSON number /// that fits uint; else null). Mirrors in structure + /// null/blank/non-object/malformed-JSON tolerance. Never throws. The live-edit composer side /// (Phase7Composer.ExtractTagArray) MUST parse identically (byte-parity). private static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig) { if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null); try { using var doc = JsonDocument.Parse(tagConfig); if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null); var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl) && (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False) && aEl.GetBoolean(); uint? arrayLength = null; if (isArray && doc.RootElement.TryGetProperty("arrayLength", out var lEl) && lEl.ValueKind == JsonValueKind.Number && lEl.TryGetUInt32(out var len)) { arrayLength = len; } return (isArray, arrayLength); } catch (JsonException) { return (false, null); } } 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); } }