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