Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/NamespaceMap.cs
T
Joseph Doherty b4f8cb8de7 refactor(opcuaclient): move NamespaceMap to a .Session leaf so Contracts is dependency-light
G-7 wired OpcUaClientTagDefinitionFactory.Inspect into the deploy-time TagConfig
gate, which gave ControlPlane a ProjectReference to
Driver.OpcUaClient.Contracts — and with it, transitively, the whole OPC UA client
stack (5 OPCFoundation packages) into every host that loads the control plane.

NamespaceMap was the only reason Contracts carried
OPCFoundation.NetStandard.Opc.Ua.Client: its entire public surface is
ISession/NamespaceTable/NodeId-typed. It moves to a new leaf project,
ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session, referenced by the runtime .Driver
and by .Browser. Contracts is left with Core.Abstractions and NOTHING else —
the same shape as Calculation.Contracts.

The namespace stays ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient, so no consumer's
`using` changed. The .Session -> .Contracts direction is deliberate: the blob
read NamespaceMap needs is OpcUaClientTagDefinitionFactory.TryReadNodeId, the
one authority it shares with the deploy gate, and that must stay in the
dependency-light half.

Why a new project rather than folding NamespaceMap into the runtime .Driver and
having .Browser reference it (the Driver.Mqtt.Browser #512-style deliberate
exception): the AdminUI project-references .Browser and NOT .Driver, so that
shape would pull the OpcUaClient driver runtime — plus Core,
Opc.Ua.Configuration and Secrets.Abstractions — into the AdminUI's build/deploy
graph. That is exactly the violation #512 spent two extractions removing from
Mqtt.Browser; re-introducing it to save one csproj is a bad trade.

Guard: ContractsIsDependencyLightTests (Driver.OpcUaClient.Tests) asserts the
Contracts manifest names no Opc.Ua* assembly, plus a counterpart asserting
.Session does. An ABSENCE assertion is sound on the manifest where a presence
one is not — the compiler elides an unused reference, but it MUST emit one the
moment Opc.Ua-typed code is added. Falsified by temporarily re-adding the
package plus an Opc.Ua.NodeId-typed member: red on Opc.Ua.Types, green again on
removal.

Evidence — `dotnet list ControlPlane package --include-transitive | grep -i opc`:
  before: OPCFoundation.NetStandard.Opc.Ua.{Client,Configuration,Core,
          Security.Certificates,Types} 1.5.378.106
  after:  none (and 0 Opc.Ua*.dll in ControlPlane's output directory)
2026-08-09 21:56:29 -04:00

259 lines
15 KiB
C#

using Opc.Ua;
using Opc.Ua.Client;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// One authored raw tag's resolution target: the upstream OPC UA node id string the driver dials
/// (authored as the <c>nodeId</c> key of the tag's <c>TagConfig</c> blob) plus the platform
/// <see cref="RawTagEntry.WriteIdempotent"/> flag threaded onto the table (not read from the blob).
/// </summary>
/// <param name="NodeId">The upstream OPC UA node id string (a stable <c>nsu=…</c> or plain <c>ns=N;…</c> reference).</param>
/// <param name="WriteIdempotent">Whether a write to this tag is idempotent (from the tag's <see cref="RawTagEntry"/>).</param>
public readonly record struct RawTagBinding(string NodeId, bool WriteIdempotent);
/// <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 the static <see cref="TryResolve(ISession?, string, out NodeId)"/>
/// 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 — the static
/// <see cref="TryResolve(ISession?, string, out NodeId)"/> falls back to a direct parse
/// against the current session.
/// </para>
/// <para>
/// <b>v3 RawPath resolution.</b> The map additionally carries the driver's authored
/// <c>RawPath → upstream node id</c> table, built from the deployed <see cref="RawTagEntry"/>
/// list at connect time. Under v3 the read/write/subscribe/history reference handed to the
/// driver is the tag's <b>RawPath</b> identity, not the upstream node id; the instance
/// <see cref="TryResolve(string, out string)"/> maps that RawPath to the upstream node id
/// string (authored as the <c>nodeId</c> key of each tag's <c>TagConfig</c> blob), which the
/// driver then re-binds against the live session via the static
/// <see cref="TryResolve(ISession?, string, out NodeId)"/>.
/// </para>
/// <para>
/// <b>Why this type sits in <c>.Session</c> and not <c>.Contracts</c>.</b> Its whole public
/// surface is OPC UA SDK-typed (<see cref="ISession"/>, <see cref="NamespaceTable"/>,
/// <see cref="NodeId"/>), so while it lived in <c>.Contracts</c> that project had to carry
/// <c>OPCFoundation.NetStandard.Opc.Ua.Client</c> — and once ControlPlane project-referenced
/// <c>.Contracts</c> for the G-7 deploy-time TagConfig gate, the whole OPC UA client stack
/// came with it into every host that loads the control plane. The blob read this class needs
/// stayed behind in <c>.Contracts</c> as
/// <c>OpcUaClientTagDefinitionFactory.TryReadNodeId</c> (the ONE authority the deploy gate and
/// this table share), which is why the reference runs <c>.Session → .Contracts</c>.
/// </para>
/// </remarks>
public sealed class NamespaceMap
{
private static readonly IReadOnlyDictionary<string, RawTagBinding> EmptyRawTable =
new Dictionary<string, RawTagBinding>(0, StringComparer.Ordinal);
// index -> URI and URI -> index, as the upstream server published them at connect time.
private readonly string[] _uris;
private readonly Dictionary<string, ushort> _uriToIndex;
// v3 authored resolution table: RawPath (tag identity) -> upstream node id + WriteIdempotent.
// Case-sensitive ordinal — a RawPath is an exact cluster-scoped slash path.
private readonly IReadOnlyDictionary<string, RawTagBinding> _rawPathToNode;
private NamespaceMap(string[] uris, IReadOnlyDictionary<string, RawTagBinding> rawPathToNode)
{
_uris = uris;
_uriToIndex = new Dictionary<string, ushort>(uris.Length, StringComparer.Ordinal);
for (var i = 0; i < uris.Length; i++)
_uriToIndex[uris[i]] = (ushort)i;
_rawPathToNode = rawPathToNode;
}
/// <summary>
/// Snapshot the namespace table from a live session and (v3) ingest the driver's authored
/// <paramref name="rawTags"/> into the <c>RawPath → upstream node id</c> resolution table.
/// </summary>
/// <param name="session">The OPC UA session to snapshot.</param>
/// <param name="rawTags">The deployed raw tags to build the RawPath resolution table from. Null/empty
/// (e.g. the AdminUI address-picker browse path, which never resolves a RawPath) yields an empty table.</param>
/// <returns>A <see cref="NamespaceMap"/> capturing the session's namespace table + RawPath table.</returns>
public static NamespaceMap FromSession(ISession session, IReadOnlyList<RawTagEntry>? rawTags = null)
{
ArgumentNullException.ThrowIfNull(session);
return FromTable(session.NamespaceUris, rawTags);
}
/// <summary>
/// Snapshot a <see cref="NamespaceTable"/> directly and (v3) ingest the driver's authored
/// <paramref name="rawTags"/> into the <c>RawPath → upstream node id</c> resolution table.
/// Separated from <see cref="FromSession"/> so the encoding + RawPath resolution can be
/// exercised without standing up a live <see cref="ISession"/>.
/// </summary>
/// <param name="namespaceUris">The namespace table to snapshot.</param>
/// <param name="rawTags">The deployed raw tags to build the RawPath resolution table from; null/empty yields an empty table.</param>
/// <returns>A <see cref="NamespaceMap"/> capturing the given namespace table + RawPath table.</returns>
public static NamespaceMap FromTable(NamespaceTable namespaceUris, IReadOnlyList<RawTagEntry>? rawTags = null)
{
ArgumentNullException.ThrowIfNull(namespaceUris);
return new NamespaceMap(namespaceUris.ToArray(), BuildRawPathTable(rawTags));
}
/// <summary>
/// Build the <c>RawPath → upstream node id</c> table from the authored raw tags. Each entry's
/// <c>TagConfig</c> blob is parsed for its <c>nodeId</c> string key (the upstream OPC UA node the
/// driver dials); an entry with a blank RawPath or a TagConfig missing a usable <c>nodeId</c> is
/// skipped (the driver surfaces such a reference as <c>BadNodeIdInvalid</c>). The entry's
/// <see cref="RawTagEntry.WriteIdempotent"/> is threaded onto the table, not read from the blob.
/// </summary>
private static IReadOnlyDictionary<string, RawTagBinding> BuildRawPathTable(IReadOnlyList<RawTagEntry>? rawTags)
{
if (rawTags is null || rawTags.Count == 0) return EmptyRawTable;
var table = new Dictionary<string, RawTagBinding>(rawTags.Count, StringComparer.Ordinal);
foreach (var entry in rawTags)
{
if (string.IsNullOrWhiteSpace(entry.RawPath)) continue;
// The blob read lives on OpcUaClientTagDefinitionFactory so the deploy-time TagConfig gate and
// this table build cannot disagree about what a usable nodeId is (G-7).
if (!OpcUaClientTagDefinitionFactory.TryReadNodeId(entry.TagConfig, out var nodeId)) continue;
table[entry.RawPath] = new RawTagBinding(nodeId, entry.WriteIdempotent);
}
return table;
}
/// <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>
/// <returns>The namespace URI at <paramref name="index"/>, or null if out of range.</returns>
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>
/// <returns>The index for <paramref name="uri"/>, or null if the URI is not in the table.</returns>
public ushort? IndexForUri(string uri) =>
_uriToIndex.TryGetValue(uri, out var idx) ? idx : null;
/// <summary>Number of authored raw tags in the RawPath resolution table.</summary>
public int RawTagCount => _rawPathToNode.Count;
/// <summary>
/// (v3) Resolve a tag's <b>RawPath</b> identity to its upstream OPC UA node id string via the
/// authored resolution table. The returned node id is still a <i>reference string</i>
/// (<c>nsu=…</c> or <c>ns=N;…</c>) — the caller re-binds it against the live session with the
/// static <see cref="TryResolve(ISession?, string, out NodeId)"/>. Returns <see langword="false"/>
/// for a blank RawPath or an unknown/un-authored RawPath.
/// </summary>
/// <param name="rawPath">The tag's RawPath (the wire reference the driver is handed under v3).</param>
/// <param name="nodeId">On success, the upstream node id string; otherwise <see cref="string.Empty"/>.</param>
/// <returns><see langword="true"/> when <paramref name="rawPath"/> maps to an authored upstream node id.</returns>
public bool TryResolve(string rawPath, out string nodeId)
{
nodeId = string.Empty;
if (string.IsNullOrWhiteSpace(rawPath)) return false;
if (!_rawPathToNode.TryGetValue(rawPath, out var binding)) return false;
nodeId = binding.NodeId;
return true;
}
/// <summary>
/// (v3) Look up the full <see cref="RawTagBinding"/> (upstream node id + <c>WriteIdempotent</c>)
/// for a tag's RawPath. Companion to <see cref="TryResolve(string, out string)"/> for callers
/// that need the write-idempotency flag threaded onto the table.
/// </summary>
/// <param name="rawPath">The tag's RawPath.</param>
/// <param name="binding">On success, the resolution binding for the tag.</param>
/// <returns><see langword="true"/> when <paramref name="rawPath"/> is an authored raw tag.</returns>
public bool TryGetBinding(string rawPath, out RawTagBinding binding) =>
_rawPathToNode.TryGetValue(rawPath, out binding);
/// <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>
/// <returns>The server-stable reference string for <paramref name="nodeId"/>.</returns>
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, an unknown URI, or a
/// null session (defensive — callers on the reconnect path may pass a session that has
/// been torn down concurrently).
/// </summary>
/// <param name="currentSession">The live OPC UA session to resolve against. Passing null
/// returns false rather than throwing, matching the TryXxx contract.</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; on failure, <c>NodeId.Null</c>.
/// Use <see cref="NodeId.IsNull(NodeId)"/> — not <c>== null</c> — to test this value.</param>
/// <returns><c>true</c> if <paramref name="reference"/> resolved to a non-null NodeId; otherwise <c>false</c>.</returns>
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}",
};
}