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
@@ -58,7 +58,28 @@ public sealed record AddressSpaceComposition(
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
public sealed record EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId);
/// <summary>One UNS level-5 equipment folder in the address space. <see cref="EquipmentId"/> is the
/// logical NodeId; <see cref="DisplayName"/> is the friendly UNS Name segment; <see cref="UnsLineId"/>
/// is the parent line the folder hangs under.
/// <para><see cref="DriverInstanceId"/> / <see cref="DeviceId"/> carry the equipment's optional bindings
/// (both <c>null</c> ⇒ driver-less / no device), copied straight from the <c>Equipment</c> row.
/// <see cref="DeviceHost"/> is the device's connection host (e.g. <c>"10.0.0.5:8193"</c>) resolved from the
/// bound <c>Device</c>'s schemaless <c>DeviceConfig</c> JSON via
/// <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> — <c>null</c> when there is no device, no
/// <c>HostAddress</c> in its config, or the host cannot be parsed. These three let a later task graft a
/// driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a
/// multi-device driver by host. The value is normalized identically on both the live-edit composer and
/// the artifact-decode sides (single source of truth: <see cref="AddressSpaceComposer.TryExtractDeviceHost"/>);
/// the later partition task MUST normalize the driver-discovered device-host folder segment the same way
/// (trim + lower-case) so the two compare equal.</para></summary>
public sealed record EquipmentNode(
string EquipmentId,
string DisplayName,
string UnsLineId,
string? DriverInstanceId = null,
string? DeviceId = null,
string? DeviceHost = null);
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
@@ -277,15 +298,17 @@ public static class AddressSpaceComposer
/// <param name="equipment">The equipment.</param>
/// <param name="driverInstances">The driver instances.</param>
/// <param name="scriptedAlarms">The scripted alarms.</param>
/// <param name="devices">The per-device rows used to resolve each equipment's <c>DeviceHost</c>. <c>null</c> = none.</param>
/// <returns>The composition result.</returns>
public static AddressSpaceComposition Compose(
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
IReadOnlyList<Device>? devices = null) =>
Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
Array.Empty<Tag>(), Array.Empty<Namespace>());
Array.Empty<Tag>(), Array.Empty<Namespace>(), devices: devices);
/// <summary>
/// Composes the address space build plan from the configuration entities.
@@ -299,6 +322,8 @@ public static class AddressSpaceComposer
/// <param name="namespaces">The namespaces.</param>
/// <param name="virtualTags">The Equipment-namespace virtual (calculated) tags. <c>null</c> = none.</param>
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
/// <param name="devices">The per-device rows (<c>DeviceId</c> + schemaless <c>DeviceConfig</c> JSON) used to resolve
/// each equipment's <c>DeviceHost</c> from its bound <c>DeviceId</c>. <c>null</c> = none.</param>
/// <returns>The composition result.</returns>
public static AddressSpaceComposition Compose(
IReadOnlyList<UnsArea> unsAreas,
@@ -309,10 +334,19 @@ public static class AddressSpaceComposer
IReadOnlyList<Tag> tags,
IReadOnlyList<Namespace> namespaces,
IReadOnlyList<VirtualTag>? virtualTags = null,
IReadOnlyList<Script>? scripts = null)
IReadOnlyList<Script>? scripts = null,
IReadOnlyList<Device>? devices = null)
{
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
var resolvedScripts = scripts ?? Array.Empty<Script>();
// DeviceId → connection host, resolved once from each bound Device's schemaless DeviceConfig JSON
// via the shared TryExtractDeviceHost (single source of truth + normalization for both this
// composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
// byte-parity-equal). Ordinal comparer matches the decode-side map exactly.
var deviceHostById = (devices ?? Array.Empty<Device>())
.Where(d => d.DeviceId != null)
.ToDictionary(d => d.DeviceId, d => TryExtractDeviceHost(d.DeviceConfig), StringComparer.Ordinal);
var areas = unsAreas
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
@@ -328,7 +362,15 @@ public static class AddressSpaceComposer
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching the Area
// and Line projections + EquipmentNodeWalker) — NOT the colloquial MachineCode. NodeId
// stays the logical EquipmentId so browse-path resolution + ACLs are unaffected.
.Select(e => new EquipmentNode(e.EquipmentId, e.Name, e.UnsLineId))
// DriverInstanceId / DeviceId are copied straight from the row; DeviceHost resolves from the
// bound device's config (null when there's no device or no parseable HostAddress).
.Select(e => new EquipmentNode(
e.EquipmentId,
e.Name,
e.UnsLineId,
DriverInstanceId: e.DriverInstanceId,
DeviceId: e.DeviceId,
DeviceHost: e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId)))
.ToList();
var plans = driverInstances
@@ -493,6 +535,37 @@ public static class AddressSpaceComposer
return tagConfig;
}
/// <summary>
/// Extract a <see cref="Device"/>'s connection host from its schemaless <c>DeviceConfig</c> JSON:
/// the top-level <c>"HostAddress"</c> string (e.g. <c>"10.201.31.5:8193"</c>) — the same value a
/// FOCAS driver emits as its discovered device-host folder segment. Returns <c>null</c> when the
/// config is blank, not a JSON object, has no string <c>HostAddress</c>, or the value is
/// blank/whitespace. Never throws.
/// <para>The returned host is deterministically normalized — trimmed and lower-cased — so the
/// live-edit composer side and the artifact-decode side (<c>DeploymentArtifact</c>) agree
/// byte-for-byte. This method is the SINGLE SOURCE OF TRUTH for that normalization: the later
/// FixedTree-partition task MUST normalize the driver-discovered device-host folder segment the
/// same way (call this, or apply the identical trim + lower-case) before comparing the two.</para>
/// </summary>
/// <param name="deviceConfigJson">The device's schemaless <c>DeviceConfig</c> JSON blob.</param>
/// <returns>The normalized device host, or <c>null</c> when absent/blank/unparseable.</returns>
public static string? TryExtractDeviceHost(string? deviceConfigJson)
{
if (string.IsNullOrWhiteSpace(deviceConfigJson)) return null;
try
{
using var doc = JsonDocument.Parse(deviceConfigJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
if (!doc.RootElement.TryGetProperty("HostAddress", out var hostEl)
|| hostEl.ValueKind != JsonValueKind.String) return null;
var raw = hostEl.GetString();
if (string.IsNullOrWhiteSpace(raw)) return null;
// Deterministic normalization (trim + lower-case) so both seams produce the identical string.
return raw.Trim().ToLowerInvariant();
}
catch (JsonException) { return null; }
}
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>