using System.Text.Json; using System.Text.Json.Nodes; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; 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, string? ResilienceConfig = 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. /// The parsed driver-instance specs, or an empty list for empty/malformed blobs. public static IReadOnlyList ParseDriverInstances(ReadOnlySpan blob) { if (blob.IsEmpty) return Array.Empty(); try { using var doc = JsonDocument.Parse(blob.ToArray()); var root = doc.RootElement; if (!root.TryGetProperty("DriverInstances", out var arr) || arr.ValueKind != JsonValueKind.Array) { return Array.Empty(); } // v3: each spec's DriverConfig is the MERGED config the driver factory binds from — // DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored // raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so // both spawn (TryCreate) and redeploy (ApplyDelta) hand the driver the same shape. var rawTagsByDriver = BuildRawTagsByDriver(root); var deviceRowsByDriver = BuildDeviceRowsByDriver(root); var result = new List(arr.GetArrayLength()); foreach (var el in arr.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var spec = TryReadSpec(el, rawTagsByDriver, deviceRowsByDriver); if (spec is not null) result.Add(spec); } return result; } catch (JsonException) { return Array.Empty(); } } /// Flatten the artifact's raw topology (RawFolders / DriverInstances / Devices / TagGroups / /// Tags) into each driver's authored list, keyed by DriverInstanceId. Each /// tag's RawPath is computed via the shared (the single identity authority), /// so the deploy-decode side agrees byte-for-byte with the authoring/validation side. A tag whose /// device/driver chain is broken (unknown link) is dropped. /// The artifact root element. /// DriverInstanceId → authored raw tags (RawPath + TagConfig + WriteIdempotent + DeviceName). private static Dictionary> BuildRawTagsByDriver(JsonElement root) { var byDriver = new Dictionary>(StringComparer.Ordinal); var folders = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "RawFolders")) { var id = ReadString(el, "RawFolderId"); if (string.IsNullOrWhiteSpace(id)) continue; folders[id!] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id!); } var drivers = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "DriverInstances")) { var id = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id)) continue; drivers[id!] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id!); } var deviceToDriver = new Dictionary(StringComparer.Ordinal); var devices = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Devices")) { var id = ReadString(el, "DeviceId"); var di = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue; var name = ReadString(el, "Name") ?? id!; devices[id!] = (di!, name); deviceToDriver[id!] = di!; } var groups = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "TagGroups")) { var id = ReadString(el, "TagGroupId"); if (string.IsNullOrWhiteSpace(id)) continue; groups[id!] = (ReadNullableString(el, "ParentTagGroupId"), ReadString(el, "Name") ?? id!); } var resolver = new RawPathResolver(folders, drivers, devices, groups); // scriptId → SourceCode, from the artifact's Scripts array — the same join the VirtualTag plan // builder uses. A calc tag's TagConfig carries a scriptId (not the source); the calc driver is // host-blind and can't resolve it, so we inject the resolved `scriptSource` into the delivered // TagConfig here (the deploy-side counterpart of the Calculation driver). Tags with no scriptId // are untouched. var scriptSourceById = BuildScriptSourceMap(root); foreach (var el in EnumerateArray(root, "Tags")) { var deviceId = ReadString(el, "DeviceId"); var name = ReadString(el, "Name"); if (string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue; if (!deviceToDriver.TryGetValue(deviceId!, out var driverInstanceId)) continue; var tagGroupId = ReadNullableString(el, "TagGroupId"); var rawPath = resolver.TryBuildTagPath(deviceId!, tagGroupId, name!); if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names) var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String ? tcEl.GetString() ?? "{}" : "{}"; tagConfig = InjectScriptSource(tagConfig, scriptSourceById); // WriteIdempotent (bool; non-bool/absent ⇒ false — the safe R2 default: writes are non-idempotent). var writeIdempotent = el.TryGetProperty("WriteIdempotent", out var wiEl) && wiEl.ValueKind is JsonValueKind.True or JsonValueKind.False && wiEl.GetBoolean(); var deviceName = devices.TryGetValue(deviceId!, out var dev) ? dev.Name : string.Empty; if (!byDriver.TryGetValue(driverInstanceId, out var list)) byDriver[driverInstanceId] = list = new List(); list.Add(new RawTagEntry(rawPath, tagConfig, writeIdempotent, deviceName)); } // Deterministic ordering per driver (ordinal RawPath) so the injected config is byte-stable. foreach (var list in byDriver.Values) list.Sort((a, b) => string.CompareOrdinal(a.RawPath, b.RawPath)); return byDriver; } /// /// Build the per-equipment {{equip}}/<RefName> reference map — equipmentId → /// (effectiveName → backing-tag RawPath) — from the artifact's raw topology + Tags + /// UnsTagReferences via the shared + . /// The JSON-side mirror of AddressSpaceComposer.BuildEquipmentReferenceMap (entity side), so /// both compose seams resolve {{equip}} script paths to byte-identical RawPaths. Empty when /// the artifact carries no references / tags. /// /// The artifact root element. /// equipmentId → (effectiveName → RawPath). private static IReadOnlyDictionary> BuildEquipmentReferenceMap(JsonElement root) { var folders = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "RawFolders")) { var id = ReadString(el, "RawFolderId"); if (string.IsNullOrWhiteSpace(id)) continue; folders[id!] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id!); } var drivers = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "DriverInstances")) { var id = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id)) continue; drivers[id!] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id!); } var devices = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Devices")) { var id = ReadString(el, "DeviceId"); var di = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue; devices[id!] = (di!, ReadString(el, "Name") ?? id!); } var groups = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "TagGroups")) { var id = ReadString(el, "TagGroupId"); if (string.IsNullOrWhiteSpace(id)) continue; groups[id!] = (ReadNullableString(el, "ParentTagGroupId"), ReadString(el, "Name") ?? id!); } var resolver = new RawPathResolver(folders, drivers, devices, groups); var tagsById = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Tags")) { var tagId = ReadString(el, "TagId"); var deviceId = ReadString(el, "DeviceId"); var name = ReadString(el, "Name"); if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue; tagsById[tagId!] = new EquipmentReferenceMap.TagRow(name!, deviceId!, ReadNullableString(el, "TagGroupId")); } var references = new List(); foreach (var el in EnumerateArray(root, "UnsTagReferences")) { var refId = ReadString(el, "UnsTagReferenceId"); var equipmentId = ReadString(el, "EquipmentId"); var tagId = ReadString(el, "TagId"); if (string.IsNullOrWhiteSpace(refId) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId)) continue; references.Add(new EquipmentReferenceMap.ReferenceRow( refId!, equipmentId!, tagId!, ReadNullableString(el, "DisplayNameOverride"))); } if (references.Count == 0 || tagsById.Count == 0) return new Dictionary>(StringComparer.Ordinal); return EquipmentReferenceMap.Build(references, tagsById, resolver); } /// /// v3 Batch 4 — reconstruct the raw/UNS entities from the artifact JSON and drive the pure /// , returning ONLY the three dual-namespace lists /// ( / / /// ). Reusing the composer verbatim is what /// guarantees BYTE-PARITY between the live-edit compose seam and this artifact-decode seam — the same /// RawPathResolver / RawPaths / TagConfigIntent / V3NodeIds authorities /// produce every RawPath, UNS NodeId, and Organizes backing path. Enums serialize numerically in the /// artifact (no JsonStringEnumConverter), so AccessLevel is read as an int. /// /// The artifact root element. /// The Raw containers, Raw tags, and UNS reference variables. private static (IReadOnlyList Containers, IReadOnlyList Tags, IReadOnlyList UnsRefs) BuildRawAndUnsSubtrees(JsonElement root) { var rawFolders = new List(); foreach (var el in EnumerateArray(root, "RawFolders")) { var id = ReadString(el, "RawFolderId"); if (string.IsNullOrWhiteSpace(id)) continue; rawFolders.Add(new RawFolder { RawFolderId = id!, ParentRawFolderId = ReadNullableString(el, "ParentRawFolderId"), Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty, }); } var driverInstances = new List(); foreach (var el in EnumerateArray(root, "DriverInstances")) { var id = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id)) continue; driverInstances.Add(new DriverInstance { DriverInstanceId = id!, RawFolderId = ReadNullableString(el, "RawFolderId"), Name = ReadString(el, "Name") ?? id!, DriverType = ReadString(el, "DriverType") ?? string.Empty, DriverConfig = ReadString(el, "DriverConfig") ?? "{}", ClusterId = ReadString(el, "ClusterId") ?? string.Empty, }); } var devices = new List(); foreach (var el in EnumerateArray(root, "Devices")) { var id = ReadString(el, "DeviceId"); var di = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue; devices.Add(new Device { DeviceId = id!, DriverInstanceId = di!, Name = ReadString(el, "Name") ?? id!, DeviceConfig = ReadString(el, "DeviceConfig") ?? "{}", }); } var tagGroups = new List(); foreach (var el in EnumerateArray(root, "TagGroups")) { var id = ReadString(el, "TagGroupId"); if (string.IsNullOrWhiteSpace(id)) continue; tagGroups.Add(new TagGroup { TagGroupId = id!, ParentTagGroupId = ReadNullableString(el, "ParentTagGroupId"), DeviceId = ReadString(el, "DeviceId") ?? string.Empty, Name = ReadString(el, "Name") ?? id!, }); } var tags = new List(); foreach (var el in EnumerateArray(root, "Tags")) { var id = ReadString(el, "TagId"); var deviceId = ReadString(el, "DeviceId"); var name = ReadString(el, "Name"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue; var accessLevel = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind == JsonValueKind.Number ? (TagAccessLevel)alEl.GetInt32() : TagAccessLevel.Read; var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String ? tcEl.GetString() ?? "{}" : "{}"; tags.Add(new Tag { TagId = id!, DeviceId = deviceId!, TagGroupId = ReadNullableString(el, "TagGroupId"), Name = name!, DataType = ReadString(el, "DataType") ?? "Float", AccessLevel = accessLevel, TagConfig = tagConfig, }); } var unsTagReferences = new List(); foreach (var el in EnumerateArray(root, "UnsTagReferences")) { var id = ReadString(el, "UnsTagReferenceId"); var equipmentId = ReadString(el, "EquipmentId"); var tagId = ReadString(el, "TagId"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId)) continue; unsTagReferences.Add(new UnsTagReference { UnsTagReferenceId = id!, EquipmentId = equipmentId!, TagId = tagId!, DisplayNameOverride = ReadNullableString(el, "DisplayNameOverride"), }); } var unsAreas = new List(); foreach (var el in EnumerateArray(root, "UnsAreas")) { var id = ReadString(el, "UnsAreaId"); if (string.IsNullOrWhiteSpace(id)) continue; unsAreas.Add(new UnsArea { UnsAreaId = id!, Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty }); } var unsLines = new List(); foreach (var el in EnumerateArray(root, "UnsLines")) { var id = ReadString(el, "UnsLineId"); if (string.IsNullOrWhiteSpace(id)) continue; unsLines.Add(new UnsLine { UnsLineId = id!, UnsAreaId = ReadString(el, "UnsAreaId") ?? string.Empty, Name = ReadString(el, "Name") ?? id! }); } var equipment = new List(); foreach (var el in EnumerateArray(root, "Equipment")) { var id = ReadString(el, "EquipmentId"); if (string.IsNullOrWhiteSpace(id)) continue; equipment.Add(new Equipment { EquipmentId = id!, UnsLineId = ReadString(el, "UnsLineId") ?? string.Empty, Name = ReadString(el, "Name") ?? id!, // MachineCode is a required member but is not used by the raw/UNS compose path (it drives no // NodeId); read it when present, else a placeholder so the object initializer is satisfied. MachineCode = ReadString(el, "MachineCode") ?? id!, }); } var composition = AddressSpaceComposer.Compose( unsAreas, unsLines, equipment, driverInstances, Array.Empty(), unsTagReferences: unsTagReferences, tags: tags, rawFolders: rawFolders, devices: devices, tagGroups: tagGroups); return (composition.RawContainers, composition.RawTags, composition.UnsReferenceVariables); } /// Build the scriptId → SourceCode map from the artifact's Scripts array (mirrors /// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts. private static Dictionary BuildScriptSourceMap(JsonElement root) { var byId = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Scripts")) { var sid = ReadString(el, "ScriptId"); if (string.IsNullOrWhiteSpace(sid)) continue; byId[sid!] = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String ? srcEl.GetString() ?? string.Empty : string.Empty; } return byId; } /// /// When carries a scriptId (a calc tag), inject the resolved /// scriptSource so the host-blind Calculation driver can compile + evaluate it. Identity for /// tags with no scriptId (every other driver's tags), an unresolvable id, or malformed JSON — /// so no existing tag's blob changes. The pre-existing scriptId/trigger fields are preserved. /// /// The tag's raw TagConfig JSON. /// The scriptId → SourceCode map from the artifact. /// The TagConfig with scriptSource injected, or the original when nothing to inject. private static string InjectScriptSource(string tagConfig, IReadOnlyDictionary scriptSourceById) { if (scriptSourceById.Count == 0 || string.IsNullOrWhiteSpace(tagConfig)) return tagConfig; JsonNode? node; try { node = JsonNode.Parse(tagConfig); } catch (JsonException) { return tagConfig; } if (node is not JsonObject obj) return tagConfig; var scriptId = obj.TryGetPropertyValue("scriptId", out var sidNode) && sidNode is JsonValue sv && sv.TryGetValue(out var sid) ? sid : null; if (string.IsNullOrWhiteSpace(scriptId) || !scriptSourceById.TryGetValue(scriptId!, out var source)) return tagConfig; obj["scriptSource"] = source; return obj.ToJsonString(); } /// Group the artifact's Devices into per-driver /// lists (entity Name + schemaless DeviceConfig), ordered by DeviceId for a stable merge. /// The artifact root element. /// DriverInstanceId → ordered device rows. private static Dictionary> BuildDeviceRowsByDriver(JsonElement root) { var byDriver = new Dictionary>(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Devices")) { var id = ReadString(el, "DeviceId"); var di = ReadString(el, "DriverInstanceId"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue; var name = ReadString(el, "Name") ?? id!; var deviceConfig = ReadString(el, "DeviceConfig"); if (!byDriver.TryGetValue(di!, out var list)) byDriver[di!] = list = new List<(string, DriverDeviceConfigMerger.DeviceRow)>(); list.Add((id!, new DriverDeviceConfigMerger.DeviceRow(name, deviceConfig))); } return byDriver.ToDictionary( kv => kv.Key, kv => kv.Value.OrderBy(x => x.DeviceId, StringComparer.Ordinal).Select(x => x.Row).ToList(), StringComparer.Ordinal); } /// Enumerate an artifact array property's object elements (empty when absent/non-array). private static IEnumerable EnumerateArray(JsonElement root, string property) { if (!root.TryGetProperty(property, out var arr) || arr.ValueKind != JsonValueKind.Array) yield break; foreach (var el in arr.EnumerateArray()) if (el.ValueKind == JsonValueKind.Object) yield return el; } /// Reads a string property, coalescing a JSON null (or wrong type) to . private static string? ReadNullableString(JsonElement el, string property) => el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; /// /// 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. a "host:port" string) 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. a "host:port" string) 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; } /// Reads a string property, tolerating a wrong JSON type: a property that is present but /// NOT a JSON string (number / bool / object / array) yields null rather than throwing /// from GetString(). Keeps the artifact-decode lenient /// — a wrong-typed field degrades to the field default / a skipped row, matching the documented /// "malformed blobs return an empty list/composition" contract. private static string? ReadString(JsonElement el, string property) => el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; private static DriverInstanceSpec? TryReadSpec( JsonElement el, IReadOnlyDictionary> rawTagsByDriver, IReadOnlyDictionary> deviceRowsByDriver) { var rowId = el.TryGetProperty("DriverInstanceRowId", out var rowEl) && rowEl.TryGetGuid(out var rid) ? rid : Guid.Empty; var id = ReadString(el, "DriverInstanceId"); var name = ReadString(el, "Name"); var type = ReadString(el, "DriverType"); // A non-bool Enabled token degrades to the field default (true = "desired here"), never throws. var enabled = !el.TryGetProperty("Enabled", out var enEl) || enEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False) || enEl.GetBoolean(); var driverConfig = ReadString(el, "DriverConfig"); var clusterId = ReadString(el, "ClusterId"); // Per-instance resilience overrides (DriverInstance.ResilienceConfig column). Optional/nullable — // absent means tier defaults; layered onto the tier by DriverResilienceOptionsParser at spawn. var resilienceConfig = ReadString(el, "ResilienceConfig"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null; // v3 endpoint→DeviceConfig + RawTags injection: fold DriverConfig + per-device DeviceConfig + the // driver's authored raw tags into the single config the factory binds from (single-endpoint drivers // get their sole DeviceConfig merged up; multi-device drivers get a reconstructed Devices[] array). var devices = deviceRowsByDriver.TryGetValue(id!, out var dr) ? dr : (IReadOnlyList)Array.Empty(); var rawTags = rawTagsByDriver.TryGetValue(id!, out var rt) ? rt : (IReadOnlyList)Array.Empty(); var mergedConfig = DriverDeviceConfigMerger.Merge(driverConfig, devices, rawTags); return new DriverInstanceSpec( DriverInstanceRowId: rowId, DriverInstanceId: id!, Name: name ?? id!, DriverType: type!, Enabled: enabled, DriverConfig: mergedConfig, ClusterId: clusterId, ResilienceConfig: resilienceConfig); } /// /// Parse the artifact into the projected used by /// AddressSpacePlanner + AddressSpaceApplier. 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. /// The projected , or an empty composition for empty/malformed blobs. public static AddressSpaceComposition 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); // v3 DARK address space (Batch 1): NO equipment-tag variable plans materialize — the raw + UNS // variable nodes are lit up in Batch 4 (the dual namespace + UNS fan-out). EquipmentTags is // deliberately empty; {{equip}} substitution therefore has no per-equipment tag base (empty). // The retained per-equipment plans (folder hierarchy + VirtualTags + ScriptedAlarms) still exist. var equipmentTags = Array.Empty(); // Per-equipment {{equip}}/ reference map (effectiveName → RawPath), shared by the // VirtualTag + ScriptedAlarm plan builders so both substitute against the same resolved paths. var referenceMapByEquip = BuildEquipmentReferenceMap(root); var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip); var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip); // v3 Batch 4 — the Raw device subtree + UNS reference variables. Built by reconstructing the raw/UNS // entities from the artifact JSON and driving the SAME pure AddressSpaceComposer the live-edit side // uses, so the artifact-decode seam is BYTE-PARITY with the composer (identical RawPaths, identical // UNS NodeIds, identical Organizes backing paths). Only the three dual-namespace lists are taken from // the composer result; the (already byte-parity) folder/vtag/alarm plans above are kept as-is. var (rawContainers, rawTags, unsReferenceVariables) = BuildRawAndUnsSubtrees(root); return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms) { EquipmentTags = equipmentTags, EquipmentVirtualTags = equipmentVirtualTags, EquipmentScriptedAlarms = equipmentScriptedAlarms, RawContainers = rawContainers, RawTags = rawTags, UnsReferenceVariables = unsReferenceVariables, }; } 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 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 AddressSpaceComposition 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 AddressSpaceComposition( 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(), // v3 Batch 4: raw tags scope by their owning driver's cluster; UNS references by their equipment's // cluster. Raw CONTAINER nodes carry no driver id, so they are kept in full — an out-of-cluster // folder/driver/device/group node materialises only as an empty browse folder (harmless), while every // raw VALUE + UNS reference node is correctly cluster-scoped. RawContainers = full.RawContainers, RawTags = full.RawTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), UnsReferenceVariables = full.UnsReferenceVariables.Where(v => sets.EquipmentIds.Contains(v.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!; } } // v3: Equipment carries no ClusterId AND no driver binding — it is attributed solely by its // UNS line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId). if (root.TryGetProperty("Equipment", out var eq) && eq.ValueKind == JsonValueKind.Array) { foreach (var el in eq.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; 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; if (lineId is not null && lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine)) 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 AddressSpaceComposition Empty() => new( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty()); /// /// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one /// per VirtualTag. The artifact-decode mirror of /// AddressSpaceComposer.Compose's VirtualTag producer — so the compose-side + artifact-decode /// plans agree. Each {{equip}}/<RefName> in the joined Script's SourceCode is /// substituted with the owning equipment's backing RawPath (via ) /// 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, IReadOnlyDictionary> referenceMapByEquip) { if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array) return Array.Empty(); var emptyRefMap = (IReadOnlyDictionary) new Dictionary(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 AddressSpaceComposer's entity-default-false behaviour. var historize = el.TryGetProperty("Historize", out var hEl) && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False) && hEl.GetBoolean(); // Substitute each {{equip}}/ with the owning equipment's backing RawPath BEFORE // extracting refs, so both Expression and DependencyRefs are machine-specific — byte-parity // with AddressSpaceComposer.Compose. var expanded = EquipmentScriptPaths.SubstituteEquipmentToken( source, referenceMapByEquip.GetValueOrDefault(equipmentId!, emptyRefMap)); 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 /// AddressSpaceComposer.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. The /// predicate source's {{equip}}/<RefName> paths are substituted with the owning equipment's /// backing RawPath (via ) BEFORE the merge — byte-parity with /// the composer. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order. /// private static IReadOnlyList BuildEquipmentScriptedAlarmPlans( JsonElement root, IReadOnlyDictionary> referenceMapByEquip) { 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 emptyRefMap = (IReadOnlyDictionary) new Dictionary(StringComparer.Ordinal); 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 AddressSpaceComposer's skip behaviour // so both sides emit the same set (byte-parity). if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var rawSource)) continue; // Substitute each {{equip}}/ with the owning equipment's backing RawPath BEFORE the // dependency merge — byte-parity with AddressSpaceComposer.Compose. var source = EquipmentScriptPaths.SubstituteEquipmentToken( rawSource, referenceMapByEquip.GetValueOrDefault(equipmentId ?? string.Empty, emptyRefMap)); 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; } 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 AddressSpaceComposer'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 = ReadString(el, "UnsAreaId"); var name = ReadString(el, "Name"); if (string.IsNullOrWhiteSpace(id)) return null; return new UnsAreaProjection(id!, name ?? id!); } private static UnsLineProjection? ReadLineProjection(JsonElement el) { var id = ReadString(el, "UnsLineId"); var areaId = ReadString(el, "UnsAreaId"); var name = ReadString(el, "Name"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(areaId)) return null; return new UnsLineProjection(id!, areaId!, name ?? id!); } private static EquipmentNode? ReadEquipmentNode(JsonElement el) { var id = ReadString(el, "EquipmentId"); // DisplayName = the UNS 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 = ReadString(el, "Name"); var lineId = ReadString(el, "UnsLineId"); if (string.IsNullOrWhiteSpace(id)) return null; // v3: Equipment no longer binds a driver/device — it references raw tags via UnsTagReference. // The EquipmentNode's DriverInstanceId/DeviceId/DeviceHost hooks (used by the multi-device FixedTree // partition path) are therefore always null now; Batch 4 owns the UNS↔Raw fan-out that lights them up. return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty); } private static DriverInstancePlan? ReadDriverPlan(JsonElement el) { var id = ReadString(el, "DriverInstanceId"); var type = ReadString(el, "DriverType"); var config = ReadString(el, "DriverConfig"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null; return new DriverInstancePlan(id!, type!, config ?? "{}"); } private static ScriptedAlarmPlan? ReadAlarmPlan(JsonElement el) { var id = ReadString(el, "ScriptedAlarmId"); var equipmentId = ReadString(el, "EquipmentId"); var script = ReadString(el, "PredicateScriptId"); var template = ReadString(el, "MessageTemplate"); if (string.IsNullOrWhiteSpace(id)) return null; return new ScriptedAlarmPlan(id!, equipmentId ?? string.Empty, script ?? string.Empty, template ?? string.Empty); } }