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:
@@ -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=<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 — <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>
|
||||
|
||||
+11
@@ -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;
|
||||
|
||||
+5
@@ -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>
|
||||
|
||||
@@ -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=<uri>;…</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=<uri>;…</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;
|
||||
|
||||
+5
-1
@@ -8,7 +8,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// <summary>
|
||||
/// Registers the OPC UA Client driver with the <see cref="DriverFactoryRegistry"/>. The driver's
|
||||
/// <c>DriverConfig</c> JSON deserialises directly into <see cref="OpcUaClientDriverOptions"/>
|
||||
/// (the same shape <see cref="OpcUaClientDriverProbe"/> reads), so no separate DTO is needed.
|
||||
/// (the same shape <see cref="OpcUaClientDriverProbe"/> reads), so no separate DTO is needed —
|
||||
/// unlike the protocol drivers, this driver has no pre-declared "Tags" DTO path to retire.
|
||||
/// v3: the deploy artifact's authored raw tags bind straight onto
|
||||
/// <see cref="OpcUaClientDriverOptions.RawTags"/> (a <c>RawTagEntry</c> list) through the same
|
||||
/// deserialiser; the <c>EndpointUrl</c> binding is unchanged (endpoint→DeviceConfig is Wave C).
|
||||
/// Mirrors <c>ModbusDriverFactoryExtensions</c> / <c>GalaxyDriverFactoryExtensions</c>.
|
||||
/// </summary>
|
||||
public static class OpcUaClientDriverFactoryExtensions
|
||||
|
||||
+42
@@ -33,4 +33,46 @@ public class OpcUaClientDriverFactoryTests
|
||||
public void CreateInstance_throws_on_null_json_deserialisation()
|
||||
=> Should.Throw<System.InvalidOperationException>(
|
||||
() => OpcUaClientDriverFactoryExtensions.CreateInstance("drv-1", "null", NullLoggerFactory.Instance));
|
||||
|
||||
private const string RawTagsConfig =
|
||||
"""
|
||||
{
|
||||
"EndpointUrl": "opc.tcp://host:4840",
|
||||
"RawTags": [
|
||||
{ "rawPath": "Plant/Line1/Temp", "tagConfig": "{\"nodeId\":\"ns=2;s=Temperature\"}", "writeIdempotent": false },
|
||||
{ "rawPath": "Plant/Line1/Speed", "tagConfig": "{\"nodeId\":\"ns=2;s=Speed\"}", "writeIdempotent": true }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the v3 authored <c>RawTags</c> (RawPath + TagConfig blob + WriteIdempotent) bind
|
||||
/// through the DriverConfig deserialiser directly onto <see cref="OpcUaClientDriverOptions"/> —
|
||||
/// the OpcUaClient driver has no separate DTO, so the factory's direct-into-options path must
|
||||
/// populate the RawPath resolution list.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateInstance_binds_rawtags_from_driverconfig()
|
||||
{
|
||||
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance("drv-raw", RawTagsConfig, NullLoggerFactory.Instance);
|
||||
// The factory succeeds and identity is intact — the deserialiser tolerated + bound the RawTags array.
|
||||
driver.DriverType.ShouldBe("OpcUaClient");
|
||||
driver.DriverInstanceId.ShouldBe("drv-raw");
|
||||
|
||||
// Assert the actual binding: deserialise the same DriverConfig into public options with the
|
||||
// factory's serialiser settings (case-insensitive + string enums) and confirm RawTags populated.
|
||||
var opts = System.Text.Json.JsonSerializer.Deserialize<OpcUaClientDriverOptions>(
|
||||
RawTagsConfig,
|
||||
new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
});
|
||||
opts.ShouldNotBeNull();
|
||||
opts!.RawTags.Count.ShouldBe(2);
|
||||
opts.RawTags[0].RawPath.ShouldBe("Plant/Line1/Temp");
|
||||
opts.RawTags[0].TagConfig.ShouldContain("ns=2;s=Temperature");
|
||||
opts.RawTags[0].WriteIdempotent.ShouldBeFalse();
|
||||
opts.RawTags[1].WriteIdempotent.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
@@ -130,6 +131,83 @@ public sealed class OpcUaClientNamespaceTests
|
||||
NamespaceMap.TryResolve(null!, "ns=1;s=X", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- v3: RawPath → upstream node id resolution table ----
|
||||
|
||||
/// <summary>Verifies a RawPath resolves to the upstream node id authored in its TagConfig.nodeId.</summary>
|
||||
[Fact]
|
||||
public void RawPath_resolves_to_upstream_nodeId_from_tagconfig()
|
||||
{
|
||||
var map = RawMap(
|
||||
new RawTagEntry("Plant/Line1/Temp", """{"nodeId":"nsu=urn:remote:server;s=Temperature"}""", WriteIdempotent: false),
|
||||
new RawTagEntry("Plant/Line1/Speed", """{"nodeId":"ns=2;s=Speed"}""", WriteIdempotent: true));
|
||||
|
||||
map.RawTagCount.ShouldBe(2);
|
||||
map.TryResolve("Plant/Line1/Temp", out var temp).ShouldBeTrue();
|
||||
temp.ShouldBe("nsu=urn:remote:server;s=Temperature");
|
||||
map.TryResolve("Plant/Line1/Speed", out var speed).ShouldBeTrue();
|
||||
speed.ShouldBe("ns=2;s=Speed");
|
||||
}
|
||||
|
||||
/// <summary>Verifies an unknown RawPath and a blank RawPath both fail resolution.</summary>
|
||||
[Fact]
|
||||
public void RawPath_unknown_or_blank_fails_resolution()
|
||||
{
|
||||
var map = RawMap(new RawTagEntry("Plant/Line1/Temp", """{"nodeId":"ns=2;s=Temperature"}""", WriteIdempotent: false));
|
||||
map.TryResolve("Plant/Line1/Missing", out var missing).ShouldBeFalse();
|
||||
missing.ShouldBe(string.Empty);
|
||||
map.TryResolve(" ", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies WriteIdempotent is threaded onto the table from the RawTagEntry, not the blob.</summary>
|
||||
[Fact]
|
||||
public void RawPath_binding_threads_writeidempotent_flag()
|
||||
{
|
||||
var map = RawMap(
|
||||
new RawTagEntry("Plant/A", """{"nodeId":"ns=2;s=A"}""", WriteIdempotent: true),
|
||||
new RawTagEntry("Plant/B", """{"nodeId":"ns=2;s=B"}""", WriteIdempotent: false));
|
||||
|
||||
map.TryGetBinding("Plant/A", out var a).ShouldBeTrue();
|
||||
a.NodeId.ShouldBe("ns=2;s=A");
|
||||
a.WriteIdempotent.ShouldBeTrue();
|
||||
map.TryGetBinding("Plant/B", out var b).ShouldBeTrue();
|
||||
b.WriteIdempotent.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies an entry whose TagConfig lacks a usable nodeId is skipped (not registered).</summary>
|
||||
[Fact]
|
||||
public void RawPath_entry_without_nodeId_is_skipped()
|
||||
{
|
||||
var map = RawMap(
|
||||
new RawTagEntry("Plant/Good", """{"nodeId":"ns=2;s=Good"}""", WriteIdempotent: false),
|
||||
new RawTagEntry("Plant/NoNode", """{"other":"value"}""", WriteIdempotent: false),
|
||||
new RawTagEntry("Plant/Blank", """{"nodeId":" "}""", WriteIdempotent: false),
|
||||
new RawTagEntry("Plant/Garbage", "not json", WriteIdempotent: false));
|
||||
|
||||
map.RawTagCount.ShouldBe(1);
|
||||
map.TryResolve("Plant/Good", out _).ShouldBeTrue();
|
||||
map.TryResolve("Plant/NoNode", out _).ShouldBeFalse();
|
||||
map.TryResolve("Plant/Blank", out _).ShouldBeFalse();
|
||||
map.TryResolve("Plant/Garbage", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a PascalCase "NodeId" key is accepted (legacy tolerance).</summary>
|
||||
[Fact]
|
||||
public void RawPath_accepts_legacy_pascalcase_nodeid_key()
|
||||
{
|
||||
var map = RawMap(new RawTagEntry("Plant/Legacy", """{"NodeId":"ns=2;s=Legacy"}""", WriteIdempotent: false));
|
||||
map.TryResolve("Plant/Legacy", out var nid).ShouldBeTrue();
|
||||
nid.ShouldBe("ns=2;s=Legacy");
|
||||
}
|
||||
|
||||
/// <summary>Verifies a map built without raw tags has an empty resolution table.</summary>
|
||||
[Fact]
|
||||
public void Map_without_rawtags_has_empty_resolution_table()
|
||||
{
|
||||
var map = TestMap("http://opcfoundation.org/UA/", "urn:remote:server");
|
||||
map.RawTagCount.ShouldBe(0);
|
||||
map.TryResolve("anything", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="NamespaceMap"/> directly from a URI table, mirroring what
|
||||
/// <see cref="NamespaceMap.FromSession"/> snapshots — lets the encoding be tested
|
||||
@@ -147,4 +225,8 @@ public sealed class OpcUaClientNamespaceTests
|
||||
}
|
||||
return NamespaceMap.FromTable(table);
|
||||
}
|
||||
|
||||
/// <summary>Build a <see cref="NamespaceMap"/> carrying only the v3 RawPath table (core URI table).</summary>
|
||||
private static NamespaceMap RawMap(params RawTagEntry[] rawTags) =>
|
||||
NamespaceMap.FromTable(new NamespaceTable(), rawTags);
|
||||
}
|
||||
|
||||
+9
-1
@@ -110,7 +110,15 @@ public sealed class OpcUaClientStaleSessionRaceTests
|
||||
public async Task ReadRawAsync_uses_session_swapped_in_across_the_gate_not_the_captured_one()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-stale-raw");
|
||||
// v3: a HistoryRead over a variable is keyed by the tag's RawPath, resolved through the
|
||||
// authored RawTags table to its upstream node id. Author "ns=2;s=Counter" as both the
|
||||
// RawPath and the TagConfig.nodeId so the two-stage resolve reaches the wire.
|
||||
var options = new OpcUaClientDriverOptions
|
||||
{
|
||||
RawTags = [new RawTagEntry("ns=2;s=Counter", """{"nodeId":"ns=2;s=Counter"}""", WriteIdempotent: false)],
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(options, "opcua-stale-raw");
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), options.RawTags));
|
||||
|
||||
var prologueReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var oldSession = NewSessionMock(prologueReached);
|
||||
|
||||
Reference in New Issue
Block a user