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 0d4a573e..c8e4297f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -155,17 +155,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// The composition from the most-recent apply (set at the END of /// ). Discovered-node injection - /// () reads it to resolve the equipment bound to a driver (via the - /// authored EquipmentTags, since EquipmentNode carries no DriverInstanceId) and to - /// recompute the authored value + alarm subscription sets when merging FixedTree refs. Null until the - /// first apply — a arriving before any apply is - /// ignored. + /// () reads it to resolve the equipment bound to a driver (from the + /// composition's EquipmentNodes whose DriverInstanceId matches, UNION the authored + /// EquipmentTags for that driver — so a driver with zero authored tags can still graft onto an + /// equipment bound via EquipmentNode.DriverInstanceId) and to recompute the authored value + alarm + /// subscription sets when merging FixedTree refs. Null until the first apply — a + /// arriving before any apply is ignored. private AddressSpaceComposition? _lastComposition; - /// The most-recent discovered-injection plan per driver instance, cached so Task 8 (the - /// address-space cache rebuild) can re-apply the live graft after a rebuild without re-running - /// discovery. Keyed by DriverInstanceId; last-writer-wins on a re-discovery. - private readonly Dictionary _discoveredByDriver = new(StringComparer.Ordinal); + /// The most-recent discovered-injection plan(s) per driver instance, cached so the redeploy + /// re-inject tail can re-apply the live graft after an address-space rebuild without re-running discovery. + /// Keyed by DriverInstanceId at the OUTER level, then by EquipmentId at the INNER level (driver → (equipment + /// → plan)). Today only the single-equipment case is populated, so the inner map always has exactly one + /// entry; the inner map is shaped per-equipment now so the follow-up multi-device-partition task can hold + /// multiple (equipmentId → plan) entries per driver without reshaping this cache. Inner dict is mutable + /// (the redeploy tail drops stale per-equipment entries in place); both levels are Ordinal-keyed. + /// Last-writer-wins on a re-discovery (the whole inner map is replaced). + private readonly Dictionary> _discoveredByDriver = + new(StringComparer.Ordinal); /// /// Cached local from the latest @@ -570,12 +577,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// /// Handles a driver child's post-connect : - /// resolves the equipment the driver is bound to from the most-recent applied composition, maps the - /// captured FixedTree under it via (deduping any node that shadows - /// an authored equipment-tag ref), caches the plan, and grafts it onto the served address space + - /// live-value maps + subscription set via . Idempotent / - /// duplicate-safe: the mapper is pure, materialisation is idempotent, and the routing-map extension + - /// subscription merge are set-based. + /// resolves the equipment the driver is bound to from the most-recent applied composition (its + /// EquipmentNodes bound by DriverInstanceId UNION its authored EquipmentTags), + /// maps the captured FixedTree under it via (deduping any node that + /// shadows an authored equipment-tag ref), caches the per-equipment plan map, and grafts it onto the + /// served address space + live-value maps + subscription set via + /// . Idempotent / duplicate-safe: the mapper is pure, + /// materialisation is idempotent, and the routing-map extension + subscription merge are set-based. /// private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg) { @@ -586,24 +594,29 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers return; } - // Resolve the equipment bound to this driver via the authored equipment tags. EquipmentNode carries - // no DriverInstanceId, so a driver's discovered FixedTree can only be grafted when the driver has - // >=1 authored equipment tag (which is how the equipment is resolved). The FOCAS z-34184 deploy has - // authored tags, so this holds today; extending the EquipmentNode projection with DriverInstanceId to - // drop this requirement is a follow-up. - var equipmentIds = _lastComposition.EquipmentTags + // Resolve the equipment bound to this driver from BOTH the composition's EquipmentNodes (whose + // DriverInstanceId matches — this lets a driver with ZERO authored tags graft onto a tag-less + // equipment) UNION the authored EquipmentTags for the driver (the original resolution). Distinct so a + // driver that is both EquipmentNode-bound AND has authored tags under the same equipment resolves once. + var fromNodes = _lastComposition.EquipmentNodes + .Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) + .Select(e => e.EquipmentId); + var fromTags = _lastComposition.EquipmentTags .Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) - .Select(t => t.EquipmentId) - .Distinct(StringComparer.Ordinal) - .ToList(); + .Select(t => t.EquipmentId); + var equipmentIds = fromNodes.Concat(fromTags).Distinct(StringComparer.Ordinal).ToList(); if (equipmentIds.Count == 0) { - _log.Info("DriverHost {Node}: no equipment/authored tags for driver {Driver} — skipping discovered-node injection", + _log.Info("DriverHost {Node}: no equipment for driver {Driver} — skipping discovered-node injection", _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; @@ -611,7 +624,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers 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. + // 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). var authoredRefs = _lastComposition.EquipmentTags .Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) .Select(t => t.FullName) @@ -620,22 +634,26 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers var plan = DiscoveredNodeMapper.Map(equipmentId, msg.Nodes, authoredRefs); if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored) - // Unchanged-plan short-circuit: Task 6's driver re-discovers every ~2s (up to ~15 passes) until the - // FixedTree set stabilises, re-sending DiscoveredNodesReady each pass. Re-applying an IDENTICAL plan + // 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 routing is unchanged from the last applied pass; a GROWING set - // still differs (superset) and re-applies. This is _discoveredByDriver's first reader. + // 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. if (_discoveredByDriver.TryGetValue(msg.DriverInstanceId, out var cached) - && RoutingEquals(cached.RoutingByRef, plan.RoutingByRef)) + && 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); return; } - _discoveredByDriver[msg.DriverInstanceId] = plan; - ApplyDiscoveredPlan(msg.DriverInstanceId, equipmentId, plan); + _discoveredByDriver[msg.DriverInstanceId] = newPlans; + ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans); } /// Routing-map equality: same count + every key maps to the same NodeId. Lets @@ -645,42 +663,68 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers => a.Count == b.Count && a.All(kv => b.TryGetValue(kv.Key, out var v) && string.Equals(v, kv.Value, StringComparison.Ordinal)); + /// Per-equipment plan-map routing equality: same equipment keys + each equipment's plan has the + /// same (via ). Lets + /// short-circuit a re-discovery whose WHOLE per-driver set is unchanged + /// (a grown/changed set on any equipment differs and re-applies). + private static bool PlansRoutingEqual( + IReadOnlyDictionary a, + IReadOnlyDictionary b) + => a.Count == b.Count + && a.All(kv => b.TryGetValue(kv.Key, out var p) && RoutingEquals(kv.Value.RoutingByRef, p.RoutingByRef)); + /// - /// Grafts a onto the served state: extends the live-value - /// routing map (mirroring ' fan-out so - /// lands FixedTree values on the right node), materialises the discovered folders + variables under - /// the equipment (idempotent), and merges the FixedTree refs into the driver's desired subscription - /// set (recomputing the authored value + alarm refs the same way - /// does, then unioning the FixedTree refs onto the value set) so the poll engine reads them and the - /// values flow. Extracted as a standalone method so Task 8 (the address-space cache rebuild) can - /// re-apply the cached plan after a rebuild without re-running discovery. + /// Grafts a driver's per-equipment map onto the served state in + /// two phases so the resubscribe stays a single push per driver (the shape the multi-device-partition + /// follow-up needs without resubscribe churn): + /// + /// Materialise per equipment — for each (equipmentId, plan) entry, extend the + /// live-value routing map (mirroring ' fan-out so + /// lands FixedTree values on the right node) and Tell the publish actor + /// for + /// that equipment (idempotent). + /// Subscribe ONCE per driver — compute the union of the driver's authored value refs + /// (recomputed the same way does) and the FixedTree refs of + /// ALL the driver's cached plans, then Tell the child a single + /// so the poll engine reads them and the + /// values flow. For a single-equipment driver this equals the prior per-plan behavior. + /// + /// Extracted as a standalone method so the redeploy re-inject tail can re-apply the cached plans after + /// an address-space rebuild without re-running discovery. /// - private void ApplyDiscoveredPlan(string driverId, string equipmentId, DiscoveredInjectionPlan plan) + private void ApplyDiscoveredPlansForDriver( + string driverId, IReadOnlyDictionary plansByEquipment) { - // Extend the live-value routing map (fan-out), mirroring PushDesiredSubscriptions' pattern. This is + // (a) Per-equipment: extend the live-value routing map (fan-out, mirroring PushDesiredSubscriptions' + // pattern) + materialise the discovered folders + variables under that equipment (idempotent). This is // purely ADDITIVE across passes: a shrinking discovery set would leave the dropped refs' stale routes // until the next full apply (PushDesiredSubscriptions) clears + rebuilds the maps — acceptable because // a FOCAS FixedTree only grows-then-stabilises, never shrinks within a connect. - foreach (var (driverRef, nodeId) in plan.RoutingByRef) + var totalVariables = 0; + foreach (var (equipmentId, plan) in plansByEquipment) { - var key = (driverId, driverRef); - if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) - _nodeIdByDriverRef[key] = set = new HashSet(StringComparer.Ordinal); - set.Add(nodeId); - _driverRefByNodeId[nodeId] = key; + foreach (var (driverRef, nodeId) in plan.RoutingByRef) + { + var key = (driverId, driverRef); + if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) + _nodeIdByDriverRef[key] = set = new HashSet(StringComparer.Ordinal); + set.Add(nodeId); + _driverRefByNodeId[nodeId] = key; + } + _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes( + equipmentId, plan.Folders, plan.Variables)); + totalVariables += plan.Variables.Count; } - // Materialise the discovered folders + variables under the equipment (idempotent). - _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes( - equipmentId, plan.Folders, plan.Variables)); - - // Merge the FixedTree refs into the driver's desired subscription set so the poll engine reads them - // and ForwardToMux routes the values. Recompute the authored value + alarm refs the same way - // PushDesiredSubscriptions does, then union the FixedTree refs onto the value set. + // (b) ONE subscription push per driver: merge the FixedTree refs from ALL the driver's plans into the + // driver's desired subscription set so the poll engine reads them and ForwardToMux routes the values. + // Recompute the authored value + alarm refs the same way PushDesiredSubscriptions does, then union the + // FixedTree refs onto the value set. Doing the union here (rather than once per plan) means the + // multi-device task adds inner-map entries without changing this single-send shape. if (!_children.TryGetValue(driverId, out var entry)) return; // The _lastComposition null-guards below are defensive: HandleDiscoveredNodes already proved it - // non-null, but Task 8 will also call ApplyDiscoveredPlan from the PushDesiredSubscriptions tail — - // keep them so that re-apply path can't NRE. + // non-null, but the redeploy tail also calls this from the PushDesiredSubscriptions tail — keep them + // so that re-apply path can't NRE. var authoredValueRefs = _lastComposition is null ? Enumerable.Empty() : _lastComposition.EquipmentTags @@ -693,11 +737,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers .Select(t => t.FullName) .Distinct(StringComparer.Ordinal) .ToArray(); - var union = authoredValueRefs.Concat(plan.RoutingByRef.Keys).Distinct(StringComparer.Ordinal).ToArray(); + var discoveredRefs = plansByEquipment.Values.SelectMany(p => p.RoutingByRef.Keys); + var union = authoredValueRefs.Concat(discoveredRefs).Distinct(StringComparer.Ordinal).ToArray(); entry.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs)); - _log.Info("DriverHost {Node}: injected {Count} discovered node(s) for driver {Driver} under {Equipment}", - _localNode, plan.Variables.Count, driverId, equipmentId); + _log.Info("DriverHost {Node}: injected {Count} discovered node(s) for driver {Driver} across {Equipment} equipment(s)", + _localNode, totalVariables, driverId, plansByEquipment.Count); } /// @@ -1250,43 +1295,58 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect // and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor // is freshly constructed so _discoveredByDriver is empty — restart survival comes from Task 6's - // post-connect re-discovery, NOT this re-apply. Re-resolve each cached driver's equipment from the - // CURRENT composition; drop the cache entry if the driver/equipment no longer resolves to exactly one - // (a rebind or removal — the driver's next reconnect re-discovery will rebuild it cleanly). + // post-connect re-discovery, NOT this re-apply. Re-resolve each cached driver's candidate equipments + // from the CURRENT composition (the SAME EquipmentNodes-UNION-EquipmentTags logic HandleDiscoveredNodes + // uses), then validate each cached (equipmentId → plan) entry PER ENTRY: drop the entry if its + // equipmentId is no longer a resolved candidate for the driver, OR the plan's NodeIds aren't scoped to + // that equipmentId (a rebind). A driver whose inner map empties out is removed entirely. The surviving + // entries are re-applied via the single-send-per-driver structure. (The single-equipment case today has + // exactly one inner entry; the multi-device task adds more.) foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below { - var plan = _discoveredByDriver[driverId]; - var equipmentIds = composition.EquipmentTags + var fromNodes = composition.EquipmentNodes + .Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, driverId, StringComparison.Ordinal)) + .Select(e => e.EquipmentId); + var fromTags = composition.EquipmentTags .Where(t => string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(t => t.EquipmentId) - .Distinct(StringComparer.Ordinal) - .ToList(); - if (equipmentIds.Count != 1) + .Select(t => t.EquipmentId); + var candidates = fromNodes.Concat(fromTags).ToHashSet(StringComparer.Ordinal); + + var plansByEquipment = _discoveredByDriver[driverId]; + foreach (var equipmentId in plansByEquipment.Keys.ToList()) // snapshot — we mutate the inner dict + { + var plan = plansByEquipment[equipmentId]; + if (!candidates.Contains(equipmentId)) + { + plansByEquipment.Remove(equipmentId); + _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment no longer resolves", _localNode, driverId, equipmentId); + continue; + } + // If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop + + // let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...". + // KNOWN LIMITATION (follow-up, alongside the multi-device-per-driver limitation): a + // CONFIG-UNCHANGED rebind (the driver's DriverConfig is identical, only its authored tag's + // EquipmentId moved) drops the cached plan here but does NOT itself re-trigger discovery — + // ReconcileDrivers only restarts a child on a DriverConfig change, so a config-unchanged child is + // never stopped/reconnected. The FixedTree subtree therefore stays ABSENT under the new equipment + // until the driver's next reconnect/restart re-discovers it. We deliberately do NOT add re-trigger + // logic here (it would couple the subscription pass to driver-lifecycle control); the drop is the + // safe, correct fail-state (a stale EQ-1-scoped graft under EQ-2 would be worse). + var planEquipmentConsistent = plan.Variables.Count > 0 + && plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal); + if (!planEquipmentConsistent) + { + plansByEquipment.Remove(equipmentId); + _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment rebound", _localNode, driverId, equipmentId); + } + } + + if (plansByEquipment.Count == 0) { _discoveredByDriver.Remove(driverId); - _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver} — equipment no longer resolves uniquely", _localNode, driverId); continue; } - var equipmentId = equipmentIds[0]; - // If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop + - // let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...". - // KNOWN LIMITATION (follow-up, alongside the multi-device-per-driver limitation): a - // CONFIG-UNCHANGED rebind (the driver's DriverConfig is identical, only its authored tag's - // EquipmentId moved) drops the cached plan here but does NOT itself re-trigger discovery — - // ReconcileDrivers only restarts a child on a DriverConfig change, so a config-unchanged child is - // never stopped/reconnected. The FixedTree subtree therefore stays ABSENT under the new equipment - // until the driver's next reconnect/restart re-discovers it. We deliberately do NOT add re-trigger - // logic here (it would couple the subscription pass to driver-lifecycle control); the drop is the - // safe, correct fail-state (a stale EQ-1-scoped graft under EQ-2 would be worse). - var planEquipmentConsistent = plan.Variables.Count > 0 - && plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal); - if (!planEquipmentConsistent) - { - _discoveredByDriver.Remove(driverId); - _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver} — equipment rebound", _localNode, driverId); - continue; - } - ApplyDiscoveredPlan(driverId, equipmentId, plan); + ApplyDiscoveredPlansForDriver(driverId, plansByEquipment); } } 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 54706648..e32d814c 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 @@ -113,6 +113,81 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase update.TimestampUtc.ShouldBe(Ts); } + /// NEW capability (follow-up E): a driver bound to an equipment via + /// with ZERO authored equipment tags can still graft its + /// discovered FixedTree. The equipment is resolved from the composition's EquipmentNodes (not just + /// the authored EquipmentTags), so a tag-less equipment receives + /// rooted at its NodeId and the driver + /// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with + /// "no equipment/authored tags". + [Fact] + public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes() + { + var db = NewInMemoryDbFactory(); + var factory = new SubscribingDriverFactory("Modbus"); + // EQ-1 bound to driver d1 via EquipmentNode.DriverInstanceId, with NO authored equipment tags for d1. + var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1"); + + var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); + + actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] + { + new DiscoveredNode( + FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, + BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", + DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, + Writable: false, IsHistorized: false), + })); + + // (a) The discovered nodes materialise UNDER EQ-1 even though no authored tag binds d1 → EQ-1. + var materialise = publish.ExpectMsg(Timeout); + materialise.EquipmentRootNodeId.ShouldBe("EQ-1"); + materialise.Variables.Count.ShouldBe(1); + var fixedTreeNodeId = materialise.Variables[0].NodeId; + + // (b) The driver subscribes the discovered ref (the union is just the FixedTree ref — no authored ref). + AwaitAssert(() => + { + var refs = factory.LastSubscribedRefs; + refs.ShouldNotBeNull(); + refs!.ShouldContain("ft-ref-1"); + }, duration: Timeout); + + // (c) A value published for the FixedTree ref routes to its mapped NodeId (routing map was extended). + actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); + var update = publish.ExpectMsg(Timeout); + update.NodeId.ShouldBe(fixedTreeNodeId); + 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. + [Fact] + public void Driver_mapping_to_more_than_one_equipment_still_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. + 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")); + + var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); + + actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] + { + new DiscoveredNode( + FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, + BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", + DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, + Writable: false, IsHistorized: false), + })); + + // Nothing is grafted — the >1-equipment branch warn+skips (replaced by the multi-device task). + publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + /// 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). @@ -469,6 +544,66 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase return id; } + /// + /// Seeds a Sealed deployment whose artifact binds an equipment to a driver via the + /// Equipment row's DriverInstanceId (the + /// projection) but carries NO authored equipment tags — so the equipment can only be resolved from + /// EquipmentNodes, not EquipmentTags. A DriverInstances row (non-Windows-only + /// "Modbus", Enabled) is included so a REAL (non-stubbed) child is + /// spawned for the driver even though it has zero tags. + /// + private static DeploymentId SeedDeploymentWithTagLessEquipment( + IDbContextFactory db, RevisionHash rev, string equipmentId, string driverId) + { + 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", + }, + }, + // The equipment binds to the driver via DriverInstanceId — the NEW resolution path — with no tags. + Equipment = new[] + { + new + { + EquipmentId = equipmentId, + Name = equipmentId, + UnsLineId = (string?)null, + DriverInstanceId = driverId, + DeviceId = (string?)null, + }, + }, + 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