feat(otopcua): EquipmentNode carries DriverInstanceId/DeviceId/DeviceHost (follow-up E projection)

This commit is contained in:
Joseph Doherty
2026-06-26 13:07:31 -04:00
parent e7d5ebe956
commit cb7ce7f171
4 changed files with 359 additions and 8 deletions
@@ -201,7 +201,10 @@ public static class DeploymentArtifact
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
// 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 drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
var equipmentTags = BuildEquipmentTagPlans(root);
@@ -807,7 +810,29 @@ public static class DeploymentArtifact
return new UnsLineProjection(id!, areaId!, name ?? id!);
}
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
/// <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="AddressSpaceComposer.TryExtractDeviceHost"/> 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!] = AddressSpaceComposer.TryExtractDeviceHost(ReadString(el, "DeviceConfig"));
}
return map;
}
private static EquipmentNode? ReadEquipmentNode(JsonElement el, IReadOnlyDictionary<string, string?> deviceHostById)
{
var id = ReadString(el, "EquipmentId");
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching UnsArea/UnsLine
@@ -816,7 +841,19 @@ public static class DeploymentArtifact
var displayName = ReadString(el, "Name");
var lineId = ReadString(el, "UnsLineId");
if (string.IsNullOrWhiteSpace(id)) return null;
return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty);
// 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);
}
private static DriverInstancePlan? ReadDriverPlan(JsonElement el)