merge WP3 Wave B review fixes (H1 realm write-routing, M1/M2/L1/L3, byte-parity) into v3/batch4-address-space

This commit is contained in:
Joseph Doherty
2026-07-16 11:30:57 -04:00
34 changed files with 616 additions and 265 deletions
@@ -11,11 +11,17 @@ public interface IOpcUaNodeWriteGateway
{ {
/// <summary>Route a write of <paramref name="value"/> to the driver backing node /// <summary>Route a write of <paramref name="value"/> to the driver backing node
/// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary> /// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary>
/// <param name="nodeId">The folder-scoped equipment-variable node id being written.</param> /// <param name="nodeId">The full ns-qualified node id being written (<c>node.NodeId.ToString()</c>).</param>
/// <param name="value">The value the client wrote.</param> /// <param name="value">The value the client wrote.</param>
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) <paramref name="nodeId"/> lives in — the node
/// manager resolves it from the node's namespace index (<c>RealmOf</c>). It is REQUIRED for correct
/// routing: a raw <c>s=&lt;RawPath&gt;</c> and a UNS <c>s=&lt;Area/Line/Equip/Eff&gt;</c> can collide as bare
/// strings, so the routing map is keyed by <c>(realm, bareId)</c> — dropping the realm would let an
/// operator write route to the WRONG driver ref.</param>
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <returns>A task resolving to the device-write outcome.</returns> /// <returns>A task resolving to the device-write outcome.</returns>
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct); Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct);
} }
/// <summary>Outcome of routing an inbound node write to the backing driver.</summary> /// <summary>Outcome of routing an inbound node write to the backing driver.</summary>
@@ -31,6 +37,6 @@ public sealed class NullOpcUaNodeWriteGateway : IOpcUaNodeWriteGateway
public static readonly NullOpcUaNodeWriteGateway Instance = new(); public static readonly NullOpcUaNodeWriteGateway Instance = new();
private NullOpcUaNodeWriteGateway() { } private NullOpcUaNodeWriteGateway() { }
/// <inheritdoc /> /// <inheritdoc />
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct) => public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
Task.FromResult(new NodeWriteOutcome(false, "writes unavailable")); Task.FromResult(new NodeWriteOutcome(false, "writes unavailable"));
} }
@@ -349,7 +349,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="value">The new value to write.</param> /// <param name="value">The new value to write.</param>
/// <param name="quality">The OPC UA quality status code.</param> /// <param name="quality">The OPC UA quality status code.</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param> /// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{ {
ArgumentException.ThrowIfNullOrEmpty(nodeId); ArgumentException.ThrowIfNullOrEmpty(nodeId);
EnsureAddressSpaceCreated(); EnsureAddressSpaceCreated();
@@ -386,7 +386,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param> /// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
/// <param name="state">The full condition state to project onto the node.</param> /// <param name="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param> /// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{ {
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentNullException.ThrowIfNull(state); ArgumentNullException.ThrowIfNull(state);
@@ -678,7 +678,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13 /// <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
/// notes — a script alarm carries no High/Low limits to populate.</para> /// notes — a script alarm carries no High/Low limits to populate.</para>
/// </remarks> /// </remarks>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
{ {
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName); ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -1000,7 +1000,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed // revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed
// task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never // task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never
// re-enters lock (Lock) while CustomNodeManager2.Write still holds it. // re-enters lock (Lock) while CustomNodeManager2.Write still holds it.
_ = gateway.WriteAsync(nodeKey, optimisticValue, CancellationToken.None) _ = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None)
.ContinueWith( .ContinueWith(
t => t =>
{ {
@@ -1206,7 +1206,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// from <see cref="OnEquipmentTagWrite"/> to populate the audit event's ClientUserId; null when unknown.</param> /// from <see cref="OnEquipmentTagWrite"/> to populate the audit event's ClientUserId; null when unknown.</param>
internal void RevertOptimisticWriteIfNeeded( internal void RevertOptimisticWriteIfNeeded(
string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus, string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus,
string? clientUserId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) string? clientUserId, AddressSpaceRealm realm)
{ {
// Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released. // Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released.
AuditWriteUpdateEventState? auditEvent = null; AuditWriteUpdateEventState? auditEvent = null;
@@ -1398,7 +1398,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="folderNodeId">The node identifier of the folder.</param> /// <param name="folderNodeId">The node identifier of the folder.</param>
/// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param> /// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
/// <param name="displayName">The display name of the folder.</param> /// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
{ {
ArgumentException.ThrowIfNullOrEmpty(folderNodeId); ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName); ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -1443,7 +1443,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="folderNodeId">The node identifier of the folder to update in place.</param> /// <param name="folderNodeId">The node identifier of the folder to update in place.</param>
/// <param name="displayName">The new display name to apply.</param> /// <param name="displayName">The new display name to apply.</param>
/// <returns>True when the in-place update was applied; false when the folder id is unknown.</returns> /// <returns>True when the in-place update was applied; false when the folder id is unknown.</returns>
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
{ {
ArgumentException.ThrowIfNullOrEmpty(folderNodeId); ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName); ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -1485,7 +1485,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param> /// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param>
/// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when /// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when
/// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param> /// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{ {
ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName); ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -1583,7 +1583,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="isArray">When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar.</param> /// <param name="isArray">When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param> /// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
/// <returns>True when the in-place update was applied; false when the node id is unknown.</returns> /// <returns>True when the in-place update was applied; false when the node id is unknown.</returns>
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
{ {
ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(dataType); // widened surface ⇒ explicit contract (unknown names still map to BaseDataType) ArgumentException.ThrowIfNullOrEmpty(dataType); // widened surface ⇒ explicit contract (unknown names still map to BaseDataType)
@@ -1712,7 +1712,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// </para> /// </para>
/// </summary> /// </summary>
/// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param> /// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm)
{ {
ArgumentException.ThrowIfNullOrEmpty(affectedNodeId); ArgumentException.ThrowIfNullOrEmpty(affectedNodeId);
GeneralModelChangeEventState e; GeneralModelChangeEventState e;
@@ -2004,7 +2004,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
} }
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/> /// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
{ {
ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
EnsureAddressSpaceCreated(); EnsureAddressSpaceCreated();
@@ -2028,7 +2028,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
} }
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/> /// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
{ {
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
EnsureAddressSpaceCreated(); EnsureAddressSpaceCreated();
@@ -2053,7 +2053,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
} }
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/> /// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
{ {
ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId); ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId);
EnsureAddressSpaceCreated(); EnsureAddressSpaceCreated();
@@ -30,7 +30,7 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
/// <inheritdoc /> /// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative, realm); => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
@@ -38,7 +38,7 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm); => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
/// <inheritdoc /> /// <inheritdoc />
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
@@ -43,7 +43,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct) public async Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
{ {
var driverHost = _resolveDriverHost(); var driverHost = _resolveDriverHost();
if (driverHost is null) if (driverHost is null)
@@ -55,7 +55,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
try try
{ {
var result = await driverHost.Ask<DriverHostActor.NodeWriteResult>( var result = await driverHost.Ask<DriverHostActor.NodeWriteResult>(
new DriverHostActor.RouteNodeWrite(nodeId, value), _askTimeout, ct).ConfigureAwait(false); new DriverHostActor.RouteNodeWrite(nodeId, value, realm), _askTimeout, ct).ConfigureAwait(false);
if (!result.Success) if (!result.Success)
_logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason); _logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason);
return new NodeWriteOutcome(result.Success, result.Reason); return new NodeWriteOutcome(result.Success, result.Reason);
@@ -128,19 +128,22 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _nodeIdByDriverRef = new(); private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _nodeIdByDriverRef = new();
/// <summary> /// <summary>
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>materialised value NodeId (BARE <c>s=</c> id) → /// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>(Realm, BARE <c>s=</c> id) → (DriverInstanceId,
/// (DriverInstanceId, RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId AND every referencing UNS /// RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId (<see cref="AddressSpaceRealm.Raw"/>) AND every
/// NodeId (all mapping to the same driver ref — the single value source). Rebuilt every apply by /// referencing UNS NodeId (<see cref="AddressSpaceRealm.Uns"/>) — all mapping to the same driver ref
/// <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c> /// (the single value source). Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the
/// <c>UnsReferenceVariables</c>, and resolved by <see cref="HandleRouteNodeWrite"/> so an inbound /// composition's <c>RawTags</c> <c>UnsReferenceVariables</c>, and resolved by
/// operator write to EITHER NodeId is forwarded to the owning driver child as a write of its wire-ref /// <see cref="HandleRouteNodeWrite"/> so an inbound operator write to EITHER NodeId is forwarded to the
/// RawPath. Keyed by the BARE id: the node manager's write hook passes the full ns-qualified id /// owning driver child as a write of its wire-ref RawPath.
/// (<c>node.NodeId.ToString()</c>); <see cref="HandleRouteNodeWrite"/> normalises it to the bare id /// <para><b>The realm is part of the key</b> (Wave B review H1): a raw <c>s=&lt;RawPath&gt;</c> and a UNS
/// before lookup, so a raw write and a UNS write both resolve to the same driver ref (they share it — /// <c>s=&lt;Area/Line/Equip/Eff&gt;</c> can collide as bare strings (folder/driver/device names and
/// a UNS reference's backing RawPath IS the raw node's, so no cross-realm ambiguity is possible). /// Area/Line/Equip names are independent), so a bare-only key would let a colliding raw + UNS pair route
/// to the WRONG driver ref (last-writer-wins). The node manager's write hook passes the full
/// ns-qualified id PLUS the realm it resolved (<c>RealmOf</c>); <see cref="HandleRouteNodeWrite"/>
/// normalises the id to the bare form and looks up <c>(realm, bareId)</c>, preserving exactly the
/// namespace disambiguation the ns-qualified id carries.</para>
/// </summary> /// </summary>
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId = private readonly Dictionary<(AddressSpaceRealm Realm, string NodeId), (string DriverInstanceId, string RawPath)> _driverRefByNodeId = new();
new(StringComparer.Ordinal);
/// <summary>(DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged). /// <summary>(DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged).
/// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing /// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing
@@ -241,7 +244,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </summary> /// </summary>
/// <param name="NodeId">The folder-scoped equipment-variable NodeId the operator wrote to.</param> /// <param name="NodeId">The folder-scoped equipment-variable NodeId the operator wrote to.</param>
/// <param name="Value">The value to write (the driver coerces it to the attribute's data type).</param> /// <param name="Value">The value to write (the driver coerces it to the attribute's data type).</param>
public sealed record RouteNodeWrite(string NodeId, object? Value); public sealed record RouteNodeWrite(string NodeId, object? Value, AddressSpaceRealm Realm);
/// <summary>Reply to <see cref="RouteNodeWrite"/>: the outcome of forwarding the write to the driver /// <summary>Reply to <see cref="RouteNodeWrite"/>: the outcome of forwarding the write to the driver
/// (or a gate/lookup failure that never reached the driver).</summary> /// (or a gate/lookup failure that never reached the driver).</summary>
@@ -654,6 +657,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </summary> /// </summary>
private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg) 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) if (_lastComposition is null)
{ {
_log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored", _log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored",
@@ -728,6 +746,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_discoveredByDriver[msg.DriverInstanceId] = newPlans; _discoveredByDriver[msg.DriverInstanceId] = newPlans;
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans); ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
#pragma warning restore CS0162
} }
/// <summary> /// <summary>
@@ -933,7 +952,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route // v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
// through the Raw realm. // through the Raw realm.
set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw)); set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw));
_driverRefByNodeId[nodeId] = key; _driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key;
} }
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes( _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
equipmentId, plan.Folders, plan.Variables)); equipmentId, plan.Folders, plan.Variables));
@@ -1101,15 +1120,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return; return;
} }
// v3 Batch 4: the node manager's write hook passes the FULL ns-qualified NodeId string // v3 Batch 4 (review H1): the node manager's write hook passes the FULL ns-qualified NodeId string
// (node.NodeId.ToString(), e.g. "ns=3;s=<id>"). The routing map is keyed by the BARE s= identifier // (node.NodeId.ToString(), e.g. "ns=3;s=<id>") PLUS the realm it resolved from the namespace index.
// (the raw RawPath or the UNS path). Normalise before lookup so a write to EITHER a raw NodeId or a // The routing map is keyed by (realm, BARE s= identifier). Normalise the id to its bare form and look
// referencing UNS NodeId resolves to the SAME driver ref (they share it — a UNS reference's backing // up (realm, bareId) — the realm disambiguates a raw RawPath from a UNS path that happen to share a
// RawPath IS the raw node's, so there is no cross-realm ambiguity). // bare string, so a write always routes to its OWN driver ref (never a colliding sibling's).
var bareNodeId = BareNodeId(msg.NodeId); var bareNodeId = BareNodeId(msg.NodeId);
if (!_driverRefByNodeId.TryGetValue(bareNodeId, out var target)) if (!_driverRefByNodeId.TryGetValue((msg.Realm, bareNodeId), out var target))
{ {
Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId}")); Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId} ({msg.Realm})"));
return; return;
} }
@@ -1564,14 +1583,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>(); _nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw)); set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
_driverRefByNodeId[t.NodeId] = key; _driverRefByNodeId[(AddressSpaceRealm.Raw, t.NodeId)] = key;
if (unsRefsByRawPath.TryGetValue(t.NodeId, out var refs)) if (unsRefsByRawPath.TryGetValue(t.NodeId, out var refs))
{ {
foreach (var v in refs) foreach (var v in refs)
{ {
set.Add(new NodeRealmRef(v.NodeId, AddressSpaceRealm.Uns)); set.Add(new NodeRealmRef(v.NodeId, AddressSpaceRealm.Uns));
_driverRefByNodeId[v.NodeId] = key; // a UNS write resolves to the same driver ref // A UNS write resolves to the same driver ref — keyed under the Uns realm so it can never
// collide with a raw NodeId that shares its bare string.
_driverRefByNodeId[(AddressSpaceRealm.Uns, v.NodeId)] = key;
} }
} }
} }
@@ -45,7 +45,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/quality/timestamp. /// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/quality/timestamp.
/// <see cref="Realm"/> defaults to <see cref="AddressSpaceRealm.Uns"/> so pre-v3 callers (VirtualTag /// <see cref="Realm"/> defaults to <see cref="AddressSpaceRealm.Uns"/> so pre-v3 callers (VirtualTag
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.</summary> /// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.</summary>
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns); public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The /// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's /// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
@@ -57,7 +57,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param> /// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param>
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for /// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param> /// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns); public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary> /// <summary>
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment /// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the /// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
@@ -297,10 +297,12 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
// Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/ // Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
// shelving/severity/message) onto the materialised condition node via the Commons snapshot. // shelving/severity/message) onto the materialised condition node via the Commons snapshot.
// e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId). // e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
// Scripted alarms are per-equipment condition nodes in the UNS realm.
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate( _publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
AlarmNodeId: e.AlarmId, AlarmNodeId: e.AlarmId,
State: ToSnapshot(e), State: ToSnapshot(e),
TimestampUtc: e.TimestampUtc)); TimestampUtc: e.TimestampUtc,
Realm: AddressSpaceRealm.Uns));
// Publish the transition to the cluster `alerts` topic — the single historization + live // Publish the transition to the cluster `alerts` topic — the single historization + live
// fan-out path. The mediator was cached on the ACTOR thread in PreStart; we only Tell it here. // fan-out path. The mediator was cached on the ACTOR thread in PreStart; we only Tell it here.
@@ -19,9 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value. /// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
/// ///
/// <para> /// <para>
/// The published NodeId is computed by the shared <see cref="EquipmentNodeIds.Variable"/> — /// The published NodeId is computed via <see cref="V3NodeIds.Uns(string[])"/> (the equipment-anchored
/// the single source of truth <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> also /// slash path <c>{EquipmentId}[/{FolderPath}]/{Name}</c>) — byte-identical to the NodeId
/// materialises against — so the value always lands on a NodeId that exists. /// <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> materialises against — so the value
/// always lands on a NodeId that exists.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class VirtualTagHostActor : ReceiveActor public sealed class VirtualTagHostActor : ReceiveActor
@@ -192,8 +193,9 @@ public sealed class VirtualTagHostActor : ReceiveActor
// 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script // 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script
// failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the // failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the
// sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good. // sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good.
// VirtualTags materialise as equipment nodes in the UNS realm — publish there.
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate( _publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
nodeId, result.Value, result.Quality, result.TimestampUtc)); nodeId, result.Value, result.Quality, result.TimestampUtc, AddressSpaceRealm.Uns));
// Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so // Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so
// no parallel map. The historian path key is the SAME folder-scoped NodeId we just published // no parallel map. The historian path key is the SAME folder-scoped NodeId we just published
@@ -9,7 +9,7 @@ public class NullOpcUaNodeWriteGatewayTests
[Fact] [Fact]
public async Task NullGateway_returns_writes_unavailable() public async Task NullGateway_returns_writes_unavailable()
{ {
var outcome = await NullOpcUaNodeWriteGateway.Instance.WriteAsync("ns=2;s=x", 1, TestContext.Current.CancellationToken); var outcome = await NullOpcUaNodeWriteGateway.Instance.WriteAsync("ns=2;s=x", 1, AddressSpaceRealm.Uns, TestContext.Current.CancellationToken);
outcome.Success.ShouldBeFalse(); outcome.Success.ShouldBeFalse();
outcome.Reason.ShouldBe("writes unavailable"); outcome.Reason.ShouldBe("writes unavailable");
} }
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Hosting; using Akka.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Shouldly; using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime; using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -68,7 +69,7 @@ public sealed class PrimaryGateFailoverTests
private static async Task<DriverHostActor.NodeWriteResult> RouteProbeWriteAsync(IActorRef driverHost) private static async Task<DriverHostActor.NodeWriteResult> RouteProbeWriteAsync(IActorRef driverHost)
=> await driverHost.Ask<DriverHostActor.NodeWriteResult>( => await driverHost.Ask<DriverHostActor.NodeWriteResult>(
new DriverHostActor.RouteNodeWrite(UnmappedProbeNode, 0.0), TimeSpan.FromSeconds(10)); new DriverHostActor.RouteNodeWrite(UnmappedProbeNode, 0.0, AddressSpaceRealm.Uns), TimeSpan.FromSeconds(10));
private static bool IsGateReject(DriverHostActor.NodeWriteResult r) private static bool IsGateReject(DriverHostActor.NodeWriteResult r)
=> !r.Success && r.Reason is not null && r.Reason.StartsWith("not primary", StringComparison.Ordinal); => !r.Success && r.Reason is not null && r.Reason.StartsWith("not primary", StringComparison.Ordinal);
@@ -34,8 +34,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-1"); var condition = nm.TryGetAlarmCondition("alm-1");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
condition!.OnAcknowledge.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull();
@@ -66,8 +66,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2"); nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-2"); var condition = nm.TryGetAlarmCondition("alm-2");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -91,8 +91,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3"); nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-3"); var condition = nm.TryGetAlarmCondition("alm-3");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -115,8 +115,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C"); nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-c"); var condition = nm.TryGetAlarmCondition("alm-c");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
condition!.OnConfirm.ShouldNotBeNull(); condition!.OnConfirm.ShouldNotBeNull();
@@ -142,8 +142,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC"); nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ac"); var condition = nm.TryGetAlarmCondition("alm-ac");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
condition!.OnAddComment.ShouldNotBeNull(); condition!.OnAddComment.ShouldNotBeNull();
@@ -168,8 +168,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1"); nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s1"); var condition = nm.TryGetAlarmCondition("alm-s1");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
condition!.OnShelve.ShouldNotBeNull(); condition!.OnShelve.ShouldNotBeNull();
@@ -196,8 +196,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2"); nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s2"); var condition = nm.TryGetAlarmCondition("alm-s2");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -228,8 +228,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3"); nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s3"); var condition = nm.TryGetAlarmCondition("alm-s3");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -254,8 +254,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4"); nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s4"); var condition = nm.TryGetAlarmCondition("alm-s4");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -287,8 +287,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU"); nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-tu"); var condition = nm.TryGetAlarmCondition("alm-tu");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
condition!.OnTimedUnshelve.ShouldNotBeNull(); condition!.OnTimedUnshelve.ShouldNotBeNull();
@@ -322,8 +322,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1"); nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed1"); var condition = nm.TryGetAlarmCondition("alm-ed1");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull();
@@ -351,8 +351,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2"); nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed2"); var condition = nm.TryGetAlarmCondition("alm-ed2");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull();
@@ -379,8 +379,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3"); nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed3"); var condition = nm.TryGetAlarmCondition("alm-ed3");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -405,8 +405,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>(); var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add; nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4"); nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed4"); var condition = nm.TryGetAlarmCondition("alm-ed4");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull();
@@ -436,8 +436,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add; nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add; nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1"); nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak1"); var condition = nm.TryGetAlarmCondition("alm-nak1");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
condition!.OnAcknowledge.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull();
@@ -469,8 +469,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add; nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add; nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2"); nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak2"); var condition = nm.TryGetAlarmCondition("alm-nak2");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
condition!.OnAcknowledge.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull();
@@ -503,8 +503,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add; nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add; nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4"); nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak4"); var condition = nm.TryGetAlarmCondition("alm-nak4");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -531,8 +531,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add; nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add; nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3"); nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak3"); var condition = nm.TryGetAlarmCondition("alm-nak3");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -555,8 +555,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
// No router set (default null). // No router set (default null).
nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR"); nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nr"); var condition = nm.TryGetAlarmCondition("alm-nr");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -576,8 +576,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeTrue(); nm.IsNativeAlarmNode("a1").ShouldBeTrue();
@@ -591,8 +591,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false); nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a2").ShouldBeFalse(); nm.IsNativeAlarmNode("a2").ShouldBeFalse();
@@ -611,15 +611,15 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeTrue(); nm.IsNativeAlarmNode("a1").ShouldBeTrue();
// RebuildAddressSpace clears the folder set too, so the equipment folder must be re-ensured // RebuildAddressSpace clears the folder set too, so the equipment folder must be re-ensured
// before the same id can be re-materialised (ResolveParentFolder needs the parent back). // before the same id can be re-materialised (ResolveParentFolder needs the parent back).
nm.RebuildAddressSpace(); nm.RebuildAddressSpace();
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false); nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeFalse(); nm.IsNativeAlarmNode("a1").ShouldBeFalse();
@@ -636,13 +636,13 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false); nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeFalse(); nm.IsNativeAlarmNode("a1").ShouldBeFalse();
nm.RebuildAddressSpace(); nm.RebuildAddressSpace();
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeTrue(); nm.IsNativeAlarmNode("a1").ShouldBeTrue();
@@ -1,4 +1,5 @@
using Shouldly; using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit; using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
@@ -27,14 +28,14 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable
{ {
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
var first = nm.TryGetAlarmCondition("alm-1"); var first = nm.TryGetAlarmCondition("alm-1");
first.ShouldNotBeNull(); first.ShouldNotBeNull();
// Same id + same kind ⇒ skip-if-present: the existing instance is kept. // Same id + same kind ⇒ skip-if-present: the existing instance is kept.
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
var second = nm.TryGetAlarmCondition("alm-1"); var second = nm.TryGetAlarmCondition("alm-1");
second.ShouldBeSameAs(first); second.ShouldBeSameAs(first);
@@ -50,14 +51,14 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable
{ {
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
var scripted = nm.TryGetAlarmCondition("alm-1"); var scripted = nm.TryGetAlarmCondition("alm-1");
nm.IsNativeAlarmNode("alm-1").ShouldBeFalse(); nm.IsNativeAlarmNode("alm-1").ShouldBeFalse();
// Same id but the OTHER kind ⇒ recreate (a different instance) and the native flag is now set. // Same id but the OTHER kind ⇒ recreate (a different instance) and the native flag is now set.
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: true); nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
var native = nm.TryGetAlarmCondition("alm-1"); var native = nm.TryGetAlarmCondition("alm-1");
native.ShouldNotBeSameAs(scripted); native.ShouldNotBeSameAs(scripted);
@@ -75,16 +76,16 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable
{ {
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
var before = nm.TryGetAlarmCondition("alm-1"); var before = nm.TryGetAlarmCondition("alm-1");
nm.RebuildAddressSpace(); nm.RebuildAddressSpace();
nm.TryGetAlarmCondition("alm-1").ShouldBeNull(); nm.TryGetAlarmCondition("alm-1").ShouldBeNull();
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
var after = nm.TryGetAlarmCondition("alm-1"); var after = nm.TryGetAlarmCondition("alm-1");
after.ShouldNotBeNull(); after.ShouldNotBeNull();
@@ -32,7 +32,7 @@ public sealed class NodeManagerArrayTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/arr", parentFolderNodeId: null, displayName: "arr", dataType: "Int32", nm.EnsureVariable("eq-1/arr", parentFolderNodeId: null, displayName: "arr", dataType: "Int32",
writable: false, historianTagname: null, isArray: true, arrayLength: 8); writable: false, historianTagname: null, isArray: true, arrayLength: 8, realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/arr"); var variable = nm.TryGetVariable("eq-1/arr");
variable.ShouldNotBeNull(); variable.ShouldNotBeNull();
@@ -52,7 +52,7 @@ public sealed class NodeManagerArrayTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/arr-unfixed", parentFolderNodeId: null, displayName: "arr-unfixed", dataType: "Int32", nm.EnsureVariable("eq-1/arr-unfixed", parentFolderNodeId: null, displayName: "arr-unfixed", dataType: "Int32",
writable: false, historianTagname: null, isArray: true, arrayLength: null); writable: false, historianTagname: null, isArray: true, arrayLength: null, realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/arr-unfixed"); var variable = nm.TryGetVariable("eq-1/arr-unfixed");
variable.ShouldNotBeNull(); variable.ShouldNotBeNull();
@@ -72,7 +72,7 @@ public sealed class NodeManagerArrayTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/scalar", parentFolderNodeId: null, displayName: "scalar", dataType: "Int32", nm.EnsureVariable("eq-1/scalar", parentFolderNodeId: null, displayName: "scalar", dataType: "Int32",
writable: false); writable: false, realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/scalar"); var variable = nm.TryGetVariable("eq-1/scalar");
variable.ShouldNotBeNull(); variable.ShouldNotBeNull();
@@ -91,10 +91,10 @@ public sealed class NodeManagerArrayTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/arrwrite", parentFolderNodeId: null, displayName: "arrwrite", dataType: "Int32", nm.EnsureVariable("eq-1/arrwrite", parentFolderNodeId: null, displayName: "arrwrite", dataType: "Int32",
writable: false, historianTagname: null, isArray: true, arrayLength: 3); writable: false, historianTagname: null, isArray: true, arrayLength: 3, realm: AddressSpaceRealm.Uns);
var payload = new[] { 1, 2, 3 }; var payload = new[] { 1, 2, 3 };
nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow); nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/arrwrite"); var variable = nm.TryGetVariable("eq-1/arrwrite");
variable.ShouldNotBeNull(); variable.ShouldNotBeNull();
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Opc.Ua; using Opc.Ua;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
@@ -29,7 +30,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
writable: false, historianTagname: "WW.Tag"); writable: false, historianTagname: "WW.Tag", realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/temp"); var variable = nm.TryGetVariable("eq-1/temp");
variable.ShouldNotBeNull(); variable.ShouldNotBeNull();
@@ -56,9 +57,9 @@ public sealed class NodeManagerHistorizeTests : IDisposable
// Explicit null and the defaulted-param form both mean "not historized". // Explicit null and the defaulted-param form both mean "not historized".
nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32", nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32",
writable: false, historianTagname: null); writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/plain2", parentFolderNodeId: null, displayName: "Plain2", dataType: "Int32", nm.EnsureVariable("eq-1/plain2", parentFolderNodeId: null, displayName: "Plain2", dataType: "Int32",
writable: false); writable: false, realm: AddressSpaceRealm.Uns);
foreach (var nodeId in new[] { "eq-1/plain", "eq-1/plain2" }) foreach (var nodeId in new[] { "eq-1/plain", "eq-1/plain2" })
{ {
@@ -83,7 +84,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/setpoint", parentFolderNodeId: null, displayName: "Setpoint", dataType: "Float", nm.EnsureVariable("eq-1/setpoint", parentFolderNodeId: null, displayName: "Setpoint", dataType: "Float",
writable: true, historianTagname: "WW.Setpoint"); writable: true, historianTagname: "WW.Setpoint", realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/setpoint"); var variable = nm.TryGetVariable("eq-1/setpoint");
variable.ShouldNotBeNull(); variable.ShouldNotBeNull();
@@ -109,7 +110,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
writable: false, historianTagname: "WW.Tag"); writable: false, historianTagname: "WW.Tag", realm: AddressSpaceRealm.Uns);
nm.TryGetHistorizedTagname("eq-1/temp", out _).ShouldBeTrue(); nm.TryGetHistorizedTagname("eq-1/temp", out _).ShouldBeTrue();
nm.RebuildAddressSpace(); nm.RebuildAddressSpace();
@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua; using Opc.Ua;
using Opc.Ua.Server; using Opc.Ua.Server;
@@ -228,7 +229,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
{ {
var key = $"eq/tag{i}"; var key = $"eq/tag{i}";
nm.EnsureVariable(key, parentFolderNodeId: null, displayName: $"Tag{i}", dataType: "Float", nm.EnsureVariable(key, parentFolderNodeId: null, displayName: $"Tag{i}", dataType: "Float",
writable: false, historianTagname: $"WW.Tag{i}"); writable: false, historianTagname: $"WW.Tag{i}", realm: AddressSpaceRealm.Uns);
ids[i] = nm.TryGetVariable(key)!.NodeId; ids[i] = nm.TryGetVariable(key)!.NodeId;
} }
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Opc.Ua; using Opc.Ua;
using Opc.Ua.Server; using Opc.Ua.Server;
using Shouldly; using Shouldly;
@@ -42,8 +43,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
const string equipmentId = "eq-evt"; const string equipmentId = "eq-evt";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700); nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
var evtTime = new DateTime(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc); var evtTime = new DateTime(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
@@ -107,8 +108,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
const string equipmentId = "eq-unbounded"; const string equipmentId = "eq-unbounded";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600); nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
fake.EventsResult = new HistoricalEventsResult( fake.EventsResult = new HistoricalEventsResult(
@@ -143,8 +144,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
const string equipmentId = "eq-unsupported"; const string equipmentId = "eq-unsupported";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500); nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
fake.EventsResult = new HistoricalEventsResult( fake.EventsResult = new HistoricalEventsResult(
@@ -187,8 +188,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
const string equipmentId = "eq-empty"; const string equipmentId = "eq-empty";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300); nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
var details = new ReadEventDetails var details = new ReadEventDetails
@@ -221,8 +222,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
// Materialise the alarm while the source is still the Null default — the folder is promoted to // Materialise the alarm while the source is still the Null default — the folder is promoted to
// SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration. // SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration.
const string equipmentId = "eq-nosrc"; const string equipmentId = "eq-nosrc";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200); nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
// Wire a real source AFTER promotion — it must NOT retroactively make the folder a source. // Wire a real source AFTER promotion — it must NOT retroactively make the folder a source.
@@ -271,7 +272,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
// A historized variable node — has AccessLevel.HistoryRead (variable-history reads) but // A historized variable node — has AccessLevel.HistoryRead (variable-history reads) but
// EventNotifier=None (no event-notifier bit). The SDK base rejects it before our override runs. // EventNotifier=None (no event-notifier bit). The SDK base rejects it before our override runs.
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
writable: false, historianTagname: "WW.Temp"); writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;
var details = new ReadEventDetails var details = new ReadEventDetails
@@ -303,8 +304,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
const string equipmentId = "eq-boom"; const string equipmentId = "eq-boom";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900); nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
var details = new ReadEventDetails var details = new ReadEventDetails
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Opc.Ua; using Opc.Ua;
using Opc.Ua.Server; using Opc.Ua.Server;
using Shouldly; using Shouldly;
@@ -42,7 +43,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.HistorianDataSource = new SeriesHistorianDataSource(series);
nm.EnsureVariable("eq-1/pv", parentFolderNodeId: null, displayName: "PV", dataType: "Double", nm.EnsureVariable("eq-1/pv", parentFolderNodeId: null, displayName: "PV", dataType: "Double",
writable: false, historianTagname: "WW.PV"); writable: false, historianTagname: "WW.PV", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/pv")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/pv")!.NodeId;
var collected = new List<double>(); var collected = new List<double>();
@@ -84,7 +85,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 100, stepSeconds: 1)); nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 100, stepSeconds: 1));
nm.EnsureVariable("eq-1/exact", parentFolderNodeId: null, displayName: "Exact", dataType: "Double", nm.EnsureVariable("eq-1/exact", parentFolderNodeId: null, displayName: "Exact", dataType: "Double",
writable: false, historianTagname: "WW.Exact"); writable: false, historianTagname: "WW.Exact", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/exact")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/exact")!.NodeId;
var (r1, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null); var (r1, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null);
@@ -123,7 +124,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.HistorianDataSource = new SeriesHistorianDataSource(series);
nm.EnsureVariable("eq-1/tie", parentFolderNodeId: null, displayName: "Tie", dataType: "Double", nm.EnsureVariable("eq-1/tie", parentFolderNodeId: null, displayName: "Tie", dataType: "Double",
writable: false, historianTagname: "WW.Tie"); writable: false, historianTagname: "WW.Tie", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/tie")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/tie")!.NodeId;
var collected = new List<double>(); var collected = new List<double>();
@@ -167,7 +168,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.HistorianDataSource = new SeriesHistorianDataSource(series);
nm.EnsureVariable("eq-1/burst", parentFolderNodeId: null, displayName: "Burst", dataType: "Double", nm.EnsureVariable("eq-1/burst", parentFolderNodeId: null, displayName: "Burst", dataType: "Double",
writable: false, historianTagname: "WW.Burst"); writable: false, historianTagname: "WW.Burst", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/burst")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/burst")!.NodeId;
var collected = new List<double>(); var collected = new List<double>();
@@ -211,7 +212,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.HistorianDataSource = new SeriesHistorianDataSource(series);
nm.EnsureVariable("eq-1/absurd", parentFolderNodeId: null, displayName: "Absurd", dataType: "Double", nm.EnsureVariable("eq-1/absurd", parentFolderNodeId: null, displayName: "Absurd", dataType: "Double",
writable: false, historianTagname: "WW.Absurd"); writable: false, historianTagname: "WW.Absurd", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/absurd")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/absurd")!.NodeId;
// Page 1: a full page of the first 2 ties, with a continuation point. // Page 1: a full page of the first 2 ties, with a continuation point.
@@ -241,7 +242,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
nm.EnsureVariable("eq-1/all", parentFolderNodeId: null, displayName: "All", dataType: "Double", nm.EnsureVariable("eq-1/all", parentFolderNodeId: null, displayName: "All", dataType: "Double",
writable: false, historianTagname: "WW.All"); writable: false, historianTagname: "WW.All", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/all")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/all")!.NodeId;
var (r, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 0, inboundCp: null); var (r, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 0, inboundCp: null);
@@ -265,7 +266,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/bad-cp", parentFolderNodeId: null, displayName: "BadCp", dataType: "Double", nm.EnsureVariable("eq-1/bad-cp", parentFolderNodeId: null, displayName: "BadCp", dataType: "Double",
writable: false, historianTagname: "WW.BadCp"); writable: false, historianTagname: "WW.BadCp", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/bad-cp")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/bad-cp")!.NodeId;
fake.ResetReadCount(); fake.ResetReadCount();
@@ -291,7 +292,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/rel", parentFolderNodeId: null, displayName: "Rel", dataType: "Double", nm.EnsureVariable("eq-1/rel", parentFolderNodeId: null, displayName: "Rel", dataType: "Double",
writable: false, historianTagname: "WW.Rel"); writable: false, historianTagname: "WW.Rel", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/rel")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/rel")!.NodeId;
// Page 1 — get a CP. // Page 1 — get a CP.
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Opc.Ua; using Opc.Ua;
using Opc.Ua.Server; using Opc.Ua.Server;
using Shouldly; using Shouldly;
@@ -39,7 +40,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
writable: false, historianTagname: "WW.Temp"); writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;
var src = DateTime.UtcNow.AddSeconds(-5); var src = DateTime.UtcNow.AddSeconds(-5);
@@ -91,7 +92,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
// Node id "eq-9/flow" but a DISTINCT historian tagname "Plant.Flow.PV". // Node id "eq-9/flow" but a DISTINCT historian tagname "Plant.Flow.PV".
nm.EnsureVariable("eq-9/flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double", nm.EnsureVariable("eq-9/flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double",
writable: false, historianTagname: "Plant.Flow.PV"); writable: false, historianTagname: "Plant.Flow.PV", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-9/flow")!.NodeId; var nodeId = nm.TryGetVariable("eq-9/flow")!.NodeId;
var details = new ReadRawModifiedDetails var details = new ReadRawModifiedDetails
@@ -123,7 +124,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/empty", parentFolderNodeId: null, displayName: "Empty", dataType: "Float", nm.EnsureVariable("eq-1/empty", parentFolderNodeId: null, displayName: "Empty", dataType: "Float",
writable: false, historianTagname: "WW.Empty"); writable: false, historianTagname: "WW.Empty", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/empty")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/empty")!.NodeId;
var details = new ReadRawModifiedDetails var details = new ReadRawModifiedDetails
@@ -156,7 +157,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
// Plain (non-historized) variable — no HistoryRead access bit. // Plain (non-historized) variable — no HistoryRead access bit.
nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32", nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32",
writable: false, historianTagname: null); writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/plain")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/plain")!.NodeId;
var details = new ReadRawModifiedDetails var details = new ReadRawModifiedDetails
@@ -185,7 +186,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/mod", parentFolderNodeId: null, displayName: "Mod", dataType: "Float", nm.EnsureVariable("eq-1/mod", parentFolderNodeId: null, displayName: "Mod", dataType: "Float",
writable: false, historianTagname: "WW.Mod"); writable: false, historianTagname: "WW.Mod", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/mod")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/mod")!.NodeId;
var details = new ReadRawModifiedDetails var details = new ReadRawModifiedDetails
@@ -215,7 +216,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/avg", parentFolderNodeId: null, displayName: "Avg", dataType: "Float", nm.EnsureVariable("eq-1/avg", parentFolderNodeId: null, displayName: "Avg", dataType: "Float",
writable: false, historianTagname: "WW.Avg"); writable: false, historianTagname: "WW.Avg", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/avg")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/avg")!.NodeId;
var details = new ReadProcessedDetails var details = new ReadProcessedDetails
@@ -247,7 +248,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/sd", parentFolderNodeId: null, displayName: "Sd", dataType: "Float", nm.EnsureVariable("eq-1/sd", parentFolderNodeId: null, displayName: "Sd", dataType: "Float",
writable: false, historianTagname: "WW.Sd"); writable: false, historianTagname: "WW.Sd", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/sd")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/sd")!.NodeId;
var details = new ReadProcessedDetails var details = new ReadProcessedDetails
@@ -277,7 +278,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/at", parentFolderNodeId: null, displayName: "At", dataType: "Float", nm.EnsureVariable("eq-1/at", parentFolderNodeId: null, displayName: "At", dataType: "Float",
writable: false, historianTagname: "WW.At"); writable: false, historianTagname: "WW.At", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/at")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/at")!.NodeId;
var t1 = DateTime.UtcNow.AddMinutes(-2); var t1 = DateTime.UtcNow.AddMinutes(-2);
@@ -309,9 +310,9 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
// Materialise two distinct historized variables under separate equipment folders. // Materialise two distinct historized variables under separate equipment folders.
nm.EnsureVariable("eqA/good", parentFolderNodeId: null, displayName: "Good", dataType: "Float", nm.EnsureVariable("eqA/good", parentFolderNodeId: null, displayName: "Good", dataType: "Float",
writable: false, historianTagname: "A.PV"); writable: false, historianTagname: "A.PV", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eqB/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Float", nm.EnsureVariable("eqB/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Float",
writable: false, historianTagname: "B.PV"); writable: false, historianTagname: "B.PV", realm: AddressSpaceRealm.Uns);
var goodNodeId = nm.TryGetVariable("eqA/good")!.NodeId; var goodNodeId = nm.TryGetVariable("eqA/good")!.NodeId;
var badNodeId = nm.TryGetVariable("eqB/bad")!.NodeId; var badNodeId = nm.TryGetVariable("eqB/bad")!.NodeId;
@@ -368,7 +369,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake; nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/boom", parentFolderNodeId: null, displayName: "Boom", dataType: "Float", nm.EnsureVariable("eq-1/boom", parentFolderNodeId: null, displayName: "Boom", dataType: "Float",
writable: false, historianTagname: "WW.Boom"); writable: false, historianTagname: "WW.Boom", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/boom")!.NodeId; var nodeId = nm.TryGetVariable("eq-1/boom")!.NodeId;
var details = new ReadRawModifiedDetails var details = new ReadRawModifiedDetails
@@ -43,8 +43,8 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7"); nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false); nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
var parent = nm.TryGetFolder("eq-7")!; var parent = nm.TryGetFolder("eq-7")!;
var e = nm.BuildNodesAddedModelChange("eq-7"); var e = nm.BuildNodesAddedModelChange("eq-7");
@@ -95,13 +95,13 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
// Before any nodes exist under the parent — must not throw. // Before any nodes exist under the parent — must not throw.
Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9")); Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9", realm: AddressSpaceRealm.Uns));
nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9"); nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false); nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
// After the nodes are materialised — still must not throw. // After the nodes are materialised — still must not throw.
Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9")); Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9", realm: AddressSpaceRealm.Uns));
await host.DisposeAsync(); await host.DisposeAsync();
} }
@@ -38,7 +38,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
try try
{ {
var ex = Should.Throw<InvalidOperationException>(() => var ex = Should.Throw<InvalidOperationException>(() =>
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment")); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns));
ex.Message.ShouldContain("address space has not been created"); ex.Message.ShouldContain("address space has not been created");
} }
finally finally
@@ -55,7 +55,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
{ {
Should.Throw<InvalidOperationException>(() => Should.Throw<InvalidOperationException>(() =>
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp",
dataType: "Float", writable: false)); dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns));
} }
finally finally
{ {
@@ -70,7 +70,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
try try
{ {
Should.Throw<InvalidOperationException>(() => Should.Throw<InvalidOperationException>(() =>
nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow)); nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns));
} }
finally finally
{ {
@@ -85,7 +85,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
try try
{ {
Should.Throw<InvalidOperationException>(() => Should.Throw<InvalidOperationException>(() =>
nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500)); nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns));
} }
finally finally
{ {
@@ -1,4 +1,5 @@
using Opc.Ua; using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
@@ -31,13 +32,13 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
{ {
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A"); nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false); nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false, realm: AddressSpaceRealm.Uns);
var countBefore = nm.VariableCount; var countBefore = nm.VariableCount;
nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeTrue(); nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeTrue();
nm.RemoveVariableNode("eq-1/A").ShouldBeTrue(); nm.RemoveVariableNode("eq-1/A", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
nm.TryGetVariable("eq-1/A").ShouldBeNull(); nm.TryGetVariable("eq-1/A").ShouldBeNull();
nm.VariableCount.ShouldBe(countBefore - 1); nm.VariableCount.ShouldBe(countBefore - 1);
@@ -56,7 +57,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.RemoveVariableNode("eq-1/nope").ShouldBeFalse(); nm.RemoveVariableNode("eq-1/nope", realm: AddressSpaceRealm.Uns).ShouldBeFalse();
await host.DisposeAsync(); await host.DisposeAsync();
} }
@@ -68,8 +69,8 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
{ {
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false); nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, realm: AddressSpaceRealm.Uns);
var e = nm.BuildNodesRemovedModelChange("eq-1/A"); var e = nm.BuildNodesRemovedModelChange("eq-1/A");
@@ -92,17 +93,17 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
{ {
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true); nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldNotBeNull(); nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldNotBeNull();
nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeTrue(); nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeTrue();
nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeTrue(); nm.RemoveAlarmConditionNode("eq-1/OverTemp", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldBeNull(); nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldBeNull();
nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeFalse(); nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeFalse();
nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeFalse(); // already gone ⇒ false nm.RemoveAlarmConditionNode("eq-1/OverTemp", realm: AddressSpaceRealm.Uns).ShouldBeFalse(); // already gone ⇒ false
await host.DisposeAsync(); await host.DisposeAsync();
} }
@@ -114,10 +115,10 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
{ {
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false); nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false, realm: AddressSpaceRealm.Uns);
nm.RemoveAlarmConditionNode("alm-1").ShouldBeTrue(); nm.RemoveAlarmConditionNode("alm-1", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
nm.TryGetAlarmCondition("alm-1").ShouldBeNull(); nm.TryGetAlarmCondition("alm-1").ShouldBeNull();
await host.DisposeAsync(); await host.DisposeAsync();
@@ -136,17 +137,17 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
// Target equipment eq-1 with a sub-folder, two variables (one historized), and a native condition. // Target equipment eq-1 with a sub-folder, two variables (one historized), and a native condition.
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag"); nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A"); nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false); nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true); nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
// Sibling equipment eq-2 that must survive untouched. // Sibling equipment eq-2 that must survive untouched.
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2"); nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false); nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.RemoveEquipmentSubtree("eq-1").ShouldBeTrue(); nm.RemoveEquipmentSubtree("eq-1", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
// Every eq-1 descendant is gone from every map. // Every eq-1 descendant is gone from every map.
nm.TryGetFolder("eq-1").ShouldBeNull(); nm.TryGetFolder("eq-1").ShouldBeNull();
@@ -163,7 +164,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
// Re-materialising an alarm under eq-2 still works (the notifier machinery was not corrupted by the // Re-materialising an alarm under eq-2 still works (the notifier machinery was not corrupted by the
// eq-1 demotion) — proves no orphaned root-notifier ref broke the event path. // eq-1 demotion) — proves no orphaned root-notifier ref broke the event path.
Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false)); Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false, realm: AddressSpaceRealm.Uns));
await host.DisposeAsync(); await host.DisposeAsync();
} }
@@ -176,7 +177,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.RemoveEquipmentSubtree("eq-nope").ShouldBeFalse(); nm.RemoveEquipmentSubtree("eq-nope", realm: AddressSpaceRealm.Uns).ShouldBeFalse();
await host.DisposeAsync(); await host.DisposeAsync();
} }
@@ -44,13 +44,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow); nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!; var node = nm.TryGetVariable("eq-1/sp")!;
node.DataType.ShouldBe(DataTypeIds.Float); // arrange guard node.DataType.ShouldBe(DataTypeIds.Float); // arrange guard
var applied = nm.UpdateTagAttributes("eq-1/sp", writable: false, historianTagname: null, var applied = nm.UpdateTagAttributes("eq-1/sp", writable: false, historianTagname: null,
dataType: "Int32", isArray: false, arrayLength: null); dataType: "Int32", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue(); applied.ShouldBeTrue();
node.DataType.ShouldBe(DataTypeIds.Int32); // swapped in place node.DataType.ShouldBe(DataTypeIds.Int32); // swapped in place
@@ -69,13 +69,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false); nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow); nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!; var node = nm.TryGetVariable("eq-1/buf")!;
node.ValueRank.ShouldBe(ValueRanks.Scalar); // arrange guard node.ValueRank.ShouldBe(ValueRanks.Scalar); // arrange guard
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
dataType: "Int16", isArray: true, arrayLength: 8u); dataType: "Int16", isArray: true, arrayLength: 8u, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue(); applied.ShouldBeTrue();
node.ValueRank.ShouldBe(ValueRanks.OneDimension); node.ValueRank.ShouldBe(ValueRanks.OneDimension);
@@ -96,13 +96,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
writable: false, historianTagname: null, isArray: true, arrayLength: 4u); writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow); nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!; var node = nm.TryGetVariable("eq-1/buf")!;
node.ArrayDimensions![0].ShouldBe(4u); // arrange guard node.ArrayDimensions![0].ShouldBe(4u); // arrange guard
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
dataType: "Int16", isArray: true, arrayLength: 8u); dataType: "Int16", isArray: true, arrayLength: 8u, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue(); applied.ShouldBeTrue();
node.ArrayDimensions[0].ShouldBe(8u); node.ArrayDimensions[0].ShouldBe(8u);
@@ -121,13 +121,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
writable: false, historianTagname: null, isArray: true, arrayLength: 4u); writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow); nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!; var node = nm.TryGetVariable("eq-1/buf")!;
node.ValueRank.ShouldBe(ValueRanks.OneDimension); // arrange guard node.ValueRank.ShouldBe(ValueRanks.OneDimension); // arrange guard
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
dataType: "Int16", isArray: false, arrayLength: null); dataType: "Int16", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue(); applied.ShouldBeTrue();
node.ValueRank.ShouldBe(ValueRanks.Scalar); node.ValueRank.ShouldBe(ValueRanks.Scalar);
@@ -146,11 +146,11 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false); nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!; var node = nm.TryGetVariable("eq-1/buf")!;
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
dataType: "Int16", isArray: true, arrayLength: null); dataType: "Int16", isArray: true, arrayLength: null, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue(); applied.ShouldBeTrue();
node.ValueRank.ShouldBe(ValueRanks.OneDimension); node.ValueRank.ShouldBe(ValueRanks.OneDimension);
@@ -171,13 +171,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow); nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!; var node = nm.TryGetVariable("eq-1/sp")!;
// Same DataType ("Float") + same scalar shape — only Writable flips false → true. // Same DataType ("Float") + same scalar shape — only Writable flips false → true.
var applied = nm.UpdateTagAttributes("eq-1/sp", writable: true, historianTagname: null, var applied = nm.UpdateTagAttributes("eq-1/sp", writable: true, historianTagname: null,
dataType: "Float", isArray: false, arrayLength: null); dataType: "Float", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue(); applied.ShouldBeTrue();
node.Value.ShouldBe(7.0f); // value preserved (NOT reset) node.Value.ShouldBe(7.0f); // value preserved (NOT reset)
@@ -199,7 +199,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
bool result = true; bool result = true;
Should.NotThrow(() => result = nm.UpdateTagAttributes("eq-1/gone", writable: false, historianTagname: null, Should.NotThrow(() => result = nm.UpdateTagAttributes("eq-1/gone", writable: false, historianTagname: null,
dataType: "Int32", isArray: false, arrayLength: null)); dataType: "Int32", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns));
result.ShouldBeFalse(); result.ShouldBeFalse();
await host.DisposeAsync(); await host.DisposeAsync();
@@ -215,7 +215,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!; var node = nm.TryGetVariable("eq-1/sp")!;
var e = nm.BuildNodeShapeChangedEvent(node); var e = nm.BuildNodeShapeChangedEvent(node);
@@ -50,9 +50,9 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
// Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject). // Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject).
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!; var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 42; // simulate the SDK's optimistic apply of the rejected write node.Value = 42; // simulate the SDK's optimistic apply of the rejected write
@@ -62,7 +62,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
optimisticValue: 42, optimisticValue: 42,
priorValue: 7, priorValue: 7,
priorStatus: StatusCodes.Good, priorStatus: StatusCodes.Good,
clientUserId: "op"); clientUserId: "op", realm: AddressSpaceRealm.Uns);
node.Value.ShouldBe(7); // settled back to prior value node.Value.ShouldBe(7); // settled back to prior value
node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); // settled back to prior status (NOT stuck Bad) node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); // settled back to prior status (NOT stuck Bad)
@@ -78,15 +78,15 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!; var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 42; node.Value = 42;
nm.RevertOptimisticWriteIfNeeded( nm.RevertOptimisticWriteIfNeeded(
"eq-1/sp", "eq-1/sp",
outcome: new NodeWriteOutcome(true, null), outcome: new NodeWriteOutcome(true, null),
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns);
node.Value.ShouldBe(42); // success ⇒ optimistic value stands node.Value.ShouldBe(42); // success ⇒ optimistic value stands
@@ -101,15 +101,15 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!; var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42) node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42)
nm.RevertOptimisticWriteIfNeeded( nm.RevertOptimisticWriteIfNeeded(
"eq-1/sp", "eq-1/sp",
outcome: new NodeWriteOutcome(false, "device rejected"), outcome: new NodeWriteOutcome(false, "device rejected"),
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns);
node.Value.ShouldBe(99); // poll value preserved — not reverted to 7 node.Value.ShouldBe(99); // poll value preserved — not reverted to 7
@@ -127,7 +127,71 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
Should.NotThrow(() => nm.RevertOptimisticWriteIfNeeded( Should.NotThrow(() => nm.RevertOptimisticWriteIfNeeded(
"eq-1/gone", "eq-1/gone",
outcome: new NodeWriteOutcome(false, "device rejected"), outcome: new NodeWriteOutcome(false, "device rejected"),
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op")); optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns));
await host.DisposeAsync();
}
// ─────────────────── v3 Batch 4 (review M2) — realm-qualified dual-node self-correction ───────────────────
/// <summary>(M2a) A failed write driven through a UNS NodeId reverts ONLY the UNS node back to its prior
/// value; the raw node that shares the SAME bare id (but lives in the Raw realm) is NEVER touched and keeps
/// its driver value. The revert routes by (realm, bareId), so it can't clobber the sibling node.</summary>
[Fact]
public async Task Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
// Same bare id "A/B/C/D" in BOTH realms — the raw device node and the UNS reference node.
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
var now = DateTime.UtcNow;
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw); // raw driver value
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns); // fanned UNS value
var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!;
var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!;
uns.Value = 42; // the SDK optimistically applied the client's UNS write
nm.RevertOptimisticWriteIfNeeded(
"A/B/C/D", outcome: new NodeWriteOutcome(false, "device rejected"),
optimisticValue: 42, priorValue: 10, priorStatus: StatusCodes.Good, clientUserId: "op",
realm: AddressSpaceRealm.Uns);
uns.Value.ShouldBe(10); // UNS node reverted to prior
raw.Value.ShouldBe(10); // raw node NEVER changed (realm-qualified revert)
await host.DisposeAsync();
}
/// <summary>(M2b) A raw-realm revert reverts the RAW node and leaves the same-bare-id UNS node untouched.
/// This locks the realm arg: were it omitted (defaulting to Uns) the revert would target the UNS node — whose
/// value is not the optimistic one, so the still-holds-optimistic guard skips it — leaving the raw node stuck
/// at the rejected value and FAILING the raw-reverts assertion below.</summary>
[Fact]
public async Task Raw_realm_revert_reverts_raw_node_only()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
var now = DateTime.UtcNow;
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw);
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns);
var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!;
var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!;
raw.Value = 42; // optimistic write to the RAW node
nm.RevertOptimisticWriteIfNeeded(
"A/B/C/D", outcome: new NodeWriteOutcome(false, "device rejected"),
optimisticValue: 42, priorValue: 10, priorStatus: StatusCodes.Good, clientUserId: "op",
realm: AddressSpaceRealm.Raw);
raw.Value.ShouldBe(10); // raw reverted (would stay 42 if the realm were dropped to Uns)
uns.Value.ShouldBe(10); // UNS node untouched
await host.DisposeAsync(); await host.DisposeAsync();
} }
@@ -143,8 +207,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!; var node = nm.TryGetVariable("eq-1/sp")!;
var audit = nm.BuildWriteFailureAuditEvent( var audit = nm.BuildWriteFailureAuditEvent(
@@ -174,8 +238,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var nm = server.NodeManager!; var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!; var node = nm.TryGetVariable("eq-1/sp")!;
var audit = nm.BuildWriteFailureAuditEvent( var audit = nm.BuildWriteFailureAuditEvent(
@@ -1,6 +1,7 @@
using Akka.Actor; using Akka.Actor;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Shouldly; using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
@@ -24,7 +25,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
var probe = CreateTestProbe(); var probe = CreateTestProbe();
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance); var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance);
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
var routed = probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5)); var routed = probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5));
routed.NodeId.ShouldBe("eq-1/speed"); routed.NodeId.ShouldBe("eq-1/speed");
@@ -43,7 +44,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
var probe = CreateTestProbe(); var probe = CreateTestProbe();
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance); var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance);
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5)); probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5));
probe.Reply(new DriverHostActor.NodeWriteResult(false, "not primary")); probe.Reply(new DriverHostActor.NodeWriteResult(false, "not primary"));
@@ -63,7 +64,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
resolveDriverHost: () => probe.Ref, NullLogger.Instance, resolveDriverHost: () => probe.Ref, NullLogger.Instance,
askTimeout: TimeSpan.FromMilliseconds(200)); askTimeout: TimeSpan.FromMilliseconds(200));
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
outcome.Success.ShouldBeFalse(); outcome.Success.ShouldBeFalse();
outcome.Reason.ShouldNotBeNull(); outcome.Reason.ShouldNotBeNull();
@@ -77,7 +78,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
{ {
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => null, NullLogger.Instance); var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => null, NullLogger.Instance);
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
outcome.Success.ShouldBeFalse(); outcome.Success.ShouldBeFalse();
outcome.Reason.ShouldBe("writes unavailable"); outcome.Reason.ShouldBe("writes unavailable");
@@ -18,6 +18,18 @@ internal static class DarkAddressSpaceReasons
"empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " + "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."; "intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests.";
/// <summary>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 <c>/raw</c> browse-commit flow. <c>HandleDiscoveredNodes</c>
/// 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
/// <c>DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3</c>.</summary>
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.";
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary> /// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
public const string EquipmentDeviceBindingRetired = public const string EquipmentDeviceBindingRetired =
"v3: equipment no longer binds a driver/device (EquipmentNode has no DriverInstanceId/DeviceId/" + "v3: equipment no longer binds a driver/device (EquipmentNode has no DriverInstanceId/DeviceId/" +
@@ -0,0 +1,84 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// v3 Batch 4 (Wave-B review recommendation) — the artifact-decode seam
/// (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>) and the live compose seam
/// (<see cref="AddressSpaceComposer.Compose"/>) must produce BYTE-IDENTICAL <b>Raw</b> and <b>UNS</b> node
/// sets for the same seeded config. This is the guard that a future artifact-decode drift can't silently
/// diverge the deployed dual-namespace address space from what was authored/validated. The existing
/// <c>DeploymentArtifactEquipRefParityTests</c> covers <c>{{equip}}</c> script-path parity; this covers the
/// RawContainers / RawTags / UnsReferenceVariables sets (RawPaths, UNS NodeIds, Organizes backing paths,
/// writable + historian + array shape).
/// </summary>
public sealed class DeploymentArtifactRawUnsParityTests
{
[Fact]
public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode()
{
// Entity-side config: folder → driver → device → group → 2 tags (one historized+writable+override,
// one plain read-only), an area/line/equipment, and a UNS reference with a display-name override.
var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null };
var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" };
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" };
var group = new TagGroup { TagGroupId = "grp-1", DeviceId = "dev-1", Name = "Fast", ParentTagGroupId = null };
var speed = new Tag
{
TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float",
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}",
};
var run = new Tag
{
TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "a1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" };
var equip = new Equipment { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" };
var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" };
var composed = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { reference }, tags: new[] { speed, run },
rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group });
// Artifact JSON — the same shape ConfigComposer serializes (enums numeric: ReadWrite = 1, Read = 0).
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "fld-1", ParentRawFolderId = (string?)null, Name = "Cell1", ClusterId = "c1" } },
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "fld-1", ClusterId = "c1" } },
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } },
TagGroups = new[] { new { TagGroupId = "grp-1", ParentTagGroupId = (string?)null, DeviceId = "dev-1", Name = "Fast" } },
Tags = new object[]
{
new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" },
new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" } },
});
var decoded = DeploymentArtifact.ParseComposition(blob);
// Sanity: the sets are non-empty (both raw tags + the projected UNS variable materialised).
composed.RawTags.Count.ShouldBe(2);
composed.UnsReferenceVariables.Count.ShouldBe(1);
composed.UnsReferenceVariables[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed");
composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed");
// Byte-parity: the three dual-namespace node sets are record-equal between the two seams.
decoded.RawContainers.ShouldBe(composed.RawContainers);
decoded.RawTags.ShouldBe(composed.RawTags);
decoded.UnsReferenceVariables.ShouldBe(composed.UnsReferenceVariables);
}
}
@@ -108,7 +108,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
/// <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production) /// <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production)
/// overwrote the BadWaitingForInitialData seed. /// overwrote the BadWaitingForInitialData seed.
/// </summary> /// </summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good() public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -154,7 +154,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
/// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL /// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL
/// <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()). /// <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()).
/// </summary> /// </summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_node_and_value_survive_a_redeploy() public void Discovered_node_and_value_survive_a_redeploy()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -49,12 +49,39 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc); private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);
/// <summary>v3 Batch 4 (review M1) — the ONE live dormancy pin: discovered-node injection is short-circuited.
/// A driver reports a FixedTree via <see cref="DriverInstanceActor.DiscoveredNodesReady"/>, but the host must
/// NOT materialise anything (no <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> reaches the publish
/// side) — discovered raw tags are authored via the <c>/raw</c> browse-commit flow, not injected at runtime.
/// The skipped v2 injection scenarios below are the counterpart of this guard.</summary>
[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));
}
/// <summary>A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the /// <summary>A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the
/// bound equipment: (a) the publish side receives <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> /// bound equipment: (a) the publish side receives <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>
/// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the /// 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 /// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the
/// live-value routing map was extended).</summary> /// live-value routing map was extended).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription() public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -121,7 +148,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> rooted at its NodeId and the driver /// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> rooted at its NodeId and the driver
/// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with /// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with
/// "no equipment/authored tags".</summary> /// "no equipment/authored tags".</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes() public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -198,7 +225,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref /// 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" /// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
/// vs stored "h1" wrinkle proves the SHARED <c>DeviceConfigIntent.NormalizeHost</c> match.</summary> /// vs stored "h1" wrinkle proves the SHARED <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment() public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -264,7 +291,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here /// 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 /// 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).</summary> /// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft() public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -305,7 +332,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// logged at Debug, which the suite's <c>loglevel = WARNING</c> HOCON suppresses at source, so EventFilter /// logged at Debug, which the suite's <c>loglevel = WARNING</c> HOCON suppresses at source, so EventFilter
/// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on /// 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 <c>PlansRoutingEqual</c>.)</summary> /// pass 1; pass 2's matched routing is short-circuited by <c>PlansRoutingEqual</c>.)</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet() public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -378,7 +405,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// <summary>Dedup: a discovered node whose <c>FullReference</c> equals an authored equipment tag's /// <summary>Dedup: a discovered node whose <c>FullReference</c> equals an authored equipment tag's
/// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs /// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs
/// are materialised.</summary> /// are materialised.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_node_shadowing_an_authored_ref_is_not_injected() public void Discovered_node_shadowing_an_authored_ref_is_not_injected()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -413,7 +440,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to /// 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 /// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising
/// FixedTree still converges.</summary> /// FixedTree still converges.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does() public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -469,7 +496,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached /// 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 /// 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.</summary> /// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_nodes_survive_a_redeploy_rebuild() public void Discovered_nodes_survive_a_redeploy_rebuild()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -536,7 +563,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a /// (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 /// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for the now-unresolved driver.</summary> /// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for the now-unresolved driver.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_nodes_dropped_when_equipment_no_longer_resolves() public void Discovered_nodes_dropped_when_equipment_no_longer_resolves()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -578,7 +605,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// drops the entry. After the redeploy NO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is /// drops the entry. After the redeploy NO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is
/// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which /// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which
/// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary> /// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment() public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -627,7 +654,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks /// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks
/// discovery) AND a fresh <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> re-grafts the FixedTree /// discovery) AND a fresh <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> re-grafts the FixedTree
/// under the new equipment EQ-2.</summary> /// under the new equipment EQ-2.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Config_unchanged_rebind_re_triggers_discovery_on_the_child() public void Config_unchanged_rebind_re_triggers_discovery_on_the_child()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -706,7 +733,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// guard — so they would NOT catch a future regression that sets <c>droppedAny</c> on a non-drop path). The /// guard — so they would NOT catch a future regression that sets <c>droppedAny</c> on a non-drop path). The
/// regression is observable here: a spurious trigger would advance <c>DiscoverCount</c> past the single Once /// regression is observable here: a spurious trigger would advance <c>DiscoverCount</c> past the single Once
/// pass.</summary> /// pass.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void No_drop_redeploy_does_not_re_trigger_discovery() public void No_drop_redeploy_does_not_re_trigger_discovery()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -757,7 +784,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// SubscribeAsync — no de-dup in <see cref="DriverInstanceActor"/>): the count rises by EXACTLY 1 across the /// SubscribeAsync — no de-dup in <see cref="DriverInstanceActor"/>): 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 /// 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".)</summary> /// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".)</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription() public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -823,7 +850,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also /// via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also
/// gets a <see cref="DriverInstanceActor.TriggerRediscovery"/> — a different message type the non-discovery /// gets a <see cref="DriverInstanceActor.TriggerRediscovery"/> — 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.</summary> /// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback() public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -885,7 +912,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// authored-only <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> per redeploy (the re-inject tail /// authored-only <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> 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 /// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's
/// send. Observed via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only).</summary> /// send. Observed via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription() public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -934,7 +961,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// the Connected handler routes to <c>Unsubscribe</c> (dropping the stale FixedTree handle) — NOT a subscribe. /// the Connected handler routes to <c>Unsubscribe</c> (dropping the stale FixedTree handle) — NOT a subscribe.
/// Proven by <c>SubscribeCount</c> staying FLAT across the redeploy (no spurious subscribe), closing the /// Proven by <c>SubscribeCount</c> staying FLAT across the redeploy (no spurious subscribe), closing the
/// SubscribeCount-proxy blind spot for the empty-set fallback.</summary> /// SubscribeCount-proxy blind spot for the empty-set fallback.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing() public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -6,6 +6,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit; using Akka.TestKit;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Shouldly; using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
@@ -51,7 +52,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2); var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2);
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse(); result.Success.ShouldBeFalse();
@@ -75,7 +76,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() => AwaitAssert(() =>
{ {
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var res = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var res = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary"); res.Reason.ShouldNotBe("not primary");
res.Reason.ShouldNotBe("not primary (role unknown)"); res.Reason.ShouldNotBe("not primary (role unknown)");
@@ -100,7 +101,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() => AwaitAssert(() =>
{ {
var asker1 = CreateTestProbe(); var asker1 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0), asker1.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0, AddressSpaceRealm.Uns), asker1.Ref);
var res = asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var res = asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary"); res.Reason.ShouldNotBe("not primary");
res.Success.ShouldBeTrue(res.Reason); res.Success.ShouldBeTrue(res.Reason);
@@ -109,7 +110,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
// Secondary snapshot ⇒ denied with the steady-state reason. // Secondary snapshot ⇒ denied with the steady-state reason.
TellRole(actor, RedundancyRole.Secondary); TellRole(actor, RedundancyRole.Secondary);
var asker2 = CreateTestProbe(); var asker2 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0), asker2.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0, AddressSpaceRealm.Uns), asker2.Ref);
var denied = asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var denied = asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
denied.Success.ShouldBeFalse(); denied.Success.ShouldBeFalse();
denied.Reason.ShouldBe("not primary"); denied.Reason.ShouldBe("not primary");
@@ -126,7 +127,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2); var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2);
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeFalse(); asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeFalse();
AwaitAssert(() => AwaitAssert(() =>
@@ -3,6 +3,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event; using Akka.Event;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -87,7 +88,7 @@ public sealed class DriverHostActorProbeResultDropTests : RuntimeActorTestBase
// drained its mailbox past the OpcUaProbeResult message, so any dead-letter from an unhandled // drained its mailbox past the OpcUaProbeResult message, so any dead-letter from an unhandled
// OpcUaProbeResult would already be on the EventStream before we assert. // OpcUaProbeResult would already be on the EventStream before we assert.
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
// Assert no dead-letter carrying an OpcUaProbeResult was published. // Assert no dead-letter carrying an OpcUaProbeResult was published.
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
@@ -56,7 +57,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var asker = CreateTestProbe(); var asker = CreateTestProbe();
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it. // The node manager passes the FULL ns-qualified id; the host must normalise + resolve it.
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue(); asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() => AwaitAssert(() =>
@@ -79,7 +80,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder); var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue(); asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() => AwaitAssert(() =>
@@ -110,7 +111,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse(); result.Success.ShouldBeFalse();
@@ -129,7 +130,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder); var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse(); result.Success.ShouldBeFalse();
@@ -149,7 +150,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
localRoles: new HashSet<string> { "driver" })); localRoles: new HashSet<string> { "driver" }));
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2)); var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
result.Success.ShouldBeFalse(); result.Success.ShouldBeFalse();
@@ -157,6 +158,46 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
result.Reason!.ShouldContain("stale"); result.Reason!.ShouldContain("stale");
} }
/// <summary>
/// Review H1 regression — a raw <c>RawPath</c> and a UNS path that share a BARE <c>s=</c> id but back
/// DIFFERENT driver instances must each route to their OWN driver. Seeds raw tag <c>A/B/C/D</c> on
/// <c>drv-1</c> and a UnsTagReference whose UNS NodeId is ALSO <c>A/B/C/D</c> (Area A / Line B / Equip C
/// / effective name D) but which backs a raw tag <c>X/Y/Z/W</c> on <c>drv-2</c>. A bare-only routing key
/// would collide (last-writer-wins → wrong device); the <c>(realm, bareId)</c> key keeps them distinct.
/// </summary>
[Fact]
public void Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm()
{
var db = NewInMemoryDbFactory();
var factory = new PerInstanceRecordingDriverFactory("Modbus");
var deploymentId = SeedCollisionDeployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, factory);
// Write to the RAW node A/B/C/D → drv-1, forwarded by the raw tag's RawPath A/B/C/D.
var asker1 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=2;s=A/B/C/D", 111.0, AddressSpaceRealm.Raw), asker1.Ref);
asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
// Write to the UNS node A/B/C/D → drv-2, forwarded by the BACKING raw tag's RawPath X/Y/Z/W.
var asker2 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=A/B/C/D", 222.0, AddressSpaceRealm.Uns), asker2.Ref);
asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
{
// drv-1 got ONLY the raw write (A/B/C/D, 111); drv-2 got ONLY the UNS-routed write (X/Y/Z/W, 222).
var w1 = factory.WritesFor("drv-1");
var w2 = factory.WritesFor("drv-2");
w1.Count.ShouldBe(1);
w1[0].FullReference.ShouldBe("A/B/C/D");
w1[0].Value.ShouldBe(111.0);
w2.Count.ShouldBe(1);
w2[0].FullReference.ShouldBe("X/Y/Z/W"); // the backing RawPath, NOT the shared bare id
w2[0].Value.ShouldBe(222.0);
}, duration: Timeout);
}
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits for /// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits for
/// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a /// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a
/// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor.</summary> /// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor.</summary>
@@ -214,6 +255,52 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
return id; return id;
} }
/// <summary>Seeds the review-H1 collision config: raw tag <c>A/B/C/D</c> on drv-1, and a UnsTagReference
/// (Area A / Line B / Equip C, effective name D → UNS NodeId <c>A/B/C/D</c>) backing raw tag <c>X/Y/Z/W</c>
/// on drv-2. Both drivers ENABLED (Modbus) so both children spawn.</summary>
private static DeploymentId SeedCollisionDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[]
{
new { RawFolderId = "rf-a", ParentRawFolderId = (string?)null, Name = "A", ClusterId = "c1" },
new { RawFolderId = "rf-x", ParentRawFolderId = (string?)null, Name = "X", ClusterId = "c1" },
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-1", RawFolderId = "rf-a", Name = "B", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
new { DriverInstanceId = "drv-2", RawFolderId = "rf-x", Name = "Y", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
Devices = new[]
{
new { DeviceId = "dev-c", DriverInstanceId = "drv-1", Name = "C", DeviceConfig = "{}" },
new { DeviceId = "dev-z", DriverInstanceId = "drv-2", Name = "Z", DeviceConfig = "{}" },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new { TagId = "t-d", DeviceId = "dev-c", TagGroupId = (string?)null, Name = "D", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
new { TagId = "t-w", DeviceId = "dev-z", TagGroupId = (string?)null, Name = "W", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "A", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "B" } },
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "C", MachineCode = "M1" } },
// Effective name "D" (override) → UNS NodeId A/B/C/D, backing raw tag t-w (X/Y/Z/W) on drv-2.
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-w", DisplayNameOverride = "D" } },
});
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;
}
/// <summary>An <see cref="IDbContextFactory{T}"/> whose CreateDbContext always throws — drives the host /// <summary>An <see cref="IDbContextFactory{T}"/> whose CreateDbContext always throws — drives the host
/// into the Stale path.</summary> /// into the Stale path.</summary>
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext> private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
@@ -223,6 +310,41 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
throw new InvalidOperationException("config DB unreachable (test stub)"); throw new InvalidOperationException("config DB unreachable (test stub)");
} }
/// <summary>Factory producing a SEPARATE <see cref="RecordingDriver"/> per driver instance id, so a test
/// with two drivers can assert which instance received a given write (used by the H1 collision test).</summary>
private sealed class PerInstanceRecordingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly Dictionary<string, RecordingDriver> _byId = new(StringComparer.Ordinal);
private readonly object _lock = new();
public PerInstanceRecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>The writes the given driver instance received.</summary>
public IReadOnlyList<WriteRequest> WritesFor(string driverInstanceId)
{
lock (_lock) return _byId.TryGetValue(driverInstanceId, out var d) ? d.Writes : Array.Empty<WriteRequest>();
}
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
lock (_lock)
{
if (!_byId.TryGetValue(driverInstanceId, out var d))
{
d = new RecordingDriver();
d.Bind(driverInstanceId, driverType);
_byId[driverInstanceId] = d;
}
return d;
}
}
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
}
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type.</summary> /// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type.</summary>
private sealed class RecordingDriverFactory : IDriverFactory private sealed class RecordingDriverFactory : IDriverFactory
{ {
@@ -51,7 +51,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
NodeId: "ns=2;s=tag-1", NodeId: "ns=2;s=tag-1",
Value: 42, Value: 42,
Quality: OpcUaQuality.Good, Quality: OpcUaQuality.Good,
TimestampUtc: DateTime.UtcNow)); TimestampUtc: DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
AwaitAssertion(() => AwaitAssertion(() =>
{ {
@@ -20,8 +20,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests() public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
{ {
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests()); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests());
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow)); actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow)); actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240)); actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));
@@ -43,8 +43,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
var sink = new RecordingSink(); var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow)); actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow)); actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
AwaitAssert(() => AwaitAssert(() =>
{ {
@@ -64,7 +64,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate( actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
"ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow)); "ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
AwaitAssert(() => AwaitAssert(() =>
{ {
@@ -138,7 +138,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
} }
/// <summary>Activation path: with the alarm loaded, pushing a value above the threshold drives an /// <summary>Activation path: with the alarm loaded, pushing a value above the threshold drives an
/// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true) and an /// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true, Realm: AddressSpaceRealm.Uns) and an
/// AlarmTransitionEvent("Activated") on the alerts topic.</summary> /// AlarmTransitionEvent("Activated") on the alerts topic.</summary>
[Fact] [Fact]
public void Dependency_change_above_threshold_activates_alarm() public void Dependency_change_above_threshold_activates_alarm()
@@ -180,7 +180,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
} }
/// <summary>Clear path: after activating, pushing a value below the threshold drives Active→Inactive /// <summary>Clear path: after activating, pushing a value below the threshold drives Active→Inactive
/// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared").</summary> /// — AlarmStateUpdate(Active=false, Realm: AddressSpaceRealm.Uns) + AlarmTransitionEvent("Cleared").</summary>
[Fact] [Fact]
public void Dependency_change_below_threshold_clears_alarm() public void Dependency_change_below_threshold_clears_alarm()
{ {
@@ -279,7 +279,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
/// <summary>Command path: an AlarmCommand("Acknowledge") for an alarm this host owns (and that is /// <summary>Command path: an AlarmCommand("Acknowledge") for an alarm this host owns (and that is
/// currently active+unacknowledged) drives the engine's AcknowledgeAsync — observed via the resulting /// currently active+unacknowledged) drives the engine's AcknowledgeAsync — observed via the resulting
/// AlarmStateUpdate(Acknowledged=true) and an AlarmTransitionEvent("Acknowledged") on the alerts topic /// AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns) and an AlarmTransitionEvent("Acknowledged") on the alerts topic
/// carrying the command's User (the user threads through AcknowledgeAsync → LastAckUser → evt.User).</summary> /// carrying the command's User (the user threads through AcknowledgeAsync → LastAckUser → evt.User).</summary>
[Fact] [Fact]
public void AlarmCommand_acknowledge_drives_engine_with_mapped_args() public void AlarmCommand_acknowledge_drives_engine_with_mapped_args()
@@ -573,7 +573,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
/// <summary>Inbound command ungated by role (T1): the alerts-publish gate must NOT affect inbound /// <summary>Inbound command ungated by role (T1): the alerts-publish gate must NOT affect inbound
/// command processing. Under a Secondary role, an AlarmCommand("Acknowledge") for an owned, active /// command processing. Under a Secondary role, an AlarmCommand("Acknowledge") for an owned, active
/// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true) /// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns)
/// (the OPC UA node write is ungated so the secondary's engine state + address space stay consistent).</summary> /// (the OPC UA node write is ungated so the secondary's engine state + address space stay consistent).</summary>
[Fact] [Fact]
public void Inbound_AlarmCommand_is_processed_regardless_of_role() public void Inbound_AlarmCommand_is_processed_regardless_of_role()