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:
Joseph Doherty
2026-07-17 00:13:47 -04:00
parent 128f159692
commit d32050da24
4 changed files with 299 additions and 6 deletions
@@ -0,0 +1,118 @@
using Opc.Ua;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <summary>
/// Translates the node-reference strings ScadaBridge stores in configuration
/// (<c>TemplateAttribute.DataSourceReference</c> and friends) to session-local
/// <see cref="NodeId"/>s, and back again for the node browser.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists.</b> A stored <c>ns=2;s=Foo</c> reference hard-codes a namespace
/// <i>index</i>, and an index is only meaningful relative to one server's namespace
/// table. A server that adds, removes or reorders a namespace silently re-points every
/// stored binding: best case the read fails <c>BadNodeIdUnknown</c>, worst case the
/// index now names a different namespace whose table happens to contain a colliding
/// identifier and the binding resolves to the wrong node with Good quality. The
/// namespace <i>URI</i> is the stable identity — the index is just its position today.
/// </para>
/// <para>
/// <b>The durable form</b> is therefore <c>nsu=&lt;namespace-uri&gt;;s=&lt;identifier&gt;</c>,
/// resolved against the live namespace table at use time. <see cref="Resolve"/> accepts
/// both forms, so existing <c>ns=</c> bindings keep working unchanged; <see cref="ToDurable"/>
/// is what the browser emits so newly-authored bindings are index-proof from the start.
/// </para>
/// <para>
/// Namespace 0 is exempt: the OPC UA spec fixes it to <c>http://opcfoundation.org/UA/</c>,
/// so standard NodeIds like <c>i=85</c> are already stable and stay in their short form.
/// </para>
/// </remarks>
public static class OpcUaNodeReference
{
/// <summary>
/// Resolves a stored node reference — either <c>ns=&lt;index&gt;;…</c> or the durable
/// <c>nsu=&lt;uri&gt;;…</c> form — to a <see cref="NodeId"/> valid for this session.
/// </summary>
/// <param name="reference">The stored reference string.</param>
/// <param name="namespaceUris">The connected session's namespace table.</param>
/// <returns>A session-local <see cref="NodeId"/>.</returns>
/// <exception cref="ArgumentException">
/// The reference is unparseable, names a namespace URI this server does not publish,
/// or points at a different server via <c>svr=</c>.
/// </exception>
public static NodeId Resolve(string reference, NamespaceTable namespaceUris)
{
ArgumentException.ThrowIfNullOrEmpty(reference);
ArgumentNullException.ThrowIfNull(namespaceUris);
ExpandedNodeId expanded;
try
{
expanded = ExpandedNodeId.Parse(reference);
}
catch (Exception ex)
{
throw new ArgumentException(
$"'{reference}' is not a valid OPC UA node reference. Expected 'ns=<index>;s=<id>' " +
$"or the namespace-URI-qualified 'nsu=<uri>;s=<id>'.",
nameof(reference), ex);
}
// svr= addresses a node on a *different* server via this server's server table.
// Nothing downstream follows that indirection, so fail loudly rather than hand
// the SDK a reference it would resolve against the wrong address space.
if (expanded.ServerIndex != 0)
{
throw new ArgumentException(
$"'{reference}' targets another server (svr={expanded.ServerIndex}); " +
"ScadaBridge binds only to nodes on the connected server.",
nameof(reference));
}
// Maps nsu= -> the live index; leaves an ns= reference's index untouched.
var nodeId = ExpandedNodeId.ToNodeId(expanded, namespaceUris);
if (NodeId.IsNull(nodeId))
{
throw new ArgumentException(
$"'{reference}' names namespace URI '{expanded.NamespaceUri}', which this server " +
"does not publish in its NamespaceArray. The binding likely predates a server " +
"upgrade that changed its namespaces.",
nameof(reference));
}
return nodeId;
}
/// <summary>
/// Renders a browse result as the durable, index-proof reference to store.
/// </summary>
/// <param name="nodeId">The <see cref="ExpandedNodeId"/> a browse returned.</param>
/// <param name="namespaceUris">The connected session's namespace table.</param>
/// <returns>
/// <c>nsu=&lt;uri&gt;;s=&lt;id&gt;</c> for server-specific namespaces; the plain short form for
/// namespace 0. Falls back to the raw string when the namespace cannot be named.
/// </returns>
public static string ToDurable(ExpandedNodeId nodeId, NamespaceTable namespaceUris)
{
ArgumentNullException.ThrowIfNull(nodeId);
ArgumentNullException.ThrowIfNull(namespaceUris);
// A browse can hand back a reference carrying a NamespaceUri or a server index
// rather than a plain index. Resolving to session-local first collapses those
// shapes; without it the raw ToString() can emit a string Resolve cannot re-read.
var local = ExpandedNodeId.ToNodeId(nodeId, namespaceUris);
if (NodeId.IsNull(local))
return nodeId.ToString() ?? string.Empty;
// ns=0 is spec-fixed, so its short form is already durable.
if (local.NamespaceIndex == 0)
return local.ToString() ?? string.Empty;
var uri = namespaceUris.GetString(local.NamespaceIndex);
if (string.IsNullOrEmpty(uri))
return local.ToString() ?? string.Empty;
return new ExpandedNodeId(local, uri).ToString() ?? string.Empty;
}
}
@@ -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));