refactor(otopcua): align device-host map parity + document EquipmentNode rebuild trade-off (follow-up E)

This commit is contained in:
Joseph Doherty
2026-06-26 13:22:26 -04:00
parent cb7ce7f171
commit 915492a759
@@ -72,7 +72,17 @@ public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, strin
/// multi-device driver by host. The value is normalized identically on both the live-edit composer and /// 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 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 /// 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> /// (trim + lower-case) so the two compare equal.</para>
/// <para><b>Address-space-rebuild interaction (accepted trade-off).</b> These three fields participate in
/// <see cref="EquipmentNode"/>'s record value-equality, which <c>AddressSpacePlan.Compute</c> uses to
/// build its changed-equipment set. So editing a <c>Device</c>'s <c>DeviceConfig</c> host/port, or
/// rebinding an equipment's <c>DriverInstanceId</c> / <c>DeviceId</c>, now yields an
/// <see cref="EquipmentNode"/> delta that triggers a full structural address-space rebuild on the next
/// deploy (a momentary subscription teardown for that equipment). This is a deliberate, accepted
/// decision: it fires only on rare operator-initiated config edits at deploy time (routine redeploys of
/// unchanged config are unaffected — the delta is empty), it is recoverable, and it is directionally
/// correct for the multi-device FixedTree re-partition (a later task). <c>AddressSpacePlan</c> is left
/// unchanged.</para></summary>
public sealed record EquipmentNode( public sealed record EquipmentNode(
string EquipmentId, string EquipmentId,
string DisplayName, string DisplayName,
@@ -343,10 +353,16 @@ public static class AddressSpaceComposer
// DeviceId → connection host, resolved once from each bound Device's schemaless DeviceConfig JSON // 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 // via the shared TryExtractDeviceHost (single source of truth + normalization for both this
// composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is // composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
// byte-parity-equal). Ordinal comparer matches the decode-side map exactly. // byte-parity-equal). This MUST match DeploymentArtifact.BuildDeviceHostMap semantics EXACTLY:
var deviceHostById = (devices ?? Array.Empty<Device>()) // Ordinal comparer, skip blank/whitespace DeviceIds, and LAST-WINS on a duplicate DeviceId (a
.Where(d => d.DeviceId != null) // foreach assignment, NOT ToDictionary which would THROW on a dupe — diverging from the decode
.ToDictionary(d => d.DeviceId, d => TryExtractDeviceHost(d.DeviceConfig), StringComparer.Ordinal); // side's last-wins). DeviceId is DB-unique so a dupe is defensive-only.
var deviceHostById = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var d in devices ?? Array.Empty<Device>())
{
if (string.IsNullOrWhiteSpace(d.DeviceId)) continue;
deviceHostById[d.DeviceId] = TryExtractDeviceHost(d.DeviceConfig);
}
var areas = unsAreas var areas = unsAreas
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal) .OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name)) .Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))