diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index a4d8eb5b..4a4a503e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -577,11 +577,23 @@ public static class AddressSpaceComposer 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(); + return NormalizeDeviceHost(raw); } catch (JsonException) { return null; } } + /// + /// The SINGLE SOURCE OF TRUTH for device-host normalization: trims surrounding whitespace and + /// lower-cases (invariant). applies this to a Device's + /// parsed HostAddress, and the FixedTree-partition path (DriverHostActor) applies the + /// SAME function to a driver-discovered device-host folder segment before comparing the two — so an + /// and a captured folder segment for the same device compare + /// equal regardless of case/whitespace. Idempotent (a value already normalized is unchanged). + /// + /// The raw host string (non-null; a non-empty HostAddress or folder segment). + /// The normalized host (trimmed + lower-cased). + public static string NormalizeDeviceHost(string host) => host.Trim().ToLowerInvariant(); + /// Parses the optional alarm object from a tag's TagConfig JSON. Returns null /// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The /// artifact-decode side (DeploymentArtifact.ExtractTagAlarm) MUST parse identically (byte-parity). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 030dc5ad..acb1e5a4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -174,6 +174,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers private readonly Dictionary> _discoveredByDriver = new(StringComparer.Ordinal); + /// Per-driver signature of the last-logged device-host PARTITION diagnostic (unmatched / ambiguous + /// / degenerate host), folded with the current revision, so the ~15 repeated re-discovery passes within a + /// connect don't re-warn an unchanged condition: it is WARNED once when it first appears (or changes), and + /// DEBUG-logged on the identical repeat passes. Folding in makes a redeploy + /// re-warn once. Best-effort LOG-LEVEL dedup ONLY — never affects grafting; the matched-plan re-apply is + /// separately short-circuited by . Cleared for a driver whose partition comes + /// back clean so a later recurrence re-warns; bounded by driver count (a few). Only touched on the + /// multi-candidate path (). + private readonly Dictionary _lastPartitionWarnSignature = new(StringComparer.Ordinal); + /// /// Cached local from the latest /// snapshot (null = unknown until the first snapshot arrives, or no local node match). The inbound @@ -611,44 +621,51 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers _localNode, msg.DriverInstanceId); return; } - if (equipmentIds.Count > 1) - { - // NEXT TASK (multi-device partition) REPLACES THIS BRANCH: a driver that fans out to multiple - // equipments (one per device-host) will partition its discovered FixedTree by DeviceHost and graft - // each partition under its matching equipment, populating multiple inner-map entries. Until then we - // keep the conservative warn+skip — a single-equipment graft is the only shape this task handles. - _log.Warning("DriverHost {Node}: driver {Driver} maps to {Count} equipments — discovered-node injection skipped (multi-equipment-per-driver is a follow-up)", - _localNode, msg.DriverInstanceId, equipmentIds.Count); - return; - } - var equipmentId = equipmentIds[0]; - - // Authored refs for THIS driver (both value + alarm tags) so a discovered node never shadows an - // authored one — the mapper drops any captured node whose FullReference is already authored. May be - // EMPTY for a tag-less equipment, which is fine: Map dedups against an empty set (keeps everything). + // Authored refs for THIS driver (DRIVER-WIDE — both value + alarm tags) so a discovered node never + // shadows an authored one — the mapper drops any captured node whose FullReference is already authored. + // May be EMPTY for a tag-less equipment, which is fine: Map dedups against an empty set (keeps + // everything). Safe even for the multi-device partition below: a FOCAS FullReference is host-prefixed, + // so a device-X discovered node can't collide with a device-Y authored ref — the driver-wide set is + // correct per partition. var authoredRefs = _lastComposition.EquipmentTags .Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) .Select(t => t.FullName) .ToHashSet(StringComparer.Ordinal); - var plan = DiscoveredNodeMapper.Map(equipmentId, msg.Nodes, authoredRefs); - if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored) + // Build this discovery's per-equipment plan map. + // • EXACTLY ONE candidate ⇒ map the WHOLE captured tree under it (the mapper collapses the single + // device-host folder ⇒ clean EQ-n/FOCAS/...). Unchanged from before. + // • MORE THAN ONE candidate ⇒ PARTITION the captured tree by its (normalized) device-host folder + // segment and graft each device's subset under the equipment whose DeviceHost matches (follow-up E + // part 2). Unmatched/ambiguous hosts are warn-skipped (safe), not mis-grafted; a degenerate case + // (>1 candidate, none has a DeviceHost) warn-skips the whole driver. See PartitionDiscoveredByDeviceHost. + Dictionary newPlans; + if (equipmentIds.Count == 1) + { + var plan = DiscoveredNodeMapper.Map(equipmentIds[0], msg.Nodes, authoredRefs); + if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored) + newPlans = new Dictionary(StringComparer.Ordinal) { [equipmentIds[0]] = plan }; + } + else + { + newPlans = PartitionDiscoveredByDeviceHost(msg, equipmentIds, authoredRefs); + if (newPlans.Count == 0) return; // degenerate / no host matched a graftable partition — already logged + } - // The driver's per-equipment plan map for this discovery. Single-equipment today ⇒ one entry; the - // multi-device task will add an entry per partitioned equipment here. - var newPlans = new Dictionary(StringComparer.Ordinal) { [equipmentId] = plan }; - - // Unchanged-plan short-circuit: the driver re-discovers every ~2s (up to ~15 passes) until the - // FixedTree set stabilises, re-sending DiscoveredNodesReady each pass. Re-applying an IDENTICAL set - // would re-send SetDesiredSubscriptions, forcing the child to UnsubscribeAsync (dropping the WHOLE - // handle — authored tags included) then re-Subscribe — blipping authored-tag values up to ~15× across - // the discovery window. Skip when the WHOLE per-equipment routing is unchanged from the last applied - // pass; a GROWING set still differs (superset) and re-applies. This is _discoveredByDriver's first reader. + // Unchanged-plan short-circuit (shared by the single- AND multi-device paths): the driver re-discovers + // every ~2s (up to ~15 passes) until the FixedTree set stabilises, re-sending DiscoveredNodesReady each + // pass. Re-applying an IDENTICAL set would re-send SetDesiredSubscriptions, forcing the child to + // UnsubscribeAsync (dropping the WHOLE handle — authored tags included) then re-Subscribe — blipping + // authored-tag values up to ~15× across the discovery window. Skip when the WHOLE per-equipment routing + // is unchanged from the last applied pass; a GROWING set still differs (superset) and re-applies. (This + // is also why an unmatched/ambiguous partition warning settles: once the matched partitions stabilise we + // short-circuit here, and the partition warns are themselves signature-deduped — see ShouldWarnPartition.) if (_discoveredByDriver.TryGetValue(msg.DriverInstanceId, out var cached) && PlansRoutingEqual(cached, newPlans)) { - _log.Debug("DriverHost {Node}: discovered set for driver {Driver} unchanged ({Count} node(s)) — re-apply skipped", - _localNode, msg.DriverInstanceId, plan.Variables.Count); + var total = newPlans.Values.Sum(p => p.Variables.Count); + _log.Debug("DriverHost {Node}: discovered set for driver {Driver} unchanged ({Count} node(s) across {Equipment} equipment(s)) — re-apply skipped", + _localNode, msg.DriverInstanceId, total, newPlans.Count); return; } @@ -656,6 +673,148 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans); } + /// + /// Partitions a multi-device driver's captured FixedTree by its (normalized) device-host folder segment + /// (FolderPathSegments[1]) and maps each device's subset under the candidate equipment whose + /// matches — the follow-up E part-2 multi-device graft. Returns + /// the per-equipment plan map (one entry per device that matched AND had at least one new variable); + /// EMPTY when nothing is graftable. + /// + /// Builds normalizedHost → equipmentId from the candidate s + /// that carry a non-null DeviceHost. Two distinct candidates sharing a host is AMBIGUOUS — that host + /// is un-mapped (its nodes are warn-skipped) rather than grafted onto an arbitrary equipment. + /// I1 divergence: a candidate WITHOUT a DeviceHost (e.g. resolved via authored tags only, + /// no device binding) simply gets no partition — the FixedTree is the device's structure, so it + /// belongs under the device-bound equipment. No crash; that candidate is just not a partition target. + /// If NO candidate has a DeviceHost at all there is nothing to partition on ⇒ DEGENERATE ⇒ + /// warn-skip the whole driver (returns empty). + /// A discovered partition whose host is unmatched (or whose node has <2 folder segments, so no + /// host folder) is warn-skipped — its nodes are NOT mis-grafted; the matched partitions still graft. + /// + /// The device-host folder segment AND the stored DeviceHost are both run through the SAME + /// (single source of truth), so they compare equal + /// regardless of case/whitespace. + /// Warn-spam taming. The unmatched/ambiguous/degenerate condition is warned ONCE then + /// Debug-logged on the repeated re-discovery passes (see ). + /// Mid-connect partition shrink (M2). If a later pass yields FEWER device partitions than a + /// prior pass within the same connect, the dropped partition's routes + materialised nodes are NOT + /// actively pruned until the next full redeploy ( Clears + rebuilds + /// the maps). This matches the existing "FixedTree grows-then-stabilises within a connect" assumption — + /// no mid-connect pruning is built here (out of scope). + /// + private Dictionary PartitionDiscoveredByDeviceHost( + DriverInstanceActor.DiscoveredNodesReady msg, + IReadOnlyList equipmentIds, + IReadOnlySet authoredRefs) + { + var driverId = msg.DriverInstanceId; + var candidateSet = equipmentIds.ToHashSet(StringComparer.Ordinal); + + // normalizedHost → equipmentId, from candidate EquipmentNodes that carry a DeviceHost. A host shared by + // two DISTINCT candidates is ambiguous: un-map it (warn-skip) so its nodes aren't grafted arbitrarily. + var hostToEquipment = new Dictionary(StringComparer.Ordinal); + var ambiguousHosts = new HashSet(StringComparer.Ordinal); + foreach (var node in _lastComposition!.EquipmentNodes) + { + if (!candidateSet.Contains(node.EquipmentId) || node.DeviceHost is null) continue; + // DeviceHost is already normalized at compose/decode time; re-normalize through the shared helper so + // the comparison is the single source of truth (idempotent — harmless if it was already normalized). + var host = AddressSpaceComposer.NormalizeDeviceHost(node.DeviceHost); + if (ambiguousHosts.Contains(host)) continue; + if (hostToEquipment.TryGetValue(host, out var existing)) + { + if (!string.Equals(existing, node.EquipmentId, StringComparison.Ordinal)) + { + hostToEquipment.Remove(host); + ambiguousHosts.Add(host); + } + continue; + } + hostToEquipment[host] = node.EquipmentId; + } + + // DEGENERATE: >1 candidate but none resolved a DeviceHost ⇒ nothing to partition on ⇒ warn-skip the + // whole driver. (Falls through the same warn-once dedup as the unmatched case.) + if (hostToEquipment.Count == 0 && ambiguousHosts.Count == 0) + { + if (ShouldWarnPartition(driverId, "degenerate")) + _log.Warning("DriverHost {Node}: driver {Driver} maps to {Count} equipments but none has a DeviceHost — discovered-node injection skipped (no device-host to partition on)", + _localNode, driverId, equipmentIds.Count); + else + _log.Debug("DriverHost {Node}: driver {Driver} still has no DeviceHost on any of {Count} equipments — skipped (repeat)", + _localNode, driverId, equipmentIds.Count); + return new Dictionary(StringComparer.Ordinal); + } + + // Partition the captured tree by its device-host folder segment (FolderPathSegments[1]); a node with + // <2 segments has no host folder (null ⇒ unmatched). Keep only nodes whose host matches a candidate. + var matchedNodes = new Dictionary>(StringComparer.Ordinal); + var unmatchedHosts = new HashSet(StringComparer.Ordinal); + foreach (var n in msg.Nodes) + { + var key = n.FolderPathSegments.Count >= 2 + ? AddressSpaceComposer.NormalizeDeviceHost(n.FolderPathSegments[1]) + : null; + if (key is not null && hostToEquipment.ContainsKey(key)) + { + if (!matchedNodes.TryGetValue(key, out var list)) + matchedNodes[key] = list = new List(); + list.Add(n); + } + else + { + unmatchedHosts.Add(key ?? "(no-device-host-folder)"); + } + } + + // Map each matched device's subset under its equipment. ONE device per partition ⇒ the mapper collapses + // that partition's single host folder ⇒ clean EQ-n/FOCAS/...; a plan with zero new variables (all + // shadowed by authored refs) contributes no entry. + var plans = new Dictionary(StringComparer.Ordinal); + foreach (var (host, nodes) in matchedNodes) + { + var equipmentId = hostToEquipment[host]; + var plan = DiscoveredNodeMapper.Map(equipmentId, nodes, authoredRefs); + if (plan.Variables.Count > 0) plans[equipmentId] = plan; + } + + // Surface unmatched/ambiguous hosts ONCE (then Debug on the repeated passes). The matched partitions + // above still graft regardless. When the partition came back fully clean, drop the driver's signature so + // a later recurrence re-warns. + if (unmatchedHosts.Count > 0 || ambiguousHosts.Count > 0) + { + var unmatched = string.Join(",", unmatchedHosts.OrderBy(h => h, StringComparer.Ordinal)); + var ambiguous = string.Join(",", ambiguousHosts.OrderBy(h => h, StringComparer.Ordinal)); + if (ShouldWarnPartition(driverId, "u:" + unmatched + "|a:" + ambiguous)) + _log.Warning("DriverHost {Node}: driver {Driver}: discovered device-host partition(s) skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}]; matched partitions still grafted", + _localNode, driverId, unmatched, ambiguous); + else + _log.Debug("DriverHost {Node}: driver {Driver}: device-host partition(s) still skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}] (repeat)", + _localNode, driverId, unmatched, ambiguous); + } + else + { + _lastPartitionWarnSignature.Remove(driverId); + } + + return plans; + } + + /// Best-effort LOG-LEVEL dedup for the device-host partition diagnostics: returns true (⇒ WARN) + /// when is newly-seen for the driver this revision, false (⇒ DEBUG) on the + /// identical repeat passes that the ~15×/connect re-discovery produces. Folds the current revision in so a + /// redeploy re-warns once. Records the signature as a side effect. Never affects grafting behavior — only + /// the log level — so a stale entry (e.g. after a transient single↔multi candidate flip) at worst demotes + /// one duplicate warn to Debug. + private bool ShouldWarnPartition(string driverId, string conditionKey) + { + var signature = (_currentRevision?.ToString() ?? "none") + "|" + conditionKey; + var isNew = !_lastPartitionWarnSignature.TryGetValue(driverId, out var prev) + || !string.Equals(prev, signature, StringComparison.Ordinal); + _lastPartitionWarnSignature[driverId] = signature; + return isNew; + } + /// Routing-map equality: same count + every key maps to the same NodeId. Lets /// skip re-applying an unchanged discovered set across the driver's /// repeated post-connect re-discovery passes (a grown/changed set differs and re-applies). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDeviceHostTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDeviceHostTests.cs index 140f6725..03700a52 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDeviceHostTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDeviceHostTests.cs @@ -91,6 +91,20 @@ public sealed class AddressSpaceComposerDeviceHostTests AddressSpaceComposer.TryExtractDeviceHost(deviceConfig).ShouldBe(expected); } + /// The extracted-out shared normalizer (the single source of truth the FixedTree-partition path + /// reuses on a driver-discovered device-host folder segment) trims + lower-cases, and is idempotent on an + /// already-normalized value — so a segment like " HOST-A:8193 " matches a stored + /// "host-a:8193" DeviceHost. + [Theory] + [InlineData("10.201.31.5:8193", "10.201.31.5:8193")] + [InlineData(" HOST-A:8193 ", "host-a:8193")] + [InlineData("host-a:8193", "host-a:8193")] // idempotent + [InlineData("H1", "h1")] + public void NormalizeDeviceHost_trims_and_lowercases(string raw, string expected) + { + AddressSpaceComposer.NormalizeDeviceHost(raw).ShouldBe(expected); + } + private static Equipment NewEquipment(string id, string? driver, string? device) => new() { EquipmentId = id, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs index fc2777a6..6a9e3ff7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs @@ -161,15 +161,17 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase update.Value.ShouldBe(42.0); } - /// Multi-equipment-per-driver still warn+skips (the multi-device partition is the NEXT follow-up - /// task). A driver that resolves to MORE THAN ONE equipment injects nothing yet: no - /// is told. + /// DEGENERATE multi-equipment case (follow-up E, part 2): a driver that resolves to MORE THAN ONE + /// equipment but where NONE of the candidates carries a has nothing + /// to partition the FixedTree on, so the whole driver is warn-skipped (nothing grafted, no crash). Here d1 + /// is bound to two equipments via two AUTHORED tags only (no Device rows ⇒ no DeviceHost), which is exactly + /// the degenerate shape: no is told. [Fact] - public void Driver_mapping_to_more_than_one_equipment_still_warn_skips() + public void Driver_mapping_to_more_than_one_equipment_with_no_device_host_warn_skips() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); - // d1 is bound to TWO equipments via two authored tags ⇒ equipmentIds.Count == 2 ⇒ warn+skip. + // d1 is bound to TWO equipments via two authored tags (no devices ⇒ no DeviceHost) ⇒ degenerate ⇒ warn-skip. var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"), (Equip: "EQ-2", Driver: "d1", FullName: "40002", Folder: (string?)null, Name: "speed2")); @@ -185,10 +187,113 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase Writable: false, IsHistorized: false), })); - // Nothing is grafted — the >1-equipment branch warn+skips (replaced by the multi-device task). + // Nothing is grafted — no candidate has a DeviceHost, so there's nothing to partition on (degenerate). publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// Multi-device split (follow-up E, part 2): a driver that resolves to >1 equipment, each bound to + /// a DEVICE with a distinct , partitions its discovered FixedTree by + /// the (normalized) device-host folder segment and grafts each device's subset under the equipment whose + /// DeviceHost matches. Asserts (a) TWO (one per + /// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref + /// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1" + /// vs stored "h1" wrinkle proves the SHARED match. + [Fact] + public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment() + { + var db = NewInMemoryDbFactory(); + var factory = new SubscribingDriverFactory("Modbus"); + // d1 fans out to EQ-A (device host "h1") + EQ-B (device host "h2"), tag-less, bound via EquipmentNode. + var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", + (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), + (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); + + var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); + + // Discovered nodes split across two device-host folder segments. "H1" is UPPERCASE to prove the shared + // NormalizeDeviceHost lower-cases the segment to match the stored lower-cased "h1" DeviceHost. + actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] + { + new DiscoveredNode( + FolderPathSegments: new[] { "FOCAS", "H1", "Identity" }, + BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", + DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), + new DiscoveredNode( + FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, + BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", + DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), + })); + + // (a) Each device's subset materialises under its OWN equipment — two messages, one per equipment. + var m1 = publish.ExpectMsg(Timeout); + var m2 = publish.ExpectMsg(Timeout); + var byEquipment = new[] { m1, m2 }.ToDictionary(m => m.EquipmentRootNodeId, m => m); + byEquipment.Keys.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true); + byEquipment["EQ-A"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Model"); + byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run"); + var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId; + var eqBNodeId = byEquipment["EQ-B"].Variables[0].NodeId; + // Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries no host. + eqANodeId.ShouldStartWith("EQ-A/"); + eqANodeId.ShouldNotContain("h1"); + eqBNodeId.ShouldStartWith("EQ-B/"); + eqBNodeId.ShouldNotContain("h2"); + + // (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs). + AwaitAssert(() => + { + var refs = factory.LastSubscribedRefs; + refs.ShouldNotBeNull(); + refs!.ShouldContain("ft-h1-1"); + refs.ShouldContain("ft-h2-1"); + }, duration: Timeout); + + // (c) Routing is keyed per device: h1's ref lands on EQ-A's node, h2's on EQ-B's node. + actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h1-1", 1.0, OpcUaQuality.Good, Ts)); + publish.ExpectMsg(Timeout).NodeId.ShouldBe(eqANodeId); + actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h2-1", 2.0, OpcUaQuality.Good, Ts)); + publish.ExpectMsg(Timeout).NodeId.ShouldBe(eqBNodeId); + } + + /// Multi-device unmatched warn-skip (follow-up E, part 2): a discovered device-host partition with + /// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here + /// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only + /// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash). + [Fact] + public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft() + { + var db = NewInMemoryDbFactory(); + var factory = new SubscribingDriverFactory("Modbus"); + var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", + (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), + (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); + + var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); + + actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] + { + new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" }, + BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", + DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), + new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, + BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", + DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), + new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" }, + BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1", + DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), + })); + + // Exactly TWO materialise (EQ-A, EQ-B); the unmatched "h3" grafts nowhere ⇒ no third materialise. + var m1 = publish.ExpectMsg(Timeout); + var m2 = publish.ExpectMsg(Timeout); + new[] { m1.EquipmentRootNodeId, m2.EquipmentRootNodeId }.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true); + publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + + // The ghost ref was never materialised, so a value for it routes nowhere — dropped, not a crash. + actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h3-1", 9.0, OpcUaQuality.Good, Ts)); + publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + /// Guard: a arriving BEFORE any deployment /// is applied (_lastComposition still null) is ignored — nothing is materialised on the publish /// side (the equipment can't be resolved without a composition). @@ -970,6 +1075,72 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase return id; } + /// + /// Seeds a Sealed deployment whose artifact binds ONE driver to MULTIPLE equipments, each via a + /// distinct Device row whose DeviceConfig carries a HostAddress — so each + /// resolves a distinct (the shape + /// the multi-device FixedTree partition keys on). No authored tags (tag-less): the equipments are + /// resolved purely from EquipmentNodes. The driver row is non-Windows-only ("Modbus", Enabled) + /// so a REAL (non-stubbed) child is spawned. + /// + private static DeploymentId SeedDeploymentWithMultiDeviceEquipments( + IDbContextFactory db, RevisionHash rev, string driverId, + params (string Equip, string DeviceId, string Host)[] equipments) + { + var artifact = JsonSerializer.SerializeToUtf8Bytes(new + { + Namespaces = new[] + { + new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 + }, + DriverInstances = new[] + { + new + { + DriverInstanceRowId = Guid.NewGuid(), + DriverInstanceId = driverId, + Name = driverId, + DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) + Enabled = true, + DriverConfig = "{}", + NamespaceId = "ns-eq", + }, + }, + // Each device carries a HostAddress so EquipmentNode.DeviceHost resolves (via the shared extractor). + Devices = equipments.Select(e => new + { + DeviceId = e.DeviceId, + DriverInstanceId = driverId, + Name = e.DeviceId, + DeviceConfig = JsonSerializer.Serialize(new { HostAddress = e.Host }), + }).ToArray(), + // Each equipment binds to the driver AND a device — the multi-device resolution path. + Equipment = equipments.Select(e => new + { + EquipmentId = e.Equip, + Name = e.Equip, + UnsLineId = (string?)null, + DriverInstanceId = driverId, + DeviceId = e.DeviceId, + }).ToArray(), + Tags = Array.Empty(), + }); + + var id = DeploymentId.NewId(); + using var ctx = db.CreateDbContext(); + ctx.Deployments.Add(new Deployment + { + DeploymentId = id.Value, + RevisionHash = rev.Value, + Status = DeploymentStatus.Sealed, + CreatedBy = "test", + SealedAtUtc = DateTime.UtcNow, + ArtifactBlob = artifact, + }); + ctx.SaveChanges(); + return id; + } + /// Factory producing a single shared for the supported /// type, exposing its most-recent subscribed reference set for assertions (mirrors /// DriverHostActorWriteRoutingTests.RecordingDriverFactory, but the driver is