From ec016499056b2507af09f2676215b40820f997e1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 20:11:44 -0400 Subject: [PATCH] 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 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. --- .../NamespaceMap.cs | 157 +++++++++++++++--- .../OpcUaClientDriverOptions.cs | 11 ++ ...tOpcUa.Driver.OpcUaClient.Contracts.csproj | 5 + .../OpcUaClientDriver.cs | 87 ++++++---- .../OpcUaClientDriverFactoryExtensions.cs | 6 +- .../OpcUaClientDriverFactoryTests.cs | 42 +++++ .../OpcUaClientNamespaceTests.cs | 82 +++++++++ .../OpcUaClientStaleSessionRaceTests.cs | 10 +- 8 files changed, 348 insertions(+), 52 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs index ed879916..776dfa40 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs @@ -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; +/// +/// 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 @@ -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 server-stable form — the namespace /// URI plus the identifier — via . At -/// read/write time re-binds that stable form against the -/// current session's namespace table, so a table reorder is transparently -/// corrected instead of silently misaddressing nodes. +/// 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 — falls back to a -/// direct parse against the current session. +/// 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 +/// . /// /// 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; - 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 _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; - } - - /// Snapshot the namespace table from a live session. - /// The OPC UA session to snapshot. - /// A capturing the session's current namespace table. - public static NamespaceMap FromSession(ISession session) - { - ArgumentNullException.ThrowIfNull(session); - return FromTable(session.NamespaceUris); + _rawPathToNode = rawPathToNode; } /// - /// Snapshot a directly. Separated from - /// so the URI encoding can be exercised without standing up - /// a live . + /// 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. - /// A capturing the given namespace table. - public static NamespaceMap FromTable(NamespaceTable namespaceUris) + /// 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()); + 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; + if (!TryReadNodeId(entry.TagConfig, out var nodeId)) continue; + table[entry.RawPath] = new RawTagBinding(nodeId, entry.WriteIdempotent); + } + return table; + } + + /// + /// Read the upstream node id string from an authored TagConfig blob's nodeId key + /// (camelCase, per the AdminUI OpcUaClientTagConfigModel; a legacy PascalCase NodeId + /// is also accepted). Never throws — a malformed blob yields . + /// + 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; } /// Number of namespaces captured. Index 0 is always the OPC UA core namespace. @@ -83,6 +165,39 @@ public sealed class NamespaceMap 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=… diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs index 022a45ef..9259a552 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs @@ -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 /// public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840"; + /// + /// 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 carries the tag's + /// RawPath identity plus a driver TagConfig blob whose nodeId key holds the + /// upstream OPC UA node id string the driver dials. The driver builds a RawPath → nodeId + /// resolution table (via ) from this list at connect time; a + /// read/write/subscribe/history reference is always a RawPath resolved through that table. + /// + public IReadOnlyList RawTags { get; init; } = []; + /// /// Ordered list of candidate endpoint URLs for failover. The driver tries each in /// order at OpcUaClientDriver.InitializeAsync and on session drop; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj index 8e2c9fac..aa67cc0d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj @@ -8,4 +8,9 @@ + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 3484b526..1df13d30 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -93,6 +93,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit /// The session instance to install, or null to clear it. internal void SetSessionForTest(ISession? session) => Session = session; + /// + /// Test-only seam to install the 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 ). Production code never calls + /// this — / own the real build. + /// + /// The namespace map to install, or null to clear it. + internal void SetNamespaceMapForTest(NamespaceMap? map) => _namespaceMap = map; + private DriverHealth _health = new(DriverState.Unknown, null, null); private bool _disposed; /// URL of the endpoint the driver actually connected to. Exposed via . @@ -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(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(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 } /// - /// Parse a tag's full-reference string as a NodeId, resolved against the - /// 's current namespace table. Accepts both the - /// server-stable nsu=<uri>;… form the driver persists (see - /// ) and plain OPC UA serialized forms - /// (ns=2;s=…, i=2253, ns=4;g=…, ns=3;b=…). Resolving the - /// nsu=… 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 without a wire round-trip. + /// (v3) Resolve a tag's RawPath reference to a live against the + /// 's current namespace table. Two stages: + /// + /// Map the RawPath (the wire reference handed to the driver under v3) to its + /// upstream node id string via the authored RawTags table on + /// (). + /// Re-bind that node id string — a server-stable nsu=<uri>;… form or a plain + /// ns=2;s=… serialized NodeId — against the current session so a remote + /// namespace-table reorder across a restart is transparently corrected (driver-specs.md §8). + /// + /// An unknown/un-authored RawPath, a missing map, or a node id that won't parse returns false; + /// the driver surfaces that as without a wire round-trip. /// /// The OPC UA session to resolve the node ID against. - /// The full reference string to parse. - /// The parsed node ID when successful. - /// true if the reference was parsed successfully; otherwise false. - internal static bool TryParseNodeId(ISession session, string fullReference, out NodeId nodeId) => - NamespaceMap.TryResolve(session, fullReference, out nodeId); + /// The tag's RawPath reference to resolve. + /// The resolved live node ID when successful. + /// true if the RawPath resolved to a live node ID; otherwise false. + 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); + } /// /// 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(); 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; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs index 6943927f..ad47daff 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs @@ -8,7 +8,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// /// Registers the OPC UA Client driver with the . The driver's /// DriverConfig JSON deserialises directly into -/// (the same shape reads), so no separate DTO is needed. +/// (the same shape 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 +/// (a RawTagEntry list) through the same +/// deserialiser; the EndpointUrl binding is unchanged (endpoint→DeviceConfig is Wave C). /// Mirrors ModbusDriverFactoryExtensions / GalaxyDriverFactoryExtensions. /// public static class OpcUaClientDriverFactoryExtensions diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverFactoryTests.cs index 27a4df15..1c6d2571 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverFactoryTests.cs @@ -33,4 +33,46 @@ public class OpcUaClientDriverFactoryTests public void CreateInstance_throws_on_null_json_deserialisation() => Should.Throw( () => 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 } + ] + } + """; + + /// + /// Verifies the v3 authored RawTags (RawPath + TagConfig blob + WriteIdempotent) bind + /// through the DriverConfig deserialiser directly onto — + /// the OpcUaClient driver has no separate DTO, so the factory's direct-into-options path must + /// populate the RawPath resolution list. + /// + [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( + 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(); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientNamespaceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientNamespaceTests.cs index 54163151..5ecabe44 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientNamespaceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientNamespaceTests.cs @@ -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 ---- + + /// Verifies a RawPath resolves to the upstream node id authored in its TagConfig.nodeId. + [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"); + } + + /// Verifies an unknown RawPath and a blank RawPath both fail resolution. + [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(); + } + + /// Verifies WriteIdempotent is threaded onto the table from the RawTagEntry, not the blob. + [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(); + } + + /// Verifies an entry whose TagConfig lacks a usable nodeId is skipped (not registered). + [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(); + } + + /// Verifies a PascalCase "NodeId" key is accepted (legacy tolerance). + [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"); + } + + /// Verifies a map built without raw tags has an empty resolution table. + [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(); + } + /// /// Build a directly from a URI table, mirroring what /// snapshots — lets the encoding be tested @@ -147,4 +225,8 @@ public sealed class OpcUaClientNamespaceTests } return NamespaceMap.FromTable(table); } + + /// Build a carrying only the v3 RawPath table (core URI table). + private static NamespaceMap RawMap(params RawTagEntry[] rawTags) => + NamespaceMap.FromTable(new NamespaceTable(), rawTags); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs index 89bbe95c..23dc9ac1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs @@ -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);