feat(dcl): bind OPC UA nodes by namespace URI, not baked index
A stored ns=<index> reference is only meaningful against one server's namespace table. A server that adds, removes or reorders a namespace silently re-points every binding: best case BadNodeIdUnknown, worst case the index now names a different namespace holding a colliding identifier and the binding resolves to the wrong node with Good quality. Nothing could detect that, because ScadaBridge stored no namespace URI anywhere. OtOpcUa v3.0 makes this concrete: v2's sole custom namespace and v3's raw namespace both sit at index 2, so v2-era bindings resolve against v3 without error while meaning something else entirely. Adds OpcUaNodeReference as the single translation seam between stored references and the wire. Resolve() accepts both ns=<index>;s=<id> (existing bindings, which keep working unchanged) and the durable nsu=<uri>;s=<id>, mapping the URI to the live index at use time; it is wired into every parse site — subscribe, read, write, alarm-subscribe and browse. An unpublished URI now throws naming the URI rather than binding to whatever occupies that index, and a svr= reference to another server is rejected instead of being resolved against the wrong address space. The browser emits ToDurable(), so what the picker shows is what gets stored and newly-authored bindings are index-proof from the start. That also closes a round-trip gap: browse previously emitted ExpandedNodeId.ToString(), which for a URI- or server-index-carrying reference produced a string NodeId.Parse could not read back — the same method already resolved it correctly 57 lines later. Bindings stored before this change keep their ns= form and keep working; they are only as durable as the server's namespace order. Re-authoring against the picker is what makes them durable, and that re-bind still needs a live v3 rig. Refs: Gitea #14
This commit is contained in:
@@ -411,7 +411,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
var monitoredItem = new MonitoredItem(_subscription.DefaultItem)
|
||||
{
|
||||
DisplayName = nodeId,
|
||||
StartNodeId = nodeId,
|
||||
StartNodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
|
||||
AttributeId = Attributes.Value,
|
||||
SamplingInterval = _options.SamplingIntervalMs,
|
||||
QueueSize = (uint)_options.QueueSize,
|
||||
@@ -475,7 +475,9 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
_alarmInRefresh[handle] = false;
|
||||
_alarmLastState[handle] = new Dictionary<string, (bool, bool)>(StringComparer.Ordinal);
|
||||
|
||||
var startNode = string.IsNullOrEmpty(sourceNodeId) ? ObjectIds.Server : NodeId.Parse(sourceNodeId);
|
||||
var startNode = string.IsNullOrEmpty(sourceNodeId)
|
||||
? ObjectIds.Server
|
||||
: OpcUaNodeReference.Resolve(sourceNodeId, _session.NamespaceUris);
|
||||
var item = new MonitoredItem(_subscription.DefaultItem)
|
||||
{
|
||||
DisplayName = $"alarm:{sourceNodeId ?? "Server"}",
|
||||
@@ -829,7 +831,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
|
||||
var readValue = new ReadValueId
|
||||
{
|
||||
NodeId = nodeId,
|
||||
NodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
|
||||
AttributeId = Attributes.Value
|
||||
};
|
||||
|
||||
@@ -848,7 +850,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
|
||||
var writeValue = new WriteValue
|
||||
{
|
||||
NodeId = nodeId,
|
||||
NodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
|
||||
AttributeId = Attributes.Value,
|
||||
Value = new DataValue(new Variant(value))
|
||||
};
|
||||
@@ -960,7 +962,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// an absolute NodeId expression.
|
||||
var nodeToBrowse = string.IsNullOrEmpty(parentNodeId)
|
||||
? ObjectIds.ObjectsFolder
|
||||
: NodeId.Parse(parentNodeId);
|
||||
: OpcUaNodeReference.Resolve(parentNodeId, session.NamespaceUris);
|
||||
|
||||
// No token → fresh browse of the node. A non-empty token → continue a
|
||||
// prior browse via BrowseNext, falling back to a fresh browse if the
|
||||
@@ -1066,7 +1068,11 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
foreach (var r in refs)
|
||||
{
|
||||
children.Add(new Commons.Interfaces.Protocol.BrowseNode(
|
||||
NodeId: r.NodeId.ToString(),
|
||||
// Durable nsu= form, not r.NodeId.ToString(): the raw ExpandedNodeId
|
||||
// string can carry a namespace URI or server index that Resolve cannot
|
||||
// read back, and a bare ns= index rots the moment the server's
|
||||
// namespace table changes. What the picker shows is what gets stored.
|
||||
NodeId: OpcUaNodeReference.ToDurable(r.NodeId, session.NamespaceUris),
|
||||
DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)",
|
||||
NodeClass: MapNodeClass(r.NodeClass),
|
||||
HasChildren: r.NodeClass == NodeClass.Object));
|
||||
|
||||
Reference in New Issue
Block a user