diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index 72fafc22..a4d8eb5b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -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 /// the artifact-decode sides (single source of truth: ); /// the later partition task MUST normalize the driver-discovered device-host folder segment the same way -/// (trim + lower-case) so the two compare equal. +/// (trim + lower-case) so the two compare equal. +/// Address-space-rebuild interaction (accepted trade-off). These three fields participate in +/// 's record value-equality, which AddressSpacePlan.Compute uses to +/// build its changed-equipment set. So editing a Device's DeviceConfig host/port, or +/// rebinding an equipment's DriverInstanceId / DeviceId, now yields an +/// 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). AddressSpacePlan is left +/// unchanged. public sealed record EquipmentNode( string EquipmentId, string DisplayName, @@ -343,10 +353,16 @@ public static class AddressSpaceComposer // 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()) - .Where(d => d.DeviceId != null) - .ToDictionary(d => d.DeviceId, d => TryExtractDeviceHost(d.DeviceConfig), StringComparer.Ordinal); + // byte-parity-equal). This MUST match DeploymentArtifact.BuildDeviceHostMap semantics EXACTLY: + // Ordinal comparer, skip blank/whitespace DeviceIds, and LAST-WINS on a duplicate DeviceId (a + // foreach assignment, NOT ToDictionary which would THROW on a dupe — diverging from the decode + // side's last-wins). DeviceId is DB-unique so a dupe is defensive-only. + var deviceHostById = new Dictionary(StringComparer.Ordinal); + foreach (var d in devices ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(d.DeviceId)) continue; + deviceHostById[d.DeviceId] = TryExtractDeviceHost(d.DeviceConfig); + } var areas = unsAreas .OrderBy(a => a.UnsAreaId, StringComparer.Ordinal) .Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))