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
@@ -93,6 +93,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <param name="session">The session instance to install, or <c>null</c> to clear it.</param>
internal void SetSessionForTest(ISession? session) => Session = session;
/// <summary>
/// Test-only seam to install the <see cref="NamespaceMap"/> that connect/reconnect builds at
/// runtime, so the v3 RawPath → upstream node id resolution can be exercised without a live
/// connect (which is what populates <see cref="_namespaceMap"/>). Production code never calls
/// this — <see cref="InitializeAsync"/> / <see cref="OnReconnectComplete"/> own the real build.
/// </summary>
/// <param name="map">The namespace map to install, or <c>null</c> to clear it.</param>
internal void SetNamespaceMapForTest(NamespaceMap? map) => _namespaceMap = map;
private DriverHealth _health = new(DriverState.Unknown, null, null);
private bool _disposed;
/// <summary>URL of the endpoint the driver actually connected to. Exposed via <see cref="HostName"/>.</summary>
@@ -193,8 +202,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Build the bidirectional namespace map from the freshly negotiated session's
// NamespaceUris (driver-specs.md §8 "Namespace Remapping"). Stored NodeIds carry
// the namespace URI, not the session-relative ns=N index, so a remote namespace
// reorder across a restart can't silently misaddress nodes.
_namespaceMap = NamespaceMap.FromSession(session);
// reorder across a restart can't silently misaddress nodes. v3: the same map also
// ingests the authored RawTags so read/write/subscribe/history references (RawPaths)
// resolve to their upstream node ids.
_namespaceMap = NamespaceMap.FromSession(session, _options.RawTags);
Session = session;
_connectedEndpointUrl = connectedUrl;
@@ -638,14 +649,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
return results;
}
// Parse NodeIds against the live session. Tags whose reference doesn't parse get
// BadNodeIdInvalid and are omitted from the wire request — saves a round-trip for
// a fault the driver can detect locally.
// Resolve each RawPath reference (v3) to a live NodeId against the current session.
// A RawPath the authored RawTags table doesn't know — or a node id that won't parse —
// gets BadNodeIdInvalid and is omitted from the wire request, saving a round-trip for a
// fault the driver can detect locally.
var toSend = new ReadValueIdCollection();
var indexMap = new List<int>(fullReferences.Count); // maps wire-index -> results-index
for (var i = 0; i < fullReferences.Count; i++)
{
if (!TryParseNodeId(session, fullReferences[i], out var nodeId))
if (!TryResolveTagNode(session, fullReferences[i], out var nodeId))
{
results[i] = new DataValueSnapshot(null, StatusBadNodeIdInvalid, null, now);
continue;
@@ -727,7 +739,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var indexMap = new List<int>(writes.Count);
for (var i = 0; i < writes.Count; i++)
{
if (!TryParseNodeId(session, writes[i].FullReference, out var nodeId))
if (!TryResolveTagNode(session, writes[i].FullReference, out var nodeId))
{
results[i] = new WriteResult(StatusBadNodeIdInvalid);
continue;
@@ -783,22 +795,33 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
/// <summary>
/// Parse a tag's full-reference string as a NodeId, resolved against the
/// <paramref name="session"/>'s <i>current</i> namespace table. Accepts both the
/// server-stable <c>nsu=&lt;uri&gt;;…</c> form the driver persists (see
/// <see cref="NamespaceMap.ToStableReference"/>) and plain OPC UA serialized forms
/// (<c>ns=2;s=…</c>, <c>i=2253</c>, <c>ns=4;g=…</c>, <c>ns=3;b=…</c>). Resolving the
/// <c>nsu=…</c> form against the current session re-binds it through that session's
/// URI table, so a remote namespace-table reorder across a restart is transparently
/// corrected (driver-specs.md §8). Empty + malformed strings return false; the driver
/// surfaces that as <see cref="StatusBadNodeIdInvalid"/> without a wire round-trip.
/// (v3) Resolve a tag's <b>RawPath</b> reference to a live <see cref="NodeId"/> against the
/// <paramref name="session"/>'s <i>current</i> namespace table. Two stages:
/// <list type="number">
/// <item>Map the RawPath (the wire reference handed to the driver under v3) to its
/// upstream node id string via the authored <c>RawTags</c> table on
/// <see cref="_namespaceMap"/> (<see cref="NamespaceMap.TryResolve(string, out string)"/>).</item>
/// <item>Re-bind that node id string — a server-stable <c>nsu=&lt;uri&gt;;…</c> form or a plain
/// <c>ns=2;s=…</c> serialized NodeId — against the current session so a remote
/// namespace-table reorder across a restart is transparently corrected (driver-specs.md §8).</item>
/// </list>
/// An unknown/un-authored RawPath, a missing map, or a node id that won't parse returns false;
/// the driver surfaces that as <see cref="StatusBadNodeIdInvalid"/> without a wire round-trip.
/// </summary>
/// <param name="session">The OPC UA session to resolve the node ID against.</param>
/// <param name="fullReference">The full reference string to parse.</param>
/// <param name="nodeId">The parsed node ID when successful.</param>
/// <returns><c>true</c> if the reference was parsed successfully; otherwise <c>false</c>.</returns>
internal static bool TryParseNodeId(ISession session, string fullReference, out NodeId nodeId) =>
NamespaceMap.TryResolve(session, fullReference, out nodeId);
/// <param name="rawPath">The tag's RawPath reference to resolve.</param>
/// <param name="nodeId">The resolved live node ID when successful.</param>
/// <returns><c>true</c> if the RawPath resolved to a live node ID; otherwise <c>false</c>.</returns>
internal bool TryResolveTagNode(ISession session, string rawPath, out NodeId nodeId)
{
nodeId = NodeId.Null;
var map = _namespaceMap;
if (map is null) return false;
// Stage 1: RawPath -> upstream node id string (authored as TagConfig.nodeId).
if (!map.TryResolve(rawPath, out var upstreamNodeId)) return false;
// Stage 2: node id string -> live NodeId re-bound against the current session table.
return NamespaceMap.TryResolve(session, upstreamNodeId, out nodeId);
}
/// <summary>
/// Render a discovered NodeId in the server-stable form persisted into the local
@@ -1201,9 +1224,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var itemHandlers = new List<MonitoredItemNotificationHandle>();
foreach (var fullRef in fullReferences)
{
if (!TryParseNodeId(session, fullRef, out var nodeId)) continue;
// The tag string is routed through MonitoredItem.Handle so the Notification
// handler can identify which tag changed without an extra lookup.
if (!TryResolveTagNode(session, fullRef, out var nodeId)) continue;
// The RawPath (v3 tag identity) is routed through MonitoredItem.Handle so the
// Notification handler reports the change under the same reference the runtime
// subscribed with — StartNodeId is the resolved upstream node, Handle stays the RawPath.
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
{
DisplayName = fullRef,
@@ -1464,7 +1488,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var callRequests = new CallMethodRequestCollection();
foreach (var ack in acknowledgements)
{
if (!TryParseNodeId(session, ack.ConditionId, out var conditionId)) continue;
// ConditionId is an upstream condition-node id captured from the alarm event (not a
// tag RawPath), so it re-binds directly against the session via the static resolve.
if (!NamespaceMap.TryResolve(session, ack.ConditionId, out var conditionId)) continue;
callRequests.Add(new CallMethodRequest
{
ObjectId = conditionId,
@@ -1658,7 +1684,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// NodeId parsing is namespace-relative, so it must also use the current session's
// namespace table.
var session = Session;
if (session is null || !TryParseNodeId(session, fullReference, out var nodeId))
if (session is null || !TryResolveTagNode(session, fullReference, out var nodeId))
{
return new Core.Abstractions.HistoryReadResult([], null);
}
@@ -1836,7 +1862,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
notifierNodeId = ObjectIds.Server;
}
else if (!TryParseNodeId(session, sourceName, out var parsed))
// sourceName is an upstream event-notifier node id (not a tag RawPath), so it
// re-binds directly against the session via the static resolve.
else if (!NamespaceMap.TryResolve(session, sourceName, out var parsed))
{
return new Core.Abstractions.HistoricalEventsResult([], null);
}
@@ -1989,8 +2017,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Reconnect succeeded. Rebuild the namespace map from the *new* session: the
// remote server may have reordered its namespace table across the restart that
// caused the drop (driver-specs.md §8). Stable nsu= references stored in the
// address space re-resolve correctly against this fresh map.
_namespaceMap = NamespaceMap.FromSession(newSession);
// address space re-resolve correctly against this fresh map. v3: re-ingest the
// authored RawTags so RawPath resolution survives the reconnect too.
_namespaceMap = NamespaceMap.FromSession(newSession, _options.RawTags);
TransitionTo(HostState.Running);
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
return;