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:
@@ -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=<id></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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user