using Opc.Ua;
using Opc.Ua.Client;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
///
/// One authored raw tag's resolution target: the upstream OPC UA node id string the driver dials
/// (authored as the nodeId key of the tag's TagConfig blob) plus the platform
/// flag threaded onto the table (not read from the blob).
///
/// The upstream OPC UA node id string (a stable nsu=… or plain ns=N;… reference).
/// Whether a write to this tag is idempotent (from the tag's ).
public readonly record struct RawTagBinding(string NodeId, bool WriteIdempotent);
///
/// Bidirectional namespace map for the OPC UA Client (gateway) driver, built at connect
/// time from session.NamespaceUris per docs/v2/driver-specs.md §8
/// "Namespace Remapping".
///
///
///
/// The session-relative namespace index embedded in an ns=N;… NodeId string is
/// not stable: the OPC UA spec permits a server to reorder its namespace table
/// across a restart. A driver that stores raw ns=N references and re-parses
/// them verbatim for reads/writes will, after such a reorder, silently address the
/// wrong namespace.
///
///
/// This map captures the upstream namespace table as it was at connect time and lets
/// the driver persist NodeIds in a server-stable form — the namespace
/// URI plus the identifier — via . At
/// read/write time the static
/// re-binds that stable form against the current session's namespace table, so a
/// table reorder is transparently corrected instead of silently misaddressing nodes.
///
///
/// Stable references use the form nsu=<uri>;<idType>=<identifier>,
/// which is the standard OPC UA namespace-URI NodeId encoding the SDK already
/// understands. References that are already in plain ns=N;… form (e.g. a
/// hand-entered config tag) still resolve — the static
/// falls back to a direct parse
/// against the current session.
///
///
/// v3 RawPath resolution. The map additionally carries the driver's authored
/// RawPath → upstream node id table, built from the deployed
/// list at connect time. Under v3 the read/write/subscribe/history reference handed to the
/// driver is the tag's RawPath identity, not the upstream node id; the instance
/// maps that RawPath to the upstream node id
/// string (authored as the nodeId key of each tag's TagConfig blob), which the
/// driver then re-binds against the live session via the static
/// .
///
///
/// Why this type sits in .Session and not .Contracts. Its whole public
/// surface is OPC UA SDK-typed (, ,
/// ), so while it lived in .Contracts that project had to carry
/// OPCFoundation.NetStandard.Opc.Ua.Client — and once ControlPlane project-referenced
/// .Contracts 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 .Contracts as
/// OpcUaClientTagDefinitionFactory.TryReadNodeId (the ONE authority the deploy gate and
/// this table share), which is why the reference runs .Session → .Contracts.
///
///
public sealed class NamespaceMap
{
private static readonly IReadOnlyDictionary EmptyRawTable =
new Dictionary(0, StringComparer.Ordinal);
// index -> URI and URI -> index, as the upstream server published them at connect time.
private readonly string[] _uris;
private readonly Dictionary _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 _rawPathToNode;
private NamespaceMap(string[] uris, IReadOnlyDictionary rawPathToNode)
{
_uris = uris;
_uriToIndex = new Dictionary(uris.Length, StringComparer.Ordinal);
for (var i = 0; i < uris.Length; i++)
_uriToIndex[uris[i]] = (ushort)i;
_rawPathToNode = rawPathToNode;
}
///
/// Snapshot the namespace table from a live session and (v3) ingest the driver's authored
/// into the RawPath → upstream node id resolution table.
///
/// The OPC UA session to snapshot.
/// 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.
/// A capturing the session's namespace table + RawPath table.
public static NamespaceMap FromSession(ISession session, IReadOnlyList? rawTags = null)
{
ArgumentNullException.ThrowIfNull(session);
return FromTable(session.NamespaceUris, rawTags);
}
///
/// Snapshot a directly and (v3) ingest the driver's authored
/// into the RawPath → upstream node id resolution table.
/// Separated from so the encoding + RawPath resolution can be
/// exercised without standing up a live .
///
/// The namespace table to snapshot.
/// The deployed raw tags to build the RawPath resolution table from; null/empty yields an empty table.
/// A capturing the given namespace table + RawPath table.
public static NamespaceMap FromTable(NamespaceTable namespaceUris, IReadOnlyList? rawTags = null)
{
ArgumentNullException.ThrowIfNull(namespaceUris);
return new NamespaceMap(namespaceUris.ToArray(), BuildRawPathTable(rawTags));
}
///
/// Build the RawPath → upstream node id table from the authored raw tags. Each entry's
/// TagConfig blob is parsed for its nodeId string key (the upstream OPC UA node the
/// driver dials); an entry with a blank RawPath or a TagConfig missing a usable nodeId is
/// skipped (the driver surfaces such a reference as BadNodeIdInvalid). The entry's
/// is threaded onto the table, not read from the blob.
///
private static IReadOnlyDictionary BuildRawPathTable(IReadOnlyList? rawTags)
{
if (rawTags is null || rawTags.Count == 0) return EmptyRawTable;
var table = new Dictionary(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;
}
/// Number of namespaces captured. Index 0 is always the OPC UA core namespace.
public int Count => _uris.Length;
/// The namespace URI at the given index, or null if out of range.
/// The zero-based index in the namespace table.
/// The namespace URI at , or null if out of range.
public string? UriForIndex(int index) =>
index >= 0 && index < _uris.Length ? _uris[index] : null;
/// The index for a namespace URI, or null if the URI is not in the table.
/// The namespace URI to look up.
/// The index for , or null if the URI is not in the table.
public ushort? IndexForUri(string uri) =>
_uriToIndex.TryGetValue(uri, out var idx) ? idx : null;
/// Number of authored raw tags in the RawPath resolution table.
public int RawTagCount => _rawPathToNode.Count;
///
/// (v3) Resolve a tag's RawPath identity to its upstream OPC UA node id string via the
/// authored resolution table. The returned node id is still a reference string
/// (nsu=… or ns=N;…) — the caller re-binds it against the live session with the
/// static . Returns
/// for a blank RawPath or an unknown/un-authored RawPath.
///
/// The tag's RawPath (the wire reference the driver is handed under v3).
/// On success, the upstream node id string; otherwise .
/// when maps to an authored upstream node id.
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;
}
///
/// (v3) Look up the full (upstream node id + WriteIdempotent)
/// for a tag's RawPath. Companion to for callers
/// that need the write-idempotency flag threaded onto the table.
///
/// The tag's RawPath.
/// On success, the resolution binding for the tag.
/// when is an authored raw tag.
public bool TryGetBinding(string rawPath, out RawTagBinding binding) =>
_rawPathToNode.TryGetValue(rawPath, out binding);
///
/// Render a NodeId resolved against this map's session as a server-stable
/// reference string — namespace URI plus identifier, in the SDK's nsu=…
/// encoding. The OPC UA core namespace (index 0) keeps the compact i=…/ns=0
/// 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.
///
/// The NodeId to render as a stable reference.
/// The server-stable reference string for .
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=;= is the standard namespace-URI NodeId encoding.
return $"nsu={uri};{IdentifierPart(nodeId)}";
}
///
/// Resolve a reference string — either a stable nsu=… reference produced by
/// or a plain ns=N;… NodeId — against the
/// 's namespace table. A nsu=… 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).
///
/// The live OPC UA session to resolve against. Passing null
/// returns false rather than throwing, matching the TryXxx contract.
/// The reference string to resolve (either nsu=… or ns=N;… format).
/// On success, the resolved NodeId; on failure, NodeId.Null.
/// Use — not == null — to test this value.
/// true if resolved to a non-null NodeId; otherwise false.
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;
}
}
/// Render just the identifier portion (s=…, i=…, g=…, b=…) of a NodeId.
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}",
};
}