b4f8cb8de7
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)
259 lines
15 KiB
C#
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=<uri>;<idType>=<identifier></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}",
|
|
};
|
|
}
|