v3(b1-opcuaclient): re-key OpcUaClient resolution to RawPath via RawTags

NamespaceMap now carries a RawPath -> upstream node id table built from the
deployed RawTagEntry list (reads each tag's TagConfig.nodeId, threads
WriteIdempotent). Under v3 the read/write/subscribe/history reference handed to
the driver is the tag's RawPath identity; the driver resolves it in two stages:
RawPath -> nodeId string (instance TryResolve) -> live NodeId re-bound against
the session (static TryResolve). Alarm ConditionId + event-history sourceName
stay on the direct session parse (they are upstream node ids, not RawPaths).

- Options: add IReadOnlyList<RawTagEntry> RawTags (Contracts now refs Core.Abstractions).
- Factory: RawTags binds straight into options (no separate DTO); EndpointUrl kept.
- Browser: unchanged (emits neutral BrowseNode DTOs, no TagConfig FullName key).
- Tests: 138 green; new RawPath resolution + factory-binding coverage; migrated
  the stale-session ReadRaw test to author a resolving RawTag.

Contracts + Driver + Browser build clean (0 warn). Wave C wires endpoint->DeviceConfig
and the deploy artifact that populates RawTags.
This commit is contained in:
Joseph Doherty
2026-07-15 20:11:44 -04:00
parent c379e246d0
commit ec01649905
8 changed files with 348 additions and 52 deletions
@@ -1,8 +1,19 @@
using System.Text.Json;
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
@@ -20,52 +31,123 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// 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.
/// 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 — <see cref="TryResolve"/> falls back to a
/// direct parse against the current session.
/// 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>
/// </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;
private NamespaceMap(string[] uris)
// 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;
}
/// <summary>Snapshot the namespace table from a live session.</summary>
/// <param name="session">The OPC UA session to snapshot.</param>
/// <returns>A <see cref="NamespaceMap"/> capturing the session's current namespace table.</returns>
public static NamespaceMap FromSession(ISession session)
{
ArgumentNullException.ThrowIfNull(session);
return FromTable(session.NamespaceUris);
_rawPathToNode = rawPathToNode;
}
/// <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"/>.
/// 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>
/// <returns>A <see cref="NamespaceMap"/> capturing the given namespace table.</returns>
public static NamespaceMap FromTable(NamespaceTable namespaceUris)
/// <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());
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;
if (!TryReadNodeId(entry.TagConfig, out var nodeId)) continue;
table[entry.RawPath] = new RawTagBinding(nodeId, entry.WriteIdempotent);
}
return table;
}
/// <summary>
/// Read the upstream node id string from an authored <c>TagConfig</c> blob's <c>nodeId</c> key
/// (camelCase, per the AdminUI <c>OpcUaClientTagConfigModel</c>; a legacy PascalCase <c>NodeId</c>
/// is also accepted). Never throws — a malformed blob yields <see langword="false"/>.
/// </summary>
private static bool TryReadNodeId(string tagConfig, out string nodeId)
{
nodeId = string.Empty;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
if ((root.TryGetProperty("nodeId", out var n) || root.TryGetProperty("NodeId", out n))
&& n.ValueKind == JsonValueKind.String)
{
var s = n.GetString();
if (!string.IsNullOrWhiteSpace(s)) { nodeId = s; return true; }
}
}
catch (JsonException) { /* malformed blob — treat as unresolvable */ }
return false;
}
/// <summary>Number of namespaces captured. Index 0 is always the OPC UA core namespace.</summary>
@@ -83,6 +165,39 @@ public sealed class NamespaceMap
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>
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
@@ -23,6 +24,16 @@ public sealed class OpcUaClientDriverOptions
/// </summary>
public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840";
/// <summary>
/// v3 authored raw tags for this driver instance — the deploy artifact (Wave C) populates this
/// with the cluster's raw OPC UA Client tags. Each <see cref="RawTagEntry"/> carries the tag's
/// <b>RawPath</b> identity plus a driver <c>TagConfig</c> blob whose <c>nodeId</c> key holds the
/// upstream OPC UA node id string the driver dials. The driver builds a <c>RawPath → nodeId</c>
/// resolution table (via <see cref="NamespaceMap"/>) from this list at connect time; a
/// read/write/subscribe/history reference is always a RawPath resolved through that table.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Ordered list of candidate endpoint URLs for failover. The driver tries each in
/// order at <c>OpcUaClientDriver.InitializeAsync</c> and on session drop;
@@ -8,4 +8,9 @@
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
</ItemGroup>
<ItemGroup>
<!-- v3: NamespaceMap resolves a RawPath (RawTagEntry identity) to the upstream node id.
RawTagEntry lives in the Core.Abstractions leaf beside EquipmentTagRefResolver. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>