From 5534698d7097ae20472e2dc8c4e225198af061be Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 09:33:45 -0400 Subject: [PATCH] feat(v3-batch4-wp2): node manager dual-namespace + realm-aware sink surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register both v3 namespaces (Raw first, UNS second) on OtOpcUaNodeManager and thread an AddressSpaceRealm discriminator through every node-naming sink method so a bare node id is resolved to the correct namespace by realm — never parsed out of the id string. - IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink: every node-naming method gains `AddressSpaceRealm realm = AddressSpaceRealm.Uns` (transitional default so un-migrated WP3 call sites still compile; WP3/Wave B makes them explicit and removes the default). Null + SdkAddressSpaceSink impls updated; DeferredAddress- SpaceSink forwards realm through every method (the forwarding trap). - DeferredSinkForwardingReflectionTests: existing exhaustive-forwarding guard auto-covers the new signatures; added an explicit guard that every node-naming sink method carries an AddressSpaceRealm parameter (RebuildAddressSpace exempt). - OtOpcUaNodeManager: register RawNamespaceUri + UnsNamespaceUri; realm->namespace index via NamespaceIndexForRealm; all node maps (_variables/_folders/ _alarmConditions/_nativeAlarmNodeIds/_historizedTagnames/_eventNotifierSources) re-keyed by the full ns-qualified NodeId string so Raw and UNS nodes sharing a bare id stay distinct; _notifierFolders already NodeId-keyed. A historized UNS reference node registers the SAME historian tagname as its backing raw node (both NodeIds -> one tagname). Inbound-write hook routes the full ns-qualified NodeId to the write gateway (realm-aware) and reverts by bare id + realm. HistoryRead seams resolve via NodeId directly. DefaultNamespaceUri kept as a transitional alias to UnsNamespaceUri for the SubscriptionSurvivalTests. - Test doubles across Commons.Tests / OpcUaServer.Tests / Runtime.Tests updated to the new interface signatures; SdkAddressSpaceSinkTests asserts the UNS namespace index for default-realm nodes. Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors. Tests: Commons.Tests 310/310; OpcUaServer.Tests 335 passed / 4 pre-existing skips. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../OpcUa/DeferredAddressSpaceSink.cs | 42 +-- .../OpcUa/IOpcUaAddressSpaceSink.cs | 43 ++- .../OpcUa/ISurgicalAddressSpaceSink.cs | 21 +- .../OtOpcUaNodeManager.cs | 291 ++++++++++++------ .../SdkAddressSpaceSink.cs | 42 +-- .../OpcUa/DeferredAddressSpaceSinkTests.cs | 34 +- .../DeferredSinkForwardingReflectionTests.cs | 18 ++ .../AddressSpaceApplierFailureSurfaceTests.cs | 12 +- .../AddressSpaceApplierHierarchyTests.cs | 12 +- .../AddressSpaceApplierTests.cs | 46 +-- .../DeferredAddressSpaceSinkTests.cs | 34 +- .../SdkAddressSpaceSinkTests.cs | 7 +- .../DiscoveryInjectionEndToEndTests.cs | 12 +- .../OtOpcUaTelemetryHookTests.cs | 12 +- .../OpcUaPublishActorApplyFailureTests.cs | 24 +- .../OpcUa/OpcUaPublishActorRebuildTests.cs | 22 +- .../OpcUa/OpcUaPublishActorTests.cs | 12 +- 17 files changed, 413 insertions(+), 271 deletions(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs index f70d8be2..d4e0e39f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -23,30 +23,30 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical _inner = sink ?? NullOpcUaAddressSpaceSink.Instance; /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) - => _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc); + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm); /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) - => _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc); + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm); /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) - => _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative, realm); /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) - => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) - => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); + 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) + => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm); /// public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); /// - public void RaiseNodesAddedModelChange(string affectedNodeId) => _inner.RaiseNodesAddedModelChange(affectedNodeId); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm); /// // Forwards to the inner sink when it supports the surgical capability; returns false otherwise — @@ -55,9 +55,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical // 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 // 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) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _inner is ISurgicalAddressSpaceSink surgical - && surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength); + && surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm); /// // Forwards to the inner sink when it supports the surgical capability; returns false otherwise — @@ -65,9 +65,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical // 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 // actors inject THIS wrapper, not the inner sink. - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _inner is ISurgicalAddressSpaceSink surgical - && surgical.UpdateFolderDisplayName(folderNodeId, displayName); + && surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm); // R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes / // UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false @@ -75,14 +75,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical // (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. /// - public bool RemoveVariableNode(string variableNodeId) - => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId); + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm); /// - public bool RemoveAlarmConditionNode(string alarmNodeId) - => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId); + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm); /// - public bool RemoveEquipmentSubtree(string equipmentNodeId) - => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId); + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId, realm); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs index 87c9cac7..cfc6800c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs @@ -13,7 +13,12 @@ public interface IOpcUaAddressSpaceSink /// The value to write. /// The quality status of the value. /// The source timestamp in UTC. - void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc); + /// Which of the two v3 namespaces ( device tree or + /// equipment tree) the lives in — the sink + /// 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. + // 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 = AddressSpaceRealm.Uns); /// Write an alarm-condition's full Part 9 state. When a real condition node has been /// materialised for via , @@ -25,7 +30,10 @@ public interface IOpcUaAddressSpaceSink /// The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions). /// The full condition state to project onto the node. /// The source timestamp in UTC. - void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc); + /// The namespace realm lives in (must match the realm + /// the condition was materialised under). + // 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 = AddressSpaceRealm.Uns); /// /// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients @@ -41,7 +49,10 @@ public interface IOpcUaAddressSpaceSink /// When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when /// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later /// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine. - void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false); + /// The namespace realm the condition (and its parent folder ) + /// live in. + // 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, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// /// Ensure a folder node exists under the given parent. Used by AddressSpaceApplier to @@ -52,7 +63,9 @@ public interface IOpcUaAddressSpaceSink /// The OPC UA node ID for the folder. /// The parent folder node ID, or null for namespace root. /// The display name for the folder. - void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName); + /// The namespace realm the folder (and its ) live in. + // 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 = AddressSpaceRealm.Uns); /// /// Ensure a Variable node exists at , parented under @@ -77,7 +90,11 @@ public interface IOpcUaAddressSpaceSink /// rank + dimensions carry the array-ness. /// The declared length of the 1-D array when is /// true; ignored for scalars. Null ⇒ length 0 (unbounded). - void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null); + /// The namespace realm the variable (and its ) live + /// in. A historized UNS reference node registers the SAME as its backing + /// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm. + // 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, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// /// Tear down + repopulate the address space. Called by OpcUaPublishActor after a @@ -91,7 +108,9 @@ public interface IOpcUaAddressSpaceSink /// (Part 3 GeneralModelChangeEvent, verb NodeAdded). /// /// The node under which discovered nodes were added. - void RaiseNodesAddedModelChange(string affectedNodeId); + /// The namespace realm lives in. + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); } /// OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained @@ -106,23 +125,23 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink private NullOpcUaAddressSpaceSink() { } /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + 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) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + 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) { } + 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 RebuildAddressSpace() { } /// - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs index f4033030..656fcb78 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs @@ -21,8 +21,11 @@ public interface ISurgicalAddressSpaceSink /// The OPC UA built-in data type name (e.g. "Boolean", "Int32", "Float"). /// When true the node is a 1-D array (ValueRank=OneDimension); when false scalar. /// The declared length of the 1-D array when is true; ignored for scalars. + /// The namespace realm lives in — the sink resolves the + /// namespace index from the realm rather than parsing it out of the id string. /// True when the in-place update was applied; false when the node is missing (caller rebuilds). - bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength); + // 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 = AddressSpaceRealm.Uns); /// Update an existing folder node's DisplayName IN PLACE, notifying subscribers /// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line @@ -32,8 +35,10 @@ public interface ISurgicalAddressSpaceSink /// should rebuild instead). /// The node id of the folder whose display name to update in place. /// The new display name to apply. + /// The namespace realm lives in. /// True when the in-place update was applied; false when the folder is missing (caller rebuilds). - bool UpdateFolderDisplayName(string folderNodeId, string displayName); + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// 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 @@ -43,8 +48,10 @@ public interface ISurgicalAddressSpaceSink /// re-subscription attempts get BadNodeIdUnknown from the SDK. Returns false when the node id is unknown /// (the node-manager maps drifted from the plan — caller falls back to a full rebuild). /// The folder-scoped node id of the variable to remove in place. + /// The namespace realm lives in. /// True when the node was removed; false when the id is unknown (caller rebuilds). - bool RemoveVariableNode(string variableNodeId); + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// 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 @@ -52,8 +59,10 @@ public interface ISurgicalAddressSpaceSink /// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller /// rebuilds). /// The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id). + /// The namespace realm lives in. /// True when the condition was removed; false when the id is unknown (caller rebuilds). - bool RemoveAlarmConditionNode(string alarmNodeId); + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// 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, @@ -62,6 +71,8 @@ public interface ISurgicalAddressSpaceSink /// the equipment folder outside the lock. Returns false when the folder id is unknown (caller /// rebuilds). /// The equipment folder node id whose entire subtree to remove. + /// The namespace realm lives in. /// True when the subtree was removed; false when the id is unknown (caller rebuilds). - bool RemoveEquipmentSubtree(string equipmentNodeId); + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 335840d8..b6375a5e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -36,7 +36,20 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// public sealed class OtOpcUaNodeManager : CustomNodeManager2 { - public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns"; + /// v3 dual-namespace: both the Raw (device tree) and UNS (equipment tree) namespaces are + /// registered with the SDK so nodes can be minted in either. is + /// registered FIRST (namespace index [0]) and + /// SECOND ([1]); maps a realm + /// to its index. Which realm a node belongs to travels with each sink call as an + /// — the namespace is never parsed back out of the id string. + private static readonly string[] RegisteredNamespaceUris = { V3NodeIds.RawNamespaceUri, V3NodeIds.UnsNamespaceUri }; + + /// Transitional alias kept so the pre-v3 single-namespace integration tests + /// (SubscriptionSurvivalTests) still resolve a namespace index. It points at + /// — the same realm as the transitional default of every sink + /// method — so those tests keep resolving the namespace the applier's default-realm nodes live in. The two + /// v3 URIs are the real namespaces; this alias is removed once those tests target Raw/UNS explicitly. + public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri; private readonly ConcurrentDictionary _variables = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _folders = new(StringComparer.Ordinal); @@ -73,11 +86,47 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The OPC UA server instance. /// The application configuration. public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration) - : base(server, configuration, DefaultNamespaceUri) + : base(server, configuration, RegisteredNamespaceUris) { - // SystemContext is initialised by the base ctor. + // SystemContext is initialised by the base ctor; both namespace URIs are now registered and their + // indexes captured (in the order passed) in NamespaceIndexes. } + /// Resolve a realm to the SDK namespace index its nodes are minted under. Raw is registered + /// first ([0]), UNS second ([1]) — see . + /// The address-space realm. + /// The namespace index for the realm. + internal ushort NamespaceIndexForRealm(AddressSpaceRealm realm) => realm switch + { + AddressSpaceRealm.Raw => NamespaceIndexes[0], + AddressSpaceRealm.Uns => NamespaceIndexes[1], + _ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."), + }; + + /// Recover the realm of an already-resolved SDK from its namespace index — + /// used at the SDK-facing seams (inbound write, HistoryRead) where the request carries a full NodeId. + /// Any index that is not the Raw namespace is treated as UNS (the two registered indexes are the only + /// ones this manager mints into). + /// The resolved SDK node id. + /// The realm the node belongs to. + private AddressSpaceRealm RealmOf(NodeId nodeId) => + nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns; + + /// Build the internal map key for a realm-qualified string id. The key is the node's full + /// namespace-qualified NodeId string (e.g. ns=2;s=<id>) so a Raw node and a UNS node that + /// share the same s= identifier are distinct keys — a bare id is no longer globally unique across + /// the two namespaces. Matches for the same node. + /// The realm the id lives in. + /// The bare s= identifier. + /// The namespace-qualified map key. + private string MapKey(AddressSpaceRealm realm, string id) => new NodeId(id, NamespaceIndexForRealm(realm)).ToString(); + + /// Build the internal map key for an already-resolved SDK (the SDK-facing + /// seams). Equal to for the same node. + /// The resolved SDK node id. + /// The namespace-qualified map key. + private static string MapKey(NodeId nodeId) => nodeId.ToString(); + /// Gets the count of variable nodes currently managed. public int VariableCount => _variables.Count; /// Gets the count of folder nodes currently managed. @@ -246,8 +295,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics. /// The alarm node identifier (== ScriptedAlarmId). /// The cached , or null when none is registered. - public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId) => - _alarmConditions.TryGetValue(alarmNodeId, out var condition) ? condition : null; + public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + _alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var condition) ? condition : null; /// Phase C: look up the resolved historian tagname registered for a historized variable /// node, or null when the node is not historized. The (later) HistoryRead override resolves an @@ -255,9 +304,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The variable node identifier. /// The resolved historian tagname when historized; otherwise null. /// True when the node is registered as historized; otherwise false. - public bool TryGetHistorizedTagname(string nodeId, out string? tagname) + public bool TryGetHistorizedTagname(string nodeId, out string? tagname, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - if (_historizedTagnames.TryGetValue(nodeId, out var t)) { tagname = t; return true; } + if (_historizedTagnames.TryGetValue(MapKey(realm, nodeId), out var t)) { tagname = t; return true; } + tagname = null; + return false; + } + + /// NodeId-typed overload used by the HistoryRead override, which holds a request's full + /// (namespace-qualified) NodeId — resolves the historian tagname without recovering the realm first. + /// A historized UNS reference node and its backing raw node register the SAME tagname under distinct + /// NodeIds, so a read against either resolves the one series. + /// The resolved SDK node id from the HistoryRead request. + /// The resolved historian tagname when historized; otherwise null. + /// True when the node is registered as historized; otherwise false. + private bool TryGetHistorizedTagname(NodeId nodeId, out string? tagname) + { + if (_historizedTagnames.TryGetValue(MapKey(nodeId), out var t)) { tagname = t; return true; } tagname = null; return false; } @@ -266,16 +329,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Exposed for tests so they can assert the SDK node's Historizing / AccessLevel attributes. /// The variable node identifier. /// The cached , or null when none is registered. - internal BaseDataVariableState? TryGetVariable(string nodeId) => - _variables.TryGetValue(nodeId, out var variable) ? variable : null; + internal BaseDataVariableState? TryGetVariable(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + _variables.TryGetValue(MapKey(realm, nodeId), out var variable) ? variable : null; /// Look up a materialised folder node by its NodeId string, or null if not present. /// Exposed for tests so they can resolve an equipment folder's NodeId (e.g. the event-notifier /// node a HistoryReadEvents request targets). /// The folder node identifier. /// The cached , or null when none is registered. - internal FolderState? TryGetFolder(string nodeId) => - _folders.TryGetValue(nodeId, out var folder) ? folder : null; + internal FolderState? TryGetFolder(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + _folders.TryGetValue(MapKey(realm, nodeId), out var folder) ? folder : null; /// /// Apply a value write from . Creates the @@ -286,19 +349,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The new value to write. /// The OPC UA quality status code. /// The timestamp of the value in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(nodeId); EnsureAddressSpaceCreated(); + var ns = NamespaceIndexForRealm(realm); + var key = MapKey(realm, nodeId); lock (Lock) { // CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode), // so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too. - if (!_variables.TryGetValue(nodeId, out var variable)) + if (!_variables.TryGetValue(key, out var variable)) { - variable = CreateVariable(nodeId); - _variables[nodeId] = variable; + variable = CreateVariable(nodeId, ns); + _variables[key] = variable; } variable.Value = value; @@ -321,17 +386,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the alarm (== ScriptedAlarmId for materialised conditions). /// The full condition state to project onto the node. /// The timestamp of the alarm state change in UTC. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentNullException.ThrowIfNull(state); EnsureAddressSpaceCreated(); + var ns = NamespaceIndexForRealm(realm); + var key = MapKey(realm, alarmNodeId); // Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear // _alarmConditions / detach the condition node between the lookup and the Set* calls. lock (Lock) { - if (_alarmConditions.TryGetValue(alarmNodeId, out var condition)) + if (_alarmConditions.TryGetValue(key, out var condition)) { // T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting // the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming @@ -396,10 +463,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so // un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates // the SDK address space, so it MUST run under Lock (see WriteValue). - if (!_variables.TryGetValue(alarmNodeId, out var variable)) + if (!_variables.TryGetValue(key, out var variable)) { - variable = CreateVariable(alarmNodeId); - _variables[alarmNodeId] = variable; + variable = CreateVariable(alarmNodeId, ns); + _variables[key] = variable; } variable.Value = new[] { state.Active, state.Acknowledged }; @@ -611,12 +678,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// . LimitAlarm deliberately falls back to base per the T13 /// notes — a script alarm carries no High/Low limits to populate. /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); EnsureAddressSpaceCreated(); + var ns = NamespaceIndexForRealm(realm); + var conditionKey = MapKey(realm, alarmNodeId); lock (Lock) { // R2-07 T4a: idempotent skip-if-present-and-same-kind. On a PURE-ADD apply, @@ -627,12 +696,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared // first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate // stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag. - if (_alarmConditions.ContainsKey(alarmNodeId) && _nativeAlarmNodeIds.Contains(alarmNodeId) == isNative) + if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative) return; // Idempotent: drop any prior node for this id so a re-materialise (e.g. changed // type/severity on redeploy) reflects cleanly instead of leaking the old node. - if (_alarmConditions.TryRemove(alarmNodeId, out var existing)) + if (_alarmConditions.TryRemove(conditionKey, out var existing)) { existing.Parent?.RemoveChild(existing); PredefinedNodes?.Remove(existing.NodeId); @@ -640,9 +709,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new // kind, so always drop the stale native flag first and only re-add it below when isNative. - _nativeAlarmNodeIds.Remove(alarmNodeId); + _nativeAlarmNodeIds.Remove(conditionKey); - var parent = ResolveParentFolder(equipmentNodeId); + var parent = ResolveParentFolder(equipmentNodeId, realm); AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent); alarm.SymbolicName = displayName; @@ -654,8 +723,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // type's embedded definition; we do not hand-build them. alarm.Create( SystemContext, - new NodeId(alarmNodeId, NamespaceIndex), - new QualifiedName(displayName, NamespaceIndex), + new NodeId(alarmNodeId, ns), + new QualifiedName(displayName, ns), new LocalizedText(displayName), assignNodeIds: true); @@ -688,7 +757,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. Confirm/AddComment/ // Shelve stay on the scripted path even for native conditions (H6c scope is Acknowledge only). alarm.OnAcknowledge = (context, condition, _, comment) => - IsNativeAlarmNode(alarmNodeId) + IsNativeAlarmNode(alarmNodeId, realm) ? HandleNativeAlarmAck(context, condition, comment) : HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null); alarm.OnConfirm = (context, condition, _, comment) => @@ -736,7 +805,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // engine enable/disable surface — they short-circuit to BadNotSupported. alarm.OnEnableDisable = (context, condition, enabling) => { - if (IsNativeAlarmNode(alarmNodeId)) + if (IsNativeAlarmNode(alarmNodeId, realm)) return new ServiceResult(StatusCodes.BadNotSupported); return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null); }; @@ -749,10 +818,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 EnsureFolderIsEventNotifier(parent); AddPredefinedNode(SystemContext, alarm); - _alarmConditions[alarmNodeId] = alarm; + _alarmConditions[conditionKey] = alarm; // H6a: record native (driver-fed) conditions so a later task can route their inbound // Acknowledge to the driver rather than the scripted engine. - if (isNative) _nativeAlarmNodeIds.Add(alarmNodeId); + if (isNative) _nativeAlarmNodeIds.Add(conditionKey); } } @@ -761,11 +830,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// inbound Acknowledge to the driver instead of the scripted engine. /// The alarm condition node id. /// True when the condition is native (driver-fed); false when it is scripted or not found. - internal bool IsNativeAlarmNode(string alarmNodeId) + internal bool IsNativeAlarmNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { // _nativeAlarmNodeIds is a plain HashSet mutated only under Lock (in MaterialiseAlarmCondition / // RebuildAddressSpace), so guard the read with the same Lock rather than risk a torn concurrent read. - lock (Lock) return _nativeAlarmNodeIds.Contains(alarmNodeId); + lock (Lock) return _nativeAlarmNodeIds.Contains(MapKey(realm, alarmNodeId)); } /// @@ -906,7 +975,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write // (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`). var optimisticValue = value; - var nodeKey = node.NodeId.Identifier?.ToString() ?? string.Empty; + // v3 dual-namespace: route by the FULL (namespace-qualified) NodeId string so a Raw write and a UNS + // write to nodes that share a bare id are distinguishable downstream — WP3's write gateway keys its + // (DriverInstanceId, RawPath) resolution on this ns-qualified id. Revert keys off the bare id + realm. + var nodeKey = node.NodeId.ToString(); + var nodeRealm = RealmOf(node.NodeId); + var nodeBareId = node.NodeId.Identifier?.ToString() ?? string.Empty; object? priorValue = null; StatusCode priorStatus = StatusCodes.Good; if (node is BaseDataVariableState variable) @@ -931,7 +1005,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 t => { var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted"); - RevertOptimisticWriteIfNeeded(nodeKey, outcome, optimisticValue, priorValue, priorStatus, clientUserId); + RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm); }, CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default); @@ -1132,14 +1206,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// from to populate the audit event's ClientUserId; null when unknown. internal void RevertOptimisticWriteIfNeeded( string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus, - string? clientUserId) + string? clientUserId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { // Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released. AuditWriteUpdateEventState? auditEvent = null; + var key = MapKey(realm, nodeId); lock (Lock) { - if (!_variables.TryGetValue(nodeId, out var variable)) return; // rebuilt/removed ⇒ no-op + if (!_variables.TryGetValue(key, out var variable)) return; // rebuilt/removed ⇒ no-op if (!ShouldRevert(outcome, variable.Value, optimisticValue)) return; // success, or poll moved it on // Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore @@ -1292,8 +1367,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // A historian is wired: advertise event history on this notifier and register it as a source. // The equipment-folder NodeId identifier IS the equipment id IS the ReadEventsAsync sourceName. folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead); + // Key by the full (namespace-qualified) NodeId string so a Raw and a UNS folder that share the + // same bare id are distinct sources; the VALUE stays the bare id — that is the historian + // ReadEventsAsync sourceName (== the equipment id). var sourceName = folder.NodeId.Identifier?.ToString() ?? string.Empty; - _eventNotifierSources[sourceName] = sourceName; + _eventNotifierSources[MapKey(folder.NodeId)] = sourceName; } AddRootNotifier(folder); folder.ClearChangeMasks(SystemContext, includeChildren: false); @@ -1320,23 +1398,25 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the folder. /// The node identifier of the parent folder; null to use the namespace root. /// The display name of the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(folderNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); EnsureAddressSpaceCreated(); - if (_folders.ContainsKey(folderNodeId)) return; + var ns = NamespaceIndexForRealm(realm); + var key = MapKey(realm, folderNodeId); + if (_folders.ContainsKey(key)) return; lock (Lock) { - if (_folders.ContainsKey(folderNodeId)) return; + if (_folders.ContainsKey(key)) return; - var parent = ResolveParentFolder(parentNodeId); + var parent = ResolveParentFolder(parentNodeId, realm); var folder = new FolderState(parent) { - NodeId = new NodeId(folderNodeId, NamespaceIndex), - BrowseName = new QualifiedName(folderNodeId, NamespaceIndex), + NodeId = new NodeId(folderNodeId, ns), + BrowseName = new QualifiedName(folderNodeId, ns), DisplayName = displayName, EventNotifier = EventNotifiers.None, TypeDefinitionId = ObjectTypeIds.FolderType, @@ -1344,7 +1424,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 }; parent.AddChild(folder); AddPredefinedNode(SystemContext, folder); - _folders[folderNodeId] = folder; + _folders[key] = folder; } } @@ -1363,13 +1443,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the folder to update in place. /// The new display name to apply. /// True when the in-place update was applied; false when the folder id is unknown. - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(folderNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); lock (Lock) { - if (!_folders.TryGetValue(folderNodeId, out var folder)) return false; + if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return false; folder.DisplayName = displayName; folder.ClearChangeMasks(SystemContext, includeChildren: false); return true; @@ -1405,20 +1485,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// — rank + dimensions carry the array-ness. /// Phase 4c: the declared length of the 1-D array when /// is true; ignored for scalars. Null ⇒ length 0. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + 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) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); EnsureAddressSpaceCreated(); + var ns = NamespaceIndexForRealm(realm); + var key = MapKey(realm, variableNodeId); // If already present, leave it alone (idempotent re-applies). - if (_variables.ContainsKey(variableNodeId)) return; + if (_variables.ContainsKey(key)) return; lock (Lock) { - if (_variables.ContainsKey(variableNodeId)) return; + if (_variables.ContainsKey(key)) return; - var parent = ResolveParentFolder(parentFolderNodeId); + var parent = ResolveParentFolder(parentFolderNodeId, realm); // Phase C: a non-null historian tagname makes the node Historizing and grants the HistoryRead // access bit (on top of the writable composite) so clients can browse + HistoryRead it. var historized = historianTagname is not null; @@ -1427,8 +1509,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var access = ComposeAccessLevel(writable, historized); var variable = new BaseDataVariableState(parent) { - NodeId = new NodeId(variableNodeId, NamespaceIndex), - BrowseName = new QualifiedName(variableNodeId, NamespaceIndex), + NodeId = new NodeId(variableNodeId, ns), + BrowseName = new QualifiedName(variableNodeId, ns), DisplayName = displayName, TypeDefinitionId = VariableTypeIds.BaseDataVariableType, ReferenceTypeId = ReferenceTypeIds.Organizes, @@ -1452,10 +1534,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } parent.AddChild(variable); AddPredefinedNode(SystemContext, variable); - _variables[variableNodeId] = variable; + _variables[key] = variable; // Phase C: register the resolved historian tagname so the HistoryRead override can map this - // NodeId back to its Aveva/historian source. - if (historized) _historizedTagnames[variableNodeId] = historianTagname!; + // NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME + // tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname. + if (historized) _historizedTagnames[key] = historianTagname!; } } @@ -1500,22 +1583,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar. /// The declared length of the 1-D array when is true; ignored for scalars. /// True when the in-place update was applied; false when the node id is unknown. - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(dataType); // widened surface ⇒ explicit contract (unknown names still map to BaseDataType) + var key = MapKey(realm, variableNodeId); BaseDataVariableState? shapeChangedNode = null; lock (Lock) { - if (!_variables.TryGetValue(variableNodeId, out var v)) return false; + if (!_variables.TryGetValue(key, out var v)) return false; var historized = historianTagname is not null; var access = ComposeAccessLevel(writable, historized); v.AccessLevel = access; v.UserAccessLevel = access; v.Historizing = historized; v.OnWriteValue = writable ? OnEquipmentTagWrite : null; - if (historized) _historizedTagnames[variableNodeId] = historianTagname!; - else _historizedTagnames.TryRemove(variableNodeId, out _); + if (historized) _historizedTagnames[key] = historianTagname!; + else _historizedTagnames.TryRemove(key, out _); // Swap DataType + ValueRank + ArrayDimensions in place, but only when they actually differ // from the live node — a Writable/Historizing-only change leaves the shape untouched (no value @@ -1628,13 +1712,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// /// /// The folder-scoped node id of the parent under which nodes were added. - public void RaiseNodesAddedModelChange(string affectedNodeId) + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(affectedNodeId); GeneralModelChangeEventState e; lock (Lock) { - e = BuildNodesAddedModelChange(affectedNodeId); + e = BuildNodesAddedModelChange(affectedNodeId, realm); } // Report OUTSIDE Lock — Server.ReportEvent re-enters the server's own subscription/event path; holding // Lock across it risks a lock-order inversion (mirrors ReportNodeShapeChangedEvent). @@ -1663,9 +1747,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// monitored-item to observe). /// The folder-scoped node id of the parent under which nodes were added. /// A populated, unreported . - internal GeneralModelChangeEventState BuildNodesAddedModelChange(string affectedNodeId) + internal GeneralModelChangeEventState BuildNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - var affected = new NodeId(affectedNodeId, NamespaceIndex); + var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm)); var e = new GeneralModelChangeEventState(null); e.Initialize( SystemContext, @@ -1683,7 +1767,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The affected node is the parent the children were added under; carry its TypeDefinition (a Folder // for an equipment parent) just as the shape-changed builder carries the variable's. Null when the // id is unknown — a valid Part 3 "type not applicable", and clients re-browse Affected regardless. - AffectedType = ResolveAffectedTypeDefinition(affectedNodeId), + AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm), Verb = (byte)ModelChangeStructureVerbMask.NodeAdded, }; // SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder @@ -1700,9 +1784,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// can assert the populated Changes structure at the nearest deterministic seam. /// The node id of the deleted node. /// A populated, unreported . - internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId) + internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - var affected = new NodeId(affectedNodeId, NamespaceIndex); + var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm)); var e = new GeneralModelChangeEventState(null); e.Initialize( SystemContext, @@ -1717,7 +1801,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { Affected = affected, // The node is already gone from the maps, so its TypeDefinition is not applicable (Null). - AffectedType = ResolveAffectedTypeDefinition(affectedNodeId), + AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm), Verb = (byte)ModelChangeStructureVerbMask.NodeDeleted, }; e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false); @@ -1748,10 +1832,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Resolve the TypeDefinition of a materialised node id from the live folder/variable maps for a /// model-change event's AffectedType; when the id is not registered. /// The folder-scoped node id whose TypeDefinition is wanted. - private NodeId ResolveAffectedTypeDefinition(string nodeId) + private NodeId ResolveAffectedTypeDefinition(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - if (_folders.TryGetValue(nodeId, out var folder)) return folder.TypeDefinitionId; - if (_variables.TryGetValue(nodeId, out var variable)) return variable.TypeDefinitionId; + var key = MapKey(realm, nodeId); + if (_folders.TryGetValue(key, out var folder)) return folder.TypeDefinitionId; + if (_variables.TryGetValue(key, out var variable)) return variable.TypeDefinitionId; return NodeId.Null; } @@ -1828,34 +1913,36 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - public bool RemoveVariableNode(string variableNodeId) + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); EnsureAddressSpaceCreated(); + var key = MapKey(realm, variableNodeId); GeneralModelChangeEventState e; lock (Lock) { // Unknown id ⇒ the node-manager maps drifted from what the planner believes; return false so the // caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node // teardown inside RebuildAddressSpace, scoped to this one id. - if (!_variables.TryRemove(variableNodeId, out var variable)) return false; + if (!_variables.TryRemove(key, out var variable)) return false; variable.Parent?.RemoveChild(variable); PredefinedNodes?.Remove(variable.NodeId); // Drop the historized-tagname registration alongside the variable it maps (Phase C parity). - _historizedTagnames.TryRemove(variableNodeId, out _); - e = BuildNodesRemovedModelChange(variableNodeId); + _historizedTagnames.TryRemove(key, out _); + e = BuildNodesRemovedModelChange(variableNodeId, realm); } ReportModelChangeOutsideLock(e, variableNodeId); return true; } /// - public bool RemoveAlarmConditionNode(string alarmNodeId) + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); EnsureAddressSpaceCreated(); + var key = MapKey(realm, alarmNodeId); GeneralModelChangeEventState e; lock (Lock) { @@ -1864,33 +1951,36 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // promotion is intentionally NOT demoted here — other conditions may still hang under it, and a // lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole // equipment goes). - if (!_alarmConditions.TryRemove(alarmNodeId, out var condition)) return false; + if (!_alarmConditions.TryRemove(key, out var condition)) return false; condition.Parent?.RemoveChild(condition); PredefinedNodes?.Remove(condition.NodeId); - _nativeAlarmNodeIds.Remove(alarmNodeId); - e = BuildNodesRemovedModelChange(alarmNodeId); + _nativeAlarmNodeIds.Remove(key); + e = BuildNodesRemovedModelChange(alarmNodeId, realm); } ReportModelChangeOutsideLock(e, alarmNodeId); return true; } /// - public bool RemoveEquipmentSubtree(string equipmentNodeId) + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId); EnsureAddressSpaceCreated(); + var equipKey = MapKey(realm, equipmentNodeId); GeneralModelChangeEventState e; lock (Lock) { // Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds). - if (!_folders.ContainsKey(equipmentNodeId)) return false; + if (!_folders.ContainsKey(equipKey)) return false; - // Folder-scoped NodeIds mean every descendant's id is the equipment id itself or begins with - // "/" (sub-folders, variables, condition nodes). Snapshot each map's in-scope keys - // first (ToList) so we don't mutate while enumerating. - var prefix = equipmentNodeId + "/"; - bool InScope(string id) => id == equipmentNodeId || id.StartsWith(prefix, StringComparison.Ordinal); + // Maps key on the full namespace-qualified NodeId string (e.g. "ns=3;s="). Every + // descendant in the SAME realm shares that key exactly or begins with "/" (sub-folders, + // variables, condition nodes); the "ns=N;s=" prefix makes the match realm-scoped for free, so a + // node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot each + // map's in-scope keys first (ToList) so we don't mutate while enumerating. + var prefix = equipKey + "/"; + bool InScope(string key) => key == equipKey || key.StartsWith(prefix, StringComparison.Ordinal); // Value variables (+ their historized-tagname registrations). foreach (var id in _variables.Keys.Where(InScope).ToList()) @@ -1917,8 +2007,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for // every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an // alarm), else the removal leaks an orphaned root-notifier reference on the Server object. - // _notifierFolders is keyed by the folder's NodeId; match on its string identifier. - foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.Identifier?.ToString() ?? string.Empty)).ToList()) + // _notifierFolders is keyed by the folder's NodeId; match InScope on its full (ns-qualified) string. + foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.ToString())).ToList()) { RemoveRootNotifier(kvp.Value); _notifierFolders.Remove(kvp.Key); @@ -1937,17 +2027,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } - e = BuildNodesRemovedModelChange(equipmentNodeId); + e = BuildNodesRemovedModelChange(equipmentNodeId, realm); } ReportModelChangeOutsideLock(e, equipmentNodeId); return true; } - private FolderState ResolveParentFolder(string? parentNodeId) + private FolderState ResolveParentFolder(string? parentNodeId, AddressSpaceRealm realm) { EnsureAddressSpaceCreated(); if (string.IsNullOrEmpty(parentNodeId)) return _root!; - return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!; + // A node's parent lives in the SAME realm (a raw tag's group is raw; a UNS reference's equipment + // folder is UNS), so resolve the parent key in that realm; unknown parent ⇒ hang under the shared root. + return _folders.TryGetValue(MapKey(realm, parentNodeId), out var existing) ? existing : _root!; } /// Guard the address-space mutators against a too-early call. _root @@ -2124,8 +2216,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var work = new List>(nodesToProcess.Count); foreach (var handle in nodesToProcess) { - var idString = handle.NodeId.Identifier?.ToString(); - if (idString is null || !_eventNotifierSources.TryGetValue(idString, out var sourceName)) + // Key on the full (namespace-qualified) NodeId so a Raw and a UNS notifier folder that share a + // bare id resolve to their own source registration. + if (!_eventNotifierSources.TryGetValue(MapKey(handle.NodeId), out var sourceName)) { // Not a registered event-history source (plain folder / Null-source promotion) ⇒ unsupported. // Set both errors and results explicitly on every bad path — don't rely on the SDK base @@ -2438,8 +2531,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 Func> read, CancellationToken ct) { - var idString = handle.NodeId.Identifier?.ToString(); - if (idString is null || !TryGetHistorizedTagname(idString, out var tagname)) + if (!TryGetHistorizedTagname(handle.NodeId, out var tagname)) { // Not a historized node we own a tagname for — unsupported. (The base pre-seeds this same // status, but set it explicitly so the contract is local + obvious.) @@ -2557,8 +2649,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 else { // Fresh read: resolve the node's historian tagname (as ServeNode does). - var idString = handle.NodeId.Identifier?.ToString(); - if (idString is null || !TryGetHistorizedTagname(idString, out var resolved) || resolved is null) + if (!TryGetHistorizedTagname(handle.NodeId, out var resolved) || resolved is null) { errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; return; @@ -2800,12 +2891,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } - private BaseDataVariableState CreateVariable(string nodeId) + private BaseDataVariableState CreateVariable(string nodeId, ushort ns) { var v = new BaseDataVariableState(_root) { - NodeId = new NodeId(nodeId, NamespaceIndex), - BrowseName = new QualifiedName(nodeId, NamespaceIndex), + NodeId = new NodeId(nodeId, ns), + BrowseName = new QualifiedName(nodeId, ns), DisplayName = nodeId, TypeDefinitionId = VariableTypeIds.BaseDataVariableType, ReferenceTypeId = ReferenceTypeIds.Organizes, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index 16350b02..65305767 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -21,48 +21,48 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre } /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) - => _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc); + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm); /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) - => _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc); + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm); /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) - => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative, realm); /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) - => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) - => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); + 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) + => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm); /// - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) - => _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength); + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm); /// - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) - => _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName); + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName, realm); /// - public bool RemoveVariableNode(string variableNodeId) - => _nodeManager.RemoveVariableNode(variableNodeId); + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.RemoveVariableNode(variableNodeId, realm); /// - public bool RemoveAlarmConditionNode(string alarmNodeId) - => _nodeManager.RemoveAlarmConditionNode(alarmNodeId); + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.RemoveAlarmConditionNode(alarmNodeId, realm); /// - public bool RemoveEquipmentSubtree(string equipmentNodeId) - => _nodeManager.RemoveEquipmentSubtree(equipmentNodeId); + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.RemoveEquipmentSubtree(equipmentNodeId, realm); /// public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace(); /// - public void RaiseNodesAddedModelChange(string affectedNodeId) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm); } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs index 6b2e2ae8..33082333 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs @@ -186,15 +186,15 @@ public class DeferredAddressSpaceSinkTests public bool WriteValueCalled { get; private set; } public bool RebuildCalled { get; private set; } - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => WriteValueCalled = true; - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + 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 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 RebuildAddressSpace() => RebuildCalled = true; - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } private sealed class SpySurgicalSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink @@ -202,21 +202,21 @@ public class DeferredAddressSpaceSinkTests public bool UpdateCalled { get; private set; } public bool FolderRenameCalled { get; private set; } - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + 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 MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, 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 RebuildAddressSpace() { } - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { UpdateCalled = true; return true; } - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { FolderRenameCalled = true; return true; @@ -226,8 +226,8 @@ public class DeferredAddressSpaceSinkTests public bool RemoveAlarmCalled { get; private set; } public bool RemoveSubtreeCalled { get; private set; } - public bool RemoveVariableNode(string variableNodeId) { RemoveVariableCalled = true; return true; } - public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveAlarmCalled = true; return true; } - public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveSubtreeCalled = true; return true; } + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveVariableCalled = true; return true; } + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveAlarmCalled = true; return true; } + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveSubtreeCalled = true; return true; } } } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs index 7903ac34..5a005ee0 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs @@ -74,6 +74,24 @@ public class DeferredSinkForwardingReflectionTests } } + [Fact] + public void Every_node_naming_sink_method_carries_a_realm_discriminator() + { + // v3 B4-WP2 dual-namespace invariant: a node id alone is no longer globally unique across the two + // namespaces (Raw vs UNS), so every sink method that NAMES a node must carry an AddressSpaceRealm + // discriminator — the sink resolves the namespace from the realm rather than parsing the id string. + // RebuildAddressSpace is the sole node-naming-free method (it clears BOTH realms). This guard locks in + // the realm surface so a future method can't be added that names a node without a realm. + foreach (var method in ForwardingInterfaces.SelectMany(i => i.GetMethods())) + { + if (method.Name == nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)) continue; + + method.GetParameters().Any(p => p.ParameterType == typeof(AddressSpaceRealm)).ShouldBeTrue( + $"{method.DeclaringType!.Name}.{method.Name} names a node but has no AddressSpaceRealm parameter — " + + "a bare node id is ambiguous across the Raw and UNS namespaces (B4-WP2). Add the realm discriminator."); + } + } + [Fact] public void Every_IServiceLevelPublisher_method_reaches_the_inner_publisher() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index 9582c8d5..6e6d0729 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -161,24 +161,24 @@ public sealed class AddressSpaceApplierFailureSurfaceTests public bool ThrowOnAlarmWrite { get; init; } public bool ThrowOnMaterialiseAlarm { get; init; } - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault"); } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault"); } - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { 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) + 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) { if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault"); } @@ -188,6 +188,6 @@ public sealed class AddressSpaceApplierFailureSurfaceTests if (ThrowOnRebuild) throw new InvalidOperationException("simulated RebuildAddressSpace fault"); } - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs index 14cc8f87..c5210663 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs @@ -307,24 +307,24 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable /// The value to write. /// The OPC UA quality value. /// The source timestamp in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Records an alarm condition write (stub implementation for testing). /// The node ID of the alarm condition. /// The full condition state snapshot. /// The source timestamp in UTC. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Materialises an alarm condition (stub implementation for testing). /// The alarm node ID (== ScriptedAlarmId). /// The equipment folder node ID. /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Records a folder creation request. /// The node ID of the folder. /// The node ID of the parent folder, or null for root. /// The display name of the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _calls.Enqueue((folderNodeId, parentNodeId, displayName)); /// Ensures a variable exists (stub implementation for testing). /// The node ID of the variable. @@ -333,10 +333,10 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + 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) { } /// Rebuilds the address space (stub implementation for testing). public void RebuildAddressSpace() { } /// Announces a NodeAdded model-change (stub implementation for testing). - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index 97dfefc5..5d6ff679 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -2170,7 +2170,7 @@ public sealed class AddressSpaceApplierTests /// The new OPC UA data type name to apply in place. /// The new array-ness of the node. /// The new 1-D array length when is true. - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength)); return SurgicalReturns; @@ -2187,7 +2187,7 @@ public sealed class AddressSpaceApplierTests /// Records a surgical in-place folder display-name update; returns . /// The folder node ID to update in place. /// The new display name to apply. - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { FolderRenameQueue.Enqueue((folderNodeId, displayName)); return FolderRenameReturns; @@ -2202,21 +2202,21 @@ public sealed class AddressSpaceApplierTests public bool RemoveReturns { get; init; } = true; /// Records a surgical variable-node removal; returns . - public bool RemoveVariableNode(string variableNodeId) + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveQueue.Enqueue(("var", variableNodeId)); return RemoveReturns; } /// Records a surgical alarm-condition-node removal; returns . - public bool RemoveAlarmConditionNode(string alarmNodeId) + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveQueue.Enqueue(("alarm", alarmNodeId)); return RemoveReturns; } /// Records a surgical equipment-subtree removal; returns . - public bool RemoveEquipmentSubtree(string equipmentNodeId) + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveQueue.Enqueue(("equipment", equipmentNodeId)); return RemoveReturns; @@ -2262,13 +2262,13 @@ public sealed class AddressSpaceApplierTests /// The value to write. /// The OPC UA quality. /// The source timestamp in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ValueWriteQueue.Enqueue((nodeId, quality)); /// Records an alarm condition write call. /// The alarm node ID. /// The full condition state snapshot. /// The source timestamp in UTC. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => AlarmQueue.Enqueue((alarmNodeId, state)); /// Records an alarm-condition materialise call. /// The alarm node ID (== ScriptedAlarmId). @@ -2276,13 +2276,13 @@ public sealed class AddressSpaceApplierTests /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative)); /// Records a folder creation call. /// The folder node ID. /// The parent folder node ID, if any. /// The display name for the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName)); /// Records a variable creation call. /// The variable node ID. @@ -2291,7 +2291,7 @@ public sealed class AddressSpaceApplierTests /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + 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) { VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); HistorianQueue.Enqueue((variableNodeId, historianTagname)); @@ -2306,7 +2306,7 @@ public sealed class AddressSpaceApplierTests public List ModelChangeCalls => ModelChangeQueue.ToList(); /// Records a NodeAdded model-change announcement. /// The node under which discovered nodes were added. - public void RaiseNodesAddedModelChange(string affectedNodeId) => ModelChangeQueue.Enqueue(affectedNodeId); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId); } /// A recording sink that does NOT implement — used to @@ -2318,19 +2318,19 @@ public sealed class AddressSpaceApplierTests public int RebuildCalls; /// Records a value write (no-op in this sink). - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op alarm condition write call. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op alarm-condition materialise call. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op folder creation call. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op variable creation call. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + 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) { } /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// No-op NodeAdded model-change announcement. - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } private sealed class ThrowingSink : IOpcUaAddressSpaceSink @@ -2345,13 +2345,13 @@ public sealed class AddressSpaceApplierTests /// The value to write. /// The OPC UA quality. /// The source timestamp in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Throws an exception if configured to do so. /// The alarm node ID. /// The full condition state snapshot. /// The source timestamp in UTC. /// Thrown when configured to throw on alarm write. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault"); } @@ -2361,12 +2361,12 @@ public sealed class AddressSpaceApplierTests /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op folder creation call. /// The folder node ID. /// The parent folder node ID, if any. /// The display name for the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op variable creation call. /// The variable node ID. /// The parent folder node ID, if any. @@ -2374,10 +2374,10 @@ public sealed class AddressSpaceApplierTests /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + 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) { } /// No-op rebuild address space call. public void RebuildAddressSpace() { } /// No-op NodeAdded model-change announcement. - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs index d10a9f58..2265421e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs @@ -228,19 +228,19 @@ public sealed class DeferredAddressSpaceSinkTests public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList(); /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"WV:{nodeId}"); /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"WA:{alarmNodeId}"); /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"MA:{alarmNodeId}"); /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"EF:{folderNodeId}"); /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + 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) { CallQueue.Enqueue($"EV:{variableNodeId}"); HistorianQueue.Enqueue((variableNodeId, historianTagname)); @@ -248,7 +248,7 @@ public sealed class DeferredAddressSpaceSinkTests /// public void RebuildAddressSpace() => CallQueue.Enqueue("RB"); /// - public void RaiseNodesAddedModelChange(string affectedNodeId) => CallQueue.Enqueue($"NA:{affectedNodeId}"); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"NA:{affectedNodeId}"); } private sealed class SurgicalRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink @@ -261,14 +261,14 @@ public sealed class DeferredAddressSpaceSinkTests public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls { get; } = new(); /// - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { SurgicalCalls.Add((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength)); return Result; } /// - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { FolderRenameCalls.Add((folderNodeId, displayName)); return Result; @@ -278,25 +278,25 @@ public sealed class DeferredAddressSpaceSinkTests public List<(string Kind, string NodeId)> RemoveCalls { get; } = new(); /// - public bool RemoveVariableNode(string variableNodeId) { RemoveCalls.Add(("var", variableNodeId)); return Result; } + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("var", variableNodeId)); return Result; } /// - public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveCalls.Add(("alarm", alarmNodeId)); return Result; } + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("alarm", alarmNodeId)); return Result; } /// - public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; } + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; } /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + 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) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + 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) { } + 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 RebuildAddressSpace() { } /// - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs index 85301fc4..495c8a82 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs @@ -111,11 +111,14 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable condition.ShouldNotBeNull(); // It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm"). condition.ShouldBeOfType(); - condition.NodeId.ShouldBe(new NodeId("alm-1", nm.NamespaceIndex)); + // v3 dual-namespace: the sink's transitional default realm is UNS, so default-realm nodes live in the + // UNS namespace (index 3), not the Raw namespace nm.NamespaceIndex (index 2) reports. + var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns); + condition.NodeId.ShouldBe(new NodeId("alm-1", unsNs)); // Reachable under the equipment folder: the parent is the eq-1 folder (HasComponent child). condition.Parent.ShouldNotBeNull(); - condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", nm.NamespaceIndex)); + condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", unsNs)); // Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false. condition.EnabledState.Id.Value.ShouldBeTrue(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs index 94088347..aeee15cc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs @@ -324,27 +324,27 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase public int RebuildCalls; /// Records a live-value write. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _values.Enqueue((nodeId, value, quality, sourceTimestampUtc)); /// No-op: alarm writes are not exercised by this suite. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op: alarm materialise is not exercised by this suite. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Records an EnsureFolder call. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _folders.Enqueue((folderNodeId, parentNodeId, displayName)); /// Records an EnsureVariable call. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + 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) => _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); /// Records a raw rebuild (the apply-time fallback when no dbFactory is wired). public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// Records a NodeAdded model-change announcement. - public void RaiseNodesAddedModelChange(string affectedNodeId) => _modelChanges.Enqueue(affectedNodeId); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs index 78063069..751d2c11 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs @@ -194,24 +194,24 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase /// The value being written. /// The OPC UA quality status. /// The source timestamp in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++; + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++; /// Records an alarm condition write. /// The alarm node identifier. /// The full condition state snapshot. /// The time the alarm occurred in UTC. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) => Writes++; + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++; /// Materialises an alarm condition (stub implementation). /// The alarm node identifier. /// The equipment folder node identifier. /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Ensures folder exists (stub implementation). /// The folder node identifier. /// The parent folder node identifier. /// The display name for the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Ensures variable exists (stub implementation). /// The variable node identifier. /// The parent folder node identifier. @@ -219,10 +219,10 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + 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) { } /// Rebuilds address space (recorded via span). public void RebuildAddressSpace() { /* recorded via span */ } /// Announces a NodeAdded model-change (stub implementation). - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs index 3bb225fa..1e4256ff 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs @@ -118,25 +118,25 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase /// A sink whose RebuildAddressSpace throws (drives the applier's SafeRebuild catch → RebuildFailed). private sealed class ThrowOnRebuildSink : IOpcUaAddressSpaceSink { - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + 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 MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, 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 RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault"); - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } /// A no-op sink — a clean apply (no failures). private sealed class NoopSink : IOpcUaAddressSpaceSink { - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + 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 MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, 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 RebuildAddressSpace() { } - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } /// Listens to a single instrument by name on the central meter and tallies value + tags. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index 04894b13..1a1c43d1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -437,13 +437,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// The value to write. /// The OPC UA quality code. /// The timestamp of the write. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"WV:{nodeId}"); /// Records an alarm condition write call. /// The alarm node ID. /// The full condition state snapshot. /// The timestamp of the state change. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"WA:{alarmNodeId}"); /// Records a materialise-alarm-condition call. /// The alarm node ID (== ScriptedAlarmId). @@ -451,13 +451,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"MA:{alarmNodeId}"); /// Records a folder ensure call. /// The folder node ID. /// The parent node ID, or null if this is a root folder. /// The display name of the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"EF:{folderNodeId}"); /// Records a variable ensure call. /// The variable node ID. @@ -466,15 +466,15 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + 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) => Calls.Enqueue($"EV:{variableNodeId}"); /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// Records a NodeAdded model-change announcement. /// The node under which discovered nodes were added. - public void RaiseNodesAddedModelChange(string affectedNodeId) => Calls.Enqueue($"NA:{affectedNodeId}"); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}"); /// Records a surgical in-place tag-attribute update (always succeeds in this recording sink). - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"UT:{variableNodeId}"); return true; @@ -482,25 +482,25 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// Records a surgical in-place folder display-name update (always succeeds in this recording sink). /// The folder node ID to update in place. /// The new display name to apply. - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { FolderRenameQueue.Enqueue((folderNodeId, displayName)); return true; } /// Records a surgical variable-node removal (always succeeds in this recording sink). - public bool RemoveVariableNode(string variableNodeId) + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"RV:{variableNodeId}"); return true; } /// Records a surgical alarm-condition-node removal (always succeeds in this recording sink). - public bool RemoveAlarmConditionNode(string alarmNodeId) + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"RA:{alarmNodeId}"); return true; } /// Records a surgical equipment-subtree removal (always succeeds in this recording sink). - public bool RemoveEquipmentSubtree(string equipmentNodeId) + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"RE:{equipmentNodeId}"); return true; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index e903b714..7086d3c0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -606,14 +606,14 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase /// The attribute value. /// The OPC UA quality code. /// The timestamp of the update. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) => + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ValueQueue.Enqueue((nodeId, value, quality, ts)); /// Records an alarm condition update. /// The OPC UA alarm node identifier. /// The full condition state snapshot. /// The timestamp of the update. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts) => + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => AlarmQueue.Enqueue((alarmNodeId, state, ts)); /// Materialises an alarm condition (no-op in test). @@ -622,13 +622,13 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Records a folder ensure call. /// The OPC UA folder node identifier. /// The parent folder node identifier, or null for root. /// The display name of the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) => + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName)); /// Records a variable ensure call. @@ -638,7 +638,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) => + 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) => VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); /// Records a rebuild call. @@ -646,7 +646,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase /// Records a NodeAdded model-change announcement. /// The node under which discovered nodes were added. - public void RaiseNodesAddedModelChange(string affectedNodeId) => ModelChangeQueue.Enqueue(affectedNodeId); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId); } /// Test implementation of IServiceLevelPublisher that records publishes.