diff --git a/CLAUDE.md b/CLAUDE.md
index 892546f2..7ef172fb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -155,17 +155,48 @@ Galaxy `mx_data_type` values map to OPC UA types (Boolean, Int32, Float, Double,
`DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys.
-⚠️ **Nothing consumes that signal.** No type under `src/Server/` or `src/Core/` subscribes to
-`OnRediscoveryNeeded` — the only `+=` handlers in `src/` are Galaxy re-raising its own watcher's event
-onto its own interface (`GalaxyDriver.cs:586`). `DriverHostActor.HandleDiscoveredNodes`
-(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:986-1002`) short-circuits with an
-unconditional `return;` — discovered-node injection is **dormant in v3** because raw tags are authored
-through the `/raw` browse-commit flow instead. A Galaxy redeploy therefore does **not** change the
-served address space; a config redeploy is required. The same inert-signal gap affects TwinCAT,
-MQTT/Sparkplug and MTConnect, and `IHostConnectivityProbe` is likewise unconsumed (`GetHostStatuses()`
-has no production call site; nothing ever writes a `DriverHostStatus` row). Tracked as Gitea **#518**
-(no consumer) and **#507** (re-migrate injection onto the raw subtree) — note that fixing either alone
-changes nothing observable.
+**The signal is consumed as an operator prompt, not as an address-space rebuild** (§8.2, 2026-07-27).
+`DriverInstanceActor` subscribes in `PreStart` and unsubscribes in `PostStop` — the detach is
+load-bearing, because the `IDriver` instance outlives the actor when the host respawns a child around
+the same driver object. A raise is recorded and rides the driver-health snapshot
+(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`) to the AdminUI `/hosts` page as a
+**"re-browse" chip**.
+
+⚠️ **It is advisory: a Galaxy redeploy still does NOT change the served address space.** v3 authors raw
+tags explicitly through the `/raw` browse-commit flow, so an operator must re-browse the device and
+commit. Injecting nodes at runtime would materialise nodes nobody approved and no deployment artifact
+records.
+
+Two gotchas if you touch this:
+
+- `PublishHealthSnapshot` dedups on a health fingerprint. The rediscovery timestamp **must** stay in
+ that tuple, or a raise on an otherwise-unchanged Healthy driver is swallowed and never reaches the
+ operator. Pinned by `DriverInstanceActorRediscoverySignalTests`.
+- The shared test `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs on
+ every call and the dedup never engages — a test built on it passes vacuously. The rediscovery tests
+ use a stub with stable health for exactly this reason.
+
+**#507 is closed as superseded, and the injection path is deleted.** Its retained code read
+`EquipmentNode.DriverInstanceId` and `EquipmentTags`, both *structurally empty* in v3
+(`AddressSpaceComposer.cs`, `DeploymentArtifact.cs`), so removing the guard would have changed a log
+line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`,
+`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`,
+`AddressSpaceApplier.MaterialiseDiscoveredNodes`, the connect-time discovery loop
+(`StartDiscovery`/`RediscoverTick`/`DiscoveredNodesReady`/`TriggerRediscovery`), and the 18
+`DiscoveryInjectionDormantV3` tests — v2 characterization tests asserting an equipment-rooted graft
+(`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce. `ITagDiscovery` itself stays: the `/raw`
+browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through
+the actor.
+
+**Deliberately NOT built: a discovered-vs-authored drift detector.** Modbus, S7, MQTT, AbLegacy and Sql
+all *echo authored config* from `DiscoverAsync` rather than browsing the device, so such a diff is
+permanently empty for them — another plausible-looking inert seam. `IRediscoverable` is the
+trustworthy signal because the driver asserts it deliberately.
+
+⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and
+nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so
+confirm intent before deleting). The separable `OnHostStatusChanged` event is the useful half. Tracked
+as Gitea **#521**.
## mxaccessgw
diff --git a/deferment.md b/deferment.md
index 4363b39f..b2801ebc 100644
--- a/deferment.md
+++ b/deferment.md
@@ -347,7 +347,9 @@ appear in §7.1 as well.
`docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~
✅ **Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9.
(`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.)
-2. **#518 + #507 together** — neither fix is observable alone.
+2. ~~**#518 + #507 together** — neither fix is observable alone.~~ ✅ **Done.** The framing was right
+ that they had to be handled together, but wrong about the resolution: **#507 was not revivable**,
+ so it was deleted rather than fixed. See §9.
3. **#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.
4. **G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would
have caught G-1 and G-2 for free, and this class has now recurred twice.
@@ -366,7 +368,7 @@ the work, so this register never disagrees with the tree.
| §8 item | Status | Commit |
|---|---|---|
| 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` |
-| 2. #518 + #507 | ⏳ Not started — decided *signal, not mutation* | — |
+| 2. #518 + #507 | ✅ **Done** — `IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` |
| 3. #516 config discard | ⏳ Not started — decided *both* (per-driver re-parse **and** seam respawn) | — |
| 4. G-4 dispatch-map parity | ⏳ Not started | — |
| 5. Bookkeeping sweep | ⏳ Not started | — |
@@ -415,6 +417,50 @@ Also recorded in #520 and not previously known: every existing evaluator unit te
is effectively untested; and `NodeAcl.ScopeId` has no FK or existence check, so scope ids dangle
silently.
+### 2026-07-27 — §8.2 rediscovery as a signal; injection deleted
+
+Decision: **signal, not mutation.**
+
+`DriverInstanceActor` now consumes `IRediscoverable.OnRediscoveryNeeded` (attach in `PreStart`, detach
+in `PostStop`) and carries it on the driver-health snapshot to `/hosts` as a **re-browse chip**. It is
+advisory — the served address space is unchanged, because v3 authors raw tags through `/raw`
+browse-commit and a runtime graft would materialise nodes nobody approved.
+
+**#507's injection path was deleted, not fixed.** §2 recorded it as a "deferred (deliberate guard)".
+That was too generous: its two inputs — `EquipmentNode.DriverInstanceId` and `EquipmentTags` — are
+*structurally empty* in v3, so removing the guard would have changed a log line and injected nothing.
+Deleted with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`,
+`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`,
+`AddressSpaceApplier.MaterialiseDiscoveredNodes`, `OpcUaPublishActor.MaterialiseDiscoveredNodes`, the
+connect-time discovery loop, and 4 files' worth of tests. `ITagDiscovery` stays — the `/raw` browse
+picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through the actor.
+
+**§4.4's "18 `DiscoveryInjectionDormantV3` — Real, blocked on #507" was wrong.** They were v2
+characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3
+cannot produce. They could never have unskipped as written; they are deleted. Skipped tests in
+`Runtime.Tests` went 31 → 13.
+
+**The planned drift detector was deliberately NOT built.** The plan proposed diffing each connect-time
+discovery pass against the authored raw tags. Reading the drivers killed it: **Modbus, S7, MQTT,
+AbLegacy and Sql all echo authored config from `DiscoverAsync`** rather than browsing the device, so
+the diff is permanently empty for them. It would have been another plausible-looking inert seam —
+precisely the class this register exists to remove. That also removed the last consumer of the
+connect-time loop, which is why the loop went too: it was browsing real devices up to ~15× per connect
+and dropping the result.
+
+Two traps worth keeping:
+
+- `PublishHealthSnapshot` dedups on a health fingerprint, and a rediscovery raise changes none of the
+ four fields it hashed. The timestamp had to join the tuple or the dedup swallows the signal.
+ **Verified by reverting the one line — 3 of the 5 new tests go red.**
+- The shared `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs every call
+ and the dedup never engages. A dedup test built on it would pass whether or not the fix is present;
+ the new tests use a stub with stable health.
+
+`IHostConnectivityProbe` was **not** resolved — it is a keep-or-delete decision, and the
+`DriverHostStatus` table was re-created deliberately in the v3 initial migration, so deleting on
+inference would be wrong. Split to Gitea **#521** with both options costed.
+
---
*Generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents.
diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md
index 14def81a..4807d4fb 100644
--- a/docs/drivers/Galaxy.md
+++ b/docs/drivers/Galaxy.md
@@ -70,7 +70,7 @@ Project root files:
| Capability | Implementation entry point |
|------------|---------------------------|
| `ITagDiscovery` | `Browse/GalaxyDiscoverer.cs` |
-| `IRediscoverable` | `Browse/DeployWatcher.cs` — ⚠️ raised but **unconsumed**; a Galaxy redeploy does not rebuild the address space ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518), [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)) |
+| `IRediscoverable` | `Browse/DeployWatcher.cs` — consumed as an **operator prompt**: a Galaxy redeploy raises a "re-browse" chip on `/hosts`. ⚠️ It does **not** rebuild the address space — re-browse the device under `/raw` and commit ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)) |
| `IReadable` | `Runtime/GalaxyMxSession.cs` |
| `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` |
| `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (driven by `EventPump`) |
diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md
index f1f15662..e694b5eb 100644
--- a/docs/drivers/MTConnect.md
+++ b/docs/drivers/MTConnect.md
@@ -220,8 +220,9 @@ what the driver's cursor expects) is detected **two ways**, both triggering a
**before** the sequence-gap check (a restart also usually trips the gap, so
the two need disambiguating, not just OR-ing together). On a changed
`instanceId` the driver clears its cached probe model and raises
-`OnRediscoveryNeeded` — see [Known limitations](#known-limitations) for why
-that signal currently has no consumer.
+`OnRediscoveryNeeded`, which surfaces a **re-browse prompt** on the AdminUI
+`/hosts` page — see [Known limitations](#known-limitations) for what that does
+and does not do.
## Browse — free via the universal browser
@@ -249,16 +250,20 @@ need re-authoring. See [Deferred](#deferred-not-in-this-build).
These are real, not placeholders — read them before relying on the driver
for anything beyond values-and-conditions.
-1. **`IRediscoverable` and `IHostConnectivityProbe` have no consumer in the
- server.** `DriverInstanceActor` wires only `ISubscribable.OnDataChange` and
- `IAlarmSource.OnAlarmEvent`; nothing in the server subscribes to
- `OnRediscoveryNeeded` or `OnHostStatusChanged` except `GalaxyDriver`
- wiring its own internal `DeployWatcher` sub-component. So a restarted
- Agent (a changed `instanceId`) leaves a stale address space behind an
- otherwise-Healthy driver. **This is a pre-existing fleet-wide gap
- affecting every driver that implements either interface** (eight drivers
- besides Galaxy), not something specific to MTConnect — this build simply
- surfaced it again.
+1. **A restarted Agent prompts an operator; it does not re-shape the address
+ space.** `DriverInstanceActor` now consumes `OnRediscoveryNeeded`
+ (2026-07-27), so a changed `instanceId` raises a "re-browse" chip against
+ this driver on `/hosts` carrying the reason. It is **advisory**: v3 authors
+ raw tags through the `/raw` browse-commit flow, so until an operator
+ re-browses the Agent and commits, any DataItem that appeared or vanished is
+ not reflected in the served tree. A runtime graft was deliberately rejected
+ — it would materialise nodes nobody approved and no deployment artifact
+ records.
+
+ **`IHostConnectivityProbe` is still unconsumed** — `GetHostStatuses()` has
+ no production call site and nothing writes a `DriverHostStatus` row. That
+ half remains a fleet-wide gap affecting every driver that implements it
+ (Gitea #521).
2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for
routing.** One driver instance owns exactly one Agent client scoped by
`MTConnectDriverOptions.DeviceName` (the top-level config key); the
diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md
index 252157fd..081f8752 100644
--- a/docs/drivers/TwinCAT.md
+++ b/docs/drivers/TwinCAT.md
@@ -155,7 +155,7 @@ fixture is down during normal dev.
**Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router).
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional
-arrays, per-element historization. `IRediscoverable` still fires (but is unconsumed — #518)
+arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518)
on PLC re-download (unchanged semantics from scalar nodes).
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
index 2c6b8d41..bb776ad2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
@@ -886,49 +886,6 @@ public sealed class AddressSpaceApplier
return failed;
}
- ///
- /// Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent:
- /// re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so
- /// this is safely re-run after every address-space rebuild. Folders are ensured parent-first.
- /// Emits a NodeAdded model-change so connected clients can refresh. Discovered nodes are read-only
- /// value nodes; array discovered nodes (rare) are forced read-only like the equipment-tag pass.
- ///
- /// The equipment root node the discovered nodes hang under; the
- /// NodeAdded model-change is announced under this node.
- /// The discovered folders to ensure (parent-first by depth).
- /// The discovered variables to ensure (read-only value nodes).
- /// The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
- /// surfaces a non-zero count as a degraded discovered-node injection (archreview 01/S-1).
- public int MaterialiseDiscoveredNodes(
- string equipmentRootNodeId,
- IReadOnlyList folders,
- IReadOnlyList variables)
- {
- ArgumentException.ThrowIfNullOrEmpty(equipmentRootNodeId);
- ArgumentNullException.ThrowIfNull(folders);
- ArgumentNullException.ThrowIfNull(variables);
- if (folders.Count == 0 && variables.Count == 0) return 0;
-
- var failed = 0;
- // Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth.
- foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
- if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName, AddressSpaceRealm.Raw)) failed++;
-
- foreach (var v in variables)
- {
- // Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
- var writable = v.Writable && !v.IsArray;
- if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable,
- AddressSpaceRealm.Raw, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++;
- }
-
- _sink.RaiseNodesAddedModelChange(equipmentRootNodeId, AddressSpaceRealm.Raw);
-
- _logger.LogInformation(
- "AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})",
- equipmentRootNodeId, folders.Count, variables.Count, failed);
- return failed;
- }
///
/// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs
deleted file mode 100644
index 90a1abe1..00000000
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
-
-/// A folder to ensure during discovered-node injection (NodeId + parent + display).
-public sealed record DiscoveredFolder(string NodeId, string? ParentNodeId, string DisplayName);
-
-/// A read-or-write variable to ensure during discovered-node injection.
-public sealed record DiscoveredVariable(
- string NodeId, string ParentNodeId, string DisplayName, string DataType, bool Writable, bool IsArray, uint? ArrayLength);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs
deleted file mode 100644
index 626807d5..00000000
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs
+++ /dev/null
@@ -1,71 +0,0 @@
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
-
-///
-/// An that RECORDS the streamed tree instead of creating OPC UA
-/// nodes — used to capture an driver's discovered hierarchy so the
-/// runtime can graft it under an equipment node. Folder nesting is tracked (each child builder
-/// carries its accumulated path), so every variable records its full .
-/// Value nodes only: is ignored and alarm marking returns a no-op sink
-/// (discovered alarms are out of scope — alarms come via the config path).
-/// Single-threaded: a driver's DiscoverAsync streams on one caller; the root and its child
-/// builders share one . Not thread-safe by design.
-///
-public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
-{
- private readonly List _nodes;
- private readonly IReadOnlyList _path;
-
- /// Create a root capturing builder with an empty folder path and a fresh node list.
- public CapturingAddressSpaceBuilder() : this([], []) { }
-
- private CapturingAddressSpaceBuilder(List nodes, IReadOnlyList path)
- {
- _nodes = nodes;
- _path = path;
- }
-
- /// All variables captured across the whole tree (shared by the root and every child scope).
- public IReadOnlyList Nodes => _nodes;
-
- ///
- public IAddressSpaceBuilder Folder(string browseName, string displayName)
- => new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]);
-
- ///
- public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
- {
- _nodes.Add(new DiscoveredNode(
- FolderPathSegments: _path,
- BrowseName: browseName,
- DisplayName: displayName,
- FullReference: attributeInfo.FullName,
- DataType: attributeInfo.DriverDataType,
- IsArray: attributeInfo.IsArray,
- ArrayDim: attributeInfo.ArrayDim,
- Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly,
- IsHistorized: attributeInfo.IsHistorized));
- return new NullHandle(attributeInfo.FullName);
- }
-
- ///
- public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ }
-
- /// A variable handle whose alarm marking is a no-op (discovered alarms are out of scope).
- private sealed class NullHandle(string fullRef) : IVariableHandle
- {
- ///
- public string FullReference => fullRef;
-
- ///
- public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
- }
-
- /// A null sink that ignores alarm condition transitions.
- private sealed class NullSink : IAlarmConditionSink
- {
- ///
- public void OnTransition(AlarmEventArgs args) { }
- }
-}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs
deleted file mode 100644
index 7ff7d43e..00000000
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
-
-///
-/// A flattened variable captured from a driver's stream
-/// by . Folder nesting is preserved in
-/// so the injector can re-root the node under an equipment.
-///
-public sealed record DiscoveredNode(
- IReadOnlyList FolderPathSegments,
- string BrowseName,
- string DisplayName,
- string FullReference,
- DriverDataType DataType,
- bool IsArray,
- uint? ArrayDim,
- bool Writable,
- bool IsHistorized);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs
deleted file mode 100644
index a31b1a85..00000000
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs
+++ /dev/null
@@ -1,118 +0,0 @@
-using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
-using ZB.MOM.WW.OtOpcUa.Commons.Types;
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-using ZB.MOM.WW.OtOpcUa.OpcUaServer;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
-
-/// The mapped result of grafting discovered nodes under an equipment node.
-///
-/// Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT
-/// globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a
-/// global parent-before-child ordering across the whole list.
-///
-/// Variables to ensure under the (post-collapse) folders.
-/// Driver FullReference -> equipment NodeId, for live-value routing.
-public sealed record DiscoveredInjectionPlan(
- IReadOnlyList Folders,
- IReadOnlyList Variables,
- IReadOnlyDictionary RoutingByRef); // driver FullReference -> equipment NodeId
-
-///
-/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping
-/// authored Config-DB refs and collapsing the single device-host folder. See the design doc
-/// 2026-06-26-otopcua-fixedtree-equipment-injection-design.md.
-///
-public static class DiscoveredNodeMapper
-{
- ///
- /// Maps captured into folders + variables (NodeIds scoped under
- /// ) plus a driver-FullReference → equipment-NodeId routing map.
- ///
- /// The owning equipment's NodeId (root of the grafted subtree).
- /// The captured discovery tree (from CapturingAddressSpaceBuilder).
- ///
- /// Driver FullReferences already authored as Config-DB equipment tags for this driver —
- /// skipped so a discovered node never shadows an authored one.
- ///
- /// The folders, variables, and routing map to apply against the OPC UA address space.
- public static DiscoveredInjectionPlan Map(
- string rootNodeId, IReadOnlyList nodes, IReadOnlySet authoredRefs)
- {
- // v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is
- // // (slash-joined RawPath), NOT the retired equipment-scoped
- // {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath).
- static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail;
- var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
-
- // Device-folder collapse: when every kept node shares one identical index-1 segment (the single
- // device-host folder under the driver root, e.g. "10.0.0.5:8193"), drop it so the path reads
- // FOCAS/Identity/... rather than FOCAS/10.0.0.5:8193/Identity/.... With >=2 distinct devices the
- // level is retained so identical leaf names across devices don't collide (degrades gracefully).
- var collapseIndex1 = kept.Count > 0
- && kept.All(n => n.FolderPathSegments.Count >= 2)
- && kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1;
-
- static IReadOnlyList Effective(IReadOnlyList segs, bool collapse)
- => collapse ? [segs[0], .. segs.Skip(2)] : segs;
-
- var folders = new Dictionary(StringComparer.Ordinal);
- var variables = new List();
- var routing = new Dictionary(StringComparer.Ordinal);
-
- foreach (var n in kept)
- {
- var segs = Effective(n.FolderPathSegments, collapseIndex1);
-
- // Ensure every prefix folder, deduped, each parented at its prefix (the first segment's
- // parent is the equipment itself).
- for (var i = 0; i < segs.Count; i++)
- {
- var folderPath = string.Join('/', segs.Take(i + 1));
- var nodeId = Combine(rootNodeId, folderPath);
- if (folders.ContainsKey(nodeId)) continue;
- var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i)));
- folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
- }
-
- var varFolderPath = string.Join('/', segs);
- var varNodeId = string.IsNullOrEmpty(varFolderPath)
- ? Combine(rootNodeId, n.BrowseName)
- : Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
- // A folder-less variable parents directly at the root device node.
- var varParent = string.IsNullOrEmpty(varFolderPath)
- ? rootNodeId
- : Combine(rootNodeId, varFolderPath);
- variables.Add(new DiscoveredVariable(
- varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
- routing[n.FullReference] = varNodeId;
- }
-
- return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing);
- }
-
- ///
- /// Maps a to the OPC-UA-built-in type STRING that
- /// OtOpcUaNodeManager.EnsureVariable's ResolveBuiltInDataType accepts — so a
- /// discovered variable resolves to the same built-in type as an authored equipment tag. Most
- /// enum names pass through verbatim; /
- /// map to the SDK's "Float"/"Double" names, and (a Galaxy
- /// attribute reference) is carried as an OPC UA String per the enum's own contract.
- ///
- private static string ToBuiltinTypeString(DriverDataType dt) => dt switch
- {
- DriverDataType.Boolean => "Boolean",
- DriverDataType.Int16 => "Int16",
- DriverDataType.Int32 => "Int32",
- DriverDataType.Int64 => "Int64",
- DriverDataType.UInt16 => "UInt16",
- DriverDataType.UInt32 => "UInt32",
- DriverDataType.UInt64 => "UInt64",
- DriverDataType.Float32 => "Float",
- DriverDataType.Float64 => "Double",
- DriverDataType.String => "String",
- DriverDataType.DateTime => "DateTime",
- DriverDataType.Reference => "String",
- _ => throw new ArgumentOutOfRangeException(nameof(dt), dt, "Unmapped DriverDataType."),
- };
-}
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 c1025d56..cb2152ee 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -245,36 +245,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
/// 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 (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.
+ /// ). Null until the first apply.
private AddressSpaceComposition? _lastComposition;
- /// 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);
-
- /// 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 Primary
@@ -894,7 +867,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive(ForwardToMux);
Receive(ForwardNativeAlarm);
Receive(OnDriverConnectivityChanged);
- Receive(HandleDiscoveredNodes);
Receive(HandleDeltaApplied);
Receive(HandleRestartDriver);
Receive(HandleReconnectDriver);
@@ -928,7 +900,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive(ForwardToMux);
Receive(ForwardNativeAlarm);
Receive(OnDriverConnectivityChanged);
- Receive(HandleDiscoveredNodes);
Receive(HandleDeltaApplied);
Receive(HandleRestartDriver);
Receive(HandleReconnectDriver);
@@ -973,349 +944,6 @@ 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 (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)
- {
- // v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a
- // driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3
- // retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are
- // authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The
- // downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an
- // equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any
- // caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert
- // too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree
- // is a separate follow-up.
- _log.Debug(
- "DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)",
- _localNode, msg.DriverInstanceId);
- return;
-
-#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up.
- if (_lastComposition is null)
- {
- _log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored",
- _localNode, msg.DriverInstanceId);
- return;
- }
-
- // 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);
- var equipmentIds = fromNodes.Concat(fromTags).Distinct(StringComparer.Ordinal).ToList();
- if (equipmentIds.Count == 0)
- {
- _log.Info("DriverHost {Node}: no equipment for driver {Driver} — skipping discovered-node injection",
- _localNode, msg.DriverInstanceId);
- return;
- }
- // 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);
-
- // 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
- }
-
- // 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))
- {
- 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;
- }
-
- _discoveredByDriver[msg.DriverInstanceId] = newPlans;
- ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
-#pragma warning restore CS0162
- }
-
- ///
- /// 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 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. 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 = DeviceConfigIntent.NormalizeHost(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
- ? DeviceConfigIntent.NormalizeHost(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.
- // NOTE: DiscoveredNodeMapper.Map's collapse predicate compares the host segment with RAW
- // StringComparer.Ordinal, whereas we grouped on the NORMALIZED host. Harmless: a real FOCAS device
- // emits one consistent HostAddress string per device, so a partition is single-host either way (collapse
- // fires). Even if two raw spellings of the same host slipped into one partition, the only effect would be
- // a retained (non-collapsed) host folder — never a mis-graft or NodeId collision (the equipment scope
- // already isolates them).
- 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).
- private static bool RoutingEquals(IReadOnlyDictionary a, IReadOnlyDictionary b)
- => 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 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 ApplyDiscoveredPlansForDriver(
- string driverId, IReadOnlyDictionary plansByEquipment)
- {
- // (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.
- var totalVariables = 0;
- foreach (var (equipmentId, plan) in plansByEquipment)
- {
- foreach (var (driverRef, nodeId) in plan.RoutingByRef)
- {
- var key = (driverId, driverRef);
- if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
- _nodeIdByDriverRef[key] = set = new HashSet();
- // v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
- // through the Raw realm.
- set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw));
- _driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key;
- }
- _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
- equipmentId, plan.Folders, plan.Variables));
- totalVariables += plan.Variables.Count;
- }
-
- // (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 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
- .Where(t => t.Alarm is null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal))
- .Select(t => t.FullName);
- var alarmRefs = _lastComposition is null
- ? Array.Empty()
- : _lastComposition.EquipmentTags
- .Where(t => t.Alarm is not null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal))
- .Select(t => t.FullName)
- .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} across {Equipment} equipment(s)",
- _localNode, totalVariables, driverId, plansByEquipment.Count);
- }
-
///
/// Routes a native alarm transition (published by a driver child as
/// ) to its materialised Part 9 condition
@@ -1719,10 +1347,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive(msg =>
_log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)",
_localNode, msg.ConditionNodeId));
- // A driver child's post-connect DiscoveredNodesReady can't be injected while Stale (no composition is
- // applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
- // and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
- Receive(_ => { });
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
Receive(_ => { });
@@ -2316,15 +1940,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
}
}
- // Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver
- // is still untouched (the re-inject tail below drops/removes entries). Cached drivers are SKIPPED in the
- // bulk loop because the tail sends each of them EXACTLY ONE SetDesiredSubscriptions for this pass: the
- // authored∪discovered union (ApplyDiscoveredPlansForDriver) for a survivor, or — if its plan is fully
- // dropped — an authored-only fallback. Sending the bulk authored-only set HERE too would force the child
- // to drop the whole handle (authored tags included) then re-subscribe — an extra unsub/resub blip of the
- // authored values once per cached driver per redeploy. Net effect: exactly ONE send per driver per pass.
- var cachedDriverIds = _discoveredByDriver.Keys.ToHashSet(StringComparer.Ordinal);
-
// One authored-only push (value refs + alarm refs from the maps built above), shared by the bulk loop AND
// the dropped-driver fallback so the two CANNOT drift: the fallback's correctness depends on sending the
// SAME payload the bulk loop would have, so it's a shared helper (structural), not a comment-maintained
@@ -2341,9 +1956,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
var total = 0;
foreach (var (driverId, entry) in _children)
{
- // Cached drivers are owned exclusively by the re-inject tail (one send each) — skip here. Non-cached
- // drivers keep the bulk authored-only send exactly as before.
- if (cachedDriverIds.Contains(driverId)) continue;
total += SendAuthoredOnly(entry.Actor, driverId);
}
@@ -2380,94 +1992,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, composition.EquipmentScriptedAlarms.Count);
}
- // Cache the applied composition LAST so discovered-node injection (HandleDiscoveredNodes) can resolve
- // the equipment bound to a driver + recompute the authored subscription sets when a driver later
- // reports its FixedTree. Set here (not in ApplyAndAck) so both the fresh-apply and bootstrap-restore
- // paths — which both route through this method — leave a current composition.
+ // Cache the applied composition LAST. Set here (not in ApplyAndAck) so both the fresh-apply and
+ // bootstrap-restore paths — which both route through this method — leave a current composition.
_lastComposition = composition;
- // Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared
- // _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, an IN-PROCESS
- // redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated)
- // 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 the
- // post-connect re-discovery loop, 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 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);
- var candidates = fromNodes.Concat(fromTags).ToHashSet(StringComparer.Ordinal);
-
- var plansByEquipment = _discoveredByDriver[driverId];
- // Track whether ANY entry was dropped (no-longer-candidate or rebind) so we can re-trigger this
- // driver's discovery exactly ONCE after the inner map is processed (see the post-loop block).
- var droppedAny = false;
- foreach (var equipmentId in plansByEquipment.Keys.ToList()) // snapshot — we mutate the inner dict
- {
- var plan = plansByEquipment[equipmentId];
- if (!candidates.Contains(equipmentId))
- {
- plansByEquipment.Remove(equipmentId);
- droppedAny = true;
- _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}/...".
- var planEquipmentConsistent = plan.Variables.Count > 0
- && plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal);
- if (!planEquipmentConsistent)
- {
- plansByEquipment.Remove(equipmentId);
- droppedAny = true;
- _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment rebound", _localNode, driverId, equipmentId);
- }
- }
-
- // Re-trigger discovery when ANY entry was dropped (no-longer-candidate or rebind). A CONFIG-UNCHANGED
- // rebind (the driver's DriverConfig is identical, only its authored tag's EquipmentId moved) is NOT
- // restarted by ReconcileDrivers — the child stays Connected — so without this nudge the FixedTree
- // subtree would stay ABSENT under the new equipment until the driver's next natural reconnect. We now
- // ask the child to re-run discovery so it re-grafts promptly: the next pass resolves against the new
- // _lastComposition (the now-bound equipment). This is a DISCOVERY action, not lifecycle control — no
- // stop/restart; it is idempotent, and the child no-ops it if not Connected (handled in
- // DriverInstanceActor). Sent at most ONCE per driver per re-inject pass (here, after the inner map is
- // processed — so even when the inner map empties below), guarded on the child still existing.
- if (droppedAny && _children.TryGetValue(driverId, out var rediscoverEntry))
- rediscoverEntry.Actor.Tell(new DriverInstanceActor.TriggerRediscovery());
-
- if (plansByEquipment.Count == 0)
- {
- _discoveredByDriver.Remove(driverId);
- // Drop the driver's partition warn-signature too so a permanently-removed/rebound driver doesn't
- // leak a stale entry (log-level-only state; bounded by driver count — just tidiness).
- _lastPartitionWarnSignature.Remove(driverId);
- // FALLBACK (one-send invariant): this driver was SKIPPED in the bulk loop (it was cached), and its
- // plan is now FULLY DROPPED — so ApplyDiscoveredPlansForDriver won't run for it and it would
- // otherwise receive ZERO sends this pass, losing its AUTHORED subscriptions. Send the authored-only
- // set NOW (the SAME payload the bulk loop computes), so the authored tags subscribe in THIS pass.
- // (The TriggerRediscovery above handles the async FixedTree re-graft separately; this just keeps
- // the authored values live meanwhile.) Guarded on the child still existing — a driver removed by
- // ReconcileDrivers has no child and correctly gets no send. Shares SendAuthoredOnly with the bulk
- // loop so the payload can't drift; a ZERO-authored driver sends an empty set → Unsubscribe (drops
- // the stale FixedTree handle without a spurious subscribe).
- if (_children.TryGetValue(driverId, out var fallbackEntry))
- SendAuthoredOnly(fallbackEntry.Actor, driverId);
- continue;
- }
- ApplyDiscoveredPlansForDriver(driverId, plansByEquipment);
- }
}
private void SpawnChild(DriverInstanceSpec spec)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
index b8a24c08..5fc2139e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
@@ -32,16 +32,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10);
- /// Default interval between bounded post-connect re-discovery passes.
- public static readonly TimeSpan DefaultRediscoverInterval = TimeSpan.FromSeconds(2);
-
- /// Default cap on the number of post-connect re-discovery passes.
- public const int DefaultRediscoverMaxAttempts = 15;
-
- /// Default per-pass timeout for during
- /// bounded post-connect re-discovery. Bounds the mailbox suspension time; production default 30 s.
- public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30);
-
public sealed record InitializeRequested(string DriverConfigJson);
public sealed record InitializeSucceeded(int Generation);
public sealed record InitializeFailed(string Reason, int Generation);
@@ -124,21 +114,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// subscription that un-gates an driver's feed. Handled async so the
/// call is bounded + off the synchronous handlers.
private sealed record SubscribeAlarms;
- /// Published to the parent (DriverHostActor) after each post-connect discovery pass so it can
- /// graft the driver's discovered FixedTree nodes under the equipment. Empty/duplicate sets are fine —
- /// the parent dedups and injection is idempotent.
- public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList Nodes);
-
- ///
- /// Sent by to ask this driver child to re-run post-connect discovery
- /// after the host rebinds the driver to a new equipment. Handled only in Connected, where it
- /// re-kicks — which already honours the driver's
- /// and the guard, tagging the
- /// fresh pass with the current init generation. In any non-Connected state it is a deliberate no-op:
- /// the driver's eventual (re)connect re-discovers anyway, so there is nothing to do and nothing to log.
- ///
- public sealed record TriggerRediscovery;
-
/// Self-sent when the wrapped driver raises —
/// it observed that the remote's tag set may have changed. Marshals the event off the driver's thread
/// onto the actor thread. Handled in EVERY behaviour, including Stubbed and Reconnecting: the raise has no
@@ -146,10 +121,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// between connects), and dropping it in one state would lose the signal silently.
private sealed record RediscoveryRaised(RediscoveryEventArgs Args);
- /// Internal self-tick driving bounded post-connect re-discovery (FixedTree populates ~0–2s after connect).
- /// is the ordered-distinct full-reference signature of the prior pass's
- /// captured set (empty string on the first tick); re-discovery stops once a non-empty set repeats it.
- private sealed record RediscoverTick(int Generation, int Attempt, string PreviousSignature);
public sealed class RetryConnect
{
public static readonly RetryConnect Instance = new();
@@ -174,19 +145,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private readonly IDriverHealthPublisher _healthPublisher;
private readonly TimeSpan _reconnectInterval;
- /// Interval between bounded post-connect re-discovery passes. Production default 2s; tests
- /// inject a tiny value so the loop runs without real-time waits.
- private readonly TimeSpan _rediscoverInterval;
private readonly TimeSpan _healthPollInterval;
- /// Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising
- /// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15.
- private readonly int _rediscoverMaxAttempts;
-
- /// Per-pass timeout for during bounded post-connect
- /// re-discovery. Bounds the mailbox suspension time. Production default 30 s; tests may inject a shorter
- /// value. Stored to allow injection rather than hardcoding.
- private readonly TimeSpan _rediscoverDiscoverTimeout;
private readonly ILoggingAdapter _log = Context.GetLogger();
private string? _currentConfigJson;
@@ -252,9 +212,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// stub paths don't need to provide one.
/// Optional cluster identifier forwarded in messages;
/// defaults to an empty string when not provided (e.g. in unit tests).
- /// Optional interval between post-connect re-discovery passes; defaults to 2 seconds.
- /// Optional cap on re-discovery passes; defaults to 15.
- /// Optional per-pass timeout for ; defaults to 30 seconds.
/// Optional Phase 6.1 resilience invoker wrapping this driver's capability
/// calls; defaults to (pass-through) when not supplied.
/// Optional period of the health-poll heartbeat, which also drives the
@@ -267,9 +224,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
bool startStubbed = false,
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null,
- TimeSpan? rediscoverInterval = null,
- int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
- TimeSpan? rediscoverDiscoverTimeout = null,
IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(
@@ -278,9 +232,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
startStubbed,
healthPublisher ?? NullDriverHealthPublisher.Instance,
clusterId ?? string.Empty,
- rediscoverInterval,
- rediscoverMaxAttempts,
- rediscoverDiscoverTimeout,
invoker,
healthPollInterval));
@@ -311,9 +262,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// If true, start in stub mode for testing or unavailable platforms.
/// Sink for health-change notifications; must not be null.
/// Cluster identifier forwarded in health snapshots.
- /// Interval between post-connect re-discovery passes; defaults to 2 seconds.
- /// Cap on the number of re-discovery passes; defaults to 15.
- /// Per-pass timeout for ; defaults to 30 seconds.
/// Phase 6.1 resilience invoker wrapping this driver's capability calls;
/// defaults to (pass-through) when null.
/// Period of the health-poll heartbeat, which also drives the
@@ -324,9 +272,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
bool startStubbed = false,
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null,
- TimeSpan? rediscoverInterval = null,
- int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
- TimeSpan? rediscoverDiscoverTimeout = null,
IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null)
{
@@ -336,9 +281,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_clusterId = clusterId ?? string.Empty;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
_reconnectInterval = reconnectInterval;
- _rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
- _rediscoverMaxAttempts = rediscoverMaxAttempts;
- _rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
new KeyValuePair("event", startStubbed ? "spawn_stub" : "spawn"),
@@ -382,9 +324,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
Receive(StoreDesiredSubscriptions);
// Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a
// re-discovery self-tick is ever routed here so it doesn't surface as an Akka Unhandled message.
- Receive(_ => { });
- // A TriggerRediscovery is meaningless to a stubbed (never-Connected) driver — silently ignore it.
- Receive(_ => { });
Receive(HandleRediscoveryRaised);
Receive(_ => PublishHealthSnapshot());
}
@@ -411,7 +350,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
- StartDiscovery();
});
Receive(msg =>
{
@@ -439,12 +377,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into
// this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes.
Receive(_ => { });
- // Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an
- // already-queued disconnect; swallow it — the next Connected entry re-kicks discovery.
- Receive(_ => { });
- // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path
- // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event).
- Receive(_ => { });
Receive(HandleRediscoveryRaised);
Receive(_ => PublishHealthSnapshot());
}
@@ -461,7 +393,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
_driverInstanceId, msg.Reason);
- Timers.Cancel("rediscover");
DetachSubscription();
RecordFault();
Become(Reconnecting);
@@ -470,25 +401,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
Receive(_ =>
{
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
- Timers.Cancel("rediscover");
DetachSubscription();
Become(Reconnecting);
PublishHealthSnapshot();
});
- ReceiveAsync(HandleRediscoverAsync);
- // The host asks for a fresh discovery pass after rebinding the driver to a new equipment. Cancel any
- // pending rediscover tick FIRST — mirroring ForceReconnect/DisconnectObserved — so a stale tick left
- // over from the prior loop can't fire alongside the freshly-kicked one, then re-kick the bounded loop
- // via StartDiscovery (honours RediscoverPolicy + the ITagDiscovery guard, tagged with the current
- // _initGeneration). Only handled here in Connected — non-Connected states no-op it below. A stale tick
- // that still slips through (one already mid-async-handler) is benign: the parent dedups
- // DiscoveredNodesReady and node injection is idempotent — the Cancel just avoids the avoidable double
- // pass in the common case.
- Receive(_ =>
- {
- Timers.Cancel("rediscover");
- StartDiscovery();
- });
ReceiveAsync(HandleWriteAsync);
ReceiveAsync(HandleAcknowledgeAsync);
ReceiveAsync(HandleSubscribeAsync);
@@ -602,7 +518,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
- StartDiscovery(); // re-run discovery on reconnect — keeps the injected tree fresh if the backend's capabilities changed
});
// A failure here is a no-op regardless of generation — the retry timer keeps trying the
// current config; only a (generation-matched) InitializeSucceeded transitions state.
@@ -625,12 +540,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into
// this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes.
Receive(_ => { });
- // Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an
- // already-queued disconnect; swallow it — the next Connected entry re-kicks discovery.
- Receive(_ => { });
- // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path
- // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event).
- Receive(_ => { });
Receive(HandleRediscoveryRaised);
Receive(_ => PublishHealthSnapshot());
Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval);
@@ -971,97 +880,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
- /// Kick the bounded post-connect re-discovery loop on a Connected entry. A no-op unless the
- /// driver exposes (nothing to inject otherwise). Self-sends the first
- /// tagged with the current init generation so a tick that outlives a reconnect
- /// is rejected by the generation guard in .
- /// Honours the driver's : Never opts out entirely
- /// (no tick scheduled); Once runs a single pass (the loop stops after the first publish in
- /// ); UntilStable retries each (re)connect, bounded by
- /// stop-on-stable (the discovered-set signature repeats) + the attempt cap.
- private void StartDiscovery()
- {
- if (_driver is not ITagDiscovery discovery) return; // driver doesn't expose discovery — nothing to inject
- if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Never)
- {
- // Driver opts out of post-connect discovery — don't even schedule the first tick.
- _log.Debug("DriverInstance {Id}: RediscoverPolicy=Never — skipping post-connect discovery", _driverInstanceId);
- return;
- }
- Self.Tell(new RediscoverTick(_initGeneration, Attempt: 0, PreviousSignature: string.Empty));
- }
-
- /// Runs one post-connect discovery pass: captures the driver's streamed FixedTree via a
- /// and ships the result to the parent as
- /// (empty/duplicate sets are fine — the parent dedups and injection
- /// is idempotent). Retries on the until the non-empty discovered SET
- /// has STABILISED (the ordered-distinct full-reference signature repeats — robust for incremental/paged
- /// browsers where a count alone could falsely settle a partial tree) or the
- /// cap is hit, whichever comes first; keeps retrying while empty because a FOCAS-style FixedTree cache may
- /// still be populating.
- /// Limitation: this assumes a driver's discovered set only GROWS toward a stable shape (true for
- /// FOCAS — its FixedTree appears once, and on the wonder deploy the driver-config _options.Tags is
- /// empty so the set is 0 until the cache populates). A driver that emits an initial non-empty set and
- /// later grows could stop early on a transient repeat; acceptable for current scope.
- private async Task HandleRediscoverAsync(RediscoverTick tick)
- {
- if (tick.Generation != _initGeneration) return; // stale (a reconnect superseded this pass)
- if (_driver is not ITagDiscovery discovery) return;
-
- IReadOnlyList nodes;
- try
- {
- var builder = new CapturingAddressSpaceBuilder();
- // Bound the browse — ReceiveAsync suspends the mailbox for the whole handler, so an unbounded
- // DiscoverAsync would block DisconnectObserved / ForceReconnect / writes / health-poll behind it.
- using var cts = new CancellationTokenSource(_rediscoverDiscoverTimeout);
- // NO ConfigureAwait(false) on this outer await: a genuinely-async DiscoverAsync (Galaxy /
- // OpcUaClient / TwinCAT) must resume on the actor task scheduler so the Context.Parent.Tell +
- // Timers calls below run with a live ActorContext. ConfigureAwait(false) would resume
- // off-context and throw NotSupportedException("no active ActorContext"). The invoker's own
- // internal ConfigureAwait(false) does NOT propagate to this caller's await — the actor
- // continuation still resumes on the captured actor scheduler. (Discover retries per tier.)
- await _invoker.ExecuteAsync(
- DriverCapability.Discover,
- _driverInstanceId,
- async ct => await discovery.DiscoverAsync(builder, ct),
- cts.Token);
- nodes = builder.Nodes.ToArray(); // immutable snapshot — never hand the builder's live list across actors
- }
- catch (Exception ex)
- {
- _log.Warning(ex, "DriverInstance {Id}: discovery pass {Attempt} failed; will retry", _driverInstanceId, tick.Attempt);
- nodes = Array.Empty();
- }
-
- // Belt-and-suspenders: under ReceiveAsync the mailbox is suspended for the whole handler, so
- // _initGeneration cannot change mid-await — the pre-await guard + Timers.Cancel("rediscover") on
- // disconnect + single-timer key reuse are the primary protections. Re-checked in case that changes.
- if (tick.Generation != _initGeneration) return;
-
- Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, nodes));
-
- // Honour the driver's re-discovery policy. A Once driver runs a single post-connect pass per
- // (re)connect regardless of whether DiscoverAsync is synchronous or async — one published pass is
- // complete, so the retry loop is skipped (no further tick scheduled). (Never never reaches here —
- // StartDiscovery returns before the first tick.) UntilStable falls through to the stop-on-stable +
- // attempt-cap logic below.
- if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Once)
- {
- _log.Debug("DriverInstance {Id}: RediscoverPolicy=Once — single discovery pass, not scheduling another", _driverInstanceId);
- return;
- }
-
- // Stop when the non-empty discovered SET has stabilised (its signature repeats), or the attempt cap
- // is hit. Keep retrying while empty (a FixedTree cache may still be populating). First tick carries "".
- var signature = string.Join('\u0001',
- nodes.Select(n => n.FullReference).Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal));
- var stableNonEmpty = nodes.Count > 0 && string.Equals(signature, tick.PreviousSignature, StringComparison.Ordinal);
- if (tick.Attempt + 1 < _rediscoverMaxAttempts && !stableNonEmpty)
- Timers.StartSingleTimer("rediscover", new RediscoverTick(tick.Generation, tick.Attempt + 1, signature), _rediscoverInterval);
- else
- _log.Debug("DriverInstance {Id}: discovery settled after {Attempt} pass(es), {Count} node(s)", _driverInstanceId, tick.Attempt + 1, nodes.Count);
- }
/// Records the host's desired subscription set without touching the live subscription.
/// The set is (re)applied by on the next Connected entry.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
index 61a0019f..e3125339 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
@@ -85,15 +85,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
public sealed record RebuildAddressSpace(
CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null);
- /// Inject driver-discovered nodes (FixedTree) under an equipment at runtime (post-connect).
- /// The OPC UA NodeId of the equipment root folder to inject the
- /// discovered nodes under (e.g. "EQ-3686c0272279"); also the node the NodeAdded model-change is
- /// announced under.
- public sealed record MaterialiseDiscoveredNodes(
- string EquipmentRootNodeId,
- IReadOnlyList Folders,
- IReadOnlyList Variables);
-
public sealed record ServiceLevelChanged(byte ServiceLevel);
private readonly IOpcUaAddressSpaceSink _sink;
@@ -284,7 +275,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
Receive(HandleAlarmUpdate);
Receive(HandleAlarmQualityUpdate);
Receive(HandleRebuild);
- Receive(HandleMaterialiseDiscovered);
Receive(HandleServiceLevelChanged);
Receive(HandleRedundancyStateChanged);
Receive(HandleDbHealthStatus);
@@ -539,28 +529,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
}
- /// Forwards driver-discovered (FixedTree) nodes to the applier so they are injected under
- /// the equipment at runtime. No-op (logged) when no applier is wired (dev/Mac/legacy seam), matching the
- /// optional-applier tolerance of .
- private void HandleMaterialiseDiscovered(MaterialiseDiscoveredNodes msg)
- {
- if (_applier is null)
- {
- _log.Debug("OpcUaPublish: no applier wired — discarding MaterialiseDiscoveredNodes for {Equipment}", msg.EquipmentRootNodeId);
- return;
- }
- var failedNodes = _applier.MaterialiseDiscoveredNodes(msg.EquipmentRootNodeId, msg.Folders, msg.Variables);
- if (failedNodes > 0)
- {
- // archreview 01/S-1: a swallowed discovered-node injection failure surfaces at Error + the
- // dedicated meter instead of vanishing into per-node Warnings.
- OtOpcUaTelemetry.OpcUaApplyFailed.Add(1, new KeyValuePair("kind", "nodes"));
- _log.Error(
- "OpcUaPublish: discovered-node injection DEGRADED for {Equipment} (failedNodes={FailedNodes}) — some discovered nodes may be missing",
- msg.EquipmentRootNodeId, failedNodes);
- }
- }
-
private void HandleServiceLevelChanged(ServiceLevelChanged msg)
{
// Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
index 7bcfe1ee..b2b970e3 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
@@ -94,11 +94,6 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
applier.MaterialiseEquipmentTags(tags).ShouldBe(0);
applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0);
applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0);
- applier.MaterialiseDiscoveredNodes(
- "eq-1",
- Array.Empty(),
- new[] { new DiscoveredVariable("eq-1/D", "eq-1", "D", "Float", Writable: false, IsArray: false, ArrayLength: null) })
- .ShouldBe(0);
}
// ---------------- fixtures ----------------
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
index ddbf81bd..a25562e0 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
@@ -8,679 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class AddressSpaceApplierTests
{
- /// Verifies that an empty plan does not call the sink or trigger a rebuild.
- [Fact]
- public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
- var outcome = applier.Apply(EmptyPlan);
- outcome.RebuildCalled.ShouldBeFalse();
- outcome.AddedNodes.ShouldBe(0);
- outcome.RemovedNodes.ShouldBe(0);
- outcome.ChangedNodes.ShouldBe(0);
- sink.RebuildCalls.ShouldBe(0);
- sink.AlarmWrites.ShouldBeEmpty();
- }
- /// R2-07 T11 — removed equipment is a PureRemove: the applier writes each id's terminal
- /// "no-event" condition state (top-of-Apply block) then tears down each equipment SUBTREE in place via
- /// RemoveEquipmentSubtree — NO full rebuild (other clients' subscriptions survive). (Supersedes the
- /// pre-R2-07 "removed equipment ⇒ rebuild" pin.)
- [Fact]
- public void Removed_equipment_writes_terminal_state_and_removes_subtree_without_rebuild()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var plan = WithEquipmentRemoval("eq-1", "eq-2");
- var outcome = applier.Apply(plan);
-
- outcome.RemovedNodes.ShouldBe(2);
- outcome.RebuildCalled.ShouldBeFalse();
- sink.RebuildCalls.ShouldBe(0);
- // Terminal "no-event" condition state written per id (inactive + acked + confirmed).
- sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
- sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue();
- // Each equipment torn down as a subtree.
- sink.RemoveCalls.OrderBy(x => x.NodeId).ShouldBe(new[] { ("equipment", "eq-1"), ("equipment", "eq-2") });
- }
-
- /// R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent
- /// Materialise passes create the new folder; existing client subscriptions survive) and writes no alarm
- /// state. (Supersedes the pre-R2-07 "added equipment ⇒ rebuild" pin.)
- [Fact]
- public void Added_equipment_is_pure_add_and_skips_rebuild()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var plan = new AddressSpacePlan(
- AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") },
- RemovedEquipment: Array.Empty(),
- ChangedEquipment: Array.Empty(),
- AddedDrivers: Array.Empty(),
- RemovedDrivers: Array.Empty(),
- ChangedDrivers: Array.Empty(),
- AddedAlarms: Array.Empty(),
- RemovedAlarms: Array.Empty(),
- ChangedAlarms: Array.Empty());
-
- var outcome = applier.Apply(plan);
-
- outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved
- outcome.AddedNodes.ShouldBe(1);
- sink.AlarmWrites.ShouldBeEmpty();
- sink.RebuildCalls.ShouldBe(0);
- }
-
- /// Verifies that driver-only changes do not trigger address space rebuild.
- [Fact]
- public void Driver_only_changes_do_not_trigger_address_space_rebuild()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var plan = new AddressSpacePlan(
- AddedEquipment: Array.Empty(),
- RemovedEquipment: Array.Empty(),
- ChangedEquipment: Array.Empty(),
- AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") },
- RemovedDrivers: Array.Empty(),
- ChangedDrivers: new[]
- {
- new AddressSpacePlan.DriverDelta(
- new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"),
- new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")),
- },
- AddedAlarms: Array.Empty(),
- RemovedAlarms: Array.Empty(),
- ChangedAlarms: Array.Empty());
-
- var outcome = applier.Apply(plan);
-
- outcome.RebuildCalled.ShouldBeFalse();
- sink.RebuildCalls.ShouldBe(0);
- }
-
- /// Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires.
- [Fact]
- public void Sink_exception_in_WriteAlarmCondition_does_not_propagate_and_rebuild_still_fires()
- {
- var sink = new ThrowingSink(throwOnAlarmWrite: true);
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var plan = WithEquipmentRemoval("eq-1");
-
- var outcome = applier.Apply(plan); // should not throw
- outcome.RemovedNodes.ShouldBe(1);
- outcome.RebuildCalled.ShouldBeTrue();
- }
-
- /// Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly
- /// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw
- /// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment
- /// folder (decision #4).
- [Fact]
- public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- UnsAreas: Array.Empty(),
- UnsLines: Array.Empty(),
- EquipmentNodes: Array.Empty(),
- DriverInstancePlans: Array.Empty(),
- ScriptedAlarmPlans: Array.Empty())
- {
- EquipmentTags = new[]
- {
- new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
-
- sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
- // A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite).
- sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true));
- // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
- sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
- }
-
- /// Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
- /// folder (not the namespace root), with the variable parented to that sub-folder and a
- /// folder-scoped NodeId.
- [Fact]
- public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentTags = new[]
- {
- new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
-
- sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
- // A Read plan threads Writable: false (the node stays CurrentRead).
- sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false));
- // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
- sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
- }
-
- /// Regression for the FullName-as-NodeId collision: two identical machines exposing the
- /// SAME driver FullName (e.g. Modbus register 40001) must produce TWO distinct variables — one
- /// under each equipment folder — because the NodeId is folder-scoped, not the raw FullName.
- [Fact]
- public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentTags = new[]
- {
- new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
- new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
-
- sink.VariableCalls.Count.ShouldBe(2);
- sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float", false));
- sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false));
- }
-
- /// Phase B WS-3 — an alarm-bearing equipment tag (Alarm is not null) materialises a
- /// real OPC UA Part 9 condition node (via the same path scripted alarms use) instead of a value
- /// variable; a plain tag (Alarm == null) stays a value variable. The alarm tag's condition
- /// uses the tag's folder-scoped NodeId, the equipment folder as parent, and carries the tag's
- /// AlarmType/Severity. Proves BOTH branches in one composition.
- [Fact]
- public void MaterialiseEquipmentTags_alarm_bearing_tag_becomes_condition_plain_tag_stays_variable()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentTags = new[]
- {
- new EquipmentTagPlan("tag-plain", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
- new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
-
- // The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition.
- var plainNodeId = V3NodeIds.Uns("eq-1", "Speed");
- sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true));
- sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId);
-
- // The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent,
- // matching display/type/severity) and did NOT drive EnsureVariable.
- var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp");
- // A native equipment-tag alarm: the call-site threads isNative: true.
- sink.AlarmConditionCalls.ShouldHaveSingleItem()
- .ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true));
- sink.VariableCalls.ShouldNotContain(v => v.NodeId == alarmNodeId);
- }
-
- /// Phase B WS-3 — an alarm-bearing equipment tag WITH a FolderPath still gets its
- /// sub-folder created, and its condition is parented to that sub-folder (not the equipment folder),
- /// using the folder-scoped NodeId.
- [Fact]
- public void MaterialiseEquipmentTags_alarm_bearing_tag_with_FolderPath_conditions_under_subfolder()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentTags = new[]
- {
- new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "Diagnostics", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500)),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
-
- // The sub-folder is still created for an alarm tag with a FolderPath.
- sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
- // Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable.
- var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp");
- // A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true.
- sink.AlarmConditionCalls.ShouldHaveSingleItem()
- .ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true));
- sink.VariableCalls.ShouldBeEmpty();
- }
-
- /// Phase C Task 2 — the applier resolves the historian tagname per value tag and threads it
- /// to EnsureVariable: a historized tag with NO override falls back to its FullName; a
- /// historized tag WITH an override passes the override verbatim; a non-historized tag passes null.
- [Fact]
- public void MaterialiseEquipmentTags_resolves_historian_tagname_default_override_and_null()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentTags = new[]
- {
- // Historized, no override ⇒ tagname defaults to FullName ("T.A").
- new EquipmentTagPlan("tag-def", "eq-1", "drv", FolderPath: "", Name: "ADefault", DataType: "Float",
- FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null),
- // Historized, override ⇒ tagname is the override ("WW.Override"), NOT FullName.
- new EquipmentTagPlan("tag-ovr", "eq-1", "drv", FolderPath: "", Name: "BOverride", DataType: "Float",
- FullName: "T.B", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Override"),
- // Not historized ⇒ tagname is null.
- new EquipmentTagPlan("tag-no", "eq-1", "drv", FolderPath: "", Name: "CPlain", DataType: "Float",
- FullName: "T.C", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
-
- var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname);
- byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName
- byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim
- byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null
- }
-
- /// Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to
- /// FullName (the resolve uses string.IsNullOrWhiteSpace, not just null).
- [Fact]
- public void MaterialiseEquipmentTags_blank_override_falls_back_to_full_name()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentTags = new[]
- {
- new EquipmentTagPlan("tag-blank", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
- FullName: "40001", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: " "),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
-
- var call = sink.HistorianCalls.ShouldHaveSingleItem();
- call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
- call.HistorianTagname.ShouldBe("40001");
- }
-
- /// Array-support Task 2 — an with IsArray: true,
- /// ArrayLength: 16 flowing through must
- /// forward BOTH flags verbatim to the sink's EnsureVariable. Guards against arg-order swaps or
- /// accidental drops in the wire-through.
- [Fact]
- public void MaterialiseEquipmentTags_array_plan_forwards_isArray_and_arrayLength_to_sink()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentTags = new[]
- {
- new EquipmentTagPlan("tag-arr", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
- FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
-
- var call = sink.ArrayCalls.ShouldHaveSingleItem();
- call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer"));
- call.IsArray.ShouldBeTrue();
- call.ArrayLength.ShouldBe(16u);
- }
-
- /// Array-support Task 2 — a scalar (IsArray: false,
- /// ArrayLength: null) must pass isArray == false through to the sink. Guards against a
- /// default flip that would silently materialise scalar tags as 1-D arrays.
- [Fact]
- public void MaterialiseEquipmentTags_scalar_plan_forwards_isArray_false_to_sink()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentTags = new[]
- {
- new EquipmentTagPlan("tag-scalar", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
- FullName: "40002", Writable: false, Alarm: null, IsArray: false, ArrayLength: null),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
-
- var call = sink.ArrayCalls.ShouldHaveSingleItem();
- call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
- call.IsArray.ShouldBeFalse();
- call.ArrayLength.ShouldBeNull();
- }
-
- /// Review M-1 — an array equipment tag authored with Writable: true must be
- /// materialised as READ-ONLY (writable == false) because array writes are out of scope
- /// (Phase 4c read-only surface). The driver write path does not handle arrays and would crash
- /// (e.g. S7 BoxValueForWrite). Guards against a future refactor that accidentally enables the
- /// writable path for arrays.
- [Fact]
- public void MaterialiseEquipmentTags_array_writable_true_is_forced_read_only()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentTags = new[]
- {
- // Authored ReadWrite AND IsArray — the applier must clamp to read-only.
- new EquipmentTagPlan("tag-arr-rw", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
- FullName: "40001", Writable: true, Alarm: null, IsArray: true, ArrayLength: 8u),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
-
- // writable must be false (array writes out of scope), isArray must be true (forwarded verbatim).
- var varCall = sink.VariableCalls.ShouldHaveSingleItem();
- varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true
- var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
- arrCall.IsArray.ShouldBeTrue();
- arrCall.ArrayLength.ShouldBe(8u);
- }
-
- /// Review M-1 regression — a scalar tag authored with Writable: true must still
- /// be materialised as read/write (writable == true). The array-clamp must NOT affect
- /// scalar tags.
- [Fact]
- public void MaterialiseEquipmentTags_scalar_writable_true_stays_writable()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentTags = new[]
- {
- // Authored ReadWrite, scalar — must pass through writable: true unchanged.
- new EquipmentTagPlan("tag-scalar-rw", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
- FullName: "40002", Writable: true, Alarm: null, IsArray: false, ArrayLength: null),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
-
- var varCall = sink.VariableCalls.ShouldHaveSingleItem();
- varCall.Writable.ShouldBeTrue(); // scalar: writable unchanged
- var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
- arrCall.IsArray.ShouldBeFalse();
- }
-
- /// Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
- /// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the
- /// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create
- /// the equipment folder (no sub-folder when FolderPath is empty).
- [Fact]
- public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentVirtualTags = new[]
- {
- new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
- Expression: "ctx.GetTag(\"x\") * 60", DependencyRefs: new[] { "x" }),
- },
- };
-
- applier.MaterialiseEquipmentVirtualTags(composition);
-
- sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
- // VirtualTags are computed outputs — always read-only (Writable: false).
- sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
- // Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
- sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm"));
- }
-
- /// Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and
- /// the equipment-VirtualTag pass is byte-identical to V3NodeIds.Uns — the
- /// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty
- /// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test
- /// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the
- /// shared helper fails here.
- [Fact]
- public void Materialised_variable_node_ids_match_shared_EquipmentNodeIds_formula()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentTags = new[]
- {
- new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
- new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
- },
- EquipmentVirtualTags = new[]
- {
- new EquipmentVirtualTagPlan("vt-flat", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64",
- Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
- new EquipmentVirtualTagPlan("vt-nested", "eq-2", FolderPath: "Calc", Name: "Avg", DataType: "Float64",
- Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }),
- },
- };
-
- applier.MaterialiseEquipmentTags(composition);
- applier.MaterialiseEquipmentVirtualTags(composition);
-
- var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList();
- nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed"));
- nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
- nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency"));
- nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg"));
- }
-
- /// Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
- /// (one EnsureVariable each, no NodeId collision), parented to the equipment folder.
- [Fact]
- public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentVirtualTags = new[]
- {
- new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
- Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
- new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "load-pct", DataType: "Float64",
- Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }),
- },
- };
-
- applier.MaterialiseEquipmentVirtualTags(composition);
-
- sink.FolderCalls.ShouldBeEmpty();
- sink.VariableCalls.Count.ShouldBe(2);
- sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
- sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64", false));
- }
-
- /// T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
- /// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS
- /// disabled alarms.
- [Fact]
- public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var composition = new AddressSpaceComposition(
- Array.Empty(), Array.Empty(), Array.Empty())
- {
- EquipmentScriptedAlarms = new[]
- {
- new EquipmentScriptedAlarmPlan(
- ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm",
- Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;",
- DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: true),
- new EquipmentScriptedAlarmPlan(
- ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition",
- Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;",
- DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: false),
- },
- };
-
- applier.MaterialiseScriptedAlarms(composition);
-
- // Only the enabled alarm is materialised; the disabled one is skipped entirely.
- // A SCRIPTED alarm: the call-site threads isNative: false (guards against a native/scripted swap).
- sink.AlarmConditionCalls.ShouldHaveSingleItem()
- .ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, false));
- }
-
- /// Task 4 — MaterialiseDiscoveredNodes ensures the discovered folders PARENT-FIRST (ordered by
- /// depth = '/' count) and the discovered variables at their folder-scoped NodeIds/parents, with variables
- /// created READ-ONLY (writable == false), then raises EXACTLY ONE NodeAdded model-change under the
- /// equipment root. Folders are passed in REVERSE (child-first) to prove the applier re-orders them
- /// parent-first before ensuring (a child folder's parent must exist first).
- [Fact]
- public void MaterialiseDiscoveredNodes_ensures_folders_parent_first_read_only_variables_and_raises_model_change_once()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- // Child folder listed BEFORE its parent — the applier must re-order parent-first.
- var folders = new[]
- {
- new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
- new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
- };
- var variables = new[]
- {
- new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
- "String", Writable: false, IsArray: false, ArrayLength: null),
- };
-
- applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
-
- // Folders ensured parent-first regardless of input order (shallowest depth first).
- sink.FolderCalls.Select(f => f.NodeId).ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
- sink.FolderCalls.ShouldContain(("EQ-1/FOCAS", "EQ-1", "FOCAS"));
- sink.FolderCalls.ShouldContain(("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"));
-
- // Variable ensured at its folder-scoped NodeId, parented to its sub-folder, READ-ONLY.
- sink.VariableCalls.ShouldHaveSingleItem()
- .ShouldBe(("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", "String", false));
-
- // Exactly one NodeAdded model-change, announced under the equipment root.
- sink.ModelChangeCalls.ShouldHaveSingleItem().ShouldBe("EQ-1");
- }
-
- /// Task 4 — a discovered array variable (rare) authored Writable: true is forced
- /// READ-ONLY (mirrors MaterialiseEquipmentTags: the driver write path can't handle arrays), while the
- /// IsArray / ArrayLength flags are forwarded verbatim to the sink.
- [Fact]
- public void MaterialiseDiscoveredNodes_array_variable_is_forced_read_only()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var variables = new[]
- {
- new DiscoveredVariable("EQ-1/FOCAS/Buffer", "EQ-1", "Buffer", "Int16",
- Writable: true, IsArray: true, ArrayLength: 8u),
- };
-
- applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty(), variables);
-
- var varCall = sink.VariableCalls.ShouldHaveSingleItem();
- varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true
- var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
- arrCall.IsArray.ShouldBeTrue();
- arrCall.ArrayLength.ShouldBe(8u);
- }
-
- /// Task 4 — re-applying the SAME discovered plan is idempotent-SAFE: it does not throw, the
- /// distinct folder/variable set the applier issues per pass is stable (the real sink early-returns on
- /// existing nodes), and a model-change is raised once PER call (twice across two calls).
- [Fact]
- public void MaterialiseDiscoveredNodes_is_idempotent_safe_on_repeated_application()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- var folders = new[]
- {
- new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
- new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
- };
- var variables = new[]
- {
- new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
- "String", Writable: false, IsArray: false, ArrayLength: null),
- };
-
- applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
- Should.NotThrow(() => applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables));
-
- // Each pass re-issues the same parent-first ensures (the real sink dedups via early-return); the
- // DISTINCT set the applier produces is stable across re-applies.
- sink.FolderCalls.Select(f => f.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
- sink.VariableCalls.Select(v => v.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS/Identity/SeriesNumber" });
- // One model-change per call ⇒ two across two calls.
- sink.ModelChangeCalls.ShouldBe(new[] { "EQ-1", "EQ-1" });
- }
-
- /// Task 4 — empty input (no folders, no variables) returns WITHOUT touching the sink: no
- /// EnsureFolder/EnsureVariable and, crucially, NO NodeAdded model-change.
- [Fact]
- public void MaterialiseDiscoveredNodes_empty_input_does_not_touch_sink()
- {
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
-
- applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty(), Array.Empty());
-
- sink.FolderCalls.ShouldBeEmpty();
- sink.VariableCalls.ShouldBeEmpty();
- sink.ModelChangeCalls.ShouldBeEmpty();
- }
/// R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier
/// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs
deleted file mode 100644
index f1da0dfd..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
-using Shouldly;
-using Xunit;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
-
-[Trait("Category", "Unit")]
-public sealed class CapturingAddressSpaceBuilderTests
-{
- [Fact]
- public void Records_nested_path_segments_full_reference_and_metadata()
- {
- var b = new CapturingAddressSpaceBuilder();
- var focas = b.Folder("FOCAS", "FOCAS");
- var device = focas.Folder("10.0.0.5:8193", "cnc");
- var identity = device.Folder("Identity", "Identity");
- identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo(
- FullName: "10.0.0.5:8193/Identity/SeriesNumber",
- DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null,
- SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false));
-
- b.Nodes.Count.ShouldBe(1);
- var n = b.Nodes[0];
- n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" });
- n.BrowseName.ShouldBe("SeriesNumber");
- n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber");
- n.DataType.ShouldBe(DriverDataType.String);
- n.Writable.ShouldBeFalse(); // ViewOnly -> read-only
- }
-
- [Fact]
- public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink()
- {
- var b = new CapturingAddressSpaceBuilder();
- var f = b.Folder("FOCAS", "FOCAS");
- f.AddProperty("Manufacturer", DriverDataType.String, "FANUC"); // ignored, no throw
- var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null,
- SecurityClassification.ViewOnly, false, IsAlarm: true));
- var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null));
- sink.ShouldNotBeNull(); // no-op sink, alarms out of scope
- b.Nodes.Count.ShouldBe(1);
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs
index c8d5ca33..9632ae74 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs
@@ -18,17 +18,11 @@ internal static class DarkAddressSpaceReasons
"empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " +
"intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests.";
- /// Discovered-node INJECTION is dormant in v3 (Wave-B review M1). The v2 path grafted a driver's
- /// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered
- /// raw tags are authored explicitly via the Batch-2 /raw browse-commit flow. HandleDiscoveredNodes
- /// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection
- /// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by
- /// DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3.
- public const string DiscoveryInjectionDormantV3 =
- "v3: discovered-node injection is DORMANT (Wave-B review M1) — equipment no longer binds a driver, so the " +
- "v2 FixedTree-under-equipment graft has no live path. Discovered raw tags are authored via the /raw " +
- "browse-commit flow. HandleDiscoveredNodes short-circuits (pinned by Discovered_nodes_are_ignored_dormant_in_v3); " +
- "re-migrating injection onto the raw device subtree is a separate follow-up.";
+ // DiscoveryInjectionDormantV3 was removed with the injection path itself (§8.2, Gitea #507). The 18
+ // tests it skipped were v2 characterization tests — they asserted an equipment-rooted graft
+ // (EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they were deleted rather than unskipped.
+ // Discovered raw tags are authored through the /raw browse-commit flow; IRediscoverable now surfaces a
+ // re-browse prompt instead (DriverInstanceActorRediscoverySignalTests).
/// Equipment↔device host binding is architecturally retired in v3.
public const string EquipmentDeviceBindingRetired =
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs
deleted file mode 100644
index cdc2dca0..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs
+++ /dev/null
@@ -1,119 +0,0 @@
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
-using Shouldly;
-using Xunit;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
-
-[Trait("Category", "Unit")]
-public sealed class DiscoveredNodeMapperTests
-{
- private static DiscoveredNode Node(string[] path, string name, string fullRef,
- DriverDataType dt = DriverDataType.Float64, bool writable = false)
- => new(path, name, name, fullRef, dt, false, null, writable, false);
-
- [Fact]
- public void Maps_under_equipment_collapsing_single_device_folder()
- {
- var nodes = new[]
- {
- Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
- Node(["FOCAS", "10.0.0.5:8193", "Axes", "X"], "AbsolutePosition", "10.0.0.5:8193/Axes/X/AbsolutePosition"),
- };
-
- var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet());
-
- result.Variables.Select(v => v.NodeId).ShouldBe(new[]
- {
- "EQ-1/FOCAS/Identity/SeriesNumber",
- "EQ-1/FOCAS/Axes/X/AbsolutePosition",
- }, ignoreOrder: true);
- result.Folders.Select(f => f.NodeId).ShouldContain("EQ-1/FOCAS/Axes/X");
- result.Folders.First(f => f.NodeId == "EQ-1/FOCAS/Axes/X").ParentNodeId.ShouldBe("EQ-1/FOCAS/Axes");
- result.RoutingByRef["10.0.0.5:8193/Identity/SeriesNumber"].ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
- result.Variables.First(v => v.NodeId.EndsWith("SeriesNumber")).Writable.ShouldBeFalse();
- }
-
- [Fact]
- public void Dedups_authored_refs()
- {
- var nodes = new[]
- {
- Node(["FOCAS", "10.0.0.5:8193"], "parts-count", "parts-count"),
- Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
- };
- var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet { "parts-count" });
- result.Variables.ShouldHaveSingleItem();
- result.Variables[0].NodeId.ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
- }
-
- [Fact]
- public void Does_not_collapse_when_two_devices_present()
- {
- var nodes = new[]
- {
- Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "a", DriverDataType.String),
- Node(["FOCAS", "10.0.0.6:8193", "Identity"], "SeriesNumber", "b", DriverDataType.String),
- };
- var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet());
- result.Variables.Select(v => v.NodeId).ShouldBe(new[]
- {
- "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber",
- "EQ-1/FOCAS/10.0.0.6:8193/Identity/SeriesNumber",
- }, ignoreOrder: true);
- }
-
- [Fact]
- public void Empty_input_yields_empty_plan()
- {
- var result = DiscoveredNodeMapper.Map("EQ-1", Array.Empty(), authoredRefs: new HashSet());
- result.Folders.ShouldBeEmpty();
- result.Variables.ShouldBeEmpty();
- result.RoutingByRef.ShouldBeEmpty();
- }
-
- [Fact]
- public void Array_metadata_passes_through_unchanged()
- {
- var node = new DiscoveredNode(
- FolderPathSegments: ["FOCAS", "10.0.0.5:8193", "Axes"],
- BrowseName: "Positions",
- DisplayName: "Positions",
- FullReference: "10.0.0.5:8193/Axes/Positions",
- DataType: DriverDataType.Float64,
- IsArray: true,
- ArrayDim: 8u,
- Writable: false,
- IsHistorized: false);
-
- var result = DiscoveredNodeMapper.Map("EQ-1", new[] { node }, authoredRefs: new HashSet());
-
- result.Variables.ShouldHaveSingleItem();
- result.Variables[0].IsArray.ShouldBeTrue();
- result.Variables[0].ArrayLength.ShouldBe(8u);
- }
-
- [Theory]
- // Mirror OtOpcUaNodeManager.ResolveBuiltInDataType's accepted string set: Float32 -> "Float",
- // Float64 -> "Double", Reference (Galaxy attr ref encoded as a string) -> "String". The pass-through
- // members must keep their enum name so the node manager resolves them to the matching built-in type.
- [InlineData(DriverDataType.Float64, "Double")]
- [InlineData(DriverDataType.Float32, "Float")]
- [InlineData(DriverDataType.Reference, "String")]
- [InlineData(DriverDataType.Boolean, "Boolean")]
- [InlineData(DriverDataType.Int16, "Int16")]
- [InlineData(DriverDataType.Int32, "Int32")]
- [InlineData(DriverDataType.Int64, "Int64")]
- [InlineData(DriverDataType.UInt16, "UInt16")]
- [InlineData(DriverDataType.UInt32, "UInt32")]
- [InlineData(DriverDataType.UInt64, "UInt64")]
- [InlineData(DriverDataType.String, "String")]
- [InlineData(DriverDataType.DateTime, "DateTime")]
- public void DataType_maps_to_node_manager_builtin_string(DriverDataType dt, string expected)
- {
- var nodes = new[] { Node(["FOCAS", "10.0.0.5:8193", "Identity"], "Value", "10.0.0.5:8193/Identity/Value", dt) };
- var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet());
- result.Variables.ShouldHaveSingleItem();
- result.Variables[0].DataType.ShouldBe(expected);
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs
deleted file mode 100644
index b692f994..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs
+++ /dev/null
@@ -1,353 +0,0 @@
-using System.Collections.Concurrent;
-using System.Text.Json;
-using Akka.Actor;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Logging.Abstractions;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
-using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
-using ZB.MOM.WW.OtOpcUa.Commons.Types;
-using ZB.MOM.WW.OtOpcUa.Configuration;
-using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
-using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-using ZB.MOM.WW.OtOpcUa.OpcUaServer;
-using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
-using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
-using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
-
-///
-/// Task 9 — the focused END-TO-END proof that a driver-discovered FixedTree node is grafted into the
-/// served Equipment OPC UA address space and a polled value reaches it. Unlike the Task-7/8 suites
-/// (which wire the OPC UA publish side as a and assert on the
-/// intercepted /
-/// messages), this suite wires the FULL real chain:
-///
-///
-/// a real (resolves equipment, maps via
-/// , extends the live-value routing map, caches the plan);
-/// a real as its opcUaPublishActor seam (so
-/// MaterialiseDiscoveredNodes + AttributeValueUpdate are actually handled, not
-/// intercepted);
-/// a real over a recording
-/// (so the materialise + value-write reach the sink).
-///
-///
-///
-/// The assertions are therefore made on the SINK's recorded EnsureVariable /
-/// RaiseNodesAddedModelChange / WriteValue calls — i.e. the discovered node was
-/// materialised through the real applier AND a published value surfaces
-/// at the mapped NodeId (in production this overwrites the BadWaitingForInitialData seed that
-/// OtOpcUaNodeManager.EnsureVariable stamps on a freshly-materialised variable; the recording
-/// sink does not model that seed, so the faithful assertion available here is that the live value
-/// lands Good at the same NodeId the materialise created).
-///
-///
-///
-/// Seam choices (faithful to the sibling suites). Discovery is driven by Telling the host
-/// directly, and the polled value by Telling
-/// directly — exactly the seams the Task-7/8
-/// tests use (there is no test seam to drive a real poll loop through a
-/// child to Connected, and the spawned child is a ). The publish
-/// actor is wired WITHOUT a dbFactory, so the host's apply-time
-/// falls back to a raw sink.RebuildAddressSpace() (no EnsureVariable); this keeps the
-/// ONLY EnsureVariable traffic on the sink the discovered-node materialise itself, so the
-/// mapped NodeId is unambiguous. The discovery-injection chain (mapper → applier → sink + routing
-/// map) is fully real.
-///
-///
-[Trait("Category", "Unit")]
-public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
-{
- private static readonly NodeId TestNode = NodeId.Parse("disc-e2e-node");
- private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
- private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
- private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
- private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);
-
- // The FixedTree node the driver "discovers": FOCAS//Identity/SeriesNumber, a String value,
- // whose FullReference differs from any authored tag so the mapper keeps it (does not shadow an authored
- // node). The single device-host folder collapses, so it materialises at EQ-1/FOCAS/Identity/SeriesNumber.
- private const string FixedTreeRef = "10.0.0.5:8193/Identity/SeriesNumber";
- private const string FixedTreeDisplayName = "SeriesNumber";
-
- // The DETERMINISTIC NodeId the chain must place the FixedTree node at: EQ-1 (the bound equipment root) +
- // the COLLAPSED folder path. The mapper's device-folder collapse drops the single shared device-host
- // segment ("10.0.0.5:8193"), so FolderPathSegments ["FOCAS","10.0.0.5:8193","Identity"] + browse
- // "SeriesNumber" → "EQ-1/FOCAS/Identity/SeriesNumber" (per EquipmentNodeIds.Variable). Asserting this
- // EXACT NodeId closes the loop on the collapse rule — a prefix/StartsWith check would still pass if the
- // collapse broke (e.g. "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber").
- private const string ExpectedFixedTreeNodeId = "EQ-1/FOCAS/Identity/SeriesNumber";
-
- private static DiscoveredNode[] FixedTreeNodes() => new[]
- {
- new DiscoveredNode(
- FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
- BrowseName: "SeriesNumber",
- DisplayName: FixedTreeDisplayName,
- FullReference: FixedTreeRef,
- DataType: DriverDataType.String,
- IsArray: false,
- ArrayDim: null,
- Writable: false,
- IsHistorized: false),
- };
-
- ///
- /// End-to-end #1: the discovered FixedTree node appears at the equipment AND a polled value flows
- /// Good. Drives the real host (deployment applied, real child spawned, discovery reported) wired to a
- /// real publish actor + real applier + recording sink, then asserts:
- /// (a) the sink recorded an EnsureVariable for the FixedTree node under EQ-1 (materialised
- /// through the REAL applier — the node now exists in the served address space), with a
- /// RaiseNodesAddedModelChange under EQ-1 so connected clients refresh;
- /// (b) after an for the FixedTree ref, the
- /// sink recorded a WriteValue at THAT SAME NodeId carrying the value with
- /// — proving the live value routed end-to-end and (in production)
- /// overwrote the BadWaitingForInitialData seed.
- ///
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good()
- {
- var db = NewInMemoryDbFactory();
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var (host, sink, _) = SpawnHostWithRealPublishActor(db, deploymentId);
-
- // Driver reports its captured FixedTree (the faithful Task-7/8 seam).
- host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes()));
-
- // (a) The discovered variable was materialised through the REAL applier onto the sink, at the EXACT
- // collapsed NodeId under the bound equipment root (proves the mapper's device-folder collapse).
- AwaitAssert(() =>
- {
- var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName);
- v.NodeId.ShouldBe(ExpectedFixedTreeNodeId); // EnsureVariable at the exact collapsed NodeId under EQ-1
- v.DataType.ShouldBe("String"); // mapper carried the driver type through to the sink
- v.Writable.ShouldBeFalse(); // discovered nodes are read-only
- sink.ModelChanges.ShouldContain("EQ-1"); // NodeAdded announced under the equipment
- }, duration: Timeout);
-
- // (b) A value published for the FixedTree ref routes to THAT exact NodeId and lands Good — the live
- // value flowed end-to-end (host routing map → publish actor → applier-backing sink WriteValue).
- host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-12345", OpcUaQuality.Good, Ts));
-
- AwaitAssert(() =>
- {
- var write = sink.Values.SingleOrDefault(x => x.NodeId == ExpectedFixedTreeNodeId);
- write.NodeId.ShouldBe(ExpectedFixedTreeNodeId);
- write.Value.ShouldBe("SN-12345");
- write.Quality.ShouldBe(OpcUaQuality.Good);
- write.Ts.ShouldBe(Ts);
- }, duration: Timeout);
- }
-
- ///
- /// End-to-end #2 (Task 8 survival): the injected FixedTree node + its live-value route SURVIVE a
- /// redeploy. After the first injection materialises + a value flows Good, a SECOND deployment (new
- /// revision, same d1 → EQ-1 binding) re-runs PushDesiredSubscriptions — which clears the
- /// routing maps and re-pushes an authored-only subscription set; the Task-8 tail re-apply re-grafts
- /// the cached discovered plan. Asserts the sink records the FixedTree EnsureVariable AGAIN
- /// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL
- /// WriteValues Good there (the routing map was rebuilt, not left empty by the Clear()).
- ///
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Discovered_node_and_value_survive_a_redeploy()
- {
- var db = NewInMemoryDbFactory();
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var (host, sink, coordinator) = SpawnHostWithRealPublishActor(db, deploymentId);
-
- host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes()));
-
- // First injection: the FixedTree node materialises at the EXACT collapsed NodeId under EQ-1.
- AwaitAssert(
- () => sink.Variables.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && x.DisplayName == FixedTreeDisplayName),
- duration: Timeout);
-
- // First value flows Good (pre-redeploy baseline).
- host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-AAA", OpcUaQuality.Good, Ts));
- AwaitAssert(
- () => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-AAA") && x.Quality == OpcUaQuality.Good),
- duration: Timeout);
-
- var ensureVarCountBefore = sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId);
-
- // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding) — re-runs PushDesiredSubscriptions
- // (clears + rebuilds the routing maps) then the Task-8 tail re-applies the cached discovered plan.
- var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
- host.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
-
- // (a) The cached discovered plan was RE-MATERIALISED at the SAME exact NodeId after the redeploy rebuild.
- AwaitAssert(
- () => sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId).ShouldBeGreaterThan(ensureVarCountBefore),
- duration: Timeout);
-
- // (b) A value published AFTER the redeploy STILL routes to the exact NodeId and lands Good — the
- // live-value routing map was rebuilt by the re-apply (not lost when PushDesiredSubscriptions cleared it).
- var tsAfter = Ts.AddSeconds(5);
- host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-BBB", OpcUaQuality.Good, tsAfter));
- AwaitAssert(
- () => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-BBB") && x.Quality == OpcUaQuality.Good),
- duration: Timeout);
- }
-
- /// Spawns the real chain — recording sink → real → real
- /// (the host's opcUaPublishActor seam) → real
- /// backed by a — dispatches the
- /// deployment, and waits for the Applied ACK so _lastComposition + the live child + the initial
- /// subscribe pass have completed before discovery is injected. The publish actor is wired with the
- /// applier but NO dbFactory, so its apply-time RebuildAddressSpace is a raw sink rebuild (no EnsureVariable)
- /// and the only EnsureVariable traffic is the discovered-node materialise itself.
- private (IActorRef Host, RecordingSink Sink, Akka.TestKit.TestProbe Coordinator) SpawnHostWithRealPublishActor(
- IDbContextFactory db, DeploymentId deploymentId)
- {
- var coordinator = CreateTestProbe();
- var vtHost = CreateTestProbe();
-
- var sink = new RecordingSink();
- var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
- var publish = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
-
- var host = Sys.ActorOf(DriverHostActor.Props(
- db, TestNode, coordinator.Ref,
- driverFactory: new SubscribingDriverFactory("Modbus"),
- localRoles: new HashSet { "driver" },
- opcUaPublishActor: publish,
- virtualTagHostOverride: vtHost.Ref));
-
- host.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
-
- return (host, sink, coordinator);
- }
-
- /// Seeds a Sealed deployment whose artifact carries the minimal arrays needed to project
- /// equipment tags + a real (non-stubbed) child for each driver
- /// (mirrors DriverHostActorDiscoveryTests.SeedDeploymentWithEquipmentTags). An authored value tag
- /// both sets _lastComposition and binds the driver → equipment (the only way the host resolves the
- /// equipment a discovered node grafts under).
- private static DeploymentId SeedDeploymentWithEquipmentTags(
- IDbContextFactory db, RevisionHash rev,
- params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
- {
- var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
-
- var artifact = JsonSerializer.SerializeToUtf8Bytes(new
- {
- Namespaces = new[]
- {
- new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
- },
- DriverInstances = driverIds.Select(d => new
- {
- DriverInstanceRowId = Guid.NewGuid(),
- DriverInstanceId = d,
- Name = d,
- DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
- Enabled = true,
- DriverConfig = "{}",
- NamespaceId = "ns-eq",
- }).ToArray(),
- Tags = tags.Select((t, i) => new
- {
- TagId = $"tag-{i}",
- EquipmentId = t.Equip,
- DriverInstanceId = t.Driver,
- Name = t.Name,
- FolderPath = t.Folder,
- DataType = "Double",
- TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
- }).ToArray(),
- });
-
- 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, so a real (non-stubbed) child is spawned for the driver and
- /// the host's subscribe path is exercised (mirrors
- /// DriverHostActorDiscoveryTests.SubscribingDriverFactory).
- private sealed class SubscribingDriverFactory : IDriverFactory
- {
- private readonly string _supportedType;
- private readonly SubscribableStubDriver _driver = new();
- public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; }
-
- ///
- public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
- string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null;
-
- ///
- public IReadOnlyCollection SupportedTypes => new[] { _supportedType };
- }
-
- /// Recording — captures the EnsureFolder / EnsureVariable /
- /// WriteValue / RaiseNodesAddedModelChange calls the real applier + publish actor drive, so the test can
- /// assert the discovered node was materialised and a value landed Good at its NodeId. Thread-safe (the
- /// publish actor runs on an Akka dispatcher thread, the test asserts from the test thread).
- private sealed class RecordingSink : IOpcUaAddressSpaceSink
- {
- private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName)> _folders = new();
- private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> _variables = new();
- private readonly ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> _values = new();
- private readonly ConcurrentQueue _modelChanges = new();
-
- /// Gets a snapshot of the recorded EnsureFolder calls.
- public List<(string NodeId, string? ParentNodeId, string DisplayName)> Folders => _folders.ToList();
- /// Gets a snapshot of the recorded EnsureVariable calls.
- public List<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> Variables => _variables.ToList();
- /// Gets a snapshot of the recorded WriteValue calls.
- public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values => _values.ToList();
- /// Gets a snapshot of the recorded RaiseNodesAddedModelChange announcements.
- public List ModelChanges => _modelChanges.ToList();
- /// Gets the count of raw RebuildAddressSpace calls (apply-time rebuild fallback).
- public int RebuildCalls;
-
- /// Records a live-value write.
- public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
- => _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
-
- /// No-op: alarm writes are not exercised by this suite.
- public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
- public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
-
- /// No-op: alarm materialise is not exercised by this suite.
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
-
- /// Records an EnsureFolder call.
- public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
- => _folders.Enqueue((folderNodeId, parentNodeId, displayName));
-
- /// Records an EnsureVariable call.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
- => _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
-
- /// Records a raw rebuild (the apply-time fallback when no dbFactory is wired).
- public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
-
- /// Records a NodeAdded model-change announcement.
- public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId);
- public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
- public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
- }
-}
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
deleted file mode 100644
index 2054eb51..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
+++ /dev/null
@@ -1,1327 +0,0 @@
-using System.Text.Json;
-using Akka.Actor;
-using Microsoft.EntityFrameworkCore;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
-using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
-using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
-using ZB.MOM.WW.OtOpcUa.Commons.Types;
-using ZB.MOM.WW.OtOpcUa.Configuration;
-using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
-using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
-using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
-using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
-
-///
-/// Verifies the discovered-node injection wired into (Task 7): when a
-/// driver child reports a captured FixedTree via ,
-/// the host resolves the bound equipment from the authored composition, maps the nodes under it via
-/// , materialises them on the OPC UA publish side
-/// (), extends the live-value routing map
-/// (_nodeIdByDriverRef), and merges the FixedTree refs into the driver's desired subscription set
-/// ().
-///
-///
-/// Drives a real apply through the existing harness (same artifact shape as
-/// DriverHostActorLiveValueTests / DriverHostActorWriteRoutingTests) so
-/// _lastComposition is set and a real (non-stubbed) child
-/// is spawned for d1. The child is backed by the shared
-/// (records LastSubscribedRefs/SubscribeCount, exactly as
-/// DriverInstanceActorTests asserts) so the merged subscription is observable; the OPC UA
-/// publish actor is a (as in
-/// DriverHostActorLiveValueTests) so the materialise + the post-injection value route are
-/// observable. There is no test seam to inject a probe AS a driver child, so this is the faithful
-/// end-to-end approach the harness allows.
-///
-///
-[Trait("Category", "Unit")]
-public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
-{
- private static readonly NodeId TestNode = NodeId.Parse("driver-disc-test");
- private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
- private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
- private static readonly RevisionHash RevC = RevisionHash.Parse(new string('c', 64));
- private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
- private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);
-
- /// v3 Batch 4 (review M1) — the ONE live dormancy pin: discovered-node injection is short-circuited.
- /// A driver reports a FixedTree via , but the host must
- /// NOT materialise anything (no reaches the publish
- /// side) — discovered raw tags are authored via the /raw browse-commit flow, not injected at runtime.
- /// The skipped v2 injection scenarios below are the counterpart of this guard.
- [Fact]
- public void Discovered_nodes_are_ignored_dormant_in_v3()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
- var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
-
- actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
- {
- new DiscoveredNode(
- FolderPathSegments: new[] { "FOCAS", "Identity" },
- BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
- DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
- Writable: false, IsHistorized: false),
- }));
-
- // Dormant: no discovered nodes are materialised (the guard short-circuits before any route/materialise).
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
- }
-
- /// A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the
- /// bound equipment: (a) the publish side receives
- /// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the
- /// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the
- /// live-value routing map was extended).
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- // One authored value tag: equipment EQ-1, driver d1, FullName "40001" — this both sets
- // _lastComposition AND binds d1 → EQ-1 (the only way the equipment is resolved, since EquipmentNode
- // carries no DriverInstanceId).
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
-
- // A FixedTree discovered node whose FullReference DIFFERS from the authored tag's FullName, so the
- // mapper keeps it (it does not shadow an authored ref).
- var discovered = 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),
- };
-
- actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered));
-
- // (a) The publish side materialises the discovered folders + variables UNDER the equipment root "EQ-1".
- var materialise = publish.ExpectMsg(Timeout);
- materialise.EquipmentRootNodeId.ShouldBe("EQ-1");
- materialise.Variables.Count.ShouldBe(1);
- materialise.Folders.Count.ShouldBeGreaterThan(0);
- var fixedTreeNodeId = materialise.Variables[0].NodeId;
-
- // (b) The driver re-subscribed the UNION of the authored value ref AND the FixedTree ref. The union
- // push is the LAST SetDesiredSubscriptions, so the most recent subscribe carries both.
- AwaitAssert(() =>
- {
- var refs = factory.LastSubscribedRefs;
- refs.ShouldNotBeNull();
- refs!.ShouldContain("40001");
- refs.ShouldContain("ft-ref-1");
- }, duration: Timeout);
-
- // (c) A value published for the FixedTree ref now routes to the mapped FixedTree NodeId — proving the
- // _nodeIdByDriverRef live-value map was extended by the injection.
- 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);
- update.Quality.ShouldBe(OpcUaQuality.Good);
- 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(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- 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);
- }
-
- /// 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_with_no_device_host_warn_skips()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- // 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"));
-
- 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 — 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 DeviceConfigIntent.NormalizeHost match.
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- 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
- // segment and reads EQ-n/FOCAS//. Assert the EXACT collapsed path (depth + leaf) so a
- // collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) —
- // fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY
- // (a lowercase-only "h1" check would miss a leaked raw "H1").
- eqANodeId.ShouldBe(V3NodeIds.Uns("EQ-A", "FOCAS", "Identity", "Model"));
- eqANodeId.ShouldNotContain("H1");
- eqANodeId.ShouldNotContain("h1");
- eqBNodeId.ShouldBe(V3NodeIds.Uns("EQ-B", "FOCAS", "Status", "Run"));
- 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(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- 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));
- }
-
- /// Warn-spam taming (follow-up E, part 2): an unmatched device-host partition WARNS exactly ONCE,
- /// then the identical repeated re-discovery passes (the driver re-discovers ~15×/connect, re-sending the
- /// same set) are quiet — proving ShouldWarnPartition's per-driver signature dedup. The repeat is
- /// logged at Debug, which the suite's loglevel = WARNING HOCON suppresses at source, so EventFilter
- /// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on
- /// pass 1; pass 2's matched routing is short-circuited by PlansRoutingEqual.)
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet()
- {
- 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);
-
- var discovered = 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),
- };
-
- // Pass 1: the unmatched "h3" partition warns EXACTLY once.
- EventFilter.Warning(contains: "discovered device-host partition(s) skipped")
- .Expect(1, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)));
-
- // Drain the matched EQ-A + EQ-B grafts from pass 1 so the assertion below is unambiguous.
- publish.ExpectMsg(Timeout);
- publish.ExpectMsg(Timeout);
-
- // Pass 2: the IDENTICAL set (same revision) does NOT warn again — the repeat is Debug (suppressed by the
- // suite's WARNING loglevel), so EventFilter sees ZERO further matching warnings.
- EventFilter.Warning(contains: "discovered device-host partition(s) skipped")
- .Expect(0, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)));
- }
-
- /// 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).
- [Fact]
- public void DiscoveredNodes_before_any_apply_are_ignored()
- {
- var db = NewInMemoryDbFactory();
- var coordinator = CreateTestProbe();
- var publish = CreateTestProbe();
- var vtHost = CreateTestProbe();
-
- // No deployment dispatched ⇒ Bootstrap enters Steady with no composition ⇒ _lastComposition is null.
- var actor = Sys.ActorOf(DriverHostActor.Props(
- db, TestNode, coordinator.Ref,
- driverFactory: new SubscribingDriverFactory("Modbus"),
- localRoles: new HashSet { "driver" },
- opcUaPublishActor: publish.Ref,
- virtualTagHostOverride: vtHost.Ref));
-
- 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),
- }));
-
- // No composition ⇒ no materialise (and no RebuildAddressSpace either, since nothing was applied).
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
- }
-
- /// Dedup: a discovered node whose FullReference equals an authored equipment tag's
- /// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs
- /// are materialised.
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Discovered_node_shadowing_an_authored_ref_is_not_injected()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
-
- // Two captured nodes: one SHADOWS the authored ref "40001" (must be dropped), one is genuinely new.
- actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
- {
- new DiscoveredNode(
- FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Registers" },
- BrowseName: "speed", DisplayName: "Speed", FullReference: "40001",
- DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
- Writable: false, IsHistorized: false),
- 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),
- }));
-
- // Exactly ONE variable materialised — the new "Model", not the authored-shadow "Speed".
- var materialise = publish.ExpectMsg(Timeout);
- materialise.Variables.Count.ShouldBe(1);
- materialise.Variables[0].DisplayName.ShouldBe("Model");
- }
-
- /// Idempotency / the unchanged-plan short-circuit: re-sending the SAME discovered set (the driver
- /// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to
- /// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising
- /// FixedTree still converges.
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
-
- var node1 = 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);
- var node2 = new DiscoveredNode(
- FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Status" },
- BrowseName: "Run", DisplayName: "Run", FullReference: "ft-ref-2",
- DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
- Writable: false, IsHistorized: false);
-
- // Pass 1: a new set ⇒ one materialise + a union re-subscribe.
- actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 }));
- publish.ExpectMsg(Timeout);
- AwaitAssert(() =>
- {
- var refs = factory.LastSubscribedRefs;
- refs.ShouldNotBeNull();
- refs!.ShouldContain("ft-ref-1");
- }, duration: Timeout);
- var subscribeCountAfterFirst = factory.SubscribeCount;
-
- // Pass 2: the IDENTICAL set ⇒ short-circuited (no materialise, no re-subscribe).
- actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 }));
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
- factory.SubscribeCount.ShouldBe(subscribeCountAfterFirst);
-
- // Pass 3: a GROWN set (superset) ⇒ re-applies (materialise again + re-subscribe with both refs).
- actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1, node2 }));
- publish.ExpectMsg(Timeout).Variables.Count.ShouldBe(2);
- AwaitAssert(() =>
- {
- factory.SubscribeCount.ShouldBeGreaterThan(subscribeCountAfterFirst);
- var refs = factory.LastSubscribedRefs;
- refs.ShouldNotBeNull();
- refs!.ShouldContain("ft-ref-1");
- refs.ShouldContain("ft-ref-2");
- }, duration: Timeout);
- }
-
- /// Task 8 survival: discovered (FixedTree) nodes injected after the first apply must SURVIVE a
- /// redeploy. A second deployment re-runs PushDesiredSubscriptions, which clears the live-value
- /// routing maps and re-pushes an authored-only subscription set; without the tail re-apply the injected
- /// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached
- /// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's
- /// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId.
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Discovered_nodes_survive_a_redeploy_rebuild()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);
-
- var discovered = 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),
- };
- actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered));
-
- // First injection: materialise #1 under EQ-1 — capture the mapped NodeId for the survival asserts.
- var materialise1 = publish.ExpectMsg(Timeout);
- materialise1.EquipmentRootNodeId.ShouldBe("EQ-1");
- materialise1.Variables.Count.ShouldBe(1);
- var fixedTreeNodeId = materialise1.Variables[0].NodeId;
-
- // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding so HandleDispatchFromSteady doesn't
- // short-circuit on an identical rev). This re-runs PushDesiredSubscriptions, which clears + rebuilds
- // the routing maps and re-pushes the authored-only subscription set — the exact path Task 8 self-heals.
- var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
- actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
-
- // The redeploy fires a fresh RebuildAddressSpace first (drain it) ...
- publish.ExpectMsg(Timeout);
-
- // (a) ... then the cached discovered plan is RE-MATERIALISED under EQ-1 (the Task-8 tail re-apply),
- // at the SAME NodeId the first injection placed it.
- var materialise2 = publish.ExpectMsg(Timeout);
- materialise2.EquipmentRootNodeId.ShouldBe("EQ-1");
- materialise2.Variables.Count.ShouldBe(1);
- materialise2.Variables[0].NodeId.ShouldBe(fixedTreeNodeId);
-
- // (b) The child's post-redeploy subscription STILL carries the FixedTree ref — it was re-merged onto
- // the freshly-cleared authored-only set, not dropped by the _nodeIdByDriverRef.Clear().
- AwaitAssert(() =>
- {
- var refs = factory.LastSubscribedRefs;
- refs.ShouldNotBeNull();
- refs!.ShouldContain("40001");
- refs.ShouldContain("ft-ref-1");
- }, duration: Timeout);
-
- // (c) A value published for the FixedTree ref STILL routes to its mapped NodeId — proving the
- // live-value routing map was rebuilt by the re-apply (not left empty after the Clear()).
- 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);
- update.Quality.ShouldBe(OpcUaQuality.Good);
- }
-
- /// Task 8 cache-drop: a redeploy whose composition no longer binds the driver to any equipment
- /// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a
- /// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell
- /// for the now-unresolved driver.
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Discovered_nodes_dropped_when_equipment_no_longer_resolves()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var (actor, publish, coordinator) = 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),
- }));
-
- // First injection materialises under EQ-1.
- publish.ExpectMsg(Timeout);
-
- // Apply a SECOND deployment that binds a DIFFERENT driver (d2 → EQ-2) and carries NO authored tags for
- // d1, so d1's equipment can no longer be resolved (equipmentIds.Count == 0) — the cache entry is dropped.
- var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
- (Equip: "EQ-2", Driver: "d2", FullName: "40002", Folder: (string?)null, Name: "speed2"));
- actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
-
- // The redeploy fires a fresh RebuildAddressSpace; after draining it, NO MaterialiseDiscoveredNodes is
- // re-told (the cached d1 plan was dropped because its equipment no longer resolves).
- publish.ExpectMsg(Timeout);
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
- }
-
- /// Task 8 rebind-guard: a redeploy that REBINDS the driver to a DIFFERENT equipment must DROP the
- /// cached discovered plan rather than re-graft EQ-1-scoped nodes under EQ-2. d1 still resolves to exactly
- /// one equipment (so the Count==0 drop does NOT fire), but the cached plan's NodeIds are scoped to the OLD
- /// equipment (EQ-1), so the StartsWith(equipmentId + "/") guard sees they no longer match EQ-2 and
- /// drops the entry. After the redeploy NO is
- /// re-told. (Complements , which
- /// covers the Count==0 branch; this covers the rebind/StartsWith branch.)
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var (actor, publish, coordinator) = 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),
- }));
-
- // First injection materialises under EQ-1 (the cached plan's NodeIds are scoped to EQ-1).
- publish.ExpectMsg(Timeout);
-
- // Apply a SECOND deployment where d1 is REBOUND to a DIFFERENT equipment EQ-2 (d1 still present + still
- // resolves to exactly one equipment, but the cached plan is scoped to EQ-1). The DriverConfig is
- // unchanged ("{}") so ReconcileDrivers does NOT restart d1 — exactly the config-unchanged rebind the
- // guard's known-limitation comment describes.
- var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
- (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
- actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
-
- // After draining the fresh RebuildAddressSpace, NO MaterialiseDiscoveredNodes is re-told — the cached
- // EQ-1-scoped plan was dropped by the rebind guard (its NodeId no longer starts with "EQ-2/").
- publish.ExpectMsg(Timeout);
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
- }
-
- /// Follow-up C, part 2: a CONFIG-UNCHANGED rebind must RE-TRIGGER discovery on the driver's child
- /// so the dropped FixedTree re-grafts under the NEW equipment on the next pass (rather than staying absent
- /// until the driver's next natural reconnect). Wires a REAL child (policy
- /// , ever-growing set) so the connect-time pass populates the
- /// per-equipment cache under EQ-1, then redeploys to rebind d1 → EQ-2 with the SAME (unchanged) DriverConfig
- /// (so ReconcileDrivers does NOT restart the child — exactly the config-unchanged rebind). The
- /// re-inject tail drops the stale EQ-1 entry and must send
- /// to d1's child. The trigger reaching the child is observed at the host level (the only faithful seam — there
- /// is no probe-as-driver-child seam): the child re-runs discovery (its DiscoverCount advances past the
- /// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks
- /// discovery) AND a fresh re-grafts the FixedTree
- /// under the new equipment EQ-2.
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Config_unchanged_rebind_re_triggers_discovery_on_the_child()
- {
- var db = NewInMemoryDbFactory();
- // A REAL ITagDiscovery child (Once policy) — so the connect-time pass populates the cache and a
- // TriggerRediscovery re-runs discovery, making the trigger observable at the host level.
- var factory = new DiscoverableSubscribingDriverFactory("Modbus");
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var coordinator = CreateTestProbe();
- var publish = CreateTestProbe();
- var vtHost = CreateTestProbe();
-
- var actor = Sys.ActorOf(DriverHostActor.Props(
- db, TestNode, coordinator.Ref,
- driverFactory: factory,
- localRoles: new HashSet { "driver" },
- opcUaPublishActor: publish.Ref,
- virtualTagHostOverride: vtHost.Ref));
-
- actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
- publish.ExpectMsg(Timeout);
-
- // The child connects and runs its single (Once) post-connect discovery pass, which the host grafts
- // under EQ-1 — this POPULATES the per-equipment cache (_discoveredByDriver[d1] = { EQ-1: plan }).
- publish.FishForMessage(
- m => m.EquipmentRootNodeId == "EQ-1", Timeout);
- AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout);
-
- // Redeploy: REBIND d1 from EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged so ReconcileDrivers
- // does NOT restart the child). The re-inject tail drops the stale EQ-1-scoped cache entry.
- var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
- (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
- actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
-
- // The drop must SEND DriverInstanceActor.TriggerRediscovery to d1's (still-Connected) child, which
- // re-runs discovery: DiscoverCount advances past the single connect pass — the observable proof the
- // trigger reached the child. (Pre-task: no trigger ⇒ Once already settled, child never reconnects ⇒
- // DiscoverCount stays 1 ⇒ this fails.)
- AwaitAssert(() => factory.DiscoverCount.ShouldBeGreaterThan(1), duration: TimeSpan.FromSeconds(10));
-
- // ... and the re-triggered pass re-grafts the FixedTree under the NEW equipment EQ-2 (the host resolves
- // d1 → EQ-2 against the new _lastComposition). The re-discovery re-populates the cache as
- // { EQ-2: plan-scoped-to-EQ-2 }.
- publish.FishForMessage(
- m => m.EquipmentRootNodeId == "EQ-2", Timeout);
- var discoverCountAfterRebind = factory.DiscoverCount; // 2 (connect pass + the rebind re-trigger pass)
-
- // Convergence: a FURTHER unchanged redeploy (SAME EQ-2 composition) must NOT drop again, NOT re-trigger,
- // and NOT advance discovery — proving the re-trigger CONVERGES and doesn't loop. The cached EQ-2 plan now
- // SURVIVES the re-inject tail (candidate + NodeIds still scoped to EQ-2), so EQ-2 simply re-materialises
- // (the Task-8 survivor re-apply) — but droppedAny stays false, so no TriggerRediscovery is sent and no
- // fresh discovery pass runs.
- var deploymentId3 = SeedDeploymentWithEquipmentTags(db, RevC,
- (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
- actor.Tell(new DispatchDeployment(deploymentId3, RevC, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
- publish.ExpectMsg(Timeout);
- // The survivor re-apply re-materialises EQ-2 (NOT a drop+re-trigger).
- publish.ExpectMsg(Timeout)
- .EquipmentRootNodeId.ShouldBe("EQ-2");
- // No further publish traffic and NO further discovery pass — the re-trigger converged.
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
- factory.DiscoverCount.ShouldBe(discoverCountAfterRebind);
- }
-
- /// Negative / regression guard: a routine redeploy of a discovery-capable driver that does NOT
- /// rebind (SAME composition, new revision) must NOT spuriously re-trigger discovery. The cached EQ-1 plan
- /// SURVIVES the re-inject tail (its equipment still resolves and its NodeIds are still EQ-1-scoped), so it is
- /// re-applied (EQ-1 re-materialises — the Task-8 survival path) — but NOTHING is dropped, so droppedAny
- /// stays false and no is sent. Uses the REAL
- /// child (the existing ExpectNoMsg drop tests use a NON-discovery driver,
- /// whose child would no-op a TriggerRediscovery in StartDiscovery's is not ITagDiscovery
- /// guard — so they would NOT catch a future regression that sets droppedAny on a non-drop path). The
- /// regression is observable here: a spurious trigger would advance DiscoverCount past the single Once
- /// pass.
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void No_drop_redeploy_does_not_re_trigger_discovery()
- {
- var db = NewInMemoryDbFactory();
- var factory = new DiscoverableSubscribingDriverFactory("Modbus");
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var coordinator = CreateTestProbe();
- var publish = CreateTestProbe();
- var vtHost = CreateTestProbe();
-
- var actor = Sys.ActorOf(DriverHostActor.Props(
- db, TestNode, coordinator.Ref,
- driverFactory: factory,
- localRoles: new HashSet { "driver" },
- opcUaPublishActor: publish.Ref,
- virtualTagHostOverride: vtHost.Ref));
-
- actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
- publish.ExpectMsg(Timeout);
-
- // Connect-time discovery grafts under EQ-1 and populates the cache (DiscoverCount == 1).
- publish.FishForMessage(
- m => m.EquipmentRootNodeId == "EQ-1", Timeout);
- AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout);
-
- // Redeploy with the SAME composition (new revision so it applies; NO rebind ⇒ NO drop).
- var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
- actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
- publish.ExpectMsg(Timeout);
- // The cached EQ-1 plan SURVIVES the re-inject tail and is re-applied — EQ-1 re-materialises.
- publish.ExpectMsg(Timeout)
- .EquipmentRootNodeId.ShouldBe("EQ-1");
- // But NOTHING was dropped, so NO TriggerRediscovery was sent: no fresh discovery pass runs and no
- // further publish traffic arrives. DiscoverCount stays 1.
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
- factory.DiscoverCount.ShouldBe(1);
- }
-
- /// Follow-up D (one-send invariant — SURVIVOR path): a CACHED (FixedTree-discovered) driver whose
- /// plan SURVIVES a redeploy must receive EXACTLY ONE
- /// for that pass — the authored∪discovered UNION the re-inject tail sends — NOT the old TWO (a bulk
- /// authored-only send that dropped the whole handle, then the tail union that re-subscribed it). Observed via
- /// the shared driver's SubscribeCount (each non-empty SetDesiredSubscriptions ⇒ exactly one
- /// SubscribeAsync — no de-dup in ): the count rises by EXACTLY 1 across the
- /// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only
- /// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".)
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var (actor, publish, coordinator) = 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),
- }));
-
- // First injection: the union subscribe (authored "40001" + discovered "ft-ref-1") lands and the cache is
- // populated (_discoveredByDriver[d1] = { EQ-1: plan }).
- publish.ExpectMsg(Timeout);
- AwaitAssert(() =>
- {
- var refs = factory.LastSubscribedRefs;
- refs.ShouldNotBeNull();
- refs!.ShouldContain("40001");
- refs.ShouldContain("ft-ref-1");
- }, duration: Timeout);
- // Let the first-injection traffic settle, then snapshot the subscribe count as the redeploy baseline.
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
- var countBeforeRedeploy = factory.SubscribeCount;
-
- // Redeploy the SAME composition (new revision so it applies; d1 → EQ-1 unchanged ⇒ the cached plan
- // SURVIVES the re-inject tail ⇒ re-applied as a single union send).
- var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
- actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
- publish.ExpectMsg(Timeout);
- publish.ExpectMsg(Timeout); // tail survivor re-materialise
-
- // EXACTLY ONE SetDesiredSubscriptions this redeploy: the count rises by 1 and the set is the union (the
- // combined condition is UNSATISFIABLE under the old double-send — at count+1 the set was authored-only
- // (no "ft-ref-1"), at count+2 the count overshoots — so this fails RED before the fix).
- AwaitAssert(() =>
- {
- var refs = factory.LastSubscribedRefs;
- refs.ShouldNotBeNull();
- refs!.ShouldContain("40001");
- refs.ShouldContain("ft-ref-1");
- factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
- }, duration: Timeout);
- // Settle + re-confirm the count did NOT creep to +2 (the retired bulk authored-only + tail union double-send).
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
- factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
- }
-
- /// Follow-up D (one-send invariant — DROPPED path): a CACHED driver whose plan is FULLY DROPPED by a
- /// config-unchanged rebind (the inner map empties ⇒ the driver is removed from _discoveredByDriver)
- /// must still receive EXACTLY ONE — the AUTHORED-ONLY
- /// fallback — so its authored subscriptions are not lost now that the bulk loop SKIPS cached drivers. The
- /// re-inject tail no longer re-applies a (now-empty) plan for it, so the fallback is the only send. Observed
- /// via SubscribeCount (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also
- /// gets a — a different message type the non-discovery
- /// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends.
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var (actor, publish, coordinator) = 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),
- }));
-
- publish.ExpectMsg(Timeout);
- AwaitAssert(() =>
- {
- var refs = factory.LastSubscribedRefs;
- refs.ShouldNotBeNull();
- refs!.ShouldContain("40001");
- refs.ShouldContain("ft-ref-1");
- }, duration: Timeout);
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
- var countBeforeRedeploy = factory.SubscribeCount;
-
- // Redeploy REBINDING d1 EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged ⇒ child NOT restarted).
- // The cached EQ-1-scoped plan is dropped by the rebind guard ⇒ the inner map empties ⇒ d1 is removed from
- // _discoveredByDriver ⇒ NO survivor re-apply. The fallback must send the authored-only set so "40001"
- // stays subscribed this pass.
- var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
- (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
- actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
- publish.ExpectMsg(Timeout);
- // No MaterialiseDiscoveredNodes — the plan was dropped, not re-grafted — so no further publish traffic.
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
-
- // EXACTLY ONE SetDesiredSubscriptions this redeploy: the authored-only fallback. The count rises by 1 and
- // the set is "40001" only (the dropped FixedTree ref is gone).
- AwaitAssert(() =>
- {
- var refs = factory.LastSubscribedRefs;
- refs.ShouldNotBeNull();
- refs!.ShouldContain("40001");
- refs.ShouldNotContain("ft-ref-1");
- refs.Count.ShouldBe(1);
- factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
- }, duration: Timeout);
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
- factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
- }
-
- /// Follow-up D (one-send invariant — NON-CACHED path): a driver that was NEVER cached (no FixedTree
- /// discovered) is unaffected by the cached-driver bulk-loop skip — it still gets EXACTLY ONE bulk
- /// authored-only per redeploy (the re-inject tail
- /// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's
- /// send. Observed via SubscribeCount (+1) and the subscribed set ("40001" only).
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
-
- var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);
-
- // No DiscoveredNodesReady ⇒ d1 is never cached. Wait for the initial bulk subscribe to settle, then
- // snapshot the count as the redeploy baseline.
- AwaitAssert(() =>
- {
- var refs = factory.LastSubscribedRefs;
- refs.ShouldNotBeNull();
- refs!.ShouldContain("40001");
- }, duration: Timeout);
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
- var countBeforeRedeploy = factory.SubscribeCount;
-
- // Redeploy SAME composition (new rev). d1 is NOT in _discoveredByDriver ⇒ the bulk loop sends it once and
- // the re-inject tail skips it.
- var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
- (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
- actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
- publish.ExpectMsg(Timeout);
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // no materialise — d1 was never cached
-
- AwaitAssert(() =>
- {
- var refs = factory.LastSubscribedRefs;
- refs.ShouldNotBeNull();
- refs!.ShouldContain("40001");
- refs.Count.ShouldBe(1);
- factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
- }, duration: Timeout);
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
- factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
- }
-
- /// Follow-up D (one-send invariant — EMPTY-authored DROPPED path, a capability THIS branch newly
- /// enabled): a CACHED driver with ZERO authored tags (bound to its equipment only via
- /// ) whose FixedTree plan is FULLY DROPPED by a rebind redeploy
- /// receives an EMPTY authored-only fallback , which
- /// the Connected handler routes to Unsubscribe (dropping the stale FixedTree handle) — NOT a subscribe.
- /// Proven by SubscribeCount staying FLAT across the redeploy (no spurious subscribe), closing the
- /// SubscribeCount-proxy blind spot for the empty-set fallback.
- [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
- public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing()
- {
- var db = NewInMemoryDbFactory();
- var factory = new SubscribingDriverFactory("Modbus");
- // d1 bound to EQ-1 via EquipmentNode.DriverInstanceId, with NO authored tags.
- var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1");
-
- var (actor, publish, coordinator) = 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),
- }));
-
- // First injection: the discovered FixedTree materialises under EQ-1 and the child subscribes the
- // discovered-only set (no authored ref to union) — this populates _discoveredByDriver[d1] = { EQ-1: plan }.
- publish.ExpectMsg(Timeout);
- AwaitAssert(() =>
- {
- var refs = factory.LastSubscribedRefs;
- refs.ShouldNotBeNull();
- refs!.ShouldContain("ft-ref-1");
- }, duration: Timeout);
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
- var countBeforeRedeploy = factory.SubscribeCount;
-
- // Redeploy REBINDING the tag-less d1 EQ-1 → EQ-2 (still tag-less; DriverConfig "{}" unchanged ⇒ child NOT
- // restarted). The cached EQ-1 plan is no longer a candidate ⇒ dropped ⇒ inner map empties ⇒ d1 removed
- // from _discoveredByDriver. With ZERO authored tags the fallback set is EMPTY ⇒ the child Unsubscribes.
- var deploymentId2 = SeedDeploymentWithTagLessEquipment(db, RevB, equipmentId: "EQ-2", driverId: "d1");
- actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
- publish.ExpectMsg(Timeout);
- // No re-materialise — the cached plan was dropped (not re-grafted).
- publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
-
- // The empty fallback routes to Unsubscribe, NOT SubscribeAsync ⇒ the count does NOT rise. (A non-empty or
- // spurious subscribe — the bug this guards against — would increment it.)
- factory.SubscribeCount.ShouldBe(countBeforeRedeploy);
- }
-
- /// Spawns the host with the subscribing driver factory + a publish probe, dispatches the
- /// deployment, and waits for the Applied ACK so the apply (and thus _lastComposition + the live
- /// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A
- /// VirtualTag-host probe is injected so the real host isn't spawned. The
- /// that lands on the publish probe during apply is drained so the test's materialise / value-update
- /// assertions see only post-apply traffic.
- private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply(
- IDbContextFactory db, DeploymentId deploymentId, IDriverFactory factory)
- {
- var coordinator = CreateTestProbe();
- var publish = CreateTestProbe();
- var vtHost = CreateTestProbe();
-
- var actor = Sys.ActorOf(DriverHostActor.Props(
- db, TestNode, coordinator.Ref,
- driverFactory: factory,
- localRoles: new HashSet { "driver" },
- opcUaPublishActor: publish.Ref,
- virtualTagHostOverride: vtHost.Ref));
-
- actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
- coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
-
- publish.ExpectMsg(Timeout);
-
- return (actor, publish, coordinator);
- }
-
- ///
- /// Seeds a Sealed deployment whose artifact carries the minimal arrays
- /// DeploymentArtifact.BuildEquipmentTagPlans needs to project equipment tags, plus a
- /// DriverInstances row with a non-Windows-only DriverType ("Modbus") + Enabled flag so
- /// a REAL (non-stubbed) child is spawned (mirrors
- /// DriverHostActorWriteRoutingTests.SeedDeploymentWithEquipmentTags).
- ///
- private static DeploymentId SeedDeploymentWithEquipmentTags(
- IDbContextFactory db, RevisionHash rev,
- params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
- {
- var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
-
- var artifact = JsonSerializer.SerializeToUtf8Bytes(new
- {
- Namespaces = new[]
- {
- new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
- },
- DriverInstances = driverIds.Select(d => new
- {
- DriverInstanceRowId = Guid.NewGuid(),
- DriverInstanceId = d,
- Name = d,
- DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
- Enabled = true,
- DriverConfig = "{}",
- NamespaceId = "ns-eq",
- }).ToArray(),
- Tags = tags.Select((t, i) => new
- {
- TagId = $"tag-{i}",
- EquipmentId = t.Equip,
- DriverInstanceId = t.Driver,
- Name = t.Name,
- FolderPath = t.Folder,
- DataType = "Double",
- TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
- }).ToArray(),
- });
-
- 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;
- }
-
- ///
- /// 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