Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs
T
Joseph Doherty 7cd5cde315 refactor(opcuaclient): move NamespaceMap to Contracts, make public
Browser project (Phase 3) needs to share namespace-stable address encoding
with the runtime driver. Move keeps the same namespace, so existing usages
in OpcUaClientDriver compile unchanged.
2026-05-28 15:35:21 -04:00

147 lines
7.1 KiB
C#

using Opc.Ua;
using Opc.Ua.Client;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// Bidirectional namespace map for the OPC UA Client (gateway) driver, built at connect
/// time from <c>session.NamespaceUris</c> per <c>docs/v2/driver-specs.md</c> §8
/// "Namespace Remapping".
/// </summary>
/// <remarks>
/// <para>
/// The session-relative namespace index embedded in an <c>ns=N;…</c> NodeId string is
/// <b>not</b> stable: the OPC UA spec permits a server to reorder its namespace table
/// across a restart. A driver that stores raw <c>ns=N</c> references and re-parses
/// them verbatim for reads/writes will, after such a reorder, silently address the
/// wrong namespace.
/// </para>
/// <para>
/// This map captures the upstream namespace table as it was at connect time and lets
/// the driver persist NodeIds in a <b>server-stable</b> form — the namespace
/// <i>URI</i> plus the identifier — via <see cref="ToStableReference"/>. At
/// read/write time <see cref="TryResolve"/> re-binds that stable form against the
/// <i>current</i> session's namespace table, so a table reorder is transparently
/// corrected instead of silently misaddressing nodes.
/// </para>
/// <para>
/// Stable references use the form <c>nsu=&lt;uri&gt;;&lt;idType&gt;=&lt;identifier&gt;</c>,
/// which is the standard OPC UA namespace-URI NodeId encoding the SDK already
/// understands. References that are already in plain <c>ns=N;…</c> form (e.g. a
/// hand-entered config tag) still resolve — <see cref="TryResolve"/> falls back to a
/// direct parse against the current session.
/// </para>
/// </remarks>
public sealed class NamespaceMap
{
// index -> URI and URI -> index, as the upstream server published them at connect time.
private readonly string[] _uris;
private readonly Dictionary<string, ushort> _uriToIndex;
private NamespaceMap(string[] uris)
{
_uris = uris;
_uriToIndex = new Dictionary<string, ushort>(uris.Length, StringComparer.Ordinal);
for (var i = 0; i < uris.Length; i++)
_uriToIndex[uris[i]] = (ushort)i;
}
/// <summary>Snapshot the namespace table from a live session.</summary>
/// <param name="session">The OPC UA session to snapshot.</param>
public static NamespaceMap FromSession(ISession session)
{
ArgumentNullException.ThrowIfNull(session);
return FromTable(session.NamespaceUris);
}
/// <summary>
/// Snapshot a <see cref="NamespaceTable"/> directly. Separated from
/// <see cref="FromSession"/> so the URI encoding can be exercised without standing up
/// a live <see cref="ISession"/>.
/// </summary>
/// <param name="namespaceUris">The namespace table to snapshot.</param>
public static NamespaceMap FromTable(NamespaceTable namespaceUris)
{
ArgumentNullException.ThrowIfNull(namespaceUris);
return new NamespaceMap(namespaceUris.ToArray());
}
/// <summary>Number of namespaces captured. Index 0 is always the OPC UA core namespace.</summary>
public int Count => _uris.Length;
/// <summary>The namespace URI at the given index, or null if out of range.</summary>
/// <param name="index">The zero-based index in the namespace table.</param>
public string? UriForIndex(int index) =>
index >= 0 && index < _uris.Length ? _uris[index] : null;
/// <summary>The index for a namespace URI, or null if the URI is not in the table.</summary>
/// <param name="uri">The namespace URI to look up.</param>
public ushort? IndexForUri(string uri) =>
_uriToIndex.TryGetValue(uri, out var idx) ? idx : null;
/// <summary>
/// Render a NodeId resolved against this map's session as a <b>server-stable</b>
/// reference string — namespace URI plus identifier, in the SDK's <c>nsu=…</c>
/// encoding. The OPC UA core namespace (index 0) keeps the compact <c>i=…</c>/<c>ns=0</c>
/// form since URI 0 never moves. Used to persist a discovered NodeId into the local
/// address space so it survives a remote namespace-table reorder.
/// </summary>
/// <param name="nodeId">The NodeId to render as a stable reference.</param>
public string ToStableReference(NodeId nodeId)
{
ArgumentNullException.ThrowIfNull(nodeId);
// Namespace 0 is fixed by the spec — the compact form is already stable.
if (nodeId.NamespaceIndex == 0)
return nodeId.ToString() ?? string.Empty;
var uri = UriForIndex(nodeId.NamespaceIndex);
if (uri is null)
// Namespace index not in the captured table — fall back to the raw form rather
// than throwing; the read/write path will surface BadNodeIdInvalid if it truly
// can't resolve.
return nodeId.ToString() ?? string.Empty;
// nsu=<uri>;<idType>=<identifier> is the standard namespace-URI NodeId encoding.
return $"nsu={uri};{IdentifierPart(nodeId)}";
}
/// <summary>
/// Resolve a reference string — either a stable <c>nsu=…</c> reference produced by
/// <see cref="ToStableReference"/> or a plain <c>ns=N;…</c> NodeId — against the
/// <paramref name="currentSession"/>'s namespace table. A <c>nsu=…</c> reference is
/// re-bound through the current session's URI table so a remote reorder since connect
/// time is corrected. Returns false for empty/malformed input or an unknown URI.
/// </summary>
/// <param name="currentSession">The OPC UA session whose namespace table to resolve against.</param>
/// <param name="reference">The reference string to resolve (either <c>nsu=…</c> or <c>ns=N;…</c> format).</param>
/// <param name="nodeId">On success, the resolved NodeId; otherwise NodeId.Null.</param>
public static bool TryResolve(ISession currentSession, string reference, out NodeId nodeId)
{
nodeId = NodeId.Null;
if (currentSession is null || string.IsNullOrWhiteSpace(reference)) return false;
try
{
// NodeId.Parse with a NamespaceUriString form (nsu=…) re-maps the URI against
// the supplied context's NamespaceUris — i.e. the *current* session table — so a
// reorder since the reference was captured resolves correctly. Plain ns=N forms
// resolve directly.
nodeId = NodeId.Parse(currentSession.MessageContext, reference);
return !NodeId.IsNull(nodeId);
}
catch
{
return false;
}
}
/// <summary>Render just the identifier portion (<c>s=…</c>, <c>i=…</c>, <c>g=…</c>, <c>b=…</c>) of a NodeId.</summary>
private static string IdentifierPart(NodeId nodeId) => nodeId.IdType switch
{
IdType.Numeric => $"i={nodeId.Identifier}",
IdType.String => $"s={nodeId.Identifier}",
IdType.Guid => $"g={nodeId.Identifier}",
IdType.Opaque => $"b={Convert.ToBase64String((byte[])nodeId.Identifier)}",
_ => $"s={nodeId.Identifier}",
};
}