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}", }; }