feat(v3-batch4-wp3): raw-only binding + UNS fan-out + write routing

Wave B of Batch 4 — the runtime binding seam for the dual namespace.

Applier (both realms, explicit realm at every sink call site):
- MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables
  keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath),
  all in AddressSpaceRealm.Raw; historian tagname = override else RawPath.
- MaterialiseUnsReferences: each UNS reference Variable under its equipment
  folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/
  historian tagname from the backing raw tag (both NodeIds -> one tagname).
- FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref
  stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS
  refs in place (raw-container removal falls back to rebuild).

DriverHostActor (dual-NodeId, single-source fan-out):
- _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from
  RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans
  to the raw NodeId AND every referencing UNS NodeId with identical
  value/quality/timestamp. Write inverse map keyed by the bare id; the
  ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a
  write to either NodeId resolves the same driver ref (-> RawPath write).
- Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate
  + AlarmStateUpdate carry the realm through to the sink.

Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and
RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now).

DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity
with the composer (reconstruct entities -> AddressSpaceComposer.Compose).

Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from
IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink /
DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now
explicit (realm reordered before the trailing optionals on EnsureVariable +
MaterialiseAlarmCondition). Node-manager convenience methods keep their
defaults (they are not the interface impl; Sdk delegates explicitly).

Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) +
DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3
raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags
provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 10:56:31 -04:00
parent 8b0b627f81
commit 3efcf8014b
30 changed files with 1186 additions and 746 deletions
@@ -23,30 +23,30 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance; _inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
/// <inheritdoc /> /// <inheritdoc />
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)
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm); => _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
/// <inheritdoc /> /// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm); => _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
/// <inheritdoc /> /// <inheritdoc />
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)
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative, realm); => _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc /> /// <inheritdoc />
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)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm); => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
/// <inheritdoc /> /// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm); public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
@@ -59,7 +59,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
// Without this forward the surgical optimization is inert on every driver-role host, because // Without this forward the surgical optimization is inert on every driver-role host, because
// actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape // actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape
// ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host. // ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host.
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)
=> _inner is ISurgicalAddressSpaceSink surgical => _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm); && surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
@@ -69,7 +69,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild. // sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
// Without this forward the rename-refresh optimization is inert on every driver-role host, because // Without this forward the rename-refresh optimization is inert on every driver-role host, because
// actors inject THIS wrapper, not the inner sink. // actors inject THIS wrapper, not the inner sink.
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical => _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm); && surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
@@ -79,14 +79,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is // (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
// inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch. // inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch.
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm); => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm); => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId, realm); => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId, realm);
} }
@@ -1,31 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// Single source of truth for equipment-namespace OPC UA NodeId strings. The variable NodeId is
/// FOLDER-SCOPED (<c>{parent}/{Name}</c>), NOT the driver-side FullName — a driver wire ref is not
/// unique across identical machines, so FullName-as-NodeId would collide in the sink. Used by the
/// materialiser (AddressSpaceApplier), the VirtualTag publish map, and the driver live-value router so all
/// three agree on the exact NodeId a variable was placed at.
/// </summary>
public static class EquipmentNodeIds
{
/// <summary>The sub-folder NodeId under an equipment for a non-empty FolderPath: <c>{equipmentId}/{folderPath}</c>.</summary>
/// <param name="equipmentId">The owning equipment's NodeId.</param>
/// <param name="folderPath">The tag/vtag FolderPath (must be non-empty for this to be meaningful).</param>
/// <returns>The sub-folder NodeId string.</returns>
public static string SubFolder(string equipmentId, string folderPath) => $"{equipmentId}/{folderPath}";
/// <summary>
/// The folder-scoped variable NodeId: <c>{parent}/{name}</c> where <c>parent = equipmentId</c> when
/// <paramref name="folderPath"/> is null/empty, else <see cref="SubFolder"/>.
/// </summary>
/// <param name="equipmentId">The owning equipment's NodeId.</param>
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
/// <param name="name">The tag/vtag Name (the leaf browse segment).</param>
/// <returns>The folder-scoped variable NodeId string.</returns>
public static string Variable(string equipmentId, string? folderPath, string name)
{
var parent = string.IsNullOrWhiteSpace(folderPath) ? equipmentId : SubFolder(equipmentId, folderPath);
return $"{parent}/{name}";
}
}
@@ -17,8 +17,7 @@ public interface IOpcUaAddressSpaceSink
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink /// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink
/// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out /// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param> /// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
// transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns);
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been /// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>, /// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
@@ -32,8 +31,7 @@ public interface IOpcUaAddressSpaceSink
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param> /// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in (must match the realm /// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in (must match the realm
/// the condition was materialised under).</param> /// the condition was materialised under).</param>
// transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns);
/// <summary> /// <summary>
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients /// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
@@ -51,8 +49,7 @@ public interface IOpcUaAddressSpaceSink
/// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.</param> /// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.</param>
/// <param name="realm">The namespace realm the condition (and its parent folder <paramref name="equipmentNodeId"/>) /// <param name="realm">The namespace realm the condition (and its parent folder <paramref name="equipmentNodeId"/>)
/// live in.</param> /// live in.</param>
// transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false);
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns);
/// <summary> /// <summary>
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to /// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
@@ -64,8 +61,7 @@ public interface IOpcUaAddressSpaceSink
/// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param> /// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param>
/// <param name="displayName">The display name for the folder.</param> /// <param name="displayName">The display name for the folder.</param>
/// <param name="realm">The namespace realm the folder (and its <paramref name="parentNodeId"/>) live in.</param> /// <param name="realm">The namespace realm the folder (and its <paramref name="parentNodeId"/>) live in.</param>
// transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm);
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns);
/// <summary> /// <summary>
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under /// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
@@ -93,8 +89,7 @@ public interface IOpcUaAddressSpaceSink
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live /// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing /// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param> /// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
// transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
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);
/// <summary> /// <summary>
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a /// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
@@ -109,8 +104,7 @@ public interface IOpcUaAddressSpaceSink
/// </summary> /// </summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param> /// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
/// <param name="realm">The namespace realm <paramref name="affectedNodeId"/> lives in.</param> /// <param name="realm">The namespace realm <paramref name="affectedNodeId"/> lives in.</param>
// transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm);
void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns);
/// <summary> /// <summary>
/// Add an OPC UA reference from an already-materialised source node to an already-materialised /// Add an OPC UA reference from an already-materialised source node to an already-materialised
@@ -145,25 +139,25 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
private NullOpcUaAddressSpaceSink() { } private NullOpcUaAddressSpaceSink() { }
/// <inheritdoc /> /// <inheritdoc />
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) { }
/// <inheritdoc /> /// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc /> /// <inheritdoc />
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) { }
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
/// <inheritdoc /> /// <inheritdoc />
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) { }
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <inheritdoc /> /// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
/// <inheritdoc /> /// <inheritdoc />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
@@ -24,8 +24,7 @@ public interface ISurgicalAddressSpaceSink
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in — the sink resolves the /// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in — the sink resolves the
/// namespace index from the realm rather than parsing it out of the id string.</param> /// namespace index from the realm rather than parsing it out of the id string.</param>
/// <returns>True when the in-place update was applied; false when the node is missing (caller rebuilds).</returns> /// <returns>True when the in-place update was applied; false when the node is missing (caller rebuilds).</returns>
// transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm);
bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns);
/// <summary>Update an existing folder node's <c>DisplayName</c> IN PLACE, notifying subscribers /// <summary>Update an existing folder node's <c>DisplayName</c> IN PLACE, notifying subscribers
/// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line /// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line
@@ -37,8 +36,7 @@ public interface ISurgicalAddressSpaceSink
/// <param name="displayName">The new display name to apply.</param> /// <param name="displayName">The new display name to apply.</param>
/// <param name="realm">The namespace realm <paramref name="folderNodeId"/> lives in.</param> /// <param name="realm">The namespace realm <paramref name="folderNodeId"/> lives in.</param>
/// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns> /// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns>
// transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm);
bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns);
/// <summary>Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the /// <summary>Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the
/// predefined-node set, drop any historized-tagname registration), then raise a Part 3 /// predefined-node set, drop any historized-tagname registration), then raise a Part 3
@@ -50,8 +48,7 @@ public interface ISurgicalAddressSpaceSink
/// <param name="variableNodeId">The folder-scoped node id of the variable to remove in place.</param> /// <param name="variableNodeId">The folder-scoped node id of the variable to remove in place.</param>
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in.</param> /// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in.</param>
/// <returns>True when the node was removed; false when the id is unknown (caller rebuilds).</returns> /// <returns>True when the node was removed; false when the id is unknown (caller rebuilds).</returns>
// transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm);
bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns);
/// <summary>Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the /// <summary>Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the
/// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The /// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The
@@ -61,8 +58,7 @@ public interface ISurgicalAddressSpaceSink
/// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param> /// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param>
/// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in.</param> /// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in.</param>
/// <returns>True when the condition was removed; false when the id is unknown (caller rebuilds).</returns> /// <returns>True when the condition was removed; false when the id is unknown (caller rebuilds).</returns>
// transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm);
bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns);
/// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables, /// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables,
/// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames, /// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames,
@@ -73,6 +69,5 @@ public interface ISurgicalAddressSpaceSink
/// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param> /// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param>
/// <param name="realm">The namespace realm <paramref name="equipmentNodeId"/> lives in.</param> /// <param name="realm">The namespace realm <paramref name="equipmentNodeId"/> lives in.</param>
/// <returns>True when the subtree was removed; false when the id is unknown (caller rebuilds).</returns> /// <returns>True when the subtree was removed; false when the id is unknown (caller rebuilds).</returns>
// transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm);
bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns);
} }
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -96,29 +97,33 @@ public sealed class AddressSpaceApplier
var failedNodes = 0; var failedNodes = 0;
foreach (var eq in plan.RemovedEquipment) foreach (var eq in plan.RemovedEquipment)
{ {
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts)) failedNodes++; if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
removedCount++; removedCount++;
} }
foreach (var alarm in plan.RemovedAlarms) foreach (var alarm in plan.RemovedAlarms)
{ {
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts)) failedNodes++; if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
removedCount++; removedCount++;
} }
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write // Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
// before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes // before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. // is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4:
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count; // removed raw containers/tags + UNS reference variables are likewise real removals.
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count;
var changedCount = var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count + plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedEquipmentTags.Count + plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.Count + plan.ChangedEquipmentVirtualTags.Count +
plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count +
// A UNS Area/Line rename is an in-place change to an existing folder node. // A UNS Area/Line rename is an in-place change to an existing folder node.
plan.RenamedFolders.Count; plan.RenamedFolders.Count;
var addedCount = var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count + plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedEquipmentTags.Count + plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count; plan.AddedEquipmentVirtualTags.Count +
plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count;
// R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of // R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
// rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over // rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
@@ -172,7 +177,7 @@ public sealed class AddressSpaceApplier
foreach (var rename in renamedFolders) foreach (var rename in renamedFolders)
{ {
bool ok; bool ok;
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName); } try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName, AddressSpaceRealm.Uns); }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId); _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId);
@@ -185,13 +190,13 @@ public sealed class AddressSpaceApplier
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags // Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
// would so the in-place update matches what a rebuild would have produced. Array tags are // would so the in-place update matches what a rebuild would have produced. Array tags are
// forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays). // forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
var nodeId = EquipmentNodeIds.Variable(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name); var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
var writable = d.Current.Writable && !d.Current.IsArray; var writable = d.Current.Writable && !d.Current.IsArray;
var historian = d.Current.IsHistorized var historian = d.Current.IsHistorized
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname) ? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
: null; : null;
bool ok; bool ok;
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength); } try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId); _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
@@ -263,77 +268,108 @@ public sealed class AddressSpaceApplier
// Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment. // Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId))) foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
{ {
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name); var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
if (tag.Alarm is not null) if (tag.Alarm is not null)
{ {
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap // Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
// closed), then remove the condition in place. // closed), then remove the condition in place.
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts)) failedNodes++; if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
if (!SafeRemoveAlarmCondition(surgical, nodeId)) return false; if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
} }
else else
{ {
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal. // Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts); SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId)) return false; if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
} }
} }
// Removed VirtualTags (always plain value variables) for surviving equipment. // Removed VirtualTags (always plain value variables) for surviving equipment.
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId))) foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
{ {
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name); var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts); SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId)) return false; if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
} }
// Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already // Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already
// written by the top-of-Apply removal block; here we tear the condition node down in place. // written by the top-of-Apply removal block; here we tear the condition node down in place.
foreach (var alarm in plan.RemovedAlarms.Where(a => NotSubsumed(a.EquipmentId))) foreach (var alarm in plan.RemovedAlarms.Where(a => NotSubsumed(a.EquipmentId)))
{ {
if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId)) return false; if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId, AddressSpaceRealm.Uns)) return false;
} }
// Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The // Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
// top-of-Apply block already wrote the equipment id's terminal condition state. // top-of-Apply block already wrote the equipment id's terminal condition state.
foreach (var eq in plan.RemovedEquipment) foreach (var eq in plan.RemovedEquipment)
{ {
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId)) return false; if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId, AddressSpaceRealm.Uns)) return false;
} }
// v3 Batch 4 — removed UNS reference variables (Uns realm, plain value nodes): terminal Bad then
// in-place remove. A backing-raw rename is a re-point (Changed, → rebuild), NOT a removal, so a UNS
// ref only reaches here on a genuine dereference (the equipment dropped the reference).
foreach (var v in plan.RemovedUnsReferenceVariables)
{
SafeWriteValue(v.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, v.NodeId, AddressSpaceRealm.Uns)) return false;
}
// v3 Batch 4 — removed RAW tags (Raw realm): an alarm-bearing raw tag is a condition node (terminal
// RemovedConditionState + RemoveAlarmConditionNode); a plain raw value tag is a variable (terminal Bad
// + RemoveVariableNode).
foreach (var t in plan.RemovedRawTags)
{
if (t.Alarm is not null)
{
if (!SafeWriteAlarmCondition(t.NodeId, RemovedConditionState, ts, AddressSpaceRealm.Raw)) failedNodes++;
if (!SafeRemoveAlarmCondition(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
}
else
{
SafeWriteValue(t.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Raw);
if (!SafeRemoveVariable(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
}
}
// Removed raw CONTAINER nodes (Folder/Driver/Device/TagGroup) have no surgical folder-remove on the
// sink surface, so any container removal falls back to a full rebuild (the ratchet). Evaluated LAST so
// the cheap variable/condition removals above still run in place when only tags were removed.
if (plan.RemovedRawContainers.Count > 0) return false;
return true; return true;
} }
/// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary> /// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns> /// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId) private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
{ {
try { return surgical.RemoveVariableNode(nodeId); } try { return surgical.RemoveVariableNode(nodeId, realm); }
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveVariableNode threw for {Node}", nodeId); return false; } catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveVariableNode threw for {Node}", nodeId); return false; }
} }
/// <summary>Remove an alarm-condition node in place, treating a throw as a false (⇒ rebuild fallback).</summary> /// <summary>Remove an alarm-condition node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns> /// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId) private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
{ {
try { return surgical.RemoveAlarmConditionNode(nodeId); } try { return surgical.RemoveAlarmConditionNode(nodeId, realm); }
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveAlarmConditionNode threw for {Node}", nodeId); return false; } catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveAlarmConditionNode threw for {Node}", nodeId); return false; }
} }
/// <summary>Remove an equipment subtree in place, treating a throw as a false (⇒ rebuild fallback).</summary> /// <summary>Remove an equipment subtree in place, treating a throw as a false (⇒ rebuild fallback).</summary>
/// <returns><c>true</c> when the subtree was removed; <c>false</c> when unknown or the sink threw.</returns> /// <returns><c>true</c> when the subtree was removed; <c>false</c> when unknown or the sink threw.</returns>
private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId) private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
{ {
try { return surgical.RemoveEquipmentSubtree(nodeId); } try { return surgical.RemoveEquipmentSubtree(nodeId, realm); }
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveEquipmentSubtree threw for {Node}", nodeId); return false; } catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveEquipmentSubtree threw for {Node}", nodeId); return false; }
} }
/// <summary>Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad /// <summary>Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad
/// on a removed variable so an in-flight MonitoredItem observes the removal.</summary> /// on a removed variable so an in-flight MonitoredItem observes the removal.</summary>
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns> /// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm)
{ {
try { _sink.WriteValue(nodeId, value, quality, ts); return true; } try { _sink.WriteValue(nodeId, value, quality, ts, realm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; }
} }
@@ -373,7 +409,22 @@ public sealed class AddressSpaceApplier
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param> /// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
/// <returns>The materialise-parent node id.</returns> /// <returns>The materialise-parent node id.</returns>
private static string MaterialiseParent(string equipmentId, string? folderPath) => private static string MaterialiseParent(string equipmentId, string? folderPath) =>
string.IsNullOrWhiteSpace(folderPath) ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, folderPath); string.IsNullOrWhiteSpace(folderPath) ? equipmentId : UnsSubFolder(equipmentId, folderPath);
// ----- v3 UNS-equipment NodeId helpers (replace the retired EquipmentNodeIds) -----
// Equipment-owned UNS nodes (per-equipment VirtualTags, the vestigial equipment-tag path, and the
// discovered-node graft) keep their EquipmentId-anchored slash-path NodeId ({equipmentId}/{folderPath}/{name}),
// now expressed through the v3 identity authority V3NodeIds.Uns so the retired EquipmentNodeIds type can go.
// These live in the UNS realm. FolderPath may be multi-segment ("a/b") — each segment is validated
// by V3NodeIds.Uns like a RawPath. (UnsTagReference variables use the composer's Area/Line/Equipment/Name
// NodeId directly and are NOT built here.)
private static string UnsSubFolder(string equipmentId, string folderPath) =>
V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator)));
private static string UnsEquipmentVar(string equipmentId, string? folderPath, string name) =>
string.IsNullOrWhiteSpace(folderPath)
? V3NodeIds.Uns(equipmentId, name)
: V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator)).Append(name));
/// <summary> /// <summary>
/// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from /// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from
@@ -389,7 +440,7 @@ public sealed class AddressSpaceApplier
{ {
foreach (var id in ComputeAddAnnouncements(plan)) foreach (var id in ComputeAddAnnouncements(plan))
{ {
try { _sink.RaiseNodesAddedModelChange(id); } try { _sink.RaiseNodesAddedModelChange(id, AddressSpaceRealm.Uns); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
} }
} }
@@ -407,11 +458,11 @@ public sealed class AddressSpaceApplier
try try
{ {
List<HistorianTagProvisionRequest>? requests = null; List<HistorianTagProvisionRequest>? requests = null;
foreach (var tag in plan.AddedEquipmentTags) // v3 Batch 4: historized value tags are RAW tags (added-raw-tag delta). Native-alarm tags
// materialise as Part 9 conditions (never historized value variables), so mirror the materialiser
// and skip them.
foreach (var tag in plan.AddedRawTags)
{ {
// Only historized value variables are provisioned. Native-alarm tags materialise as
// Part 9 condition nodes (never historized value variables) — the materialiser resolves
// a historian tagname only for the non-alarm branch, so mirror that and skip them.
if (!tag.IsHistorized || tag.Alarm is not null) continue; if (!tag.IsHistorized || tag.Alarm is not null) continue;
// Parse the driver-agnostic data type from the tag's DataType string. An unparseable // Parse the driver-agnostic data type from the tag's DataType string. An unparseable
@@ -424,9 +475,9 @@ public sealed class AddressSpaceApplier
continue; continue;
} }
// Resolve the historian name EXACTLY as MaterialiseEquipmentTags does: a null/blank // Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override
// override falls back to the driver-side FullName. // falls back to the RawPath (== NodeId).
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname; var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
(requests ??= new List<HistorianTagProvisionRequest>()).Add( (requests ??= new List<HistorianTagProvisionRequest>()).Add(
new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name)); new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
} }
@@ -493,22 +544,26 @@ public sealed class AddressSpaceApplier
List<HistorizedTagRef>? added = null; List<HistorizedTagRef>? added = null;
List<HistorizedTagRef>? removed = null; List<HistorizedTagRef>? removed = null;
// Added historized value variables → new interest. // v3 Batch 4: historized value tags are RAW tags (keyed by RawPath). The mux HistorizedTagRef
foreach (var tag in plan.AddedEquipmentTags) // stays SINGLE, keyed by RawPath — a UNS reference node registers the same historian tagname in the
// node manager but does NOT add a second mux ref (no double historization). So this feed emits raw
// refs only.
// Added historized raw value tags → new interest.
foreach (var tag in plan.AddedRawTags)
{ {
if (HistorizedRef(tag) is { } r) (added ??= new List<HistorizedTagRef>()).Add(r); if (HistorizedRef(tag) is { } r) (added ??= new List<HistorizedTagRef>()).Add(r);
} }
// Removed historized value variables → drop interest. // Removed historized raw value tags → drop interest.
foreach (var tag in plan.RemovedEquipmentTags) foreach (var tag in plan.RemovedRawTags)
{ {
if (HistorizedRef(tag) is { } r) (removed ??= new List<HistorizedTagRef>()).Add(r); if (HistorizedRef(tag) is { } r) (removed ??= new List<HistorizedTagRef>()).Add(r);
} }
// Changed tags: the historized ref may have flipped on/off or been renamed (override/FullName // Changed raw tags: the historized ref may have flipped on/off or the historian override changed
// change). Compare previous-vs-current resolved ref PAIRS (record equality compares both the // (a rename is remove+add, handled above). Compare previous-vs-current resolved ref PAIRS — an
// mux ref and the historian name) — an unchanged pair is a no-op. // unchanged pair is a no-op.
foreach (var d in plan.ChangedEquipmentTags) foreach (var d in plan.ChangedRawTags)
{ {
var prev = HistorizedRef(d.Previous); var prev = HistorizedRef(d.Previous);
var cur = HistorizedRef(d.Current); var cur = HistorizedRef(d.Current);
@@ -540,13 +595,15 @@ public sealed class AddressSpaceApplier
/// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized /// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized
/// value variable (not historized, or a native-alarm condition node). /// value variable (not historized, or a native-alarm condition node).
/// </summary> /// </summary>
/// <param name="tag">The equipment tag to resolve a historized ref for.</param> /// <param name="tag">The raw tag to resolve a historized ref for.</param>
/// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns> /// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns>
private static HistorizedTagRef? HistorizedRef(EquipmentTagPlan tag) => private static HistorizedTagRef? HistorizedRef(RawTagPlan tag) =>
tag.IsHistorized && tag.Alarm is null tag.IsHistorized && tag.Alarm is null
// v3: the RawPath (== NodeId) is BOTH the mux ref (the driver fans values by RawPath) and the
// default historian tagname; an override diverges only the historian name.
? new HistorizedTagRef( ? new HistorizedTagRef(
tag.FullName, tag.NodeId,
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname)
: null; : null;
/// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault. /// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault.
@@ -585,17 +642,17 @@ public sealed class AddressSpaceApplier
var failed = 0; var failed = 0;
foreach (var area in composition.UnsAreas) foreach (var area in composition.UnsAreas)
{ {
if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName)) failed++; if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
} }
foreach (var line in composition.UnsLines) foreach (var line in composition.UnsLines)
{ {
if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName)) failed++; if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
} }
foreach (var equipment in composition.EquipmentNodes) foreach (var equipment in composition.EquipmentNodes)
{ {
// Equipment with no UnsLineId (legacy / dev rows) hang under the root. // Equipment with no UnsLineId (legacy / dev rows) hang under the root.
var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId; var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName)) failed++; if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
} }
_logger.LogInformation( _logger.LogInformation(
@@ -604,6 +661,112 @@ public sealed class AddressSpaceApplier
return failed; return failed;
} }
/// <summary>
/// v3 Batch 4 — materialise the <b>Raw</b> device-oriented subtree (<c>ns=Raw</c>): the container
/// nodes (Folder→Driver→Device→TagGroup, each an <c>Object</c>/<c>Folder</c>) parents-before-children,
/// then the tag Variable nodes keyed <c>s=&lt;RawPath&gt;</c>. A native-alarm tag (<see cref="RawTagPlan.Alarm"/>
/// non-null) materialises as a Part 9 condition node at its RawPath (ConditionId = RawPath, parent = its
/// device/group folder) instead of a value variable — the single condition instance; WP4 wires the extra
/// per-equipment notifiers. A historized value tag resolves its effective historian tagname HERE
/// (override else the RawPath). Array tags are forced read-only (the driver write path can't handle
/// arrays). Every sink call passes <see cref="AddressSpaceRealm.Raw"/> EXPLICITLY — the driver binds
/// values to these raw NodeIds, and every referencing UNS node fans out from them. Idempotent.
/// </summary>
/// <param name="composition">The composition carrying the Raw subtree.</param>
/// <returns>The count of swallowed sink failures in this pass.</returns>
public int MaterialiseRawSubtree(AddressSpaceComposition composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.RawContainers.Count == 0 && composition.RawTags.Count == 0) return 0;
var failed = 0;
// Containers first — the composer sorts them by NodeId (ordinal) so a parent's RawPath precedes each
// child's; EnsureFolder is idempotent, so a re-apply is cheap.
foreach (var c in composition.RawContainers)
{
if (!SafeEnsureFolder(c.NodeId, c.ParentNodeId, c.DisplayName, AddressSpaceRealm.Raw)) failed++;
}
foreach (var t in composition.RawTags)
{
if (t.Alarm is not null)
{
// Native alarm tag → a single Part 9 condition node at the RawPath (ConditionId = RawPath).
// Parent is its device/group folder. Multi-notifier fan-out to referencing equipment is WP4.
if (!SafeMaterialiseAlarmCondition(t.NodeId, t.ParentNodeId ?? string.Empty, t.Name, t.Alarm.AlarmType, t.Alarm.Severity, isNative: true, AddressSpaceRealm.Raw)) failed++;
}
else
{
// A historized tag materialises Historizing + HistoryRead; the effective historian tagname is
// the override else the RawPath (== NodeId). Array writes are out of scope → force read-only.
string? historianTagname = t.IsHistorized
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
: null;
var writable = t.Writable && !t.IsArray;
if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength)) failed++;
}
}
_logger.LogInformation(
"AddressSpaceApplier: raw subtree materialised (containers={Containers}, tags={Tags}, failed={Failed})",
composition.RawContainers.Count, composition.RawTags.Count, failed);
return failed;
}
/// <summary>
/// v3 Batch 4 — materialise the <b>UNS</b> reference Variable nodes (<c>ns=UNS</c>): one per
/// <see cref="UnsReferenceVariable"/>, keyed <c>s=&lt;Area&gt;/&lt;Line&gt;/&lt;Equipment&gt;/&lt;EffectiveName&gt;</c>,
/// parented under its equipment folder (the logical <see cref="UnsReferenceVariable.EquipmentId"/> node
/// <see cref="MaterialiseHierarchy"/> already created), THEN an <c>Organizes</c> reference
/// UNS→Raw to its backing raw node. The UNS node inherits DataType / writable / array shape / historian
/// tagname from its backing RAW tag (looked up by <see cref="UnsReferenceVariable.BackingRawPath"/>), so a
/// historized raw tag's UNS reference registers the SAME tagname (both NodeIds → one tagname → HistoryRead
/// works against either). The UNS node binds NO driver — the driver publish path fans values to it (WP3
/// runtime binding). Every sink call passes <see cref="AddressSpaceRealm.Uns"/> / the Organizes edge
/// passes both realms EXPLICITLY. Idempotent.
/// </summary>
/// <param name="composition">The composition carrying the UNS reference variables + Raw tags (for inherited shape).</param>
/// <returns>The count of swallowed sink failures in this pass.</returns>
public int MaterialiseUnsReferences(AddressSpaceComposition composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.UnsReferenceVariables.Count == 0) return 0;
// Backing raw tag shape/historian by RawPath — a UNS reference inherits these so its node matches the
// raw node it mirrors (a historized raw tag registers the SAME tagname on the UNS node for HistoryRead).
var rawByPath = new Dictionary<string, RawTagPlan>(StringComparer.Ordinal);
foreach (var t in composition.RawTags) rawByPath[t.NodeId] = t;
var failed = 0;
foreach (var v in composition.UnsReferenceVariables)
{
var backing = rawByPath.GetValueOrDefault(v.BackingRawPath);
var isArray = backing?.IsArray ?? false;
uint? arrayLength = backing?.ArrayLength;
// A UNS reference to a historized raw tag registers the SAME effective historian tagname (override
// else the backing RawPath) so HistoryRead resolves against the UNS NodeId too. The mux ref stays
// single (keyed by RawPath) — FeedHistorizedRefs emits raw refs only.
string? historianTagname = backing is { IsHistorized: true }
? (string.IsNullOrWhiteSpace(backing.HistorianTagname) ? backing.NodeId : backing.HistorianTagname)
: null;
var writable = v.Writable && !isArray;
if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength))
{
failed++;
continue; // node not created — skip the Organizes edge (a missing endpoint would no-op anyway)
}
// Organizes UNS→Raw so the linkage is browsable. Both endpoints are realm-qualified; a missing
// endpoint is a logged no-op in the sink (never throws), so this can't fault a deploy.
try { _sink.AddReference(v.NodeId, AddressSpaceRealm.Uns, v.BackingRawPath, AddressSpaceRealm.Raw); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: AddReference (Organizes UNS->Raw) threw for {Node}", v.NodeId); failed++; }
}
_logger.LogInformation(
"AddressSpaceApplier: UNS reference variables materialised (refs={Refs}, failed={Failed})",
composition.UnsReferenceVariables.Count, failed);
return failed;
}
/// <summary> /// <summary>
/// Materialise Equipment-namespace tags from a composition snapshot. /// Materialise Equipment-namespace tags from a composition snapshot.
/// For each <see cref="EquipmentTagPlan"/>, /// For each <see cref="EquipmentTagPlan"/>,
@@ -635,9 +798,9 @@ public sealed class AddressSpaceApplier
foreach (var tag in composition.EquipmentTags) foreach (var tag in composition.EquipmentTags)
{ {
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
var folderNodeId = EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath); var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue; if (!foldersCreated.Add(folderNodeId)) continue;
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath)) failed++; if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
} }
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver // Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
@@ -650,13 +813,13 @@ public sealed class AddressSpaceApplier
{ {
var parent = string.IsNullOrWhiteSpace(tag.FolderPath) var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
? tag.EquipmentId ? tag.EquipmentId
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath); : UnsSubFolder(tag.EquipmentId, tag.FolderPath);
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name); var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
if (tag.Alarm is not null) if (tag.Alarm is not null)
{ {
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path), // Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder. // NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true)) failed++; if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++;
} }
else else
{ {
@@ -670,7 +833,7 @@ public sealed class AddressSpaceApplier
// even if authored ReadWrite, so a client write cannot reach the driver write path which // even if authored ReadWrite, so a client write cannot reach the driver write path which
// does not handle arrays (e.g. S7 BoxValueForWrite would crash). // does not handle arrays (e.g. S7 BoxValueForWrite would crash).
var writable = tag.Writable && !tag.IsArray; var writable = tag.Writable && !tag.IsArray;
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, historianTagname, tag.IsArray, tag.ArrayLength)) failed++; if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
} }
} }
@@ -708,17 +871,17 @@ public sealed class AddressSpaceApplier
var failed = 0; var failed = 0;
// Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth. // Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth.
foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/'))) foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName)) failed++; if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName, AddressSpaceRealm.Raw)) failed++;
foreach (var v in variables) foreach (var v in variables)
{ {
// Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays). // Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
var writable = v.Writable && !v.IsArray; var writable = v.Writable && !v.IsArray;
if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable, if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable,
historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++; AddressSpaceRealm.Raw, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++;
} }
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId); _sink.RaiseNodesAddedModelChange(equipmentRootNodeId, AddressSpaceRealm.Raw);
_logger.LogInformation( _logger.LogInformation(
"AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})", "AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})",
@@ -754,9 +917,9 @@ public sealed class AddressSpaceApplier
foreach (var v in composition.EquipmentVirtualTags) foreach (var v in composition.EquipmentVirtualTags)
{ {
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue; if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
var folderNodeId = EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath); var folderNodeId = UnsSubFolder(v.EquipmentId, v.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue; if (!foldersCreated.Add(folderNodeId)) continue;
if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath)) failed++; if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
} }
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), mirroring the equipment-tag pass. // Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), mirroring the equipment-tag pass.
@@ -769,10 +932,10 @@ public sealed class AddressSpaceApplier
{ {
var parent = string.IsNullOrWhiteSpace(v.FolderPath) var parent = string.IsNullOrWhiteSpace(v.FolderPath)
? v.EquipmentId ? v.EquipmentId
: EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath); : UnsSubFolder(v.EquipmentId, v.FolderPath);
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name); var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
// VirtualTags are computed outputs — read-only nodes (no inbound write). // VirtualTags are computed outputs — read-only nodes (no inbound write).
if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false)) failed++; if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false, realm: AddressSpaceRealm.Uns)) failed++;
} }
_logger.LogInformation( _logger.LogInformation(
@@ -805,7 +968,7 @@ public sealed class AddressSpaceApplier
foreach (var alarm in composition.EquipmentScriptedAlarms) foreach (var alarm in composition.EquipmentScriptedAlarms)
{ {
if (!alarm.Enabled) continue; if (!alarm.Enabled) continue;
if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false)) failed++; if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false, realm: AddressSpaceRealm.Uns)) failed++;
materialised++; materialised++;
} }
@@ -822,9 +985,9 @@ public sealed class AddressSpaceApplier
/// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node /// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
/// count so a swallowed materialise failure is operator-visible (archreview 01/S-1).</summary> /// count so a swallowed materialise failure is operator-visible (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the folder was ensured; <c>false</c> when the sink threw.</returns> /// <returns><c>true</c> when the folder was ensured; <c>false</c> when the sink threw.</returns>
private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName) private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
{ {
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); return true; } try { _sink.EnsureFolder(nodeId, parentNodeId, displayName, realm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureFolder threw for {Node}", nodeId); return false; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureFolder threw for {Node}", nodeId); return false; }
} }
@@ -832,9 +995,9 @@ public sealed class AddressSpaceApplier
/// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node /// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
/// count (archreview 01/S-1).</summary> /// count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns> /// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns>
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{ {
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); return true; } try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
} }
@@ -859,9 +1022,9 @@ public sealed class AddressSpaceApplier
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — the removal pass tallies the /// Returns <c>true</c> on success, <c>false</c> when the sink threw — the removal pass tallies the
/// false into <see cref="AddressSpaceApplyOutcome.FailedNodes"/> (archreview 01/S-1).</summary> /// false into <see cref="AddressSpaceApplyOutcome.FailedNodes"/> (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns> /// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts) private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm)
{ {
try { _sink.WriteAlarmCondition(nodeId, state, ts); return true; } try { _sink.WriteAlarmCondition(nodeId, state, ts, realm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteAlarmCondition threw for {Node}", nodeId); return false; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteAlarmCondition threw for {Node}", nodeId); return false; }
} }
@@ -869,9 +1032,9 @@ public sealed class AddressSpaceApplier
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — callers tally the false into /// Returns <c>true</c> on success, <c>false</c> when the sink threw — callers tally the false into
/// their pass's failed-node count (archreview 01/S-1).</summary> /// their pass's failed-node count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the condition was materialised; <c>false</c> when the sink threw.</returns> /// <returns><c>true</c> when the condition was materialised; <c>false</c> when the sink threw.</returns>
private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative) private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative, AddressSpaceRealm realm)
{ {
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); return true; } try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); return false; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); return false; }
} }
} }
@@ -21,50 +21,50 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
} }
/// <inheritdoc /> /// <inheritdoc />
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)
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm); => _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
/// <inheritdoc /> /// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm); => _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
/// <inheritdoc /> /// <inheritdoc />
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)
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative, realm); => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative, realm);
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc /> /// <inheritdoc />
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)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm); => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm);
/// <inheritdoc /> /// <inheritdoc />
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)
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm); => _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName, realm); => _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
=> _nodeManager.RemoveVariableNode(variableNodeId, realm); => _nodeManager.RemoveVariableNode(variableNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId, realm); => _nodeManager.RemoveAlarmConditionNode(alarmNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId, realm); => _nodeManager.RemoveEquipmentSubtree(equipmentNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace(); public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
/// <inheritdoc /> /// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm); public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -253,6 +254,142 @@ public static class DeploymentArtifact
return EquipmentReferenceMap.Build(references, tagsById, resolver); return EquipmentReferenceMap.Build(references, tagsById, resolver);
} }
/// <summary>
/// v3 Batch 4 — reconstruct the raw/UNS <b>entities</b> from the artifact JSON and drive the pure
/// <see cref="AddressSpaceComposer.Compose"/>, returning ONLY the three dual-namespace lists
/// (<see cref="AddressSpaceComposition.RawContainers"/> / <see cref="AddressSpaceComposition.RawTags"/> /
/// <see cref="AddressSpaceComposition.UnsReferenceVariables"/>). Reusing the composer verbatim is what
/// guarantees BYTE-PARITY between the live-edit compose seam and this artifact-decode seam — the same
/// <c>RawPathResolver</c> / <c>RawPaths</c> / <c>TagConfigIntent</c> / <c>V3NodeIds</c> authorities
/// produce every RawPath, UNS NodeId, and Organizes backing path. Enums serialize numerically in the
/// artifact (no <c>JsonStringEnumConverter</c>), so <c>AccessLevel</c> is read as an int.
/// </summary>
/// <param name="root">The artifact root element.</param>
/// <returns>The Raw containers, Raw tags, and UNS reference variables.</returns>
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags, IReadOnlyList<UnsReferenceVariable> UnsRefs)
BuildRawAndUnsSubtrees(JsonElement root)
{
var rawFolders = new List<RawFolder>();
foreach (var el in EnumerateArray(root, "RawFolders"))
{
var id = ReadString(el, "RawFolderId");
if (string.IsNullOrWhiteSpace(id)) continue;
rawFolders.Add(new RawFolder
{
RawFolderId = id!, ParentRawFolderId = ReadNullableString(el, "ParentRawFolderId"),
Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
});
}
var driverInstances = new List<DriverInstance>();
foreach (var el in EnumerateArray(root, "DriverInstances"))
{
var id = ReadString(el, "DriverInstanceId");
if (string.IsNullOrWhiteSpace(id)) continue;
driverInstances.Add(new DriverInstance
{
DriverInstanceId = id!, RawFolderId = ReadNullableString(el, "RawFolderId"),
Name = ReadString(el, "Name") ?? id!, DriverType = ReadString(el, "DriverType") ?? string.Empty,
DriverConfig = ReadString(el, "DriverConfig") ?? "{}", ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
});
}
var devices = new List<Device>();
foreach (var el in EnumerateArray(root, "Devices"))
{
var id = ReadString(el, "DeviceId");
var di = ReadString(el, "DriverInstanceId");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue;
devices.Add(new Device
{
DeviceId = id!, DriverInstanceId = di!, Name = ReadString(el, "Name") ?? id!,
DeviceConfig = ReadString(el, "DeviceConfig") ?? "{}",
});
}
var tagGroups = new List<TagGroup>();
foreach (var el in EnumerateArray(root, "TagGroups"))
{
var id = ReadString(el, "TagGroupId");
if (string.IsNullOrWhiteSpace(id)) continue;
tagGroups.Add(new TagGroup
{
TagGroupId = id!, ParentTagGroupId = ReadNullableString(el, "ParentTagGroupId"),
DeviceId = ReadString(el, "DeviceId") ?? string.Empty, Name = ReadString(el, "Name") ?? id!,
});
}
var tags = new List<Tag>();
foreach (var el in EnumerateArray(root, "Tags"))
{
var id = ReadString(el, "TagId");
var deviceId = ReadString(el, "DeviceId");
var name = ReadString(el, "Name");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue;
var accessLevel = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind == JsonValueKind.Number
? (TagAccessLevel)alEl.GetInt32() : TagAccessLevel.Read;
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
? tcEl.GetString() ?? "{}" : "{}";
tags.Add(new Tag
{
TagId = id!, DeviceId = deviceId!, TagGroupId = ReadNullableString(el, "TagGroupId"),
Name = name!, DataType = ReadString(el, "DataType") ?? "Float",
AccessLevel = accessLevel, TagConfig = tagConfig,
});
}
var unsTagReferences = new List<UnsTagReference>();
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
{
var id = ReadString(el, "UnsTagReferenceId");
var equipmentId = ReadString(el, "EquipmentId");
var tagId = ReadString(el, "TagId");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId)) continue;
unsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = id!, EquipmentId = equipmentId!, TagId = tagId!,
DisplayNameOverride = ReadNullableString(el, "DisplayNameOverride"),
});
}
var unsAreas = new List<UnsArea>();
foreach (var el in EnumerateArray(root, "UnsAreas"))
{
var id = ReadString(el, "UnsAreaId");
if (string.IsNullOrWhiteSpace(id)) continue;
unsAreas.Add(new UnsArea { UnsAreaId = id!, Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty });
}
var unsLines = new List<UnsLine>();
foreach (var el in EnumerateArray(root, "UnsLines"))
{
var id = ReadString(el, "UnsLineId");
if (string.IsNullOrWhiteSpace(id)) continue;
unsLines.Add(new UnsLine { UnsLineId = id!, UnsAreaId = ReadString(el, "UnsAreaId") ?? string.Empty, Name = ReadString(el, "Name") ?? id! });
}
var equipment = new List<Equipment>();
foreach (var el in EnumerateArray(root, "Equipment"))
{
var id = ReadString(el, "EquipmentId");
if (string.IsNullOrWhiteSpace(id)) continue;
equipment.Add(new Equipment
{
EquipmentId = id!, UnsLineId = ReadString(el, "UnsLineId") ?? string.Empty,
Name = ReadString(el, "Name") ?? id!,
// MachineCode is a required member but is not used by the raw/UNS compose path (it drives no
// NodeId); read it when present, else a placeholder so the object initializer is satisfied.
MachineCode = ReadString(el, "MachineCode") ?? id!,
});
}
var composition = AddressSpaceComposer.Compose(
unsAreas, unsLines, equipment, driverInstances, Array.Empty<ScriptedAlarm>(),
unsTagReferences: unsTagReferences, tags: tags,
rawFolders: rawFolders, devices: devices, tagGroups: tagGroups);
return (composition.RawContainers, composition.RawTags, composition.UnsReferenceVariables);
}
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors /// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary> /// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root) private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
@@ -482,11 +619,21 @@ public static class DeploymentArtifact
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip); var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip);
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip); var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip);
// v3 Batch 4 — the Raw device subtree + UNS reference variables. Built by reconstructing the raw/UNS
// entities from the artifact JSON and driving the SAME pure AddressSpaceComposer the live-edit side
// uses, so the artifact-decode seam is BYTE-PARITY with the composer (identical RawPaths, identical
// UNS NodeIds, identical Organizes backing paths). Only the three dual-namespace lists are taken from
// the composer result; the (already byte-parity) folder/vtag/alarm plans above are kept as-is.
var (rawContainers, rawTags, unsReferenceVariables) = BuildRawAndUnsSubtrees(root);
return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms) return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms)
{ {
EquipmentTags = equipmentTags, EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags, EquipmentVirtualTags = equipmentVirtualTags,
EquipmentScriptedAlarms = equipmentScriptedAlarms, EquipmentScriptedAlarms = equipmentScriptedAlarms,
RawContainers = rawContainers,
RawTags = rawTags,
UnsReferenceVariables = unsReferenceVariables,
}; };
} }
catch (JsonException) catch (JsonException)
@@ -548,6 +695,13 @@ public static class DeploymentArtifact
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(), EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(), EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(),
// v3 Batch 4: raw tags scope by their owning driver's cluster; UNS references by their equipment's
// cluster. Raw CONTAINER nodes carry no driver id, so they are kept in full — an out-of-cluster
// folder/driver/device/group node materialises only as an empty browse folder (harmless), while every
// raw VALUE + UNS reference node is correctly cluster-scoped.
RawContainers = full.RawContainers,
RawTags = full.RawTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
UnsReferenceVariables = full.UnsReferenceVariables.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
}; };
} }
@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -36,8 +37,12 @@ public static class DiscoveredNodeMapper
/// </param> /// </param>
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns> /// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
public static DiscoveredInjectionPlan Map( public static DiscoveredInjectionPlan Map(
string equipmentId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs) string rootNodeId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
{ {
// v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is
// <rootDevicePath>/<folder…>/<name> (slash-joined RawPath), NOT the retired equipment-scoped
// {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath).
static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail;
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList(); var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
// Device-folder collapse: when every kept node shares one identical index-1 segment (the single // Device-folder collapse: when every kept node shares one identical index-1 segment (the single
@@ -64,20 +69,20 @@ public static class DiscoveredNodeMapper
for (var i = 0; i < segs.Count; i++) for (var i = 0; i < segs.Count; i++)
{ {
var folderPath = string.Join('/', segs.Take(i + 1)); var folderPath = string.Join('/', segs.Take(i + 1));
var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath); var nodeId = Combine(rootNodeId, folderPath);
if (folders.ContainsKey(nodeId)) continue; if (folders.ContainsKey(nodeId)) continue;
var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i))); var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i)));
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]); folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
} }
var varFolderPath = string.Join('/', segs); var varFolderPath = string.Join('/', segs);
var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName); var varNodeId = string.IsNullOrEmpty(varFolderPath)
// Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly ? Combine(rootNodeId, n.BrowseName)
// at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the : Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
// EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace). // A folder-less variable parents directly at the root device node.
var varParent = string.IsNullOrEmpty(varFolderPath) var varParent = string.IsNullOrEmpty(varFolderPath)
? equipmentId ? rootNodeId
: EquipmentNodeIds.SubFolder(equipmentId, varFolderPath); : Combine(rootNodeId, varFolderPath);
variables.Add(new DiscoveredVariable( variables.Add(new DiscoveredVariable(
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim)); varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
routing[n.FullReference] = varNodeId; routing[n.FullReference] = varNodeId;
@@ -111,34 +111,43 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// spawn so a restart's respawn never collides with the still-terminating old child. // spawn so a restart's respawn never collides with the still-terminating old child.
private long _childSpawnGeneration; private long _childSpawnGeneration;
/// <summary> /// <summary>A materialised NodeId together with the v3 <see cref="AddressSpaceRealm"/> it lives in, so the
/// Driver live-value routing map: <c>(DriverInstanceId, FullName) → folder-scoped equipment /// driver value fan-out posts each update to the sink with the right namespace. The same driver ref fans
/// NodeId(s)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the /// to its raw node (<see cref="AddressSpaceRealm.Raw"/>) and to every referencing UNS node
/// composition's <c>EquipmentTags</c> (mirroring <c>VirtualTagHostActor._nodeIdByVtag</c>), and /// (<see cref="AddressSpaceRealm.Uns"/>).</summary>
/// resolved in <see cref="ForwardToMux"/> so a driver value published by wire-ref FullName lands private readonly record struct NodeRealmRef(string NodeId, AddressSpaceRealm Realm);
/// on the variable's actual folder-scoped NodeId. A set because the same driver ref can back
/// several equipment variables (e.g. identical machines sharing a register), and the per-apply
/// rebuild dedups by NodeId.
/// </summary>
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _nodeIdByDriverRef = new();
/// <summary> /// <summary>
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>folder-scoped equipment NodeId → /// Driver live-value routing map: <c>(DriverInstanceId, RawPath) → materialised NodeId(s) (realm-tagged)</c>.
/// (DriverInstanceId, FullName)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> /// Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c>
/// from the same <c>EquipmentTags</c> pass, and resolved by <see cref="HandleRouteNodeWrite"/> so an /// <c>UnsReferenceVariables</c>, and resolved in <see cref="ForwardToMux"/> so a driver value
/// inbound operator write targeting an equipment variable's NodeId is forwarded to the owning /// published by wire-ref RawPath fans to its raw node AND every referencing UNS node with identical
/// driver child as a write of its wire-ref <c>FullName</c>. Each NodeId maps to exactly one driver /// value/quality/timestamp — the single-source-fan-out (no independent buffer, so no drift). A set
/// ref (a variable is backed by a single driver attribute), so this is a flat 1:1 map (the forward /// because one driver ref can back the raw node plus N UNS references; the per-apply rebuild dedups.
/// map fans out 1:N because one ref can back several variables). /// </summary>
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _nodeIdByDriverRef = new();
/// <summary>
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>materialised value NodeId (BARE <c>s=</c> id) →
/// (DriverInstanceId, RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId AND every referencing UNS
/// NodeId (all mapping to the same driver ref — the single value source). Rebuilt every apply by
/// <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c>
/// <c>UnsReferenceVariables</c>, and resolved by <see cref="HandleRouteNodeWrite"/> so an inbound
/// operator write to EITHER NodeId is forwarded to the owning driver child as a write of its wire-ref
/// RawPath. Keyed by the BARE id: the node manager's write hook passes the full ns-qualified id
/// (<c>node.NodeId.ToString()</c>); <see cref="HandleRouteNodeWrite"/> normalises it to the bare id
/// before lookup, so a raw write and a UNS write both resolve to the same driver ref (they share it —
/// a UNS reference's backing RawPath IS the raw node's, so no cross-realm ambiguity is possible).
/// </summary> /// </summary>
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId = private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId =
new(StringComparer.Ordinal); new(StringComparer.Ordinal);
/// <summary>(DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s). /// <summary>(DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged).
/// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native /// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing
/// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not /// equipment notifier NodeIds. Resolves a native alarm transition to the materialised Part 9 condition
/// value variables, so they are kept OUT of the value maps + value-subscription set.</summary> /// node(s). Alarm tags are conditions, not value variables, so they are kept OUT of the value maps +
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _alarmNodeIdByDriverRef = new(); /// value-subscription set.</summary>
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _alarmNodeIdByDriverRef = new();
/// <summary> /// <summary>
/// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId → /// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId →
@@ -620,13 +629,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// equipment variables (identical machines sharing a register), hence the fan-out. // equipment variables (identical machines sharing a register), hence the fan-out.
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds)) if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds))
{ {
foreach (var nodeId in nodeIds) // v3 Batch 4 single-source fan-out: one driver publish for a RawPath lands on the raw NodeId AND
// every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/quality/timestamp.
foreach (var n in nodeIds)
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate( _opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate(
nodeId, msg.Value, msg.Quality, msg.TimestampUtc)); n.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, n.Realm));
} }
else else
{ {
_log.Debug("DriverHost {Node}: no equipment-tag NodeId for ({Driver},{Ref}) — value dropped", _log.Debug("DriverHost {Node}: no bound NodeId for ({Driver},{Ref}) — value dropped",
_localNode, msg.DriverInstanceId, msg.FullReference); _localNode, msg.DriverInstanceId, msg.FullReference);
} }
} }
@@ -918,8 +929,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
{ {
var key = (driverId, driverRef); var key = (driverId, driverRef);
if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal); _nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
set.Add(nodeId); // v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
// through the Raw realm.
set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw));
_driverRefByNodeId[nodeId] = key; _driverRefByNodeId[nodeId] = key;
} }
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes( _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
@@ -1005,11 +1018,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, msg.DriverInstanceId, msg.Args.ConditionId); _localNode, msg.DriverInstanceId, msg.Args.ConditionId);
} }
foreach (var nodeId in nodeIds) foreach (var n in nodeIds)
{ {
var nodeId = n.NodeId;
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args); var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate( _opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
nodeId, snapshot, msg.Args.SourceTimestampUtc)); nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
// Only the Primary publishes the cluster-wide alerts transition (see the gate above). // Only the Primary publishes the cluster-wide alerts transition (see the gate above).
if (!serviceAlertsAsPrimary) continue; if (!serviceAlertsAsPrimary) continue;
@@ -1087,7 +1101,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return; return;
} }
if (!_driverRefByNodeId.TryGetValue(msg.NodeId, out var target)) // v3 Batch 4: 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
// (the raw RawPath or the UNS path). Normalise before lookup so a write to EITHER a raw NodeId or a
// referencing UNS NodeId resolves to the SAME driver ref (they share it — a UNS reference's backing
// RawPath IS the raw node's, so there is no cross-realm ambiguity).
var bareNodeId = BareNodeId(msg.NodeId);
if (!_driverRefByNodeId.TryGetValue(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}"));
return; return;
@@ -1115,6 +1135,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
.PipeTo(replyTo); .PipeTo(replyTo);
} }
/// <summary>
/// Normalise an OPC UA NodeId string to its BARE <c>s=</c> identifier. The node manager's inbound-write
/// hook passes the full ns-qualified form (<c>NodeId.ToString()</c> == <c>"ns=&lt;N&gt;;s=&lt;id&gt;"</c>);
/// the routing maps are keyed by the bare id (the RawPath / UNS path). The SDK format always prefixes
/// <c>"ns=N;s="</c> for a string identifier in a non-zero namespace, so the first <c>";s="</c> is the
/// delimiter (an id may itself contain <c>";s="</c> later — <c>IndexOf</c> takes the FIRST, which is the
/// namespace delimiter). A bare id (no prefix) passes through unchanged.
/// </summary>
/// <param name="nodeId">The (possibly ns-qualified) NodeId string.</param>
/// <returns>The bare <c>s=</c> identifier.</returns>
internal static string BareNodeId(string nodeId)
{
if (string.IsNullOrEmpty(nodeId)) return nodeId;
var i = nodeId.IndexOf(";s=", StringComparison.Ordinal);
if (i >= 0) return nodeId[(i + 3)..];
if (nodeId.StartsWith("s=", StringComparison.Ordinal)) return nodeId[2..];
return nodeId;
}
/// <summary> /// <summary>
/// Routes an inbound native-condition acknowledge (the host Tells this from the OPC UA node-manager /// Routes an inbound native-condition acknowledge (the host Tells this from the OPC UA node-manager
/// side when a client Acknowledges a NATIVE Part 9 condition) to the owning driver child. Mirrors /// side when a client Acknowledges a NATIVE Part 9 condition) to the owning driver child. Mirrors
@@ -1456,89 +1495,85 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return; return;
} }
// Value-subscription set: alarm-bearing tags are Part 9 conditions, not value variables, so they // v3 Batch 4: the driver subscribes + publishes by RawPath (the wire-ref == RawPath == RawTagPlan.NodeId
// are excluded — the driver must not value-subscribe an alarm attribute (it is fed via the native // per the v3 identity contract). Value-subscription set = the non-alarm raw tags' RawPaths; alarm-bearing
// alarm event stream, routed by ForwardNativeAlarm). // raw tags are Part 9 conditions, fed via the native alarm event stream (ForwardNativeAlarm), so they are
var refsByDriver = composition.EquipmentTags // excluded from the value set.
var refsByDriver = composition.RawTags
.Where(t => t.Alarm is null) .Where(t => t.Alarm is null)
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal) .GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary( .ToDictionary(
g => g.Key, g => g.Key,
g => (IReadOnlyList<string>)g.Select(t => t.FullName) g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.ToArray(), .ToArray(),
StringComparer.Ordinal); StringComparer.Ordinal);
// Native-alarm subscription set: the alarm-bearing tags' FullNames (= the driver's // Native-alarm subscription set: the alarm-bearing raw tags' RawPaths (= the driver's ConditionId).
// ConditionId/AlarmFullReference). An IAlarmSource driver suppresses OnAlarmEvent until at least one // An IAlarmSource driver suppresses OnAlarmEvent until at least one alarm subscription exists, so the
// alarm subscription exists (e.g. GalaxyDriver gates its central feed on _alarmSubscriptions), so the // instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by ConditionId
// instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by // in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
// ConditionId in ForwardNativeAlarm; this set just opens (and scopes) the subscription. var alarmRefsByDriver = composition.RawTags
var alarmRefsByDriver = composition.EquipmentTags
.Where(t => t.Alarm is not null) .Where(t => t.Alarm is not null)
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal) .GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary( .ToDictionary(
g => g.Key, g => g.Key,
g => (IReadOnlyList<string>)g.Select(t => t.FullName) g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.ToArray(), .ToArray(),
StringComparer.Ordinal); StringComparer.Ordinal);
// Rebuild the driver live-value routing map from the SAME EquipmentTags pass (mirrors // Referencing UNS nodes by backing RawPath — each raw value tag fans out to its own raw NodeId PLUS
// VirtualTagHostActor._nodeIdByVtag): map each tag's (DriverInstanceId, FullName) wire-ref to // every UNS reference that projects it (dual-NodeId registration against the SAME driver ref).
// the folder-scoped equipment NodeId the materialiser placed its variable at, so ForwardToMux var unsRefsByRawPath = new Dictionary<string, List<UnsReferenceVariable>>(StringComparer.Ordinal);
// can land driver values on the right node. Clear-and-repopulate every apply so renames foreach (var v in composition.UnsReferenceVariables)
// (Name/FolderPath/EquipmentId changes) and removals are reflected. {
if (!unsRefsByRawPath.TryGetValue(v.BackingRawPath, out var list))
unsRefsByRawPath[v.BackingRawPath] = list = new List<UnsReferenceVariable>();
list.Add(v);
}
// Rebuild the driver live-value + write routing maps from the RawTags UnsReferenceVariables pass.
// Clear-and-repopulate every apply so renames/removals/re-points are reflected.
_nodeIdByDriverRef.Clear(); _nodeIdByDriverRef.Clear();
// Inverse map for the inbound operator-write path (NodeId → (DriverInstanceId, FullName)): an
// operator writes to the variable's folder-scoped NodeId, but the driver writes by its wire-ref
// FullName. Cleared + repopulated from the SAME EquipmentTags pass so renames/removals are
// reflected. Each NodeId maps to exactly one driver ref (a variable is backed by a single driver
// attribute), so last-writer-wins on the rare duplicate is harmless.
_driverRefByNodeId.Clear(); _driverRefByNodeId.Clear();
// Alarm condition routing map: (DriverInstanceId, FullName = alarm ConditionId/AlarmFullReference) → folder-scoped
// condition NodeId(s). Built from the SAME EquipmentTags pass (alarm-bearing tags only) so
// ForwardNativeAlarm can land a native transition on the right condition node. Clear-and-rebuild
// every apply; the projector is Clear()'d too so stale per-condition state never leaks across
// redeploys (renames/removals/address-space rebuilds).
_alarmNodeIdByDriverRef.Clear(); _alarmNodeIdByDriverRef.Clear();
// Inverse alarm map for the inbound native-condition ack path (condition NodeId → (DriverInstanceId,
// FullName)): an OPC UA client acknowledges the condition's folder-scoped NodeId, but the driver
// acknowledges by its wire-ref FullName (= ConditionId). Cleared + repopulated from the SAME
// alarm-bearing EquipmentTags pass so renames/removals are reflected. Each condition NodeId maps to
// exactly one driver ref (a condition is backed by a single driver alarm), so last-writer-wins on the
// rare duplicate is harmless.
_driverRefByAlarmNodeId.Clear(); _driverRefByAlarmNodeId.Clear();
// Per-condition metadata (EquipmentId / Name / OPC UA alarm type) for the alerts fan-out, built in
// the SAME alarm branch as the node map so a redeploy can't leave it out of sync. Cleared alongside it.
_alarmMetaByNodeId.Clear(); _alarmMetaByNodeId.Clear();
_nativeAlarmProjector.Clear(); _nativeAlarmProjector.Clear();
foreach (var t in composition.EquipmentTags) foreach (var t in composition.RawTags)
{ {
var key = (t.DriverInstanceId, t.FullName); var key = (t.DriverInstanceId, t.NodeId); // (DriverInstanceId, RawPath) — RawPath IS the wire-ref
var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name);
if (t.Alarm is not null) if (t.Alarm is not null)
{ {
// Alarm tags are conditions, not value variables: route them ONLY into the alarm map and // Native alarm → a single Part 9 condition at the RawPath (Raw realm). WP4 adds the referencing
// keep them OUT of the value maps + value-subscription set (so they don't get both a value // equipment notifier NodeIds; WP3 wires the single raw condition.
// variable AND a condition).
if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset)) if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset))
_alarmNodeIdByDriverRef[key] = aset = new HashSet<string>(StringComparer.Ordinal); _alarmNodeIdByDriverRef[key] = aset = new HashSet<NodeRealmRef>();
aset.Add(nodeId); aset.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
// Inverse 1:1 map for the inbound native-condition ack path: the materialised condition // Inverse 1:1 map for the inbound native-condition ack path (keyed by BARE condition NodeId).
// NodeId routes back to the owning (DriverInstanceId, FullName=ConditionId) so an OPC UA _driverRefByAlarmNodeId[t.NodeId] = key;
// acknowledge of this condition reaches the right driver child. // Per-condition metadata for the alerts fan-out. WP4 refines /alerts identity to the referencing
_driverRefByAlarmNodeId[nodeId] = key; // equipment paths; WP3 keys the display off the RawPath.
// Capture the per-condition metadata the alerts fan-out (ForwardNativeAlarm) needs to build _alarmMetaByNodeId[t.NodeId] = (t.NodeId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva);
// the AlarmTransitionEvent: the equipment path, the operator-visible alarm name, and the
// OPC UA Part 9 subtype. Keyed by the condition NodeId (the projection's own key).
_alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva);
continue; continue;
} }
// Value tag: register the RAW NodeId (Raw realm) AND every referencing UNS NodeId (Uns realm)
// against the SAME driver ref — the single value source fans to all of them.
if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal); _nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
set.Add(nodeId); set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
_driverRefByNodeId[nodeId] = key; _driverRefByNodeId[t.NodeId] = key;
if (unsRefsByRawPath.TryGetValue(t.NodeId, out var refs))
{
foreach (var v in refs)
{
set.Add(new NodeRealmRef(v.NodeId, AddressSpaceRealm.Uns));
_driverRefByNodeId[v.NodeId] = key; // a UNS write resolves to the same driver ref
}
}
} }
// Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver // Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver
@@ -39,16 +39,25 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// for its cached status. Private singleton — the actor pumps this for itself, no external sender.</summary> /// for its cached status. Private singleton — the actor pumps this for itself, no external sender.</summary>
private sealed class HealthTick { public static readonly HealthTick Instance = new(); private HealthTick() { } } private sealed class HealthTick { public static readonly HealthTick Instance = new(); private HealthTick() { } }
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc); /// <summary>A driver/vtag value update for a single materialised Variable node. v3 Batch 4: carries the
/// node's <see cref="AddressSpaceRealm"/> so the sink resolves the right namespace — the driver fan-out
/// posts one of these per registered NodeId (the raw node in <see cref="AddressSpaceRealm.Raw"/> and each
/// 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
/// 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);
/// <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
/// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from /// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
/// <c>Core.ScriptedAlarms</c>.</summary> /// <c>Core.ScriptedAlarms</c>.</summary>
/// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for materialised conditions).</param> /// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for scripted conditions; == RawPath for
/// v3 native raw 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="TimestampUtc">The source timestamp of the transition in UTC.</param> /// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param>
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc); /// <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>
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
/// <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
@@ -263,7 +272,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{ {
try try
{ {
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc); _sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes); Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value")); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
} }
@@ -277,7 +286,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{ {
try try
{ {
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc); _sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes); Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm")); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
} }
@@ -340,6 +349,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter. // Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter.
var failedNodes = outcome.FailedNodes; var failedNodes = outcome.FailedNodes;
failedNodes += _applier.MaterialiseHierarchy(composition); failedNodes += _applier.MaterialiseHierarchy(composition);
// v3 Batch 4 — the Raw device subtree (ns=Raw): containers (Folder→Driver→Device→TagGroup) then
// tag Variables keyed by RawPath. Materialised BEFORE the UNS references so each UNS Organizes→Raw
// edge finds its raw target. The driver binds live values to these raw NodeIds.
failedNodes += _applier.MaterialiseRawSubtree(composition);
// v3 Batch 4 — the UNS reference Variables (ns=UNS): each projects a raw tag under its equipment
// folder (created by MaterialiseHierarchy) with an Organizes→Raw edge; values fan out from the raw
// node. Runs AFTER both the equipment folders AND the raw subtree exist.
failedNodes += _applier.MaterialiseUnsReferences(composition);
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment // T14 — scripted alarms get their own pass right after the hierarchy so the equipment
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState // folders they parent under already exist. Materialises real Part 9 AlarmConditionState
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled // nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
@@ -223,9 +223,13 @@ public sealed class VirtualTagHostActor : ReceiveActor
} }
} }
/// <summary>Folder-scoped NodeId for a VirtualTag plan. The formula now lives in the shared /// <summary>UNS-realm NodeId for a VirtualTag plan — the equipment-anchored slash path
/// <see cref="EquipmentNodeIds"/> (the single source of truth that <c>AddressSpaceApplier</c> also /// <c>{EquipmentId}[/{FolderPath}]/{Name}</c>, expressed through the v3 identity authority
/// materialises against), so the published value always lands on the NodeId that was materialised.</summary> /// <see cref="V3NodeIds.Uns(string[])"/>. Byte-identical to the applier's equipment-VirtualTag materialise
/// pass (its private <c>UnsEquipmentVar</c>), so the published value always lands on the materialised
/// NodeId. FolderPath is empty for VirtualTags today, but a multi-segment path is split into segments.</summary>
private static string NodeIdFor(EquipmentVirtualTagPlan p) => private static string NodeIdFor(EquipmentVirtualTagPlan p) =>
EquipmentNodeIds.Variable(p.EquipmentId, p.FolderPath, p.Name); string.IsNullOrWhiteSpace(p.FolderPath)
? V3NodeIds.Uns(p.EquipmentId, p.Name)
: V3NodeIds.Uns(new[] { p.EquipmentId }.Concat(p.FolderPath.Split('/')).Append(p.Name));
} }
@@ -19,7 +19,7 @@ public class DeferredAddressSpaceSinkTests
{ {
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
// Must not throw. // Must not throw.
sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
} }
[Fact] [Fact]
@@ -34,7 +34,7 @@ public class DeferredAddressSpaceSinkTests
{ {
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null, sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
dataType: "Boolean", isArray: false, arrayLength: null).ShouldBeFalse(); dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
} }
// ---------- after SetSink — operations are forwarded ---------- // ---------- after SetSink — operations are forwarded ----------
@@ -46,7 +46,7 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.SetSink(inner); sink.SetSink(inner);
sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.WriteValueCalled.ShouldBeTrue(); inner.WriteValueCalled.ShouldBeTrue();
} }
@@ -73,7 +73,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(new SpySink()); sink.SetSink(new SpySink());
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null, sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
dataType: "Int32", isArray: false, arrayLength: null).ShouldBeFalse(); dataType: "Int32", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
} }
[Fact] [Fact]
@@ -84,7 +84,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(surgical); sink.SetSink(surgical);
var result = sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null, var result = sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
dataType: "Float", isArray: false, arrayLength: null); dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns);
result.ShouldBeTrue(); result.ShouldBeTrue();
surgical.UpdateCalled.ShouldBeTrue(); surgical.UpdateCalled.ShouldBeTrue();
@@ -97,7 +97,7 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.SetSink(new SpySink()); sink.SetSink(new SpySink());
sink.UpdateFolderDisplayName("area-1", "Plant South").ShouldBeFalse(); sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns).ShouldBeFalse();
} }
[Fact] [Fact]
@@ -108,7 +108,7 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.SetSink(surgical); sink.SetSink(surgical);
var result = sink.UpdateFolderDisplayName("area-1", "Plant South"); var result = sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns);
result.ShouldBeTrue(); result.ShouldBeTrue();
surgical.FolderRenameCalled.ShouldBeTrue(); surgical.FolderRenameCalled.ShouldBeTrue();
@@ -122,9 +122,9 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.SetSink(new SpySink()); sink.SetSink(new SpySink());
sink.RemoveVariableNode("v-1").ShouldBeFalse(); sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse(); sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse(); sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
} }
[Fact] [Fact]
@@ -134,9 +134,9 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.SetSink(surgical); sink.SetSink(surgical);
sink.RemoveVariableNode("v-1").ShouldBeTrue(); sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeTrue();
sink.RemoveAlarmConditionNode("a-1").ShouldBeTrue(); sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeTrue();
sink.RemoveEquipmentSubtree("eq-1").ShouldBeTrue(); sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
surgical.RemoveVariableCalled.ShouldBeTrue(); surgical.RemoveVariableCalled.ShouldBeTrue();
surgical.RemoveAlarmCalled.ShouldBeTrue(); surgical.RemoveAlarmCalled.ShouldBeTrue();
@@ -147,9 +147,9 @@ public class DeferredAddressSpaceSinkTests
public void Before_SetSink_remove_members_return_false() public void Before_SetSink_remove_members_return_false()
{ {
var sink = new DeferredAddressSpaceSink(); var sink = new DeferredAddressSpaceSink();
sink.RemoveVariableNode("v-1").ShouldBeFalse(); sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse(); sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse(); sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
} }
// ---------- SetSink(null) reverts to null sink ---------- // ---------- SetSink(null) reverts to null sink ----------
@@ -163,7 +163,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(null); // revert sink.SetSink(null); // revert
sink.UpdateTagAttributes("ns=2;s=x", writable: false, historianTagname: null, sink.UpdateTagAttributes("ns=2;s=x", writable: false, historianTagname: null,
dataType: "Boolean", isArray: false, arrayLength: null).ShouldBeFalse(); dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
} }
[Fact] [Fact]
@@ -174,7 +174,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(inner); sink.SetSink(inner);
sink.SetSink(null); // revert sink.SetSink(null); // revert
sink.WriteValue("ns=2;s=x", 1, OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue("ns=2;s=x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.WriteValueCalled.ShouldBeFalse("write should be no-op after reverting to null sink"); inner.WriteValueCalled.ShouldBeFalse("write should be no-op after reverting to null sink");
} }
@@ -190,9 +190,9 @@ public class DeferredAddressSpaceSinkTests
=> WriteValueCalled = true; => WriteValueCalled = true;
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
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) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
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) { }
public void RebuildAddressSpace() => RebuildCalled = true; public void RebuildAddressSpace() => RebuildCalled = true;
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
@@ -205,9 +205,9 @@ public class DeferredAddressSpaceSinkTests
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 = AddressSpaceRealm.Uns) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
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) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
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) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
@@ -1,28 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;
public class EquipmentNodeIdsTests
{
[Fact]
public void Variable_with_no_folder_is_equipment_slash_name()
=> EquipmentNodeIds.Variable("eq-1", "", "speed").ShouldBe("eq-1/speed");
[Fact]
public void Variable_with_null_folder_is_equipment_slash_name()
=> EquipmentNodeIds.Variable("eq-1", null, "speed").ShouldBe("eq-1/speed");
[Fact]
public void Variable_with_folder_is_equipment_slash_folder_slash_name()
=> EquipmentNodeIds.Variable("eq-1", "registers", "speed").ShouldBe("eq-1/registers/speed");
[Fact]
public void Variable_with_whitespace_folder_is_equipment_slash_name()
=> EquipmentNodeIds.Variable("eq-1", " ", "speed").ShouldBe("eq-1/speed");
[Fact]
public void SubFolder_is_equipment_slash_folder()
=> EquipmentNodeIds.SubFolder("eq-1", "registers").ShouldBe("eq-1/registers");
}
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua; using Opc.Ua;
using Opc.Ua.Client; using Opc.Ua.Client;
using Shouldly; using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit; using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
@@ -65,8 +66,8 @@ public sealed class SubscriptionSurvivalTests
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null)); new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
applier.MaterialiseHierarchy(withA); applier.MaterialiseHierarchy(withA);
applier.MaterialiseEquipmentTags(withA); applier.MaterialiseEquipmentTags(withA);
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A"); var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
// --- Open a client session + subscription + monitored item on A. --- // --- Open a client session + subscription + monitored item on A. ---
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
@@ -115,7 +116,7 @@ public sealed class SubscriptionSurvivalTests
// --- A's monitored item must still be alive: a new write to A is delivered. --- // --- A's monitored item must still be alive: a new write to A is delivered. ---
lock (gate) received.Clear(); lock (gate) received.Clear();
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
lock (gate) lock (gate)
@@ -125,8 +126,8 @@ public sealed class SubscriptionSurvivalTests
} }
// --- B is browsable + readable (the add landed). --- // --- B is browsable + readable (the add landed). ---
var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns); var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var bValue = await session.ReadValueAsync(nodeIdB, ct); var bValue = await session.ReadValueAsync(nodeIdB, ct);
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown); bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
bValue.Value.ShouldBe(7); bValue.Value.ShouldBe(7);
@@ -173,10 +174,10 @@ public sealed class SubscriptionSurvivalTests
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
applier.MaterialiseHierarchy(withAB); applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB); applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A"); var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
var nodeIdBString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"); var nodeIdBString = V3NodeIds.Uns("eq-1", "B");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
@@ -212,7 +213,7 @@ public sealed class SubscriptionSurvivalTests
await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5));
// A stays alive: a fresh write to A is delivered. // A stays alive: a fresh write to A is delivered.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path // B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
@@ -261,8 +262,8 @@ public sealed class SubscriptionSurvivalTests
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
applier.MaterialiseHierarchy(withAB); applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB); applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A"); var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
@@ -295,18 +296,18 @@ public sealed class SubscriptionSurvivalTests
applier.AnnounceAddedNodes(plan); applier.AnnounceAddedNodes(plan);
// A stays alive. // A stays alive.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone. (1.5.378 node-cache read throws for an unknown node — see above.) // B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns); var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
var bReadEx = await Should.ThrowAsync<ServiceResultException>( var bReadEx = await Should.ThrowAsync<ServiceResultException>(
async () => await session.ReadValueAsync(nodeIdB, ct)); async () => await session.ReadValueAsync(nodeIdB, ct));
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown); bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
// C is browsable + readable. // C is browsable + readable.
var nodeIdC = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "C"), ns); var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns);
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var cRead = await session.ReadValueAsync(nodeIdC, ct); var cRead = await session.ReadValueAsync(nodeIdC, ct);
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown); cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
cRead.Value.ShouldBe(9); cRead.Value.ShouldBe(9);
@@ -168,7 +168,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault"); if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
} }
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)
{ {
if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault"); if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault");
} }
@@ -178,7 +178,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault"); if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault");
} }
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)
{ {
if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault"); if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault");
} }
@@ -319,7 +319,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// <param name="displayName">The condition display name.</param> /// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param> /// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param> /// <param name="severity">The domain severity.</param>
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) { }
/// <summary>Records a folder creation request.</summary> /// <summary>Records a folder creation request.</summary>
/// <param name="folderNodeId">The node ID of the folder.</param> /// <param name="folderNodeId">The node ID of the folder.</param>
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param> /// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
@@ -333,7 +333,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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) { }
/// <summary>Rebuilds the address space (stub implementation for testing).</summary> /// <summary>Rebuilds the address space (stub implementation for testing).</summary>
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary> /// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary>
@@ -292,8 +292,8 @@ public sealed class AddressSpaceApplierProvisioningTests
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(), RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>()) ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
{ {
RemovedEquipmentTags = new[] { removedTag }, RemovedRawTags = new[] { removedTag },
ChangedEquipmentTags = new[] { new AddressSpacePlan.EquipmentTagDelta(prev, cur) }, ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) },
}; };
applier.Apply(plan); applier.Apply(plan);
@@ -324,15 +324,20 @@ public sealed class AddressSpaceApplierProvisioningTests
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned
} }
private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref") // v3 Batch 4: historized value tags are RAW tags — the mux ref + historian default are the RawPath
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName, // (== NodeId). The `fullName` param plays the role of the RawPath here (kept as a param name so the
Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName); // existing assertions — HistorizedTagRef("ref"/"40001", …) — read unchanged).
private static RawTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
=> new("tag-" + displayName, NodeId: fullName, ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
IsHistorized: true, HistorianTagname: historianName);
private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType) private static RawTagPlan NonHistorizedTag(string displayName, string dataType)
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref", => new("tag-" + displayName, NodeId: "ref", ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null); DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
IsHistorized: false, HistorianTagname: null);
private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new( private static AddressSpacePlan PlanWithAddedTags(params RawTagPlan[] tags) => new(
AddedEquipment: Array.Empty<EquipmentNode>(), AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(), RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(), ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
@@ -343,6 +348,6 @@ public sealed class AddressSpaceApplierProvisioningTests
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(), RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>()) ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
{ {
AddedEquipmentTags = tags, AddedRawTags = tags,
}; };
} }
@@ -0,0 +1,177 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// v3 Batch 4 (B4-WP3) — the applier's dual-realm materialise passes.
/// <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> lights up the <c>ns=Raw</c> device tree
/// (containers as folders + tags as variables, each keyed by its RawPath, all in
/// <see cref="AddressSpaceRealm.Raw"/>); <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> lights
/// up the <c>ns=UNS</c> reference variables (each under its equipment folder in
/// <see cref="AddressSpaceRealm.Uns"/>) and wires an <c>Organizes</c> reference UNS→Raw. A historized raw
/// tag's UNS reference inherits the SAME historian tagname (both NodeIds → one tagname). Every call carries
/// its realm EXPLICITLY.
/// </summary>
public sealed class AddressSpaceApplierRawUnsTests
{
private static AddressSpaceApplier NewApplier(RealmRecordingSink sink) =>
new(sink, NullLogger<AddressSpaceApplier>.Instance);
[Fact]
public void MaterialiseRawSubtree_creates_raw_containers_and_tags_in_raw_realm()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawContainers = new[]
{
new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder),
new RawContainerNode("Plant/Modbus", "Plant", "Modbus", RawNodeKind.Driver),
new RawContainerNode("Plant/Modbus/dev1", "Plant/Modbus", "dev1", RawNodeKind.Device),
},
RawTags = new[]
{
new RawTagPlan("t1", "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1", "drv-1", "speed",
"Float", Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>()),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
// Containers are folders in the Raw realm, parents-before-children.
sink.Folders.Select(f => (f.NodeId, f.Realm)).ShouldBe(new[]
{
("Plant", AddressSpaceRealm.Raw),
("Plant/Modbus", AddressSpaceRealm.Raw),
("Plant/Modbus/dev1", AddressSpaceRealm.Raw),
});
// The tag is a variable keyed by its RawPath, in the Raw realm, parented under its group/device.
var v = sink.Variables.ShouldHaveSingleItem();
v.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
v.Parent.ShouldBe("Plant/Modbus/dev1");
v.Realm.ShouldBe(AddressSpaceRealm.Raw);
v.Writable.ShouldBeTrue();
v.HistorianTagname.ShouldBeNull();
}
[Fact]
public void MaterialiseRawSubtree_resolves_historian_tagname_default_and_override()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[]
{
// Historized, no override → historian tagname defaults to the RawPath.
new RawTagPlan("t1", "Plant/A/dev/def", "Plant/A/dev", "drv", "def", "Float",
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true),
// Historized with override → the override wins.
new RawTagPlan("t2", "Plant/A/dev/ovr", "Plant/A/dev", "drv", "ovr", "Float",
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true, HistorianTagname: "WW.Override"),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
var byId = sink.Variables.ToDictionary(v => v.NodeId);
byId["Plant/A/dev/def"].HistorianTagname.ShouldBe("Plant/A/dev/def"); // default = RawPath
byId["Plant/A/dev/ovr"].HistorianTagname.ShouldBe("WW.Override");
}
[Fact]
public void MaterialiseRawSubtree_alarm_tag_materialises_a_raw_condition_not_a_variable()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[]
{
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
ReferencingEquipmentPaths: Array.Empty<string>()),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
sink.Variables.ShouldBeEmpty(); // an alarm tag is a condition, not a value variable
var c = sink.Conditions.ShouldHaveSingleItem();
c.NodeId.ShouldBe("Plant/A/dev/OverTemp"); // ConditionId == RawPath
c.Parent.ShouldBe("Plant/A/dev");
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
c.IsNative.ShouldBeTrue();
}
[Fact]
public void MaterialiseUnsReferences_creates_uns_variable_and_organizes_edge_inheriting_historian()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[]
{
// Backing raw tag: historized with an override → the UNS ref must register the SAME tagname.
new RawTagPlan("t1", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
Writable: true, Alarm: null, ReferencingEquipmentPaths: new[] { "filling/line1/station1" },
IsHistorized: true, HistorianTagname: "WW.Speed"),
},
UnsReferenceVariables = new[]
{
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
"Plant/A/dev/speed", "Float", Writable: true),
},
};
NewApplier(sink).MaterialiseUnsReferences(composition);
// The UNS reference is a variable in the Uns realm, parented under its equipment folder (EquipmentId).
var v = sink.Variables.ShouldHaveSingleItem();
v.NodeId.ShouldBe("filling/line1/station1/speed");
v.Parent.ShouldBe("EQ-1");
v.Realm.ShouldBe(AddressSpaceRealm.Uns);
v.Writable.ShouldBeTrue();
v.HistorianTagname.ShouldBe("WW.Speed"); // inherited from the backing historized raw tag
// An Organizes reference UNS→Raw links the two.
var r = sink.References.ShouldHaveSingleItem();
r.Source.ShouldBe("filling/line1/station1/speed");
r.SourceRealm.ShouldBe(AddressSpaceRealm.Uns);
r.Target.ShouldBe("Plant/A/dev/speed");
r.TargetRealm.ShouldBe(AddressSpaceRealm.Raw);
r.ReferenceType.ShouldBe("Organizes");
}
/// <summary>A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges.</summary>
private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink
{
public List<(string NodeId, string? Parent, AddressSpaceRealm Realm)> Folders { get; } = new();
public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> Variables { get; } = new();
public List<(string NodeId, string? Parent, bool IsNative, AddressSpaceRealm Realm)> Conditions { get; } = new();
public List<(string Source, AddressSpaceRealm SourceRealm, string Target, AddressSpaceRealm TargetRealm, string ReferenceType)> References { get; } = new();
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> Folders.Add((folderNodeId, parentNodeId, realm));
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm));
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
}
}
@@ -148,7 +148,7 @@ public sealed class AddressSpaceApplierTests
// A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite). // A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true)); sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true));
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath). // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
} }
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment /// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
@@ -175,7 +175,7 @@ public sealed class AddressSpaceApplierTests
// A Read plan threads Writable: false (the node stays CurrentRead). // A Read plan threads Writable: false (the node stays CurrentRead).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false)); sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false));
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath). // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp")); sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
} }
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the /// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
@@ -228,13 +228,13 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentTags(composition); applier.MaterialiseEquipmentTags(composition);
// The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition. // The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition.
var plainNodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed"); var plainNodeId = V3NodeIds.Uns("eq-1", "Speed");
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true)); sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true));
sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId); sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId);
// The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent, // The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent,
// matching display/type/severity) and did NOT drive EnsureVariable. // matching display/type/severity) and did NOT drive EnsureVariable.
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp"); var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp");
// A native equipment-tag alarm: the call-site threads isNative: true. // A native equipment-tag alarm: the call-site threads isNative: true.
sink.AlarmConditionCalls.ShouldHaveSingleItem() sink.AlarmConditionCalls.ShouldHaveSingleItem()
.ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true)); .ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true));
@@ -264,7 +264,7 @@ public sealed class AddressSpaceApplierTests
// The sub-folder is still created for an alarm tag with a FolderPath. // The sub-folder is still created for an alarm tag with a FolderPath.
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
// Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable. // Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable.
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "Diagnostics", "OverTemp"); var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp");
// A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true. // A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true.
sink.AlarmConditionCalls.ShouldHaveSingleItem() sink.AlarmConditionCalls.ShouldHaveSingleItem()
.ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true)); .ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true));
@@ -300,9 +300,9 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentTags(composition); applier.MaterialiseEquipmentTags(composition);
var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname); var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname);
byNode[EquipmentNodeIds.Variable("eq-1", "", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName
byNode[EquipmentNodeIds.Variable("eq-1", "", "BOverride")].ShouldBe("WW.Override"); // override verbatim byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim
byNode[EquipmentNodeIds.Variable("eq-1", "", "CPlain")].ShouldBeNull(); // not historized ⇒ null byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null
} }
/// <summary>Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to /// <summary>Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to
@@ -326,7 +326,7 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentTags(composition); applier.MaterialiseEquipmentTags(composition);
var call = sink.HistorianCalls.ShouldHaveSingleItem(); var call = sink.HistorianCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.HistorianTagname.ShouldBe("40001"); call.HistorianTagname.ShouldBe("40001");
} }
@@ -353,7 +353,7 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentTags(composition); applier.MaterialiseEquipmentTags(composition);
var call = sink.ArrayCalls.ShouldHaveSingleItem(); var call = sink.ArrayCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Buffer")); call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer"));
call.IsArray.ShouldBeTrue(); call.IsArray.ShouldBeTrue();
call.ArrayLength.ShouldBe(16u); call.ArrayLength.ShouldBe(16u);
} }
@@ -380,7 +380,7 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentTags(composition); applier.MaterialiseEquipmentTags(composition);
var call = sink.ArrayCalls.ShouldHaveSingleItem(); var call = sink.ArrayCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.IsArray.ShouldBeFalse(); call.IsArray.ShouldBeFalse();
call.ArrayLength.ShouldBeNull(); call.ArrayLength.ShouldBeNull();
} }
@@ -471,11 +471,11 @@ public sealed class AddressSpaceApplierTests
// VirtualTags are computed outputs — always read-only (Writable: false). // VirtualTags are computed outputs — always read-only (Writable: false).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false)); sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula. // Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm")); sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm"));
} }
/// <summary>Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and /// <summary>Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and
/// the equipment-VirtualTag pass is byte-identical to <see cref="EquipmentNodeIds.Variable"/> — the /// the equipment-VirtualTag pass is byte-identical to <c>V3NodeIds.Uns</c> — the
/// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty /// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty
/// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test /// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test
/// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the /// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the
@@ -507,10 +507,10 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentVirtualTags(composition); applier.MaterialiseEquipmentVirtualTags(composition);
var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList(); var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList();
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "", "Speed")); nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed"));
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp")); nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "", "Efficiency")); nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency"));
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "Calc", "Avg")); nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg"));
} }
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables /// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
@@ -760,7 +760,7 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse(); outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
var call = sink.SurgicalCalls.ShouldHaveSingleItem(); var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.Writable.ShouldBeTrue(); call.Writable.ShouldBeTrue();
} }
@@ -815,7 +815,7 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse(); outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed"); var nodeId = V3NodeIds.Uns("eq-1", "Speed");
// Terminal Bad written to the removed variable BEFORE the removal. // Terminal Bad written to the removed variable BEFORE the removal.
sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad)); sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad));
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId)); sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
@@ -842,7 +842,7 @@ public sealed class AddressSpaceApplierTests
var outcome = applier.Apply(plan); var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse(); outcome.RebuildCalled.ShouldBeFalse();
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp"); var nodeId = V3NodeIds.Uns("eq-1", "OverTemp");
// Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node. // Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node.
var write = sink.AlarmWrites.ShouldHaveSingleItem(); var write = sink.AlarmWrites.ShouldHaveSingleItem();
write.NodeId.ShouldBe(nodeId); write.NodeId.ShouldBe(nodeId);
@@ -953,7 +953,7 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse(); outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
// The removed node is torn down in place; the add is left to the publish actor's materialise passes. // The removed node is torn down in place; the add is left to the publish actor's materialise passes.
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", EquipmentNodeIds.Variable("eq-1", "", "Old"))); sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old")));
outcome.AddedNodes.ShouldBe(1); outcome.AddedNodes.ShouldBe(1);
outcome.RemovedNodes.ShouldBe(1); outcome.RemovedNodes.ShouldBe(1);
} }
@@ -967,7 +967,7 @@ public sealed class AddressSpaceApplierTests
var sink = new RecordingSink(); var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance); var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Slot"); var nodeId = V3NodeIds.Uns("eq-1", "Slot");
var plan = EmptyPlan with var plan = EmptyPlan with
{ {
// Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot). // Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot).
@@ -1457,8 +1457,8 @@ public sealed class AddressSpaceApplierTests
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
outcome.RemovedNodes.ShouldBe(2); // both removals counted outcome.RemovedNodes.ShouldBe(2); // both removals counted
sink.RemoveCalls.Count.ShouldBe(2); sink.RemoveCalls.Count.ShouldBe(2);
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Speed"))); sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Speed")));
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Efficiency"))); sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency")));
} }
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) ----- // ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
@@ -1490,7 +1490,7 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse(); outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
var call = sink.SurgicalCalls.ShouldHaveSingleItem(); var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.Writable.ShouldBeTrue(); // the NEW Writable value call.Writable.ShouldBeTrue(); // the NEW Writable value
call.Historian.ShouldBeNull(); // not historized call.Historian.ShouldBeNull(); // not historized
outcome.ChangedNodes.ShouldBe(1); outcome.ChangedNodes.ShouldBe(1);
@@ -1584,7 +1584,7 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse(); outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved
var call = sink.SurgicalCalls.ShouldHaveSingleItem(); var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.DataType.ShouldBe("Int32"); // the NEW DataType call.DataType.ShouldBe("Int32"); // the NEW DataType
call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call
call.IsArray.ShouldBeFalse(); call.IsArray.ShouldBeFalse();
@@ -1781,8 +1781,8 @@ public sealed class AddressSpaceApplierTests
sink.SurgicalCalls.Count.ShouldBe(2); sink.SurgicalCalls.Count.ShouldBe(2);
// Both expected node ids must appear (order is not guaranteed). // Both expected node ids must appear (order is not guaranteed).
var nodeId1 = EquipmentNodeIds.Variable("eq-1", "", "Speed"); var nodeId1 = V3NodeIds.Uns("eq-1", "Speed");
var nodeId2 = EquipmentNodeIds.Variable("eq-1", "", "Pressure"); var nodeId2 = V3NodeIds.Uns("eq-1", "Pressure");
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable); sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable);
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable); sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable);
@@ -2040,7 +2040,7 @@ public sealed class AddressSpaceApplierTests
}, },
}; };
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { EquipmentNodeIds.SubFolder("eq-1", "Diag") }); applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { V3NodeIds.Uns("eq-1", "Diag") });
} }
/// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary> /// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
@@ -2101,7 +2101,7 @@ public sealed class AddressSpaceApplierTests
applier.AnnounceAddedNodes(plan); applier.AnnounceAddedNodes(plan);
sink.ModelChangeCalls.OrderBy(x => x).ShouldBe( sink.ModelChangeCalls.OrderBy(x => x).ShouldBe(
new[] { "eq-1", EquipmentNodeIds.SubFolder("eq-1", "Diag"), "line-7" }.OrderBy(x => x)); new[] { "eq-1", V3NodeIds.Uns("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
} }
private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) => private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
@@ -2276,7 +2276,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="displayName">The condition display name.</param> /// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param> /// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param> /// <param name="severity">The domain severity.</param>
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)
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative)); => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative));
/// <summary>Records a folder creation call.</summary> /// <summary>Records a folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param> /// <param name="folderNodeId">The folder node ID.</param>
@@ -2291,7 +2291,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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)
{ {
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
HistorianQueue.Enqueue((variableNodeId, historianTagname)); HistorianQueue.Enqueue((variableNodeId, historianTagname));
@@ -2323,11 +2323,11 @@ public sealed class AddressSpaceApplierTests
/// <summary>No-op alarm condition write call.</summary> /// <summary>No-op alarm condition write call.</summary>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op alarm-condition materialise call.</summary> /// <summary>No-op alarm-condition materialise call.</summary>
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) { }
/// <summary>No-op folder creation call.</summary> /// <summary>No-op folder creation call.</summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op variable creation call.</summary> /// <summary>No-op variable creation call.</summary>
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) { }
/// <summary>Records a rebuild address space call.</summary> /// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>No-op NodeAdded model-change announcement.</summary> /// <summary>No-op NodeAdded model-change announcement.</summary>
@@ -2363,7 +2363,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="displayName">The condition display name.</param> /// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param> /// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param> /// <param name="severity">The domain severity.</param>
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) { }
/// <summary>No-op folder creation call.</summary> /// <summary>No-op folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param> /// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param> /// <param name="parentNodeId">The parent folder node ID, if any.</param>
@@ -2376,7 +2376,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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) { }
/// <summary>No-op rebuild address space call.</summary> /// <summary>No-op rebuild address space call.</summary>
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <summary>No-op NodeAdded model-change announcement.</summary> /// <summary>No-op NodeAdded model-change announcement.</summary>
@@ -14,8 +14,8 @@ public sealed class DeferredAddressSpaceSinkTests
var deferred = new DeferredAddressSpaceSink(); var deferred = new DeferredAddressSpaceSink();
// No throw, no observable side effect. // No throw, no observable side effect.
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow); deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow); deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.RebuildAddressSpace(); deferred.RebuildAddressSpace();
} }
@@ -27,10 +27,10 @@ public sealed class DeferredAddressSpaceSinkTests
var inner = new RecordingSink(); var inner = new RecordingSink();
deferred.SetSink(inner); deferred.SetSink(inner);
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow); deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow); deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.RebuildAddressSpace(); deferred.RebuildAddressSpace();
deferred.RaiseNodesAddedModelChange("eq-1"); deferred.RaiseNodesAddedModelChange("eq-1", AddressSpaceRealm.Uns);
inner.Calls.ShouldBe(new[] { "WV:x", "WA:a-1", "RB", "NA:eq-1" }); inner.Calls.ShouldBe(new[] { "WV:x", "WA:a-1", "RB", "NA:eq-1" });
} }
@@ -42,11 +42,11 @@ public sealed class DeferredAddressSpaceSinkTests
var deferred = new DeferredAddressSpaceSink(); var deferred = new DeferredAddressSpaceSink();
var inner = new RecordingSink(); var inner = new RecordingSink();
deferred.SetSink(inner); deferred.SetSink(inner);
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow); deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.Calls.Count.ShouldBe(1); inner.Calls.Count.ShouldBe(1);
deferred.SetSink(null); deferred.SetSink(null);
deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow); // dropped deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); // dropped
inner.Calls.Count.ShouldBe(1); inner.Calls.Count.ShouldBe(1);
} }
@@ -59,10 +59,10 @@ public sealed class DeferredAddressSpaceSinkTests
var second = new RecordingSink(); var second = new RecordingSink();
deferred.SetSink(first); deferred.SetSink(first);
deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow); deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.SetSink(second); deferred.SetSink(second);
deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow); deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
first.Calls.Single().ShouldBe("WV:a"); first.Calls.Single().ShouldBe("WV:a");
second.Calls.Single().ShouldBe("WV:b"); second.Calls.Single().ShouldBe("WV:b");
@@ -77,7 +77,7 @@ public sealed class DeferredAddressSpaceSinkTests
var inner = new RecordingSink(); var inner = new RecordingSink();
deferred.SetSink(inner); deferred.SetSink(inner);
deferred.EnsureVariable("v-1", null, "MyVar", "Float", writable: false, historianTagname: "MyTag.PV"); deferred.EnsureVariable("v-1", null, "MyVar", "Float", writable: false, historianTagname: "MyTag.PV", realm: AddressSpaceRealm.Uns);
var call = inner.HistorianCalls.ShouldHaveSingleItem(); var call = inner.HistorianCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe("v-1"); call.NodeId.ShouldBe("v-1");
@@ -96,7 +96,7 @@ public sealed class DeferredAddressSpaceSinkTests
deferred.SetSink(inner); deferred.SetSink(inner);
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV", ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV",
dataType: "Int32", isArray: true, arrayLength: 8u) dataType: "Int32", isArray: true, arrayLength: 8u, AddressSpaceRealm.Uns)
.ShouldBeTrue(); .ShouldBeTrue();
var call = inner.SurgicalCalls.ShouldHaveSingleItem(); var call = inner.SurgicalCalls.ShouldHaveSingleItem();
@@ -119,7 +119,7 @@ public sealed class DeferredAddressSpaceSinkTests
deferred.SetSink(new SurgicalRecordingSink { Result = false }); deferred.SetSink(new SurgicalRecordingSink { Result = false });
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null, ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null,
dataType: "Float", isArray: false, arrayLength: null) dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
.ShouldBeFalse(); .ShouldBeFalse();
} }
@@ -131,12 +131,12 @@ public sealed class DeferredAddressSpaceSinkTests
{ {
var deferred = new DeferredAddressSpaceSink(); // default inner = null sink (not surgical) var deferred = new DeferredAddressSpaceSink(); // default inner = null sink (not surgical)
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null, ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
dataType: "Float", isArray: false, arrayLength: null) dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
.ShouldBeFalse(); .ShouldBeFalse();
deferred.SetSink(new RecordingSink()); // a non-surgical inner deferred.SetSink(new RecordingSink()); // a non-surgical inner
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null, ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
dataType: "Float", isArray: false, arrayLength: null) dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
.ShouldBeFalse(); .ShouldBeFalse();
} }
@@ -151,7 +151,7 @@ public sealed class DeferredAddressSpaceSinkTests
var inner = new SurgicalRecordingSink { Result = true }; var inner = new SurgicalRecordingSink { Result = true };
deferred.SetSink(inner); deferred.SetSink(inner);
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "Plant South") ((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns)
.ShouldBeTrue(); .ShouldBeTrue();
var call = inner.FolderRenameCalls.ShouldHaveSingleItem(); var call = inner.FolderRenameCalls.ShouldHaveSingleItem();
@@ -167,11 +167,11 @@ public sealed class DeferredAddressSpaceSinkTests
var deferred = new DeferredAddressSpaceSink(); var deferred = new DeferredAddressSpaceSink();
// Inner reports the folder missing ⇒ forward returns false. // Inner reports the folder missing ⇒ forward returns false.
deferred.SetSink(new SurgicalRecordingSink { Result = false }); deferred.SetSink(new SurgicalRecordingSink { Result = false });
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse(); ((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X", AddressSpaceRealm.Uns).ShouldBeFalse();
// Non-surgical inner (the null sink before swap-in) ⇒ false. // Non-surgical inner (the null sink before swap-in) ⇒ false.
deferred.SetSink(null); deferred.SetSink(null);
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse(); ((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X", AddressSpaceRealm.Uns).ShouldBeFalse();
} }
/// <summary>R2-07 Phase 2 — the three surgical remove members forward to a surgical inner with /// <summary>R2-07 Phase 2 — the three surgical remove members forward to a surgical inner with
@@ -184,9 +184,9 @@ public sealed class DeferredAddressSpaceSinkTests
var inner = new SurgicalRecordingSink { Result = true }; var inner = new SurgicalRecordingSink { Result = true };
deferred.SetSink(inner); deferred.SetSink(inner);
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("eq-1/A").ShouldBeTrue(); ((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("eq-1/A", AddressSpaceRealm.Uns).ShouldBeTrue();
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("alm-1").ShouldBeTrue(); ((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("alm-1", AddressSpaceRealm.Uns).ShouldBeTrue();
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeTrue(); ((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
inner.RemoveCalls.ShouldBe(new[] { ("var", "eq-1/A"), ("alarm", "alm-1"), ("equipment", "eq-1") }); inner.RemoveCalls.ShouldBe(new[] { ("var", "eq-1/A"), ("alarm", "alm-1"), ("equipment", "eq-1") });
} }
@@ -198,14 +198,14 @@ public sealed class DeferredAddressSpaceSinkTests
{ {
var deferred = new DeferredAddressSpaceSink(); var deferred = new DeferredAddressSpaceSink();
deferred.SetSink(new SurgicalRecordingSink { Result = false }); deferred.SetSink(new SurgicalRecordingSink { Result = false });
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse(); ((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse(); ((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse(); ((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
deferred.SetSink(null); deferred.SetSink(null);
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse(); ((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse(); ((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse(); ((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
} }
/// <summary>Builds a minimal <see cref="AlarmConditionSnapshot"/> for the forwarding tests (the /// <summary>Builds a minimal <see cref="AlarmConditionSnapshot"/> for the forwarding tests (the
@@ -234,13 +234,13 @@ public sealed class DeferredAddressSpaceSinkTests
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"WA:{alarmNodeId}"); => CallQueue.Enqueue($"WA:{alarmNodeId}");
/// <inheritdoc /> /// <inheritdoc />
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)
=> CallQueue.Enqueue($"MA:{alarmNodeId}"); => CallQueue.Enqueue($"MA:{alarmNodeId}");
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"EF:{folderNodeId}"); => CallQueue.Enqueue($"EF:{folderNodeId}");
/// <inheritdoc /> /// <inheritdoc />
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)
{ {
CallQueue.Enqueue($"EV:{variableNodeId}"); CallQueue.Enqueue($"EV:{variableNodeId}");
HistorianQueue.Enqueue((variableNodeId, historianTagname)); HistorianQueue.Enqueue((variableNodeId, historianTagname));
@@ -290,11 +290,11 @@ public sealed class DeferredAddressSpaceSinkTests
/// <inheritdoc /> /// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc /> /// <inheritdoc />
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) { }
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc /> /// <inheritdoc />
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) { }
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <inheritdoc /> /// <inheritdoc />
@@ -27,9 +27,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!); var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow); sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2); server.NodeManager!.VariableCount.ShouldBe(2);
@@ -44,8 +44,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!); var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow); sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2); server.NodeManager!.VariableCount.ShouldBe(2);
@@ -120,16 +120,16 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync(); var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!); var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow); sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(3); server.NodeManager!.VariableCount.ShouldBe(3);
sink.RebuildAddressSpace(); sink.RebuildAddressSpace();
server.NodeManager.VariableCount.ShouldBe(0); server.NodeManager.VariableCount.ShouldBe(0);
// After rebuild, subsequent writes start fresh. // After rebuild, subsequent writes start fresh.
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager.VariableCount.ShouldBe(1); server.NodeManager.VariableCount.ShouldBe(1);
await host.DisposeAsync(); await host.DisposeAsync();
@@ -142,8 +142,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// Sanity check that the F10 fallback still works — production callers default to // Sanity check that the F10 fallback still works — production callers default to
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired. // NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
var sink = NullOpcUaAddressSpaceSink.Instance; var sink = NullOpcUaAddressSpaceSink.Instance;
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow); sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.RebuildAddressSpace(); sink.RebuildAddressSpace();
await Task.CompletedTask; await Task.CompletedTask;
} }
@@ -161,10 +161,10 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var sink = new SdkAddressSpaceSink(nm); var sink = new SdkAddressSpaceSink(nm);
// Equipment folder must exist first (MaterialiseHierarchy owns this in production). // Equipment folder must exist first (MaterialiseHierarchy owns this in production).
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", AddressSpaceRealm.Uns);
// Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it. // Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it.
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700); sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
nm.AlarmConditionCount.ShouldBe(1); nm.AlarmConditionCount.ShouldBe(1);
@@ -201,7 +201,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra // WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra
// BaseDataVariable is minted for the alarm id. // BaseDataVariable is minted for the alarm id.
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow); sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
condition.ActiveState.Id.Value.ShouldBeTrue(); condition.ActiveState.Id.Value.ShouldBeTrue();
@@ -209,7 +209,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
// Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id. // Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700); sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
nm.AlarmConditionCount.ShouldBe(1); nm.AlarmConditionCount.ShouldBe(1);
// RebuildAddressSpace clears the alarm dict too. // RebuildAddressSpace clears the alarm dict too.
@@ -228,8 +228,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm); var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9"); sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500); sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-x"); var condition = nm.TryGetAlarmCondition("alm-x");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -250,8 +250,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm); var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2"); sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300); sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-rich"); var condition = nm.TryGetAlarmCondition("alm-rich");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -267,7 +267,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
Shelving: AlarmShelvingKind.Timed, Shelving: AlarmShelvingKind.Timed,
Severity: 850, Severity: 850,
Message: "Pressure above limit"), Message: "Pressure above limit"),
DateTime.UtcNow); DateTime.UtcNow, AddressSpaceRealm.Uns);
condition.ActiveState.Id.Value.ShouldBeTrue(); condition.ActiveState.Id.Value.ShouldBeTrue();
condition.AckedState.Id.Value.ShouldBeFalse(); condition.AckedState.Id.Value.ShouldBeFalse();
@@ -296,8 +296,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm); var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3"); sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200); sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-base"); var condition = nm.TryGetAlarmCondition("alm-base");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -318,7 +318,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
Shelving: AlarmShelvingKind.OneShot, Shelving: AlarmShelvingKind.OneShot,
Severity: 500, Severity: 500,
Message: "still works"), Message: "still works"),
DateTime.UtcNow)); DateTime.UtcNow, AddressSpaceRealm.Uns));
// Mandatory state still projected despite the missing optional child. // Mandatory state still projected despite the missing optional child.
condition.ActiveState.Id.Value.ShouldBeTrue(); condition.ActiveState.Id.Value.ShouldBeTrue();
@@ -346,8 +346,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm); var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt"); sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700); sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-evt"); var condition = nm.TryGetAlarmCondition("alm-evt");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -357,7 +357,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone(); var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();
// First engine-driven transition → fires an event with a fresh EventId. // First engine-driven transition → fires an event with a fresh EventId.
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow); sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
var firstEventId = (byte[]?)condition.EventId.Value?.Clone(); var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
firstEventId.ShouldNotBeNull(); firstEventId.ShouldNotBeNull();
@@ -369,7 +369,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
} }
// Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous). // Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow); sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
var secondEventId = (byte[]?)condition.EventId.Value?.Clone(); var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
secondEventId.ShouldNotBeNull(); secondEventId.ShouldNotBeNull();
@@ -395,8 +395,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm); var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2"); sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700); sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-evt2"); var condition = nm.TryGetAlarmCondition("alm-evt2");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -404,9 +404,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// Drive several transitions; each fires an event AND projects state. State must survive firing. // Drive several transitions; each fires an event AND projects state. State must survive firing.
Should.NotThrow(() => Should.NotThrow(() =>
{ {
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow); sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow); sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
}); });
// Final projected state is intact after the last firing. // Final projected state is intact after the last firing.
@@ -434,14 +434,14 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm); var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack"); sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700); sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ack"); var condition = nm.TryGetAlarmCondition("alm-ack");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
// Drive the alarm active+unacked through the engine path (a genuine transition → fires). // Drive the alarm active+unacked through the engine path (a genuine transition → fires).
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
condition!.ActiveState.Id.Value.ShouldBeTrue(); condition!.ActiveState.Id.Value.ShouldBeTrue();
condition.AckedState.Id.Value.ShouldBeFalse(); condition.AckedState.Id.Value.ShouldBeFalse();
@@ -462,7 +462,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) === // === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) ===
// Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees // Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees
// no change ⇒ NO event fires. // no change ⇒ NO event fires.
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow); sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
var afterReProject = (byte[]?)condition.EventId.Value?.Clone(); var afterReProject = (byte[]?)condition.EventId.Value?.Clone();
// EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed. // EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed.
@@ -486,8 +486,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!; var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm); var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta"); sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700); sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-delta"); var condition = nm.TryGetAlarmCondition("alm-delta");
condition.ShouldNotBeNull(); condition.ShouldNotBeNull();
@@ -496,19 +496,19 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// Genuine transition: snapshot (active, unacked) differs from the materialise state // Genuine transition: snapshot (active, unacked) differs from the materialise state
// (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes). // (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes).
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
var afterFirst = (byte[]?)condition.EventId.Value?.Clone(); var afterFirst = (byte[]?)condition.EventId.Value?.Clone();
afterFirst.ShouldNotBeNull(); afterFirst.ShouldNotBeNull();
afterFirst!.ShouldNotBe(beforeFirst); // fired afterFirst!.ShouldNotBe(beforeFirst); // fired
// Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more // Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more
// events (EventId unchanged from the first fire). // events (EventId unchanged from the first fire).
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
var afterSecond = (byte[]?)condition.EventId.Value?.Clone(); var afterSecond = (byte[]?)condition.EventId.Value?.Clone();
afterSecond.ShouldBe(afterFirst); // suppressed afterSecond.ShouldBe(afterFirst); // suppressed
// A FURTHER genuine transition (clear) differs again ⇒ fires once more. // A FURTHER genuine transition (clear) differs again ⇒ fires once more.
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow); sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
var afterThird = (byte[]?)condition.EventId.Value?.Clone(); var afterThird = (byte[]?)condition.EventId.Value?.Clone();
afterThird.ShouldNotBe(afterSecond); // fired afterThird.ShouldNotBe(afterSecond); // fired
@@ -331,14 +331,14 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary> /// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
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) { }
/// <summary>Records an EnsureFolder call.</summary> /// <summary>Records an EnsureFolder call.</summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> _folders.Enqueue((folderNodeId, parentNodeId, displayName)); => _folders.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Records an EnsureVariable call.</summary> /// <summary>Records an EnsureVariable call.</summary>
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)
=> _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); => _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// <summary>Records a raw rebuild (the apply-time fallback when no dbFactory is wired).</summary> /// <summary>Records a raw rebuild (the apply-time fallback when no dbFactory is wired).</summary>
@@ -238,10 +238,10 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
// collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) — // collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) —
// fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY // fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY
// (a lowercase-only "h1" check would miss a leaked raw "H1"). // (a lowercase-only "h1" check would miss a leaked raw "H1").
eqANodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-A", "FOCAS/Identity", "Model")); eqANodeId.ShouldBe(V3NodeIds.Uns("EQ-A", "FOCAS", "Identity", "Model"));
eqANodeId.ShouldNotContain("H1"); eqANodeId.ShouldNotContain("H1");
eqANodeId.ShouldNotContain("h1"); eqANodeId.ShouldNotContain("h1");
eqBNodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-B", "FOCAS/Status", "Run")); eqBNodeId.ShouldBe(V3NodeIds.Uns("EQ-B", "FOCAS", "Status", "Run"));
eqBNodeId.ShouldNotContain("h2"); eqBNodeId.ShouldNotContain("h2");
// (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs). // (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs).
@@ -18,20 +18,20 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary> /// <summary>
/// Verifies the equipment-tag <b>live-value routing</b> wired into <see cref="DriverHostActor"/>: /// v3 Batch 4 (B4-WP3) — the driver <b>single-source fan-out</b> wired into
/// a driver publishes a value keyed by its wire-ref <c>FullName</c>, but the equipment variable was /// <see cref="DriverHostActor"/>. The driver publishes a value keyed by its wire-ref (== the tag's
/// materialised under a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an /// <c>RawPath</c>); the host's <c>_nodeIdByDriverRef</c> map — rebuilt each apply from the composition's
/// apply, the host's <c>_nodeIdByDriverRef</c> map resolves <c>(DriverInstanceId, FullName)</c> to /// <c>RawTags</c> <c>UnsReferenceVariables</c> resolves <c>(DriverInstanceId, RawPath)</c> to the RAW
/// that folder-scoped NodeId, so <c>ForwardToMux</c> lands the value on the right node (and still /// NodeId AND every referencing UNS NodeId, so <c>ForwardToMux</c> lands ONE publish on the raw node
/// forwards the raw value to the dependency mux for VirtualTag inputs). /// (<see cref="AddressSpaceRealm.Raw"/>) plus every UNS node (<see cref="AddressSpaceRealm.Uns"/>) with
/// /// IDENTICAL value/quality/timestamp (no independent buffer ⇒ no drift). The raw value is still forwarded
/// to the dependency mux (VirtualTag inputs), keyed by the RawPath.
/// <para> /// <para>
/// Drives a real apply through the existing harness: the seeded artifact carries the /// Drives a real apply through the existing harness: the seeded artifact carries the v3 raw-tag chain
/// <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays that /// (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional UnsTagReference projecting
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags, and a /// each raw tag into an equipment. <c>DispatchDeployment</c> triggers the
/// <c>DispatchDeployment</c> triggers the <c>ApplyAndAck → PushDesiredSubscriptions</c> pass /// <c>ApplyAndAck → PushDesiredSubscriptions</c> pass that builds the map; the sink and mux are
/// that builds the map. The OPC UA sink and the dependency mux are injected as /// injected as <see cref="Akka.TestKit.TestProbe"/>s.
/// <see cref="Akka.TestKit.TestProbe"/>s.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
@@ -40,55 +40,95 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc); private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);
/// <summary>A driver value published by FullName lands on the equipment variable's folder-scoped /// <summary>A driver value published by RawPath fans to the RAW node AND the referencing UNS node, each
/// NodeId (here <c>eq-1/speed</c>, no sub-folder), carrying the value/quality/timestamp.</summary> /// carrying identical value/quality/timestamp — the fan-out drift guard.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact]
public void Driver_value_routes_to_folder_scoped_equipment_NodeId() public void Driver_value_fans_out_to_raw_and_uns_NodeIds_with_identical_value()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed". var deploymentId = SeedV3Deployment(db, RevA,
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed")); unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId); var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
// The driver publishes by its wire-ref RawPath.
actor.Tell(new DriverInstanceActor.AttributeValuePublished( actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "40001", 42.0, OpcUaQuality.Good, Ts)); "drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaQuality.Good, Ts));
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)); var a = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
update.NodeId.ShouldBe("eq-1/speed"); var b = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
update.Value.ShouldBe(42.0); var byId = new[] { a, b }.ToDictionary(u => u.NodeId);
update.Quality.ShouldBe(OpcUaQuality.Good);
update.TimestampUtc.ShouldBe(Ts);
// The raw value is still forwarded to the dependency mux (VirtualTag inputs, keyed by FullName). byId.Keys.ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" }, ignoreOrder: true);
// Realm travels with each update so the sink resolves the right namespace.
byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw);
byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns);
// Fan-out drift guard: identical value + quality + timestamp on BOTH node states.
foreach (var u in byId.Values)
{
u.Value.ShouldBe(42.0);
u.Quality.ShouldBe(OpcUaQuality.Good);
u.TimestampUtc.ShouldBe(Ts);
}
// The raw publish still reaches the dependency mux (VirtualTag inputs), keyed by RawPath.
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5)) mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
.FullReference.ShouldBe("40001"); .FullReference.ShouldBe("Plant/Modbus/dev1/speed");
} }
/// <summary>The same driver ref backing two equipments fans out: a single publish produces one /// <summary>A raw tag with NO UNS reference fans to ONLY its raw NodeId (Raw realm).</summary>
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> per equipment variable NodeId.</summary> [Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Unreferenced_raw_tag_fans_to_raw_node_only()
public void Same_ref_on_two_equipments_fans_out_to_both_NodeIds()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
// Same (drv-1, "40001") wire-ref backs eq-1/speed AND eq-2/speed (identical machines). var deploymentId = SeedV3Deployment(db, RevA,
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"), unsRefs: Array.Empty<(string, string, string, string, string?)>());
(Equip: "eq-2", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId); var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished( actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "40001", 7.0, OpcUaQuality.Good, Ts)); "drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaQuality.Good, Ts));
// One publish → two updates. Assert the SET of NodeIds (order is not contractual). var only = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)); only.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
var second = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)); only.Realm.ShouldBe(AddressSpaceRealm.Raw);
new[] { first.NodeId, second.NodeId }.ShouldBe( publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
new[] { "eq-1/speed", "eq-2/speed" }, ignoreOrder: true); }
first.Value.ShouldBe(7.0);
second.Value.ShouldBe(7.0); /// <summary>A raw tag referenced by TWO equipments fans to the raw node PLUS both UNS nodes (3 updates,
/// identical value) — the 1:N fan-out.</summary>
[Fact]
public void Raw_tag_referenced_by_two_equipments_fans_to_raw_plus_both_uns()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: new[]
{
(Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null),
(Area: "filling", Line: "line1", Equip: "station2", BackingTag: "speed", Effective: (string?)null),
});
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaQuality.Good, Ts));
var updates = new[]
{
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
};
updates.Select(u => u.NodeId).ShouldBe(new[]
{
"Plant/Modbus/dev1/speed",
"filling/line1/station1/speed",
"filling/line1/station2/speed",
}, ignoreOrder: true);
updates.ShouldAllBe(u => (double)u.Value! == 7.0);
} }
/// <summary>A value for a ref not in the composition is NOT pushed to the OPC UA sink (no matching /// <summary>A value for a ref not in the composition is NOT pushed to the OPC UA sink (no matching
@@ -97,25 +137,23 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
public void Unmatched_ref_is_dropped_from_sink_but_still_forwarded_to_mux() public void Unmatched_ref_is_dropped_from_sink_but_still_forwarded_to_mux()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, var deploymentId = SeedV3Deployment(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed")); rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId); var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished( actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "59999", 99.0, OpcUaQuality.Good, Ts)); "drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaQuality.Good, Ts));
// No equipment-tag NodeId for ("drv-1","59999") → nothing reaches the sink.
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// The raw publish still reaches the dependency mux.
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5)) mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
.FullReference.ShouldBe("59999"); .FullReference.ShouldBe("Plant/Modbus/dev1/nope");
} }
/// <summary>Spawns the host with publish + mux probes, dispatches the deployment, and waits for the /// <summary>Spawns the host with publish + mux probes, dispatches the deployment, and waits for the
/// Applied ACK so the apply (and thus the map build in PushDesiredSubscriptions) has completed /// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a
/// before the test publishes a value. A VirtualTag-host probe is injected so the real host isn't /// value. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
/// spawned (it would consume the mux-forwarded publishes otherwise — see SpawnVirtualTagHost).</summary>
private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply( private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId) IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{ {
@@ -134,47 +172,68 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
// RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's
// ExpectMsg<AttributeValueUpdate> assertions see only value updates.
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5)); publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
return (actor, publish, mux); return (actor, publish, mux);
} }
/// <summary> /// <summary>Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
/// Seeds a Sealed deployment whose artifact carries the minimal arrays /// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags: /// Area/Line/Equipment. Each raw tag's RawPath is <c>Plant/{DriverName}/{Device}/{Tag}</c>; each UNS
/// <c>Namespaces</c> (one Equipment-kind ns), <c>DriverInstances</c> (each driver bound to that /// reference's NodeId is <c>{Area}/{Line}/{Equip}/{Effective ?? backing tag Name}</c>. Enums serialize
/// ns), and <c>Tags</c> (each with a non-null EquipmentId + a TagConfig blob carrying FullName). /// numerically (AccessLevel: ReadWrite = 1).</summary>
/// </summary> private static DeploymentId SeedV3Deployment(
private static DeploymentId SeedDeploymentWithEquipmentTags(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags) (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs)
{ {
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray(); var drivers = rawTags
.Select(t => (t.Driver, t.DriverName))
.Distinct()
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false })
.ToArray();
var devices = rawTags
.Select(t => (t.Driver, t.Device))
.Distinct()
.Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" })
.ToArray();
var tags = rawTags.Select(t => new
{
TagId = t.Tag, // tag id == name for test simplicity (unique per driver here)
DeviceId = $"{t.Driver}:{t.Device}",
TagGroupId = (string?)null,
Name = t.Tag,
DataType = t.DataType,
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
TagConfig = "{}",
}).ToArray();
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal)
.Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray();
var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct()
.Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray();
var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct()
.Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray();
var unsTagReferences = unsRefs.Select((r, i) => new
{
UnsTagReferenceId = $"ref-{i}",
EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}",
TagId = r.BackingTag,
DisplayNameOverride = r.Effective,
}).ToArray();
var artifact = JsonSerializer.SerializeToUtf8Bytes(new var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{ {
Namespaces = new[] RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
{ DriverInstances = drivers,
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 Devices = devices,
}, TagGroups = Array.Empty<object>(),
DriverInstances = driverIds.Select(d => new Tags = tags,
{ UnsAreas = areas,
DriverInstanceId = d, UnsLines = lines,
NamespaceId = "ns-eq", Equipment = equipment,
}).ToArray(), UnsTagReferences = unsTagReferences,
Tags = tags.Select((t, i) => new
{
TagId = $"tag-{i}",
EquipmentId = t.Equip,
DriverInstanceId = t.Driver,
Name = t.Name,
FolderPath = t.Folder,
DataType = "Double",
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
}).ToArray(),
}); });
var id = DeploymentId.NewId(); var id = DeploymentId.NewId();
@@ -20,22 +20,17 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary> /// <summary>
/// Verifies the inbound operator-write routing wired into <see cref="DriverHostActor"/>: a /// v3 Batch 4 (B4-WP3) — inbound operator-write routing through EITHER NodeId. A
/// <see cref="DriverHostActor.RouteNodeWrite"/> for a materialised equipment-variable NodeId resolves /// <see cref="DriverHostActor.RouteNodeWrite"/> carries the FULL ns-qualified NodeId string (the node
/// the <c>NodeId → (DriverInstanceId, FullName)</c> reverse map (built alongside the forward map in /// manager's write hook passes <c>node.NodeId.ToString()</c>); the host normalises it to the bare id and
/// <c>PushDesiredSubscriptions</c>), gates on this node being the driver PRIMARY (reusing the same /// resolves the <c>NodeId → (DriverInstanceId, RawPath)</c> reverse map — populated in
/// <c>RedundancyStateChanged</c> signal the alarm-emit gate uses), forwards a /// <c>PushDesiredSubscriptions</c> for BOTH the raw NodeId AND every referencing UNS NodeId — gates on the
/// <see cref="DriverInstanceActor.WriteAttribute"/> carrying the driver-side <c>FullName</c> to the /// driver PRIMARY, and forwards a <see cref="DriverInstanceActor.WriteAttribute"/> carrying the tag's
/// right driver child, and replies a <see cref="DriverHostActor.NodeWriteResult"/> to the asker. /// <c>RawPath</c> to the driver child. So a write to the raw node and a write to the referencing UNS node
/// /// both reach the SAME driver point (they share the driver ref).
/// <para> /// <para>
/// Drives a real apply through the existing harness (same artifact shape as /// Drives a real apply (Enabled Modbus driver ⇒ a real <see cref="DriverInstanceActor"/> child backed
/// <c>DriverHostActorLiveValueTests</c>) so the reverse map is populated authentically and a real /// by a recording driver), then routes writes and asserts the forwarded wire-ref + the primary gate.
/// (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned. The child is backed by a
/// recording <see cref="IWritable"/> driver so the test can observe the forwarded write and assert
/// the no-write case on the secondary. There is no test seam to inject a <c>TestProbe</c> as a
/// driver child, so this end-to-end approach (recording driver) is the closest faithful test the
/// harness allows.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
@@ -44,48 +39,68 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// <summary>On the PRIMARY, a RouteNodeWrite for a mapped NodeId forwards the driver-side FullName to // The single seeded raw tag + its UNS reference.
/// the right driver child (observed via the recording driver) and replies NodeWriteResult(true).</summary> private const string RawPath = "Plant/Modbus/dev1/speed";
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] private const string UnsNodeId = "filling/line1/station1/speed";
public void Primary_routes_write_to_driver_by_full_name_and_replies_success()
/// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified UNS NodeId resolves to the raw tag's
/// driver ref and forwards the write keyed by the tag's RawPath (dual-NodeId write routing).</summary>
[Fact]
public void Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus"); var recorder = new RecordingDriverFactory("Modbus");
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed" → NodeId "eq-1/speed". var deploymentId = SeedV3Deployment(db, RevA);
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var actor = SpawnHostAndApply(db, deploymentId, recorder); var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Local role unknown ⇒ treated as Primary ⇒ write allowed (default-emit semantics).
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); // 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);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
result.Success.ShouldBeTrue();
// The driver received exactly the write, keyed by its wire-ref FullName (not the NodeId).
AwaitAssert(() => AwaitAssert(() =>
{ {
recorder.Writes.Count.ShouldBe(1); recorder.Writes.Count.ShouldBe(1);
recorder.Writes[0].FullReference.ShouldBe("40001"); recorder.Writes[0].FullReference.ShouldBe(RawPath); // the driver writes by RawPath, not the UNS id
recorder.Writes[0].Value.ShouldBe(123.0); recorder.Writes[0].Value.ShouldBe(123.0);
}, duration: Timeout); }, duration: Timeout);
} }
/// <summary>On a SECONDARY, RouteNodeWrite replies NodeWriteResult(false, "not primary") and the /// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified RAW NodeId resolves to the same driver
/// driver receives NO write — the primary gate fires before the reverse-map lookup.</summary> /// ref and forwards the write keyed by the RawPath.</summary>
[Fact]
public void Primary_routes_write_via_raw_nodeid_to_driver_by_rawpath()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var deploymentId = SeedV3Deployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
recorder.Writes[0].FullReference.ShouldBe(RawPath);
recorder.Writes[0].Value.ShouldBe(456.0);
}, duration: Timeout);
}
/// <summary>On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver
/// receives NO write — the primary gate fires before the reverse-map lookup.</summary>
[Fact] [Fact]
public void Secondary_rejects_write_and_does_not_forward_to_driver() public void Secondary_rejects_write_and_does_not_forward_to_driver()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus"); var recorder = new RecordingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, var deploymentId = SeedV3Deployment(db, RevA);
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var actor = SpawnHostAndApply(db, deploymentId, recorder); var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Force this node Secondary so the primary gate rejects.
actor.Tell(new RedundancyStateChanged( actor.Tell(new RedundancyStateChanged(
new[] new[]
{ {
@@ -95,29 +110,26 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse(); result.Success.ShouldBeFalse();
result.Reason.ShouldBe("not primary"); result.Reason.ShouldBe("not primary");
// No write reached the driver — the gate short-circuited before the reverse-map lookup.
recorder.Writes.ShouldBeEmpty(); recorder.Writes.ShouldBeEmpty();
} }
/// <summary>An unknown NodeId (no reverse-map entry) replies NodeWriteResult(false) and writes nothing.</summary> /// <summary>An unknown NodeId (no reverse-map entry) replies failure and writes nothing.</summary>
[Fact] [Fact]
public void Unknown_node_id_replies_failure() public void Unknown_node_id_replies_failure()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus"); var recorder = new RecordingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, var deploymentId = SeedV3Deployment(db, RevA);
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var actor = SpawnHostAndApply(db, deploymentId, recorder); var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/does-not-exist", 123.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse(); result.Success.ShouldBeFalse();
@@ -125,49 +137,11 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
recorder.Writes.ShouldBeEmpty(); recorder.Writes.ShouldBeEmpty();
} }
/// <summary>A protocol TagConfig blob with no <c>FullName</c> key routes by the equipment NodeId, and /// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) fast-fails with an
/// the forwarded wire-ref is the raw blob verbatim. <c>ExtractTagFullName</c> falls back to the raw /// immediate negative reply mentioning "stale".</summary>
/// blob string when no top-level <c>FullName</c> property is present, so the reverse map keys on
/// <c>(DriverInstanceId, &lt;raw-blob&gt;)</c> and the driver receives that exact string as its
/// <c>WriteRequest.FullReference</c> — not a FullName value extracted from the blob.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Primary_routes_write_for_raw_protocol_blob_tag()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
// Seed the tag with a genuine protocol blob that has NO FullName key (pure Modbus wire config).
// ExtractTagFullName falls back to returning the raw blob string verbatim, so that string IS the
// wire-ref the reverse map keys on and the driver receives as WriteRequest.FullReference.
var (deploymentId, rawBlobString) = SeedDeploymentWithRawBlobTag(db, RevA,
equip: "eq-2", driver: "drv-2", name: "torque");
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Local role unknown ⇒ treated as Primary ⇒ write allowed.
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-2/torque", 456.0), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeTrue();
// The forwarded wire-ref is the raw blob string verbatim — ExtractTagFullName fell back because
// there is no top-level "FullName" key in the blob.
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
recorder.Writes[0].FullReference.ShouldBe(rawBlobString);
recorder.Writes[0].Value.ShouldBe(456.0);
}, duration: Timeout);
}
/// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) must fast-fail
/// with an immediate negative NodeWriteResult (reason mentions "stale") instead of dead-lettering into
/// the node-manager's bounded-Ask timeout. Drives the host into Stale via a DB factory whose
/// CreateDbContext throws on bootstrap (the same fall-through to <c>Become(Stale)</c> production uses).</summary>
[Fact] [Fact]
public void Stale_host_fast_fails_route_node_write() public void Stale_host_fast_fails_route_node_write()
{ {
// A factory that always throws on CreateDbContext ⇒ Bootstrap's try fails ⇒ Become(Stale).
var db = new ThrowingDbFactory(); var db = new ThrowingDbFactory();
var coordinator = CreateTestProbe(); var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props( var actor = Sys.ActorOf(DriverHostActor.Props(
@@ -175,7 +149,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("eq-1/speed", 123.0), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), 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();
@@ -183,10 +157,9 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
result.Reason!.ShouldContain("stale"); result.Reason!.ShouldContain("stale");
} }
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits /// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits for
/// for the Applied ACK so the apply (and thus the reverse-map build in PushDesiredSubscriptions) has /// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a
/// completed before the test routes a write. No OPC UA / mux probes are wired — this test exercises /// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor.</summary>
/// only the write path, which doesn't depend on the publish actor.</summary>
private IActorRef SpawnHostAndApply( private IActorRef SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory) IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
{ {
@@ -198,49 +171,32 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
return actor; return actor;
} }
/// <summary> /// <summary>Seeds a Sealed v3 deployment: RawFolder "Plant" → DriverInstance "drv-1" (Modbus, ENABLED so a
/// Seeds a Sealed deployment whose artifact carries the minimal arrays /// real child spawns) → Device "dev1" → Tag "speed" (RawPath <c>Plant/Modbus/dev1/speed</c>, ReadWrite),
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags. Mirrors /// projected into equipment <c>filling/line1/station1</c> by a UnsTagReference (UNS NodeId
/// <c>DriverHostActorLiveValueTests.SeedDeploymentWithEquipmentTags</c> but also carries a /// <c>filling/line1/station1/speed</c>).</summary>
/// <c>DriverInstances</c> row with a non-Windows-only <c>DriverType</c> ("Modbus") + Enabled flag private static DeploymentId SeedV3Deployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
/// so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned for the write path.
/// </summary>
private static DeploymentId SeedDeploymentWithEquipmentTags(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
{ {
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
var artifact = JsonSerializer.SerializeToUtf8Bytes(new var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{ {
Namespaces = new[] RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{ {
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
}, },
DriverInstances = driverIds.Select(d => new Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = new[]
{ {
DriverInstanceRowId = Guid.NewGuid(), new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
DriverInstanceId = d, },
Name = d, UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
Enabled = true, Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
DriverConfig = "{}", UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-speed", DisplayNameOverride = (string?)null } },
NamespaceId = "ns-eq",
}).ToArray(),
Tags = tags.Select((t, i) => new
{
TagId = $"tag-{i}",
EquipmentId = t.Equip,
DriverInstanceId = t.Driver,
Name = t.Name,
FolderPath = t.Folder,
DataType = "Double",
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
}).ToArray(),
}); });
var id = DeploymentId.NewId(); var id = DeploymentId.NewId();
@@ -258,77 +214,8 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
return id; return id;
} }
/// <summary> /// <summary>An <see cref="IDbContextFactory{T}"/> whose CreateDbContext always throws — drives the host
/// Seeds a single-tag Sealed deployment whose tag's <c>TagConfig</c> is a genuine protocol-driver /// into the Stale path.</summary>
/// blob with <strong>no <c>FullName</c> key</strong> (pure Modbus wire config:
/// <c>{"region":"HoldingRegister","address":200,"dataType":"UInt16"}</c>). Because
/// <c>ExtractTagFullName</c> finds no top-level <c>FullName</c> property, it falls back to
/// returning the raw blob string verbatim — that raw string becomes the
/// <c>(DriverInstanceId, &lt;raw-blob&gt;)</c> reverse-map key, and the driver receives it as
/// <c>WriteRequest.FullReference</c>. Returns both the <see cref="DeploymentId"/> and the exact
/// raw blob string so the caller can assert the forwarded wire-ref precisely.
/// </summary>
private static (DeploymentId DeploymentId, string RawBlobString) SeedDeploymentWithRawBlobTag(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
string equip, string driver, string name)
{
// Serialize the blob with NO FullName field — ExtractTagFullName will fall back to this verbatim.
var rawBlobString = JsonSerializer.Serialize(
new { region = "HoldingRegister", address = 200, dataType = "UInt16" });
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
},
DriverInstances = new[]
{
new
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = driver,
Name = driver,
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
Enabled = true,
DriverConfig = "{}",
NamespaceId = "ns-eq",
},
},
Tags = new[]
{
new
{
TagId = "tag-raw",
EquipmentId = equip,
DriverInstanceId = driver,
Name = name,
FolderPath = (string?)null,
DataType = "Double",
// Pure protocol blob: no FullName key. ExtractTagFullName falls back to raw blob.
TagConfig = rawBlobString,
},
},
});
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, rawBlobString);
}
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> always throws,
/// driving <see cref="DriverHostActor"/>'s bootstrap into the <c>catch</c> ⇒ <c>Become(Stale)</c> path
/// so a write can be routed at a Stale host.</summary>
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext> private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -336,15 +223,14 @@ 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 single <see cref="RecordingDriver"/> for the supported type, whose /// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type.</summary>
/// recorded write list is exposed for assertions.</summary>
private sealed class RecordingDriverFactory : IDriverFactory private sealed class RecordingDriverFactory : IDriverFactory
{ {
private readonly string _supportedType; private readonly string _supportedType;
private readonly RecordingDriver _driver = new(); private readonly RecordingDriver _driver = new();
public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; } public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>The writes the spawned driver received (thread-safe — WriteAsync runs off the actor thread).</summary> /// <summary>The writes the spawned driver received.</summary>
public IReadOnlyList<WriteRequest> Writes => _driver.Writes; public IReadOnlyList<WriteRequest> Writes => _driver.Writes;
/// <inheritdoc /> /// <inheritdoc />
@@ -389,7 +275,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
{ {
foreach (var w in writes) _writes.Enqueue(w); foreach (var w in writes) _writes.Enqueue(w);
return Task.FromResult<IReadOnlyList<WriteResult>>( return Task.FromResult<IReadOnlyList<WriteResult>>(
writes.Select(_ => new WriteResult(0u)).ToArray()); // 0x0 = Good writes.Select(_ => new WriteResult(0u)).ToArray());
} }
} }
} }
@@ -175,15 +175,19 @@ public sealed class AddHistorianProvisioningTests
prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32); prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32);
} }
private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref") // v3 Batch 4: historized value tags are RAW tags (the applier provisions from AddedRawTags). NodeId plays
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName, // the RawPath role (== mux ref + historian default).
Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName); private static RawTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
=> new("tag-" + displayName, NodeId: fullName, ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
IsHistorized: true, HistorianTagname: historianName);
private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType) private static RawTagPlan NonHistorizedTag(string displayName, string dataType)
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref", => new("tag-" + displayName, NodeId: "ref", ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null); DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
IsHistorized: false, HistorianTagname: null);
private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new( private static AddressSpacePlan PlanWithAddedTags(params RawTagPlan[] tags) => new(
AddedEquipment: Array.Empty<EquipmentNode>(), AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(), RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(), ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
@@ -194,6 +198,6 @@ public sealed class AddHistorianProvisioningTests
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(), RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>()) ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
{ {
AddedEquipmentTags = tags, AddedRawTags = tags,
}; };
} }
@@ -206,7 +206,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <param name="displayName">The condition display name.</param> /// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param> /// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param> /// <param name="severity">The domain severity.</param>
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) { }
/// <summary>Ensures folder exists (stub implementation).</summary> /// <summary>Ensures folder exists (stub implementation).</summary>
/// <param name="folderNodeId">The folder node identifier.</param> /// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier.</param> /// <param name="parentNodeId">The parent folder node identifier.</param>
@@ -219,7 +219,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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) { }
/// <summary>Rebuilds address space (recorded via span).</summary> /// <summary>Rebuilds address space (recorded via span).</summary>
public void RebuildAddressSpace() { /* recorded via span */ } public void RebuildAddressSpace() { /* recorded via span */ }
/// <summary>Announces a NodeAdded model-change (stub implementation).</summary> /// <summary>Announces a NodeAdded model-change (stub implementation).</summary>
@@ -120,9 +120,9 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
{ {
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 = AddressSpaceRealm.Uns) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
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) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
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) { }
public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault"); public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault");
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
@@ -133,9 +133,9 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
{ {
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 = AddressSpaceRealm.Uns) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
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) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
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) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
@@ -451,7 +451,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="displayName">The condition display name.</param> /// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param> /// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param> /// <param name="severity">The domain severity.</param>
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)
=> Calls.Enqueue($"MA:{alarmNodeId}"); => Calls.Enqueue($"MA:{alarmNodeId}");
/// <summary>Records a folder ensure call.</summary> /// <summary>Records a folder ensure call.</summary>
/// <param name="folderNodeId">The folder node ID.</param> /// <param name="folderNodeId">The folder node ID.</param>
@@ -466,7 +466,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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)
=> Calls.Enqueue($"EV:{variableNodeId}"); => Calls.Enqueue($"EV:{variableNodeId}");
/// <summary>Records a rebuild address space call.</summary> /// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
@@ -622,7 +622,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <param name="displayName">The condition display name.</param> /// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param> /// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param> /// <param name="severity">The domain severity.</param>
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) { }
/// <summary>Records a folder ensure call.</summary> /// <summary>Records a folder ensure call.</summary>
/// <param name="folderNodeId">The OPC UA folder node identifier.</param> /// <param name="folderNodeId">The OPC UA folder node identifier.</param>
@@ -638,7 +638,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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) =>
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// <summary>Records a rebuild call.</summary> /// <summary>Records a rebuild call.</summary>