dc0d7653b9
Replace the deleted equipment-base-prefix {{equip}} mechanism (impossible now
that equipment is reference-only) with per-reference resolution:
{{equip}}/<RefName> resolves through the owning equipment's UnsTagReference rows
(by effective name) to the backing raw tag's RawPath. Every unresolved <RefName>
is a clear deploy error + authoring rejection + Monaco diagnostic — preserving
"editor accepts <=> publish accepts".
Commons:
- EquipmentScriptPaths: delete DeriveEquipmentBase; SubstituteEquipmentToken now
takes the equipment's reference map (effectiveName -> RawPath) and substitutes
{{equip}}/<RefName> inside path literals (unresolved left intact, never throws);
add ExtractEquipReferenceNames (path-literal scoped) +
ExtractEquipReferenceNamesFromText (message templates). Slash syntax replaces
the v2 dot joint.
- New EquipmentReferenceMap: shared, input-shape-agnostic builder (entity + JSON
sides) over RawPathResolver — the single authority both compose seams + the
validator use, so resolved RawPaths agree byte-for-byte.
Compose seams (byte-parity kept):
- AddressSpaceComposer.Compose + DeploymentArtifact.ParseComposition both build
the per-equipment reference map from UnsTagReferences + Tags + raw topology and
substitute VirtualTag AND ScriptedAlarm-predicate sources before dependency
extraction (runtime dep refs = resolved RawPaths).
Deploy gate + authoring + editor:
- DraftValidator.ValidateEquipReferenceResolution: EquipReferenceUnresolved error
for unresolved {{equip}}/<RefName> in VirtualTag/ScriptedAlarm scripts + alarm
message-template tokens (naming script, equipment, missing ref).
- UnsTreeService.ValidateEquipTokenAsync (Wave-A M1): reference-relative authoring
rejection, replacing the stale DeriveEquipmentBase(empty)->reject-all logic.
- ScriptAnalysisService: {{equip}}/ completion offers the equipment's reference
effective names; DiagnoseAsync flags unresolved refs (OTSCRIPT_EQUIPREF) same as
the deploy gate. Requests carry optional EquipmentId (threaded MonacoEditor ->
monaco-init.js -> fetch bodies). IScriptTagCatalog.GetEquipmentRelativeLeavesAsync
repurposed to GetEquipmentReferenceNamesAsync(equipmentId, filter).
Tests: EquipmentScriptPaths substitution/extraction (slash pinned); composer<->
artifact reference-resolution byte-parity; DraftValidator unresolved-ref;
ScriptAnalysis completion+diagnostic; M1 authoring gate. Build clean (0/0); all
five affected suites green.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
898 lines
50 KiB
C#
898 lines
50 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Minimal driver-side view of the deployment artifact emitted by
|
|
/// <c>ConfigComposer.SnapshotAndFlattenAsync</c>. The artifact JSON is the full snapshot —
|
|
/// for driver spawning we only need the <c>DriverInstances</c> array. Reading just the
|
|
/// subset keeps allocations cheap on every deploy.
|
|
/// </summary>
|
|
public sealed record DriverInstanceSpec(
|
|
Guid DriverInstanceRowId,
|
|
string DriverInstanceId,
|
|
string Name,
|
|
string DriverType,
|
|
bool Enabled,
|
|
string DriverConfig,
|
|
string? ClusterId = null,
|
|
string? ResilienceConfig = null);
|
|
|
|
/// <summary>How a node should scope a deployment artifact to its own ClusterId.</summary>
|
|
public enum ClusterFilterMode
|
|
{
|
|
/// <summary>Apply everything (single-cluster / legacy deployments).</summary>
|
|
None,
|
|
|
|
/// <summary>Filter the artifact to the node's own ClusterId.</summary>
|
|
ScopeTo,
|
|
|
|
/// <summary>Apply nothing (node's cluster row not found in a multi-cluster artifact).</summary>
|
|
Suppress,
|
|
}
|
|
|
|
/// <summary>Resolved scoping decision for a node against an artifact.</summary>
|
|
/// <param name="Mode">
|
|
/// None = apply everything (single-cluster / legacy); ScopeTo = filter to <paramref name="ClusterId"/>;
|
|
/// Suppress = apply nothing.
|
|
/// </param>
|
|
/// <param name="ClusterId">The node's ClusterId when <paramref name="Mode"/> is ScopeTo; otherwise null.</param>
|
|
public readonly record struct ClusterScope(ClusterFilterMode Mode, string? ClusterId);
|
|
|
|
public static class DeploymentArtifact
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Parse a deployment artifact blob into the list of driver-instance specs to spawn.
|
|
/// Empty / malformed blobs return an empty list — callers log + treat as "no drivers".
|
|
/// </summary>
|
|
/// <param name="blob">The deployment artifact blob to parse.</param>
|
|
/// <returns>The parsed driver-instance specs, or an empty list for empty/malformed blobs.</returns>
|
|
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob)
|
|
{
|
|
if (blob.IsEmpty) return Array.Empty<DriverInstanceSpec>();
|
|
|
|
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<DriverInstanceSpec>();
|
|
}
|
|
|
|
// 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<DriverInstanceSpec>(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<DriverInstanceSpec>();
|
|
}
|
|
}
|
|
|
|
/// <summary>Flatten the artifact's raw topology (RawFolders / DriverInstances / Devices / TagGroups /
|
|
/// Tags) into each driver's authored <see cref="RawTagEntry"/> list, keyed by DriverInstanceId. Each
|
|
/// tag's RawPath is computed via the shared <see cref="RawPathResolver"/> (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.</summary>
|
|
/// <param name="root">The artifact root element.</param>
|
|
/// <returns>DriverInstanceId → authored raw tags (RawPath + TagConfig + WriteIdempotent + DeviceName).</returns>
|
|
private static Dictionary<string, List<RawTagEntry>> BuildRawTagsByDriver(JsonElement root)
|
|
{
|
|
var byDriver = new Dictionary<string, List<RawTagEntry>>(StringComparer.Ordinal);
|
|
|
|
var folders = new Dictionary<string, (string? ParentId, string Name)>(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<string, (string? RawFolderId, string Name)>(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<string, string>(StringComparer.Ordinal);
|
|
var devices = new Dictionary<string, (string DriverInstanceId, string Name)>(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<string, (string? ParentId, string Name)>(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<RawTagEntry>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the per-equipment <c>{{equip}}/<RefName></c> reference map — <c>equipmentId →
|
|
/// (effectiveName → backing-tag RawPath)</c> — from the artifact's raw topology + <c>Tags</c> +
|
|
/// <c>UnsTagReferences</c> via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/>.
|
|
/// The JSON-side mirror of <c>AddressSpaceComposer.BuildEquipmentReferenceMap</c> (entity side), so
|
|
/// both compose seams resolve <c>{{equip}}</c> script paths to byte-identical RawPaths. Empty when
|
|
/// the artifact carries no references / tags.
|
|
/// </summary>
|
|
/// <param name="root">The artifact root element.</param>
|
|
/// <returns><c>equipmentId → (effectiveName → RawPath)</c>.</returns>
|
|
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(JsonElement root)
|
|
{
|
|
var folders = new Dictionary<string, (string? ParentId, string Name)>(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<string, (string? RawFolderId, string Name)>(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<string, (string DriverInstanceId, string Name)>(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<string, (string? ParentId, string Name)>(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<string, EquipmentReferenceMap.TagRow>(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<EquipmentReferenceMap.ReferenceRow>();
|
|
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<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
|
|
|
|
return EquipmentReferenceMap.Build(references, tagsById, resolver);
|
|
}
|
|
|
|
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
|
|
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
|
|
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
|
|
{
|
|
var byId = new Dictionary<string, string>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// When <paramref name="tagConfig"/> carries a <c>scriptId</c> (a calc tag), inject the resolved
|
|
/// <c>scriptSource</c> so the host-blind Calculation driver can compile + evaluate it. Identity for
|
|
/// tags with no <c>scriptId</c> (every other driver's tags), an unresolvable id, or malformed JSON —
|
|
/// so no existing tag's blob changes. The pre-existing <c>scriptId</c>/trigger fields are preserved.
|
|
/// </summary>
|
|
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON.</param>
|
|
/// <param name="scriptSourceById">The <c>scriptId → SourceCode</c> map from the artifact.</param>
|
|
/// <returns>The TagConfig with <c>scriptSource</c> injected, or the original when nothing to inject.</returns>
|
|
private static string InjectScriptSource(string tagConfig, IReadOnlyDictionary<string, string> 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<string>(out var sid) ? sid : null;
|
|
if (string.IsNullOrWhiteSpace(scriptId) || !scriptSourceById.TryGetValue(scriptId!, out var source))
|
|
return tagConfig;
|
|
|
|
obj["scriptSource"] = source;
|
|
return obj.ToJsonString();
|
|
}
|
|
|
|
/// <summary>Group the artifact's Devices into per-driver <see cref="DriverDeviceConfigMerger.DeviceRow"/>
|
|
/// lists (entity Name + schemaless DeviceConfig), ordered by DeviceId for a stable merge.</summary>
|
|
/// <param name="root">The artifact root element.</param>
|
|
/// <returns>DriverInstanceId → ordered device rows.</returns>
|
|
private static Dictionary<string, List<DriverDeviceConfigMerger.DeviceRow>> BuildDeviceRowsByDriver(JsonElement root)
|
|
{
|
|
var byDriver = new Dictionary<string, List<(string DeviceId, DriverDeviceConfigMerger.DeviceRow Row)>>(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);
|
|
}
|
|
|
|
/// <summary>Enumerate an artifact array property's object elements (empty when absent/non-array).</summary>
|
|
private static IEnumerable<JsonElement> 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;
|
|
}
|
|
|
|
/// <summary>Reads a string property, coalescing a JSON <c>null</c> (or wrong type) to <see langword="null"/>.</summary>
|
|
private static string? ReadNullableString(JsonElement el, string property) =>
|
|
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
|
|
|
|
/// <summary>
|
|
/// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster
|
|
/// (or legacy) artifacts resolve to <see cref="ClusterFilterMode.None"/> so every existing
|
|
/// deployment applies unchanged. In a multi-cluster artifact the node's <c>ClusterNode</c> row
|
|
/// (matched by <paramref name="nodeId"/>) selects <see cref="ClusterFilterMode.ScopeTo"/> its
|
|
/// ClusterId; a missing row resolves to <see cref="ClusterFilterMode.Suppress"/>. Empty /
|
|
/// malformed blobs resolve to <see cref="ClusterFilterMode.None"/> (lenient, matching the
|
|
/// other parsers).
|
|
/// </summary>
|
|
/// <param name="blob">The deployment artifact blob to inspect.</param>
|
|
/// <param name="nodeId">The node's identity (e.g. a "host:port" string) to match against <c>Nodes</c>.</param>
|
|
/// <returns>The resolved <see cref="ClusterScope"/> decision for this node.</returns>
|
|
public static ClusterScope ResolveClusterScope(ReadOnlySpan<byte> blob, string nodeId)
|
|
{
|
|
if (blob.IsEmpty) return new ClusterScope(ClusterFilterMode.None, null);
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(blob.ToArray());
|
|
var root = doc.RootElement;
|
|
var clusterCount = root.TryGetProperty("Clusters", out var cl) && cl.ValueKind == JsonValueKind.Array
|
|
? cl.GetArrayLength() : 0;
|
|
if (clusterCount <= 1) return new ClusterScope(ClusterFilterMode.None, null);
|
|
|
|
string? myCluster = null;
|
|
if (root.TryGetProperty("Nodes", out var nodes) && nodes.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var el in nodes.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var nid = el.TryGetProperty("NodeId", out var nEl) ? nEl.GetString() : null;
|
|
if (!string.Equals(nid, nodeId, StringComparison.OrdinalIgnoreCase)) continue;
|
|
myCluster = el.TryGetProperty("ClusterId", out var cEl) ? cEl.GetString() : null;
|
|
break;
|
|
}
|
|
}
|
|
return string.IsNullOrWhiteSpace(myCluster)
|
|
? new ClusterScope(ClusterFilterMode.Suppress, null)
|
|
: new ClusterScope(ClusterFilterMode.ScopeTo, myCluster);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return new ClusterScope(ClusterFilterMode.None, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse a deployment artifact blob into the driver-instance specs a specific node should
|
|
/// spawn, scoped to its own ClusterId via <see cref="ResolveClusterScope"/>. Single-cluster /
|
|
/// legacy artifacts return every spec; a multi-cluster artifact returns only the matching
|
|
/// cluster's specs (or none when the node's row is absent).
|
|
/// </summary>
|
|
/// <param name="blob">The deployment artifact blob to parse.</param>
|
|
/// <param name="nodeId">The node's identity (e.g. a "host:port" string) used to resolve cluster scope.</param>
|
|
/// <returns>The driver-instance specs this node should spawn.</returns>
|
|
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob, string nodeId)
|
|
{
|
|
var scope = ResolveClusterScope(blob, nodeId);
|
|
if (scope.Mode == ClusterFilterMode.Suppress) return Array.Empty<DriverInstanceSpec>();
|
|
var all = ParseDriverInstances(blob);
|
|
return scope.Mode == ClusterFilterMode.ScopeTo
|
|
? all.Where(s => string.Equals(s.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase)).ToArray()
|
|
: all;
|
|
}
|
|
|
|
/// <summary>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
|
|
/// <see cref="InvalidOperationException"/> from <c>GetString()</c>. 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.</summary>
|
|
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<string, List<RawTagEntry>> rawTagsByDriver,
|
|
IReadOnlyDictionary<string, List<DriverDeviceConfigMerger.DeviceRow>> 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<DriverDeviceConfigMerger.DeviceRow>)Array.Empty<DriverDeviceConfigMerger.DeviceRow>();
|
|
var rawTags = rawTagsByDriver.TryGetValue(id!, out var rt) ? rt : (IReadOnlyList<RawTagEntry>)Array.Empty<RawTagEntry>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse the artifact into the projected <see cref="AddressSpaceComposition"/> used by
|
|
/// <c>AddressSpacePlanner</c> + <c>AddressSpaceApplier</c>. Returns an empty composition for empty/
|
|
/// malformed blobs so callers can treat parse failure as a no-op deploy.
|
|
///
|
|
/// The artifact JSON is produced by <c>ConfigComposer.SnapshotAndFlattenAsync</c> in the
|
|
/// ControlPlane — its Pascal-case property names match the EF entities. We only need a
|
|
/// subset of fields per entity class to drive the address-space rebuild on driver-role
|
|
/// nodes.
|
|
/// </summary>
|
|
/// <param name="blob">The deployment artifact blob to parse.</param>
|
|
/// <returns>The projected <see cref="AddressSpaceComposition"/>, or an empty composition for empty/malformed blobs.</returns>
|
|
public static AddressSpaceComposition ParseComposition(ReadOnlySpan<byte> blob)
|
|
{
|
|
if (blob.IsEmpty) return Empty();
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(blob.ToArray());
|
|
var root = doc.RootElement;
|
|
|
|
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
|
|
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
|
|
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
|
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
|
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
|
// 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<EquipmentTagPlan>();
|
|
// Per-equipment {{equip}}/<RefName> 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);
|
|
|
|
return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms)
|
|
{
|
|
EquipmentTags = equipmentTags,
|
|
EquipmentVirtualTags = equipmentVirtualTags,
|
|
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
|
};
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return Empty();
|
|
}
|
|
}
|
|
|
|
/// <summary>Cluster-scoped overload: the address-space composition a node should materialise given
|
|
/// its NodeId. Filters every projection to the node's own ClusterId (see <see cref="ResolveClusterScope"/>).
|
|
/// Equipment attribution is dual-path: driver-bound equipment (non-null DriverInstanceId) is kept when
|
|
/// its driver is in-cluster; driver-less equipment (null DriverInstanceId) is kept when its UNS line's
|
|
/// area is in-cluster.
|
|
/// When <paramref name="onInconsistency"/> is supplied it is invoked with a human-readable message for each
|
|
/// kept driver-bound equipment whose owning UNS line is NOT in the node's cluster — a cross-cluster binding
|
|
/// that violates the same-cluster invariant and would orphan the equipment folder. This is
|
|
/// detection only (observability); the equipment is still returned, since the upstream draft validator
|
|
/// is the authority that should prevent the binding in the first place.</summary>
|
|
/// <param name="blob">The deployment artifact blob.</param>
|
|
/// <param name="nodeId">This node's identity in "host:port" form.</param>
|
|
/// <param name="onInconsistency">Optional diagnostic callback for cross-cluster orphan bindings; null disables the check.</param>
|
|
/// <returns>The filtered composition per the node's scoping decision.</returns>
|
|
public static AddressSpaceComposition ParseComposition(
|
|
ReadOnlySpan<byte> blob, string nodeId, Action<string>? onInconsistency = null)
|
|
{
|
|
var scope = ResolveClusterScope(blob, nodeId);
|
|
if (scope.Mode == ClusterFilterMode.None) return ParseComposition(blob);
|
|
if (scope.Mode == ClusterFilterMode.Suppress) return Empty();
|
|
|
|
var full = ParseComposition(blob);
|
|
var sets = BuildClusterSets(blob, scope.ClusterId!);
|
|
|
|
var keptLines = full.UnsLines.Where(l => sets.AreaIds.Contains(l.UnsAreaId)).ToArray();
|
|
var keptEquipment = full.EquipmentNodes.Where(e => sets.EquipmentIds.Contains(e.EquipmentId)).ToArray();
|
|
|
|
if (onInconsistency is not null)
|
|
{
|
|
var keptLineIds = keptLines.Select(l => l.UnsLineId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
// This cross-cluster check only fires for DRIVER-BOUND equipment. Driver-less equipment
|
|
// is attributed by its UNS line's area cluster, so by construction its line is always in
|
|
// keptLines — the condition `!keptLineIds.Contains(e.UnsLineId)` is never true for it.
|
|
foreach (var e in keptEquipment)
|
|
{
|
|
if (!string.IsNullOrEmpty(e.UnsLineId) && !keptLineIds.Contains(e.UnsLineId))
|
|
onInconsistency(
|
|
$"equipment '{e.EquipmentId}' is in cluster '{scope.ClusterId}' (by its driver) but its " +
|
|
$"UNS line '{e.UnsLineId}' is not — a cross-cluster binding violates the same-cluster " +
|
|
"invariant (decision #122) and would orphan the equipment folder.");
|
|
}
|
|
}
|
|
|
|
return new 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(),
|
|
};
|
|
}
|
|
|
|
/// <summary>The in-cluster id sets used to filter a composition.</summary>
|
|
/// <param name="DriverIds">DriverInstanceIds whose row carries the in-scope ClusterId.</param>
|
|
/// <param name="AreaIds">UnsAreaIds whose row carries the in-scope ClusterId.</param>
|
|
/// <param name="EquipmentIds">In-cluster EquipmentIds: driver-bound equipment is attributed by its
|
|
/// driver's cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster.</param>
|
|
private sealed record ClusterSets(HashSet<string> DriverIds, HashSet<string> AreaIds, HashSet<string> EquipmentIds);
|
|
|
|
/// <summary>Build the in-cluster id sets used to filter a composition: DriverInstanceIds + UnsAreaIds
|
|
/// that directly carry the ClusterId, plus in-cluster EquipmentIds — driver-bound equipment attributed
|
|
/// by its driver's cluster, and driver-less equipment (null DriverInstanceId) attributed by its UNS
|
|
/// line's area cluster.</summary>
|
|
/// <param name="blob">The deployment artifact blob.</param>
|
|
/// <param name="clusterId">The node's ClusterId to scope to.</param>
|
|
/// <returns>The resolved in-cluster id sets (empty on parse failure => empty composition).</returns>
|
|
private static ClusterSets BuildClusterSets(ReadOnlySpan<byte> blob, string clusterId)
|
|
{
|
|
var driverIds = new HashSet<string>(StringComparer.Ordinal);
|
|
var areaIds = new HashSet<string>(StringComparer.Ordinal);
|
|
var equipmentIds = new HashSet<string>(StringComparer.Ordinal);
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(blob.ToArray());
|
|
var root = doc.RootElement;
|
|
CollectIdsWhereCluster(root, "DriverInstances", "DriverInstanceId", clusterId, driverIds);
|
|
CollectIdsWhereCluster(root, "UnsAreas", "UnsAreaId", clusterId, areaIds);
|
|
// Map each UnsLine to its owning UnsArea so driver-less equipment can be attributed via
|
|
// its line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId).
|
|
var lineToArea = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
if (root.TryGetProperty("UnsLines", out var lines) && lines.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var el in lines.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var lineId = el.TryGetProperty("UnsLineId", out var lEl) ? lEl.GetString() : null;
|
|
var areaId = el.TryGetProperty("UnsAreaId", out var aEl) ? aEl.GetString() : null;
|
|
if (!string.IsNullOrWhiteSpace(lineId) && !string.IsNullOrWhiteSpace(areaId))
|
|
lineToArea[lineId!] = areaId!;
|
|
}
|
|
}
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>Collect each row's <paramref name="idField"/> value from <paramref name="arrayName"/> whose
|
|
/// ClusterId equals <paramref name="clusterId"/> (case-insensitive, matching the codebase convention).</summary>
|
|
/// <param name="root">The artifact root element.</param>
|
|
/// <param name="arrayName">The array property name to scan (e.g. "DriverInstances").</param>
|
|
/// <param name="idField">The id field to collect from each in-cluster row.</param>
|
|
/// <param name="clusterId">The ClusterId rows must match.</param>
|
|
/// <param name="into">The set to collect matching ids into.</param>
|
|
private static void CollectIdsWhereCluster(
|
|
JsonElement root, string arrayName, string idField, string clusterId, HashSet<string> into)
|
|
{
|
|
if (!root.TryGetProperty(arrayName, out var arr) || arr.ValueKind != JsonValueKind.Array) return;
|
|
foreach (var el in arr.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var cid = el.TryGetProperty("ClusterId", out var cEl) ? cEl.GetString() : null;
|
|
if (!string.Equals(cid, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
|
|
var id = el.TryGetProperty(idField, out var idEl) ? idEl.GetString() : null;
|
|
if (!string.IsNullOrWhiteSpace(id)) into.Add(id!);
|
|
}
|
|
}
|
|
|
|
private static AddressSpaceComposition Empty() => new(
|
|
Array.Empty<UnsAreaProjection>(),
|
|
Array.Empty<UnsLineProjection>(),
|
|
Array.Empty<EquipmentNode>(),
|
|
Array.Empty<DriverInstancePlan>(),
|
|
Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
/// <summary>
|
|
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
|
|
/// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of
|
|
/// <c>AddressSpaceComposer.Compose</c>'s VirtualTag producer — so the compose-side + artifact-decode
|
|
/// plans agree. Each <c>{{equip}}/<RefName></c> in the joined Script's <c>SourceCode</c> is
|
|
/// substituted with the owning equipment's backing RawPath (via <paramref name="referenceMapByEquip"/>)
|
|
/// BEFORE refs are extracted, byte-parity with the composer. <c>Expression</c> = the substituted
|
|
/// source (empty when the ScriptId is absent); <c>DependencyRefs</c> = the distinct
|
|
/// <c>ctx.GetTag("…")</c> literals in that source; <c>FolderPath</c> is always "" (VirtualTag has
|
|
/// no FolderPath today). Ordered by EquipmentId then Name to match the composer's deterministic
|
|
/// ordering.
|
|
/// </summary>
|
|
private static IReadOnlyList<EquipmentVirtualTagPlan> BuildEquipmentVirtualTagPlans(
|
|
JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
|
|
{
|
|
if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array)
|
|
return Array.Empty<EquipmentVirtualTagPlan>();
|
|
|
|
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
|
new Dictionary<string, string>(StringComparer.Ordinal);
|
|
|
|
// scriptId → SourceCode (the expression source the VirtualTagActor evaluates).
|
|
var scriptSourceById = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
if (root.TryGetProperty("Scripts", out var scriptsArr) && scriptsArr.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var el in scriptsArr.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var sid = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(sid)) continue;
|
|
var src = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
|
|
? srcEl.GetString() : null;
|
|
scriptSourceById[sid!] = src ?? string.Empty;
|
|
}
|
|
}
|
|
|
|
var result = new List<EquipmentVirtualTagPlan>(vtArr.GetArrayLength());
|
|
foreach (var el in vtArr.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var virtualTagId = el.TryGetProperty("VirtualTagId", out var vidEl) ? vidEl.GetString() : null;
|
|
var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null;
|
|
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
|
|
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
|
|
var scriptId = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null;
|
|
|
|
if (string.IsNullOrWhiteSpace(virtualTagId) || string.IsNullOrWhiteSpace(equipmentId)
|
|
|| string.IsNullOrWhiteSpace(name)) continue;
|
|
|
|
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src)
|
|
? src : string.Empty;
|
|
|
|
// 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}}/<RefName> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Join the artifact's ScriptedAlarms array to its Scripts array (by PredicateScriptId) to emit
|
|
/// one <see cref="EquipmentScriptedAlarmPlan"/> per alarm. The artifact-decode mirror of
|
|
/// <c>AddressSpaceComposer.Compose</c>'s scripted-alarm producer — so the compose-side + artifact-decode
|
|
/// plans agree byte-for-byte. An alarm whose <c>PredicateScriptId</c> has no matching Script row is
|
|
/// SKIPPED (matching the composer's skip behaviour) to preserve parity. <c>PredicateSource</c> = the
|
|
/// joined script source ("" when missing — but such alarms are skipped above); <c>DependencyRefs</c>
|
|
/// = the shared <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> merge of the predicate's
|
|
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. The
|
|
/// predicate source's <c>{{equip}}/<RefName></c> paths are substituted with the owning equipment's
|
|
/// backing RawPath (via <paramref name="referenceMapByEquip"/>) BEFORE the merge — byte-parity with
|
|
/// the composer. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
|
|
/// </summary>
|
|
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(
|
|
JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
|
|
{
|
|
if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array)
|
|
return Array.Empty<EquipmentScriptedAlarmPlan>();
|
|
|
|
// 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<string, string>(StringComparer.Ordinal);
|
|
if (root.TryGetProperty("Scripts", out var scriptsArr) && scriptsArr.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var el in scriptsArr.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var sid = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(sid)) continue;
|
|
var src = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
|
|
? srcEl.GetString() : null;
|
|
scriptSourceById[sid!] = src ?? string.Empty;
|
|
}
|
|
}
|
|
|
|
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
|
new Dictionary<string, string>(StringComparer.Ordinal);
|
|
var result = new List<EquipmentScriptedAlarmPlan>(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}}/<RefName> 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<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
|
where T : class
|
|
{
|
|
if (!root.TryGetProperty(propertyName, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
|
return Array.Empty<T>();
|
|
|
|
var result = new List<T>(arr.GetArrayLength());
|
|
foreach (var el in arr.EnumerateArray())
|
|
{
|
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
|
var item = reader(el);
|
|
if (item is not null) result.Add(item);
|
|
}
|
|
// Match 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>(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);
|
|
}
|
|
}
|