v3 B1-WP4/WP5: pipeline rewire to greenfield schema + dark address space
- ConfigComposer: snapshot v3 tables (RawFolders/TagGroups/UnsTagReferences), drop the retired Namespaces snapshot; RevisionHash follows the new blob. - DeploymentArtifact: compute each raw tag's RawPath (RawFolder->Driver->Device-> TagGroup ancestry via shared RawPathResolver); per-driver RawTags + merged DriverConfig+DeviceConfig config injected into DriverInstanceSpec via DriverDeviceConfigMerger; ParseComposition emits an empty EquipmentTags set (DARK until Batch 4); EquipmentNode driver/device hooks always null; cluster scoping attributes equipment by UNS line only. - AddressSpaceComposer: rewritten to the v3 shape (no Namespace/NamespaceKind, no dropped Tag/Equipment fields); emits empty EquipmentTags (parity mirror). - DriverHostActor: fan-out/write map tuple member FullName -> RawPath. - Commons: new RawPathResolver (shared identity authority) + DriverDeviceConfigMerger (single/multi-device endpoint+RawTags merge). - DraftValidator: v3 rules — raw-name charset (RawPaths.ValidateSegment), historized effective-tagname <=255, UNS effective-leaf uniqueness across UnsTagReference/VirtualTag/ScriptedAlarm; DraftSnapshot(+Factory) carry the new tables. - AdminOperationsActor: tag-config inspection resolves driver via Device (no EquipmentId/DriverInstanceId on Tag). - Tests: RawPathResolver/merger unit tests (Commons.Tests, green) + DeploymentArtifact RawPath/dark test (Runtime.Tests, awaits WP6 corpus fixes).
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
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;
|
||||
@@ -62,17 +63,25 @@ public static class DeploymentArtifact
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||
if (!doc.RootElement.TryGetProperty("DriverInstances", out var arr)
|
||||
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);
|
||||
var spec = TryReadSpec(el, rawTagsByDriver, deviceRowsByDriver);
|
||||
if (spec is not null) result.Add(spec);
|
||||
}
|
||||
return result;
|
||||
@@ -83,6 +92,121 @@ public static class DeploymentArtifact
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
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() ?? "{}" : "{}";
|
||||
// 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>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
|
||||
@@ -155,7 +279,10 @@ public static class DeploymentArtifact
|
||||
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)
|
||||
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;
|
||||
@@ -166,7 +293,7 @@ public static class DeploymentArtifact
|
||||
var enabled = !el.TryGetProperty("Enabled", out var enEl)
|
||||
|| enEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False)
|
||||
|| enEl.GetBoolean();
|
||||
var config = ReadString(el, "DriverConfig");
|
||||
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.
|
||||
@@ -174,13 +301,20 @@ public static class DeploymentArtifact
|
||||
|
||||
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: config ?? "{}",
|
||||
DriverConfig: mergedConfig,
|
||||
ClusterId: clusterId,
|
||||
ResilienceConfig: resilienceConfig);
|
||||
}
|
||||
@@ -208,13 +342,14 @@ public static class DeploymentArtifact
|
||||
|
||||
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
|
||||
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
|
||||
// DeviceId → connection host, resolved from the artifact's Devices array via the SAME shared
|
||||
// helper the composer uses, so each EquipmentNode.DeviceHost is byte-parity-equal across seams.
|
||||
var deviceHostById = BuildDeviceHostMap(root);
|
||||
var equipment = ReadArray(root, "Equipment", el => ReadEquipmentNode(el, deviceHostById));
|
||||
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
||||
var equipmentTags = BuildEquipmentTagPlans(root);
|
||||
// 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>();
|
||||
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
|
||||
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root);
|
||||
|
||||
@@ -326,21 +461,18 @@ public static class DeploymentArtifact
|
||||
lineToArea[lineId!] = areaId!;
|
||||
}
|
||||
}
|
||||
// Equipment carries no ClusterId — driver-bound equipment is attributed by its driver's
|
||||
// cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster.
|
||||
// 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 di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
|
||||
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
||||
var lineId = el.TryGetProperty("UnsLineId", out var luEl) ? luEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
var inByDriver = di is not null && driverIds.Contains(di);
|
||||
var inByLine = di is null && lineId is not null
|
||||
&& lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine);
|
||||
if (inByDriver || inByLine) equipmentIds.Add(id!);
|
||||
if (lineId is not null && lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine))
|
||||
equipmentIds.Add(id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,119 +508,6 @@ public static class DeploymentArtifact
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
/// <summary>
|
||||
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
|
||||
/// Equipment-namespace tags (non-null EquipmentId, owning namespace Kind == Equipment), then
|
||||
/// emit one <see cref="EquipmentTagPlan"/> per qualifying tag. The artifact-decode mirror of
|
||||
/// <c>AddressSpaceComposer.Compose</c>'s equipment filter — so the compose-side + artifact-decode
|
||||
/// plans agree on the same set of tags. FullName is read from each tag's TagConfig blob
|
||||
/// (top-level "FullName" field).
|
||||
/// </summary>
|
||||
private static IReadOnlyList<EquipmentTagPlan> BuildEquipmentTagPlans(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentTagPlan>();
|
||||
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentTagPlan>();
|
||||
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentTagPlan>();
|
||||
|
||||
// namespaceId → Equipment-kind. Kind serialises as a number by default (Equipment = 0);
|
||||
// tolerate the string form too.
|
||||
var equipmentNamespaces = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var el in nsArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var id = el.TryGetProperty("NamespaceId", out var idEl) ? idEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
if (!el.TryGetProperty("Kind", out var kindEl)) continue;
|
||||
var isEquipment = kindEl.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => kindEl.GetInt32() == 0, // NamespaceKind.Equipment = 0
|
||||
JsonValueKind.String => string.Equals(kindEl.GetString(), "Equipment", StringComparison.Ordinal),
|
||||
_ => false,
|
||||
};
|
||||
if (isEquipment) equipmentNamespaces.Add(id!);
|
||||
}
|
||||
|
||||
// driverInstanceId → namespaceId.
|
||||
var driverToNamespace = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var el in diArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
|
||||
var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null;
|
||||
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns))
|
||||
driverToNamespace[id!] = ns!;
|
||||
}
|
||||
|
||||
var result = new List<EquipmentTagPlan>(tagsArr.GetArrayLength());
|
||||
foreach (var el in tagsArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
// Equipment tags REQUIRE a non-null EquipmentId (the inverse of the Galaxy filter).
|
||||
if (!el.TryGetProperty("EquipmentId", out var eqEl) || eqEl.ValueKind == JsonValueKind.Null) continue;
|
||||
var equipmentId = eqEl.GetString();
|
||||
if (string.IsNullOrWhiteSpace(equipmentId)) continue;
|
||||
|
||||
var tagId = el.TryGetProperty("TagId", out var tidEl) ? tidEl.GetString() : null;
|
||||
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
|
||||
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
|
||||
var folder = el.TryGetProperty("FolderPath", out var fpEl) && fpEl.ValueKind != JsonValueKind.Null
|
||||
? fpEl.GetString() : null;
|
||||
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
|
||||
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
||||
? tcEl.GetString() : null;
|
||||
// AccessLevel → Writable. ConfigComposer serialises the TagAccessLevel enum WITHOUT a
|
||||
// string converter, so it lands as a number (Read = 0, ReadWrite = 1); tolerate the string
|
||||
// form ("ReadWrite") too — same defensive both-forms parse as the Kind gate above. MUST match
|
||||
// AddressSpaceComposer's `AccessLevel == TagAccessLevel.ReadWrite` exactly (byte-parity). A missing
|
||||
// field defaults to non-writable (read-only).
|
||||
var writable = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => alEl.GetInt32() == (int)TagAccessLevel.ReadWrite,
|
||||
JsonValueKind.String => string.Equals(alEl.GetString(), nameof(TagAccessLevel.ReadWrite), StringComparison.Ordinal),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
|
||||
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
|
||||
// Equipment-kind namespace only — byte-parity with the composer's pure
|
||||
// `ns.Kind == NamespaceKind.Equipment` predicate (no Galaxy exception). Galaxy points are
|
||||
// ordinary equipment tags now (GalaxyMxGateway is a standard Equipment-kind driver).
|
||||
if (!equipmentNamespaces.Contains(nsId)) continue;
|
||||
|
||||
// Parse the schemaless TagConfig blob ONCE per tag via the shared byte-parity authority
|
||||
// (01/C-1) — the SAME TagConfigIntent.Parse the live-compose seam (AddressSpaceComposer)
|
||||
// consumes, so the artifact-decode plan stays byte-equal with the composer's.
|
||||
var intent = TagConfigIntent.Parse(tagConfig);
|
||||
result.Add(new EquipmentTagPlan(
|
||||
TagId: tagId!,
|
||||
EquipmentId: equipmentId!,
|
||||
DriverInstanceId: di!,
|
||||
FolderPath: folder ?? string.Empty,
|
||||
Name: name!,
|
||||
DataType: dataType ?? "BaseDataType",
|
||||
FullName: intent.FullName,
|
||||
Writable: writable,
|
||||
Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null,
|
||||
IsHistorized: intent.IsHistorized,
|
||||
HistorianTagname: intent.HistorianTagname,
|
||||
IsArray: intent.IsArray,
|
||||
ArrayLength: intent.ArrayLength));
|
||||
}
|
||||
|
||||
result.Sort((a, b) =>
|
||||
{
|
||||
var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId);
|
||||
if (byEquipment != 0) return byEquipment;
|
||||
var byFolder = string.CompareOrdinal(a.FolderPath, b.FolderPath);
|
||||
if (byFolder != 0) return byFolder;
|
||||
return string.CompareOrdinal(a.Name, b.Name);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -712,29 +731,7 @@ public static class DeploymentArtifact
|
||||
return new UnsLineProjection(id!, areaId!, name ?? id!);
|
||||
}
|
||||
|
||||
/// <summary>Build the <c>DeviceId</c> → connection-host map from the artifact's <c>Devices</c> array
|
||||
/// (each row carries a <c>DeviceId</c> + schemaless <c>DeviceConfig</c> JSON). The host is resolved via
|
||||
/// the shared <see cref="DeviceConfigIntent.TryExtractHost"/> so the artifact-decode side
|
||||
/// normalizes byte-identically to the live-edit composer. Ordinal comparer + last-wins on a duplicate
|
||||
/// DeviceId. A missing/empty/non-array <c>Devices</c> property yields an empty map (no device hosts).</summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
/// <returns>The resolved DeviceId → host map (host may be null when a device has no parseable HostAddress).</returns>
|
||||
private static IReadOnlyDictionary<string, string?> BuildDeviceHostMap(JsonElement root)
|
||||
{
|
||||
var map = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
if (!root.TryGetProperty("Devices", out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return map;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) continue;
|
||||
map[deviceId!] = DeviceConfigIntent.TryExtractHost(ReadString(el, "DeviceConfig"));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static EquipmentNode? ReadEquipmentNode(JsonElement el, IReadOnlyDictionary<string, string?> deviceHostById)
|
||||
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
|
||||
{
|
||||
var id = ReadString(el, "EquipmentId");
|
||||
// DisplayName = the UNS Name segment (friendly browse name, matching UnsArea/UnsLine
|
||||
@@ -743,19 +740,10 @@ public static class DeploymentArtifact
|
||||
var displayName = ReadString(el, "Name");
|
||||
var lineId = ReadString(el, "UnsLineId");
|
||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||
// DriverInstanceId / DeviceId copied straight from the row (null when absent / JSON null);
|
||||
// DeviceHost resolved from the device-host map by DeviceId — byte-parity with the composer's
|
||||
// `e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId)`.
|
||||
var driverInstanceId = ReadString(el, "DriverInstanceId");
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
var deviceHost = deviceId is null ? null : deviceHostById.GetValueOrDefault(deviceId);
|
||||
return new EquipmentNode(
|
||||
id!,
|
||||
displayName ?? id!,
|
||||
lineId ?? string.Empty,
|
||||
DriverInstanceId: driverInstanceId,
|
||||
DeviceId: deviceId,
|
||||
DeviceHost: deviceHost);
|
||||
// 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)
|
||||
|
||||
@@ -112,7 +112,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// several equipment variables (e.g. identical machines sharing a register), and the per-apply
|
||||
/// rebuild dedups by NodeId.
|
||||
/// </summary>
|
||||
private readonly Dictionary<(string DriverInstanceId, string FullName), HashSet<string>> _nodeIdByDriverRef = new();
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _nodeIdByDriverRef = new();
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>folder-scoped equipment NodeId →
|
||||
@@ -123,14 +123,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// ref (a variable is backed by a single driver attribute), so this is a flat 1:1 map (the forward
|
||||
/// map fans out 1:N because one ref can back several variables).
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByNodeId =
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>(DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s).
|
||||
/// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native
|
||||
/// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not
|
||||
/// value variables, so they are kept OUT of the value maps + value-subscription set.</summary>
|
||||
private readonly Dictionary<(string DriverInstanceId, string FullName), HashSet<string>> _alarmNodeIdByDriverRef = new();
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _alarmNodeIdByDriverRef = new();
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId →
|
||||
@@ -142,7 +142,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// single driver alarm), so this is a flat 1:1 map (the forward map fans out 1:N because one ref can
|
||||
/// back several conditions on identical machines).
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByAlarmNodeId =
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByAlarmNodeId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Condition NodeId → (EquipmentId, tag Name, OPC UA alarm type, HistorizeToAveva) for building
|
||||
@@ -1094,7 +1094,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// the continuation: it runs off the actor thread, where raw Sender is unsafe to read.
|
||||
var replyTo = Sender;
|
||||
entry.Actor.Ask<DriverInstanceActor.WriteAttributeResult>(
|
||||
new DriverInstanceActor.WriteAttribute(target.FullName, msg.Value!), TimeSpan.FromSeconds(8))
|
||||
new DriverInstanceActor.WriteAttribute(target.RawPath, msg.Value!), TimeSpan.FromSeconds(8))
|
||||
.ContinueWith(
|
||||
t => t.IsCompletedSuccessfully
|
||||
? new NodeWriteResult(t.Result.Success, t.Result.Reason)
|
||||
@@ -1164,7 +1164,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// Fire-and-forget: the OPC UA Part 9 ack already committed the local condition state, and the
|
||||
// driver's AcknowledgeAsync surfaces no per-condition status, so there is nothing to reply. The
|
||||
// driver correlates on ConditionId (= the authored alarm FullName the inverse map keyed on).
|
||||
entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.FullName, msg.Comment, msg.OperatorUser));
|
||||
entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.RawPath, msg.Comment, msg.OperatorUser));
|
||||
}
|
||||
|
||||
/// <summary>Whether this node should service a Primary-only data-plane operation right now — the single
|
||||
|
||||
Reference in New Issue
Block a user