diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md
index c90672ab..f8a1d6de 100644
--- a/docs/drivers/OpcUaClient.md
+++ b/docs/drivers/OpcUaClient.md
@@ -112,14 +112,15 @@ interface doesn't yet carry the EventFilter surface needed to forward it.
`AutoAcceptCertificates` accepts any self-signed / untrusted server certificate.
It is **dev-only** — leave it `false` in production so a MITM against the
-opc.tcp channel fails closed. A **revoked** certificate (`BadCertificateRevoked` /
-`BadCertificateIssuerRevoked`) is never auto-accepted (#277).
+opc.tcp channel fails closed. The SDK rejects a **revoked** certificate
+(`BadCertificateRevoked` / `BadCertificateIssuerRevoked`) before the auto-accept handler runs, and the handler
+also refuses them explicitly — defence in depth (#277), not a behaviour change.
`CertificateValidation` (defaults change nothing):
| Key | Default | Meaning |
|---|---|---|
-| `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. |
+| `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. **Only effective with `autoAcceptCertificates` off** — the auto-accept handler accepts every suppressible error, including revocation-unknown. |
| `issuerStorePath` | `null` (= `%LocalAppData%/OtOpcUa/pki/issuers`) | Directory used as the trusted-issuer store. The SDK reads issuer CRLs (`*.crl`) from its `crl/` sub-folder, so this is how an operator points the driver at a managed CRL set. |
### Subscription tuning (#273)
@@ -128,45 +129,50 @@ The per-subscription parameters that used to be hardcoded are driver options; th
| Key | Default | Notes |
|---|---|---|
-| `subscriptionPublishingInterval` | `null` | `null` = the interval the runtime requests per subscribe (floored at 50 ms). When set it overrides that value (floor still applies); monitored-item sampling follows it. |
+| `subscriptionPublishingInterval` | `null` | A .NET `TimeSpan` string (`"00:00:02"`), not a number. `null` = the interval the runtime requests per subscribe (floored at 50 ms). When set it overrides that value (floor still applies); monitored-item sampling follows it. |
| `subscriptionKeepAliveCount` | `10` | `>= 1`. |
| `subscriptionLifetimeCount` | `1000` | Must be `>= 3 x` keep-alive count (Part 4 §5.13.2). |
| `subscriptionMaxNotificationsPerPublish` | `0` | `0` = server decides. |
| `subscriptionPriority` | `0` | `0`-`255`. |
-Out-of-range values fail `InitializeAsync` (`ValidateTuning`). The alarm subscription uses the same counts.
+Out-of-range values (and a non-absolute `discoveryUrl`) are rejected when the options are loaded — driver creation / `InitializeAsync` fails once with a clear message rather than the driver faulting and retrying forever. The alarm subscription uses the same counts.
*Per-tag* tuning (deadband / sampling interval / queue size, #274) is not implemented — it needs an
`ISubscribable` signature change on Core.
### Server OperationLimits (#275)
-After connect the driver reads `Server/ServerCapabilities/OperationLimits`
-(`MaxNodesPerRead`, `MaxNodesPerWrite`, `MaxNodesPerBrowse`, `MaxMonitoredItemsPerCall`,
-`MaxNodesPerHistoryReadData/Events`; `0` or unreadable = no limit) and splits requests that exceed them:
-
-- `ReadAsync`, `WriteAsync` and the discovery attribute-enrichment read go out in `MaxNodesPerRead` /
- `MaxNodesPerWrite`-sized chunks, results re-stitched positionally. A failed Read chunk marks only its own tags
- `BadCommunicationError`; a Write that times out marks that chunk and every later one `BadTimeout`, while
- earlier chunks keep their real results (those writes happened).
-- `SubscribeAsync` creates monitored items in `MaxMonitoredItemsPerCall` batches.
-- Browse and HistoryRead already carry one node per call, so a limit `>= 1` is always met and nothing is split.
-
-`honorServerOperationLimits` (default `true`) switches it off. The limits are re-read after a successful reconnect.
+Nothing driver-side: the SDK session already honours the remote server's
+`ServerCapabilities/OperationLimits`. `Opc.Ua.Client.Session` derives from `Opc.Ua.SessionClientBatched`,
+fetches the limits on open (`FetchOperationLimitsAsync`) and splits Read / Write / CreateMonitoredItems / Browse /
+HistoryRead requests that exceed them (a limit of `0` = unlimited). The driver issues one logical call per
+operation and a unit test pins the `Session : SessionClientBatched` relationship so an SDK change that dropped
+it is caught. (A driver-side splitter was tried and removed: it duplicated the SDK and, on a stalled link, held
+the driver gate for chunks x timeout.)
### Discovery URL (#278)
`discoveryUrl` (default `null`, e.g. `opc.tcp://lds:4840`): at `InitializeAsync` the driver runs `FindServers` on it
-and `GetEndpoints` on every returned server, keeps the endpoints matching `securityPolicy` + `securityMode`, and
-tries them **before** `endpointUrls`/`endpointUrl` (case-insensitive de-dup). A discovery failure is logged and
-non-fatal — the static list is used. The discovery channel itself is always unsecured (spec).
+and `GetEndpoints` on every returned server, keeping the endpoints matching `securityPolicy` + `securityMode`.
+Discovered URLs are tried **after** `endpointUrls`/`endpointUrl` (case-insensitive de-dup) — discovery can add
+fallbacks but never outranks or replaces the configured server. Because an LDS commonly registers several servers
+(identical line PLCs, a gateway plus a historian) and the first to accept a connection would be bound silently:
+
+- `discoveryServerUri` set: only endpoints of the server with that ApplicationUri are used.
+- unset and exactly one distinct server discovered: that server is used.
+- unset and more than one: the connect attempt **fails** with an error naming the discovered ApplicationUris and
+ telling the operator to set `discoveryServerUri`.
+
+A discovery *network* failure (LDS down) is logged and non-fatal — the static list is used (note it is retried on
+every `InitializeAsync`, including comms-loss reconnects). The discovery channel itself is always unsecured (spec).
### Diagnostics counters (#276)
-`GetHealth().Diagnostics` (a `name -> double` map, not part of the health-change fingerprint) carries:
-`PublishResponseCount`, `NotificationCount`, `DataChangeCount`, `BadStatusValueCount`, `PublishErrorCount`,
-`KeepAliveMissCount`, `ReconnectAttemptCount`, `ReconnectSuccessCount`, `ReconnectExhaustedCount`,
-`LastReconnectUtcTicks`, `ReadCallCount`, `ReadFailureCount`, `WriteCallCount`, `WriteFailureCount`,
-`ChunkedRequestCount`. All counters are `Interlocked`; they are cumulative for the driver instance's lifetime.
+Session/publish events are published on the shared OTel meter as the counter
+`otopcua.driver.opcuaclient.events`, tagged `driver_instance` (the driver instance id) and `event` =
+`publish_response`, `notification` (items), `data_change`, `bad_status_value`, `publish_error`,
+`keepalive_miss`, `reconnect_attempt`, `reconnect_success`, `reconnect_exhausted`. A climbing `keepalive_miss` /
+`reconnect_attempt` rate is the signature of a flapping upstream. They are not part of `DriverHealth` or the
+`/hosts` panel.
## Testing
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs
index 6dc7c9c3..bade4e94 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs
@@ -76,6 +76,16 @@ public static class OtOpcUaTelemetry
///
public const string PollOverrunDriverInstanceTag = "driver_instance";
+ ///
+ /// Session/publish-level events of an OPC UA Client driver instance (#276): the operator's view of a
+ /// flapping upstream. Tagged (driver_instance) +
+ /// event = publish_response|notification|data_change|bad_status_value|publish_error|
+ /// keepalive_miss|reconnect_attempt|reconnect_success|reconnect_exhausted.
+ ///
+ public static readonly Counter OpcUaClientEvents =
+ Meter.CreateCounter("otopcua.driver.opcuaclient.events", unit: "{event}",
+ description: "OPC UA Client driver session/publish events (event=publish_response|notification|data_change|bad_status_value|publish_error|keepalive_miss|reconnect_attempt|reconnect_success|reconnect_exhausted).");
+
// ---------------- VirtualTag / ScriptedAlarm engines ----------------
public static readonly Counter VirtualTagEval =
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverHealth.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverHealth.cs
index 09f06269..f76d163f 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverHealth.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverHealth.cs
@@ -15,16 +15,10 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// carry a non-null message. Callers must not key behaviour on the LastError-null ↔ Healthy
/// pairing.
///
-///
-/// Optional driver-specific counters/gauges (name → value) for the diagnostics surface (#276).
-/// Null for every driver that publishes none. Deliberately NOT part of any health-change
-/// fingerprint: counters move on every poll, so keying a dedup on them would defeat it.
-///
public sealed record DriverHealth(
DriverState State,
DateTime? LastSuccessfulRead,
- string? LastError,
- IReadOnlyDictionary? Diagnostics = null);
+ string? LastError);
/// Driver-instance lifecycle state.
public enum DriverState
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 249eb775..80f56c11 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
@@ -203,33 +203,20 @@ public sealed record OpcUaClientDriverOptions
///
/// Publish cycles with no data before the server sends a keep-alive (default 10). Must be >= 1.
///
- [Range(1, int.MaxValue)]
public uint SubscriptionKeepAliveCount { get; init; } = 10;
///
/// Publish cycles with no Publish request before the server drops the subscription (default 1000).
/// Must be at least 3x (OPC UA Part 4 §5.13.2).
///
- [Range(3, int.MaxValue)]
public uint SubscriptionLifetimeCount { get; init; } = 1000;
/// Max notifications per Publish response; 0 (default) = server decides.
public uint SubscriptionMaxNotificationsPerPublish { get; init; }
/// Relative subscription priority 0-255 (default 0).
- [Range(0, 255)]
public byte SubscriptionPriority { get; init; }
- // ---- #275 ----
-
- ///
- /// When true (default) the driver reads the remote server's
- /// ServerCapabilities/OperationLimits after connect and splits Read / Write / CreateMonitoredItems
- /// requests that exceed them (a declared limit of 0 = unlimited). Set false to send every request
- /// whole and let the server answer BadTooManyOperations itself.
- ///
- public bool HonorServerOperationLimits { get; init; } = true;
-
// ---- #277 ----
/// Certificate-revocation / issuer-store settings. Defaults preserve the SDK behaviour.
@@ -241,11 +228,19 @@ public sealed record OpcUaClientDriverOptions
/// Optional discovery endpoint (a Local Discovery Server or the server itself), e.g.
/// opc.tcp://lds.internal:4840. When set, InitializeAsync calls FindServers and
/// GetEndpoints on it and tries the discovered URLs that match +
- /// before the statically configured /
- /// . A discovery failure is non-fatal: the static list is used. Discovery
+ /// after the statically configured /
+ /// (pin the server with ). A discovery failure is non-fatal: the static list is used. Discovery
/// itself always runs over an unsecured channel, as the spec requires. null (default) = off.
///
public string? DiscoveryUrl { get; init; }
+
+ ///
+ /// ApplicationUri of the one server discovery may resolve to. An LDS usually registers several servers;
+ /// with this set only that server's endpoints are used. When unset and discovery finds more than one
+ /// server, connect fails with an error listing their ApplicationUris. Discovered endpoints are always
+ /// tried AFTER the configured .
+ ///
+ public string? DiscoveryServerUri { get; init; }
}
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDiagnostics.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDiagnostics.cs
index 220ec92f..2d379a63 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDiagnostics.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDiagnostics.cs
@@ -1,102 +1,48 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Observability;
+
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
///
-/// Thread-safe counters for the OPC UA Client driver (#276), surfaced through
-/// DriverHealth.Diagnostics. Writers are the SDK's notification / keep-alive threads and
-/// the request paths, so every field is touched only through .
+/// Publishes an OPC UA Client driver instance's session/publish events (#276) on the shared
+/// otopcua.driver.opcuaclient.events OTel counter, tagged with driver_instance and
+/// event. The counter is thread-safe, so the SDK's notification / keep-alive threads record
+/// directly with no locking here.
///
internal sealed class OpcUaClientDiagnostics
{
- private long _publishResponses;
- private long _notifications;
- private long _dataChanges;
- private long _badStatusValues;
- private long _publishErrors;
- private long _keepAliveMisses;
- private long _reconnectAttempts;
- private long _reconnectsSucceeded;
- private long _reconnectsExhausted;
- private long _lastReconnectUtcTicks;
- private long _readCalls;
- private long _readFailures;
- private long _writeCalls;
- private long _writeFailures;
- private long _chunkedRequests;
-
/// A NotificationMessage arrived (one per Publish response that carried data).
- public void RecordPublishResponse() => Interlocked.Increment(ref _publishResponses);
-
- /// A Publish failed (missing publish requests, subscription errors).
- public void RecordPublishError() => Interlocked.Increment(ref _publishErrors);
-
+ public const string PublishResponse = "publish_response";
+ /// Items carried by notification messages.
+ public const string Notification = "notification";
/// One monitored-item data change was delivered.
- /// Whether the value's status code was Bad.
- public void RecordDataChange(bool bad)
- {
- Interlocked.Increment(ref _dataChanges);
- if (bad) Interlocked.Increment(ref _badStatusValues);
- }
-
- /// Count raw notification items carried by a message.
- /// Number of items in the message.
- public void RecordNotifications(int count) => Interlocked.Add(ref _notifications, count);
-
+ public const string DataChange = "data_change";
+ /// A delivered data change carried a Bad status.
+ public const string BadStatusValue = "bad_status_value";
+ /// A Publish failed (missing publish requests, subscription errors).
+ public const string PublishError = "publish_error";
/// A keep-alive came back Bad.
- public void RecordKeepAliveMiss() => Interlocked.Increment(ref _keepAliveMisses);
-
+ public const string KeepAliveMiss = "keepalive_miss";
/// The reconnect handler was armed.
- public void RecordReconnectAttempt() => Interlocked.Increment(ref _reconnectAttempts);
-
+ public const string ReconnectAttempt = "reconnect_attempt";
/// A reconnect landed a new session.
- public void RecordReconnectSucceeded()
- {
- Interlocked.Increment(ref _reconnectsSucceeded);
- Interlocked.Exchange(ref _lastReconnectUtcTicks, DateTime.UtcNow.Ticks);
- }
-
+ public const string ReconnectSuccess = "reconnect_success";
/// A reconnect handler exhausted its retry window.
- public void RecordReconnectExhausted() => Interlocked.Increment(ref _reconnectsExhausted);
+ public const string ReconnectExhausted = "reconnect_exhausted";
- /// A Read request (all chunks) finished.
- /// Whether any chunk failed on the wire.
- public void RecordRead(bool failed)
+ private readonly string _driverInstanceId;
+
+ /// Creates the recorder for one driver instance.
+ /// Value of the driver_instance tag.
+ public OpcUaClientDiagnostics(string driverInstanceId) => _driverInstanceId = driverInstanceId;
+
+ /// Add to the counter for .
+ /// One of the event constants.
+ /// Amount to add (default 1).
+ public void Record(string evt, long count = 1)
{
- Interlocked.Increment(ref _readCalls);
- if (failed) Interlocked.Increment(ref _readFailures);
+ if (count <= 0) return;
+ OtOpcUaTelemetry.OpcUaClientEvents.Add(count,
+ new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, _driverInstanceId),
+ new KeyValuePair("event", evt));
}
-
- /// A Write request (all chunks) finished.
- /// Whether any chunk failed on the wire.
- public void RecordWrite(bool failed)
- {
- Interlocked.Increment(ref _writeCalls);
- if (failed) Interlocked.Increment(ref _writeFailures);
- }
-
- /// A logical request was split into more than one wire call to honour a server OperationLimit.
- public void RecordChunkedRequest() => Interlocked.Increment(ref _chunkedRequests);
-
- ///
- /// Point-in-time copy for DriverHealth.Diagnostics. Keys are stable and
- /// PascalCase; LastReconnectUtcTicks is 0 until a reconnect succeeds.
- ///
- /// A fresh dictionary the caller may keep.
- public IReadOnlyDictionary Snapshot() => new Dictionary
- {
- ["PublishResponseCount"] = Interlocked.Read(ref _publishResponses),
- ["NotificationCount"] = Interlocked.Read(ref _notifications),
- ["DataChangeCount"] = Interlocked.Read(ref _dataChanges),
- ["BadStatusValueCount"] = Interlocked.Read(ref _badStatusValues),
- ["PublishErrorCount"] = Interlocked.Read(ref _publishErrors),
- ["KeepAliveMissCount"] = Interlocked.Read(ref _keepAliveMisses),
- ["ReconnectAttemptCount"] = Interlocked.Read(ref _reconnectAttempts),
- ["ReconnectSuccessCount"] = Interlocked.Read(ref _reconnectsSucceeded),
- ["ReconnectExhaustedCount"] = Interlocked.Read(ref _reconnectsExhausted),
- ["LastReconnectUtcTicks"] = Interlocked.Read(ref _lastReconnectUtcTicks),
- ["ReadCallCount"] = Interlocked.Read(ref _readCalls),
- ["ReadFailureCount"] = Interlocked.Read(ref _readFailures),
- ["WriteCallCount"] = Interlocked.Read(ref _writeCalls),
- ["WriteFailureCount"] = Interlocked.Read(ref _writeFailures),
- ["ChunkedRequestCount"] = Interlocked.Read(ref _chunkedRequests),
- };
}
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 87c17144..03c7e758 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
@@ -79,6 +79,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
_options = options;
_driverInstanceId = driverInstanceId;
+ _diag = new OpcUaClientDiagnostics(driverInstanceId);
_logger = logger ?? NullLogger.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
}
@@ -144,21 +145,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// The namespace map to install, or null to clear it.
internal void SetNamespaceMapForTest(NamespaceMap? map) => _namespaceMap = map;
- /// Test-only seam installing the OperationLimits that connect would have read (#275).
- /// The limits to apply to subsequent Read / Write / subscribe calls.
- internal void SetOperationLimitsForTest(OpcUaClientOperationLimits limits) => _limits = limits;
-
/// Test-only view of the diagnostics counters (#276).
internal OpcUaClientDiagnostics DiagnosticsForTest => _diag;
private DriverHealth _health = new(DriverState.Unknown, null, null);
- /// Counters surfaced through (#276).
- private readonly OpcUaClientDiagnostics _diag = new();
-
- /// Server OperationLimits read at connect (#275); None when unread / disabled.
- /// Replaced wholesale (never mutated), so readers just snapshot the reference.
- private OpcUaClientOperationLimits _limits = OpcUaClientOperationLimits.None;
+ /// Per-instance event counters published as OTel metrics (#276).
+ private readonly OpcUaClientDiagnostics _diag;
private NotificationEventHandler? _notificationHandler;
private PublishErrorEventHandler? _publishErrorHandler;
@@ -238,9 +231,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
IReadOnlyList discovered = [];
if (!string.IsNullOrWhiteSpace(_options.DiscoveryUrl))
{
+ IReadOnlyList found = [];
try
{
- discovered = await DiscoverEndpointsAsync(
+ found = await DiscoverEndpointsAsync(
appConfig, _options.DiscoveryUrl!, _options.SecurityPolicy, _options.SecurityMode,
cancellationToken).ConfigureAwait(false);
}
@@ -249,8 +243,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
_logger.LogWarning(ex,
"OpcUaClientDriver '{DriverInstanceId}': discovery against '{DiscoveryUrl}' failed; " +
- "falling back to the configured endpoint list.", _driverInstanceId, _options.DiscoveryUrl);
+ "using the configured endpoint list only.", _driverInstanceId, _options.DiscoveryUrl);
}
+
+ // Selection is OUTSIDE the try on purpose: an ambiguous LDS must fail the connect with a
+ // clear message, not be swallowed into "discovery unavailable".
+ discovered = SelectDiscoveredUrls(found, _options.DiscoveryServerUri);
}
var candidates = ResolveEndpointCandidates(_options, discovered);
@@ -306,9 +304,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
_keepAliveHandler = OnKeepAlive;
session.KeepAlive += _keepAliveHandler;
AttachDiagnostics(session);
- _limits = _options.HonorServerOperationLimits
- ? await OpcUaClientOperationLimits.ReadAsync(session, cancellationToken).ConfigureAwait(false)
- : OpcUaClientOperationLimits.None;
// Build the bidirectional namespace map from the freshly negotiated session's
// NamespaceUris (driver-specs.md §8 "Namespace Remapping"). Stored NodeIds carry
@@ -460,10 +455,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
ResolveEndpointCandidates(opts, []);
///
- /// Resolve the ordered failover list with optional discovery results (#278). Discovered URLs are
- /// prepended so a discovery sweep gets first attempt; static entries follow, de-duplicated
- /// case-insensitively so one server listed in both is not attempted twice in a row. With no
- /// discovered URLs the result is exactly the pre-discovery behaviour.
+ /// Resolve the ordered failover list with optional discovery results (#278). The configured
+ /// endpoints always come FIRST — a discovered endpoint can only ever be a fallback, never outrank or
+ /// replace the server the operator named. Discovered URLs follow, de-duplicated case-insensitively.
+ /// With no discovered URLs the result is exactly the pre-discovery behaviour.
///
/// Driver options containing endpoint configuration.
/// URLs resolved via (may be empty).
@@ -476,10 +471,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
var merged = new List(discovered.Count + staticList.Count);
- foreach (var u in discovered)
- if (!string.IsNullOrWhiteSpace(u) && seen.Add(u)) merged.Add(u);
foreach (var u in staticList)
if (!string.IsNullOrWhiteSpace(u) && seen.Add(u)) merged.Add(u);
+ foreach (var u in discovered)
+ if (!string.IsNullOrWhiteSpace(u) && seen.Add(u)) merged.Add(u);
return merged;
}
@@ -495,7 +490,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// Required security mode.
/// Cancellation token.
/// Matching endpoint URLs in discovery order, de-duplicated.
- internal static async Task> DiscoverEndpointsAsync(
+ internal static async Task> DiscoverEndpointsAsync(
ApplicationConfiguration appConfig, string discoveryUrl,
OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
{
@@ -509,7 +504,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
servers = await lds.FindServersAsync(null, ct).ConfigureAwait(false);
}
- var urls = new List();
+ var found = new List();
foreach (var server in servers)
{
if (server.DiscoveryUrls is null) continue;
@@ -526,10 +521,47 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { continue; }
- urls.AddRange(FilterEndpointUrls(endpoints, wantedPolicyUri, wantedMode));
+ found.AddRange(FilterEndpointUrls(endpoints, wantedPolicyUri, wantedMode)
+ .Select(u => new DiscoveredEndpoint(server.ApplicationUri ?? string.Empty, u)));
}
}
- return urls.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ return found;
+ }
+
+ /// One endpoint URL found by discovery, with the ApplicationUri of the server advertising it.
+ /// The advertising server's ApplicationUri (empty when it published none).
+ /// The endpoint URL.
+ internal readonly record struct DiscoveredEndpoint(string ApplicationUri, string Url);
+
+ ///
+ /// Decide which discovered endpoints may be used (#278). An LDS can register many servers (identical
+ /// line PLCs, a gateway plus a historian), and the first one that accepts a connection would be bound
+ /// silently — mis-attributing every value and write. So: with set, only
+ /// that ApplicationUri's endpoints; unset and exactly one distinct server, that server; unset and
+ /// several, throw naming them so the operator pins one.
+ ///
+ /// Matching endpoints from every server the LDS listed.
+ /// Optional DiscoveryServerUri filter.
+ /// The usable URLs, de-duplicated, in discovery order (possibly empty).
+ /// Several servers discovered and no filter configured.
+ internal static IReadOnlyList SelectDiscoveredUrls(
+ IReadOnlyList found, string? serverUri)
+ {
+ IEnumerable chosen = found;
+ if (!string.IsNullOrWhiteSpace(serverUri))
+ {
+ chosen = found.Where(e => string.Equals(e.ApplicationUri, serverUri, StringComparison.Ordinal));
+ }
+ else
+ {
+ var servers = found.Select(e => e.ApplicationUri).Distinct(StringComparer.Ordinal).ToList();
+ if (servers.Count > 1)
+ throw new InvalidOperationException(
+ "OpcUaClient discovery found endpoints for more than one server " +
+ $"({string.Join(", ", servers.Select(u => string.IsNullOrEmpty(u) ? "" : u))}). " +
+ "Set DiscoveryServerUri to the ApplicationUri of the server this driver should use.");
+ }
+ return chosen.Select(e => e.Url).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
/// The endpoint URLs in matching the wanted policy URI + mode.
@@ -816,7 +848,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
if (sessionToClose is not null) DetachDiagnostics(sessionToClose);
Session = null;
}
- _limits = OpcUaClientOperationLimits.None;
try { if (sessionToClose is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
@@ -867,7 +898,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
///
- public DriverHealth GetHealth() => _health with { Diagnostics = _diag.Snapshot() };
+ public DriverHealth GetHealth() => _health;
// ---- IReadable ----
@@ -917,59 +948,44 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
if (toSend.Count == 0) return results;
- // #275: one wire Read per OperationLimits.MaxNodesPerRead-sized chunk. A chunk that fails
- // only fails ITS tags — the rest of the batch keeps its values.
- var chunks = OpcUaClientOperationLimits.Chunk(toSend.Count, _limits.MaxNodesPerRead).ToList();
- if (chunks.Count > 1) _diag.RecordChunkedRequest();
- Exception? firstFailure = null;
- foreach (var (start, count) in chunks)
+ try
{
- var slice = new ReadValueIdCollection(count);
- for (var w = start; w < start + count; w++) slice.Add(toSend[w]);
- try
- {
- var resp = await session.ReadAsync(
- requestHeader: null,
- maxAge: 0,
- timestampsToReturn: TimestampsToReturn.Both,
- nodesToRead: slice,
- ct: cancellationToken).ConfigureAwait(false);
+ var resp = await session.ReadAsync(
+ requestHeader: null,
+ maxAge: 0,
+ timestampsToReturn: TimestampsToReturn.Both,
+ nodesToRead: toSend,
+ ct: cancellationToken).ConfigureAwait(false);
- var values = resp.Results;
- for (var w = 0; w < values.Count && w < count; w++)
- {
- var r = indexMap[start + w];
- var dv = values[w];
- // Preserve the upstream StatusCode verbatim — including Bad codes per
- // §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
- // clients can detect stale upstream data.
- results[r] = new DataValueSnapshot(
- Value: dv.Value,
- StatusCode: dv.StatusCode.Code,
- SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
- ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
- }
- }
- catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ var values = resp.Results;
+ for (var w = 0; w < values.Count; w++)
{
- throw;
- }
- catch (Exception ex)
- {
- // Transport / timeout / session-dropped — fan out the same fault across every
- // tag in this chunk. Per-tag StatusCode stays BadCommunicationError (not
- // BadInternalError) so operators distinguish "upstream unreachable" from
- // "driver bug".
- for (var w = start; w < start + count; w++)
- results[indexMap[w]] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
- firstFailure ??= ex;
+ var r = indexMap[w];
+ var dv = values[w];
+ // Preserve the upstream StatusCode verbatim — including Bad codes per
+ // §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
+ // clients can detect stale upstream data.
+ results[r] = new DataValueSnapshot(
+ Value: dv.Value,
+ StatusCode: dv.StatusCode.Code,
+ SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
+ ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
}
+ _health = new DriverHealth(DriverState.Healthy, now, null);
+ }
+ catch (Exception ex)
+ {
+ // Transport / timeout / session-dropped — fan out the same fault across every
+ // tag in this batch. Per-tag StatusCode stays BadCommunicationError (not
+ // BadInternalError) so operators distinguish "upstream unreachable" from
+ // "driver bug".
+ for (var w = 0; w < indexMap.Count; w++)
+ {
+ var r = indexMap[w];
+ results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
+ }
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
-
- _diag.RecordRead(failed: firstFailure is not null);
- _health = firstFailure is null
- ? new DriverHealth(DriverState.Healthy, now, null)
- : new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, firstFailure.Message);
}
finally { _gate.Release(); }
return results;
@@ -1019,55 +1035,40 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
if (toSend.Count == 0) return results;
- // #275: one wire Write per OperationLimits.MaxNodesPerWrite-sized chunk. Chunks are sent in
- // order and each fails independently: a later chunk that times out leaves earlier chunks'
- // real results intact (those writes DID happen).
- var chunks = OpcUaClientOperationLimits.Chunk(toSend.Count, _limits.MaxNodesPerWrite).ToList();
- if (chunks.Count > 1) _diag.RecordChunkedRequest();
- var anyFailed = false;
- foreach (var (start, count) in chunks)
+ try
{
- var slice = new WriteValueCollection(count);
- for (var w = start; w < start + count; w++) slice.Add(toSend[w]);
- try
- {
- var resp = await session.WriteAsync(
- requestHeader: null,
- nodesToWrite: slice,
- ct: cancellationToken).ConfigureAwait(false);
+ var resp = await session.WriteAsync(
+ requestHeader: null,
+ nodesToWrite: toSend,
+ ct: cancellationToken).ConfigureAwait(false);
- var codes = resp.Results;
- for (var w = 0; w < codes.Count && w < count; w++)
- {
- // Pass upstream WriteResult StatusCode through verbatim. Success codes
- // include Good (0) and any warning-level Good* variants; anything with
- // the severity bits set is a Bad.
- results[indexMap[start + w]] = new WriteResult(codes[w].Code);
- }
- }
- catch (OperationCanceledException)
+ var codes = resp.Results;
+ for (var w = 0; w < codes.Count; w++)
{
- // Timeout / cancellation after the wire request may have been dispatched.
- // Writes are non-idempotent — BadTimeout ("outcome unknown, do not blindly
- // retry") is more honest than BadCommunicationError ("definitely did not
- // happen"). Downstream callers that need retry semantics check for BadTimeout
- // and can decide whether to re-issue. Every not-yet-answered chunk (this one and
- // the ones after it) gets the same code.
- const uint StatusBadTimeout = 0x800A0000u;
- anyFailed = true;
- for (var w = start; w < toSend.Count; w++)
- results[indexMap[w]] = new WriteResult(StatusBadTimeout);
- break;
- }
- catch (Exception)
- {
- // Pre-wire transport failure — the write definitely did not reach the server.
- anyFailed = true;
- for (var w = start; w < start + count; w++)
- results[indexMap[w]] = new WriteResult(StatusBadCommunicationError);
+ var r = indexMap[w];
+ // Pass upstream WriteResult StatusCode through verbatim. Success codes
+ // include Good (0) and any warning-level Good* variants; anything with
+ // the severity bits set is a Bad.
+ results[r] = new WriteResult(codes[w].Code);
}
}
- _diag.RecordWrite(anyFailed);
+ catch (OperationCanceledException)
+ {
+ // Timeout / cancellation after the wire request may have been dispatched.
+ // Writes are non-idempotent — BadTimeout ("outcome unknown, do not blindly
+ // retry") is more honest than BadCommunicationError ("definitely did not
+ // happen"). Downstream callers that need retry semantics check for BadTimeout
+ // and can decide whether to re-issue.
+ const uint StatusBadTimeout = 0x800A0000u;
+ for (var w = 0; w < indexMap.Count; w++)
+ results[indexMap[w]] = new WriteResult(StatusBadTimeout);
+ }
+ catch (Exception)
+ {
+ // Pre-wire transport failure — the write definitely did not reach the server.
+ for (var w = 0; w < indexMap.Count; w++)
+ results[indexMap[w]] = new WriteResult(StatusBadCommunicationError);
+ }
}
finally { _gate.Release(); }
return results;
@@ -1334,27 +1335,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.Historizing });
}
- // #275: split the attribute reads to the server's MaxNodesPerRead (each variable contributes 4).
- var values = new DataValueCollection(nodesToRead.Count);
+ DataValueCollection values;
try
{
- foreach (var (start, count) in OpcUaClientOperationLimits.Chunk(nodesToRead.Count, _limits.MaxNodesPerRead))
- {
- var slice = new ReadValueIdCollection(count);
- for (var w = start; w < start + count; w++) slice.Add(nodesToRead[w]);
- var resp = await session.ReadAsync(
- requestHeader: null,
- maxAge: 0,
- timestampsToReturn: TimestampsToReturn.Neither,
- nodesToRead: slice,
- ct: ct).ConfigureAwait(false);
- values.AddRange(resp.Results);
- }
- if (values.Count < nodesToRead.Count) throw new InvalidOperationException("Short enrichment read.");
- }
- catch (OperationCanceledException) when (ct.IsCancellationRequested)
- {
- throw;
+ var resp = await session.ReadAsync(
+ requestHeader: null,
+ maxAge: 0,
+ timestampsToReturn: TimestampsToReturn.Neither,
+ nodesToRead: nodesToRead,
+ ct: ct).ConfigureAwait(false);
+ values = resp.Results;
}
catch
{
@@ -1507,7 +1497,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// captures `handle`, so we must hold the exact delegate instance returned by
// `+=` to be able to remove it.
var itemHandlers = new List();
- var itemsAdded = 0;
foreach (var fullRef in fullReferences)
{
if (!TryResolveTagNode(session, fullRef, out var nodeId)) continue;
@@ -1532,15 +1521,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
item.Notification += notifHandler;
itemHandlers.Add(new MonitoredItemNotificationHandle(item, notifHandler));
subscription.AddItem(item);
- itemsAdded++;
-
- // #275: flush a CreateMonitoredItems call whenever the server's per-call cap is reached.
- // CreateItemsAsync only sends the items not yet created, so calling it per batch is safe.
- if (_limits.MaxMonitoredItemsPerCall > 0 && itemsAdded % _limits.MaxMonitoredItemsPerCall == 0)
- {
- _diag.RecordChunkedRequest();
- await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
- }
}
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
@@ -1590,7 +1570,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var dv = mn.Value;
if (dv is null) return;
var fullRef = (item.Handle as string) ?? item.DisplayName ?? string.Empty;
- _diag.RecordDataChange(bad: StatusCode.IsBad(dv.StatusCode));
+ _diag.Record(OpcUaClientDiagnostics.DataChange);
+ if (StatusCode.IsBad(dv.StatusCode)) _diag.Record(OpcUaClientDiagnostics.BadStatusValue);
var snapshot = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
@@ -2249,13 +2230,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
_notificationHandler ??= (_, e) =>
{
- _diag.RecordPublishResponse();
+ _diag.Record(OpcUaClientDiagnostics.PublishResponse);
var msg = e.NotificationMessage;
if (msg?.NotificationData is { } data)
foreach (var nd in data)
- _diag.RecordNotifications(CountNotifications(nd));
+ _diag.Record(OpcUaClientDiagnostics.Notification, CountNotifications(nd));
};
- _publishErrorHandler ??= (_, _) => _diag.RecordPublishError();
+ _publishErrorHandler ??= (_, _) => _diag.Record(OpcUaClientDiagnostics.PublishError);
session.Notification -= _notificationHandler;
session.Notification += _notificationHandler;
session.PublishError -= _publishErrorHandler;
@@ -2280,12 +2261,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
_ => 1,
};
- private async Task RefreshOperationLimitsAsync(ISession session)
- {
- try { _limits = await OpcUaClientOperationLimits.ReadAsync(session, CancellationToken.None).ConfigureAwait(false); }
- catch { /* best-effort: keep the previous limits */ }
- }
-
///
/// Session keep-alive handler. On a healthy ping, bumps HostState back to Running
/// (typical bounce after a transient network blip). On a bad ping, starts the SDK's
@@ -2301,7 +2276,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
TransitionTo(HostState.Stopped);
- _diag.RecordKeepAliveMiss();
+ _diag.Record(OpcUaClientDiagnostics.KeepAliveMiss);
// Kick off the SDK's reconnect loop exactly once per drop. Keep-alive callbacks fire
// from the SDK keep-alive timer thread and the SDK can fire this handler repeatedly
@@ -2321,7 +2296,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// BeginReconnect is started outside the lock — it does no _reconnectHandler mutation
// and we don't want to hold _probeLock across an SDK call.
- _diag.RecordReconnectAttempt();
+ _diag.Record(OpcUaClientDiagnostics.ReconnectAttempt);
handler.BeginReconnect(
sender,
(int)_options.ReconnectPeriod.TotalMilliseconds,
@@ -2388,8 +2363,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
RecordSkippedTags(_namespaceMap);
TransitionTo(HostState.Running);
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
- _diag.RecordReconnectSucceeded();
- if (_options.HonorServerOperationLimits) _ = RefreshOperationLimitsAsync(newSession);
+ _diag.Record(OpcUaClientDiagnostics.ReconnectSuccess);
return;
}
@@ -2402,7 +2376,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// the last-known session for an always-on gateway rather than abandoning recovery
// entirely.
TransitionTo(HostState.Faulted);
- _diag.RecordReconnectExhausted();
+ _diag.Record(OpcUaClientDiagnostics.ReconnectExhausted);
_health = new DriverHealth(
DriverState.Faulted, _health.LastSuccessfulRead,
"OPC UA session reconnect exhausted its retry window without recovering. " +
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 dfd53972..146ec164 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs
@@ -86,8 +86,18 @@ public static class OpcUaClientDriverFactoryExtensions
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
- return JsonSerializer.Deserialize(driverConfigJson, JsonOptions)
+ var options = JsonSerializer.Deserialize(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
+
+ // #273/#278: nothing runs DataAnnotations on driver options, so range-check here — at options load —
+ // and fail with a clear message instead of a driver that faults and retries forever.
+ try { OpcUaClientDriver.ValidateTuning(options); }
+ catch (InvalidOperationException ex)
+ {
+ throw new InvalidOperationException(
+ $"OpcUaClient driver config for '{driverInstanceId}' is invalid: {ex.Message}", ex);
+ }
+ return options;
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientOperationLimits.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientOperationLimits.cs
deleted file mode 100644
index 7f38ac5c..00000000
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientOperationLimits.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-using Opc.Ua;
-using Opc.Ua.Client;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
-
-///
-/// The remote server's Server/ServerCapabilities/OperationLimits that bound how many
-/// items one service call may carry (#275). Every field is 0 = "no limit declared" (the
-/// OPC UA convention), so is the safe value when the server publishes nothing
-/// or the read fails.
-///
-/// Items per Read.
-/// Items per Write.
-/// Nodes per Browse.
-/// Items per CreateMonitoredItems / Modify / SetMonitoringMode.
-/// Nodes per HistoryRead (raw / processed / at-time).
-/// Nodes per HistoryRead (events).
-internal sealed record OpcUaClientOperationLimits(
- uint MaxNodesPerRead,
- uint MaxNodesPerWrite,
- uint MaxNodesPerBrowse,
- uint MaxMonitoredItemsPerCall,
- uint MaxNodesPerHistoryReadData,
- uint MaxNodesPerHistoryReadEvents)
-{
- /// No limits declared — every call goes out in one request.
- public static OpcUaClientOperationLimits None { get; } = new(0, 0, 0, 0, 0, 0);
-
- ///
- /// Split items into consecutive (Start, Count) chunks of at most
- /// each. limit == 0 (no limit) or a total that already fits yields a
- /// single chunk; total == 0 yields none.
- ///
- /// Number of items to send.
- /// The server's per-call cap; 0 = unlimited.
- /// The chunk ranges, in order, covering [0, total) exactly once.
- public static IEnumerable<(int Start, int Count)> Chunk(int total, uint limit)
- {
- if (total <= 0) yield break;
- if (limit == 0 || limit >= (uint)total)
- {
- yield return (0, total);
- yield break;
- }
- var size = (int)Math.Min(limit, int.MaxValue);
- for (var start = 0; start < total; start += size)
- yield return (start, Math.Min(size, total - start));
- }
-
- /// The standard variables read to populate the limits, in order.
- internal static readonly NodeId[] LimitNodeIds =
- [
- VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead,
- VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerWrite,
- VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse,
- VariableIds.Server_ServerCapabilities_OperationLimits_MaxMonitoredItemsPerCall,
- VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadData,
- VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadEvents,
- ];
-
- ///
- /// Build limits from the values read for . A Bad or non-uint value
- /// (server doesn't publish that limit) is treated as 0 = unlimited.
- ///
- /// One value per entry, in order.
- /// The parsed limits.
- public static OpcUaClientOperationLimits FromValues(IReadOnlyList values)
- {
- uint At(int i) =>
- i < values.Count && values[i] is { } dv && StatusCode.IsGood(dv.StatusCode) && dv.Value is uint u ? u : 0;
- return new OpcUaClientOperationLimits(At(0), At(1), At(2), At(3), At(4), At(5));
- }
-
- ///
- /// Read the server's declared limits. Best-effort: any failure (older server without the
- /// capability object, transport error) yields so connect never fails over this.
- ///
- /// The freshly connected session.
- /// Cancellation token.
- /// The declared limits, or .
- public static async Task ReadAsync(ISession session, CancellationToken ct)
- {
- try
- {
- var nodes = new ReadValueIdCollection(LimitNodeIds.Length);
- foreach (var id in LimitNodeIds)
- nodes.Add(new ReadValueId { NodeId = id, AttributeId = Attributes.Value });
- var resp = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, nodes, ct).ConfigureAwait(false);
- return FromValues(resp.Results);
- }
- catch (OperationCanceledException) { throw; }
- catch { return None; }
- }
-}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor
index ba66a78e..bf04f380 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor
@@ -214,13 +214,6 @@
-