feat(v3-batch4-wp2): node manager dual-namespace + realm-aware sink surface

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
This commit is contained in:
Joseph Doherty
2026-07-16 09:33:45 -04:00
parent a1a56e22bb
commit 5534698d70
17 changed files with 413 additions and 271 deletions
@@ -36,7 +36,20 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// </summary>
public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
/// <summary>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. <see cref="V3NodeIds.RawNamespaceUri"/> is
/// registered FIRST (namespace index <see cref="NamespaceIndexes"/>[0]) and
/// <see cref="V3NodeIds.UnsNamespaceUri"/> SECOND ([1]); <see cref="NamespaceIndexForRealm"/> maps a realm
/// to its index. Which realm a node belongs to travels with each sink call as an
/// <see cref="AddressSpaceRealm"/> — the namespace is never parsed back out of the id string.</summary>
private static readonly string[] RegisteredNamespaceUris = { V3NodeIds.RawNamespaceUri, V3NodeIds.UnsNamespaceUri };
/// <summary>Transitional alias kept so the pre-v3 single-namespace integration tests
/// (<c>SubscriptionSurvivalTests</c>) still resolve a namespace index. It points at
/// <see cref="V3NodeIds.UnsNamespaceUri"/> — 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.</summary>
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
@@ -73,11 +86,47 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="server">The OPC UA server instance.</param>
/// <param name="configuration">The application configuration.</param>
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.
}
/// <summary>Resolve a realm to the SDK namespace index its nodes are minted under. Raw is registered
/// first (<see cref="NamespaceIndexes"/>[0]), UNS second ([1]) — see <see cref="RegisteredNamespaceUris"/>.</summary>
/// <param name="realm">The address-space realm.</param>
/// <returns>The namespace index for the realm.</returns>
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."),
};
/// <summary>Recover the realm of an already-resolved SDK <see cref="NodeId"/> 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).</summary>
/// <param name="nodeId">The resolved SDK node id.</param>
/// <returns>The realm the node belongs to.</returns>
private AddressSpaceRealm RealmOf(NodeId nodeId) =>
nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
/// <summary>Build the internal map key for a realm-qualified string id. The key is the node's full
/// namespace-qualified NodeId string (e.g. <c>ns=2;s=&lt;id&gt;</c>) so a Raw node and a UNS node that
/// share the same <c>s=</c> identifier are distinct keys — a bare id is no longer globally unique across
/// the two namespaces. Matches <see cref="MapKey(NodeId)"/> for the same node.</summary>
/// <param name="realm">The realm the id lives in.</param>
/// <param name="id">The bare <c>s=</c> identifier.</param>
/// <returns>The namespace-qualified map key.</returns>
private string MapKey(AddressSpaceRealm realm, string id) => new NodeId(id, NamespaceIndexForRealm(realm)).ToString();
/// <summary>Build the internal map key for an already-resolved SDK <see cref="NodeId"/> (the SDK-facing
/// seams). Equal to <see cref="MapKey(AddressSpaceRealm, string)"/> for the same node.</summary>
/// <param name="nodeId">The resolved SDK node id.</param>
/// <returns>The namespace-qualified map key.</returns>
private static string MapKey(NodeId nodeId) => nodeId.ToString();
/// <summary>Gets the count of variable nodes currently managed.</summary>
public int VariableCount => _variables.Count;
/// <summary>Gets the count of folder nodes currently managed.</summary>
@@ -246,8 +295,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.</summary>
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
/// <returns>The cached <see cref="AlarmConditionState"/>, or null when none is registered.</returns>
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;
/// <summary>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
/// <param name="nodeId">The variable node identifier.</param>
/// <param name="tagname">The resolved historian tagname when historized; otherwise null.</param>
/// <returns>True when the node is registered as historized; otherwise false.</returns>
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;
}
/// <summary>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.</summary>
/// <param name="nodeId">The resolved SDK node id from the HistoryRead request.</param>
/// <param name="tagname">The resolved historian tagname when historized; otherwise null.</param>
/// <returns>True when the node is registered as historized; otherwise false.</returns>
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.</summary>
/// <param name="nodeId">The variable node identifier.</param>
/// <returns>The cached <see cref="BaseDataVariableState"/>, or null when none is registered.</returns>
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;
/// <summary>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).</summary>
/// <param name="nodeId">The folder node identifier.</param>
/// <returns>The cached <see cref="FolderState"/>, or null when none is registered.</returns>
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;
/// <summary>
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
@@ -286,19 +349,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="value">The new value to write.</param>
/// <param name="quality">The OPC UA quality status code.</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
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
/// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
/// <param name="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
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
/// <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
/// notes — a script alarm carries no High/Low limits to populate.</para>
/// </remarks>
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.</summary>
/// <param name="alarmNodeId">The alarm condition node id.</param>
/// <returns>True when the condition is native (driver-fed); false when it is scripted or not found.</returns>
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));
}
/// <summary>
@@ -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 <see cref="OnEquipmentTagWrite"/> to populate the audit event's ClientUserId; null when unknown.</param>
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
/// <param name="folderNodeId">The node identifier of the folder.</param>
/// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
/// <param name="displayName">The display name of the folder.</param>
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
/// <param name="folderNodeId">The node identifier of the folder to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
/// <returns>True when the in-place update was applied; false when the folder id is unknown.</returns>
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
/// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param>
/// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when
/// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param>
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
/// <param name="isArray">When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
/// <returns>True when the in-place update was applied; false when the node id is unknown.</returns>
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
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
/// </para>
/// </summary>
/// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
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).</summary>
/// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
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.</summary>
/// <param name="affectedNodeId">The node id of the deleted node.</param>
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
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
/// <summary>Resolve the TypeDefinition of a materialised node id from the live folder/variable maps for a
/// model-change event's <c>AffectedType</c>; <see cref="NodeId.Null"/> when the id is not registered.</summary>
/// <param name="nodeId">The folder-scoped node id whose TypeDefinition is wanted.</param>
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
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
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;
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>
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;
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>
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
// "<equipmentId>/" (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=<equipmentId>"). Every
// descendant in the SAME realm shares that key exactly or begins with "<equipKey>/" (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!;
}
/// <summary>Guard the address-space mutators against a too-early call. <c>_root</c>
@@ -2124,8 +2216,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var work = new List<Func<Task>>(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<IHistorianDataSource, string, CancellationToken, Task<HistorianRead>> 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,
@@ -21,48 +21,48 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
}
/// <inheritdoc />
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);
/// <inheritdoc />
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);
/// <inheritdoc />
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);
/// <inheritdoc />
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);
/// <inheritdoc />
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);
/// <inheritdoc />
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);
/// <inheritdoc />
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);
/// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId)
=> _nodeManager.RemoveVariableNode(variableNodeId);
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> _nodeManager.RemoveVariableNode(variableNodeId, realm);
/// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId)
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId);
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId, realm);
/// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId)
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId);
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId, realm);
/// <inheritdoc />
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId);
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm);
}