diff --git a/docs/Historian.md b/docs/Historian.md
index 1307f197..ae1568b3 100644
--- a/docs/Historian.md
+++ b/docs/Historian.md
@@ -431,6 +431,17 @@ RetrievalMode.Integral`) — no client-side scaling. (This replaces the retired
likewise a native gateway mode. Bucket status codes and timestamps are preserved unchanged; empty / null
buckets surface as `BadNoData`.
+### Aggregate set and `BadAggregateNotSupported` (#285)
+
+`HistoryAggregateType` carries the wider Part 13 set (Interpolative, TimeAverage, Start, End, Delta, Range,
+Min/MaxActualTime, Start/EndBound, DeltaBounds, DurationGood/Bad, PercentGood/Bad, WorstQuality,
+NumberOfTransitions, AnnotationCount, StandardDeviationPopulation, VariancePopulation); `OtOpcUaNodeManager.MapAggregate`
+maps each standard aggregate NodeId. The **HistorianGateway** backend only has retrieval modes for the original
+five (Average, Minimum, Maximum, Total, Count): for any other member `AggregateModeMapper` throws
+`HistoryAggregateNotSupportedException`, which the HistoryRead path turns into `BadAggregateNotSupported` for that
+node only (not a generic failure, and not counted as a backend fault). The OPC UA Client driver forwards every
+member upstream. A NodeId outside the mapped set (e.g. `StandardDeviationSample`) is still `BadAggregateNotSupported`.
+
### Known limitations
- **Processed and AtTime are single-shot** (no continuation points). Unlike Raw, neither
diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md
index 50bcb24d..c90672ab 100644
--- a/docs/drivers/OpcUaClient.md
+++ b/docs/drivers/OpcUaClient.md
@@ -99,8 +99,11 @@ rather than surfacing as a runtime surprise.
### History passthrough
`IHistoryProvider` forwards `HistoryRead` to the upstream server's own historian.
-Raw, processed (Average / Minimum / Maximum / Total / Count aggregates mapped to
-OPC UA Part 13 standard aggregate NodeIds), and at-time reads are supported; each
+Raw, processed (the full `HistoryAggregateType` set — Average / Minimum / Maximum / Total / Count plus
+Interpolative, TimeAverage, Start, End, Delta, Range, Min/Max ActualTime, Start/EndBound, DeltaBounds,
+DurationGood/Bad, PercentGood/Bad, WorstQuality, NumberOfTransitions, AnnotationCount and the
+population StandardDeviation/Variance — each mapped to its OPC UA Part 13 standard aggregate NodeId; #285),
+and at-time reads are supported; each
returned `DataValue` keeps its upstream `StatusCode` and timestamps verbatim.
Event-history (`ReadEventsAsync`) is left at the interface default — the
interface doesn't yet carry the EventFilter surface needed to forward it.
@@ -109,7 +112,61 @@ 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.
+opc.tcp channel fails closed. A **revoked** certificate (`BadCertificateRevoked` /
+`BadCertificateIssuerRevoked`) is never auto-accepted (#277).
+
+`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. |
+| `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)
+
+The per-subscription parameters that used to be hardcoded are driver options; the defaults are the old values.
+
+| 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. |
+| `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.
+*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.
+
+### 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).
+
+### 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.
## Testing
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 f76d163f..09f06269 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverHealth.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverHealth.cs
@@ -15,10 +15,16 @@ 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);
+ string? LastError,
+ IReadOnlyDictionary? Diagnostics = null);
/// 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 47c27525..249eb775 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
@@ -189,6 +189,86 @@ public sealed record OpcUaClientDriverOptions
///
[Range(1, 60)]
public int ProbeTimeoutSeconds { get; init; } = 15;
+
+ // ---- #273 per-subscription tuning (defaults are the values that used to be hardcoded) ----
+
+ ///
+ /// Fixed publishing interval for every data-change subscription this driver creates. null
+ /// (default) keeps the historical behaviour: the interval the runtime asks for per
+ /// SubscribeAsync call, floored at 50 ms. When set it overrides the per-call value (the
+ /// 50 ms floor still applies); monitored-item sampling intervals follow it.
+ ///
+ public TimeSpan? SubscriptionPublishingInterval { get; init; }
+
+ ///
+ /// 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.
+ public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
+
+ // ---- #278 ----
+
+ ///
+ /// 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
+ /// itself always runs over an unsecured channel, as the spec requires. null (default) = off.
+ ///
+ public string? DiscoveryUrl { get; init; }
+}
+
+///
+/// Server-certificate revocation handling for the OPC UA Client driver (#277). The SDK reads CRLs from
+/// the crl sub-folder of the trusted-peer / trusted-issuer stores; these settings only decide how
+/// a certificate whose revocation status cannot be determined is treated and where the issuer store lives.
+///
+public sealed record OpcUaCertificateValidationOptions
+{
+ ///
+ /// When true, a server certificate whose revocation status is unknown (no CRL for its issuer, or a
+ /// stale one) is rejected. Default false keeps brownfield deployments without CRL infrastructure
+ /// connecting. A certificate that IS revoked is always rejected — even with
+ /// set.
+ ///
+ public bool RejectUnknownRevocationStatus { get; init; }
+
+ ///
+ /// Directory used as the trusted-issuer certificate store. Its crl/ sub-folder is where the SDK
+ /// looks for issuer CRLs (.crl files), so this is the knob for pointing the driver at an
+ /// operator-managed CRL set. null/empty (default) = %LocalAppData%/OtOpcUa/pki/issuers.
+ ///
+ public string? IssuerStorePath { 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
new file mode 100644
index 00000000..220ec92f
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDiagnostics.cs
@@ -0,0 +1,102 @@
+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 .
+///
+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);
+
+ /// 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);
+
+ /// A keep-alive came back Bad.
+ public void RecordKeepAliveMiss() => Interlocked.Increment(ref _keepAliveMisses);
+
+ /// The reconnect handler was armed.
+ public void RecordReconnectAttempt() => Interlocked.Increment(ref _reconnectAttempts);
+
+ /// A reconnect landed a new session.
+ public void RecordReconnectSucceeded()
+ {
+ Interlocked.Increment(ref _reconnectsSucceeded);
+ Interlocked.Exchange(ref _lastReconnectUtcTicks, DateTime.UtcNow.Ticks);
+ }
+
+ /// A reconnect handler exhausted its retry window.
+ public void RecordReconnectExhausted() => Interlocked.Increment(ref _reconnectsExhausted);
+
+ /// A Read request (all chunks) finished.
+ /// Whether any chunk failed on the wire.
+ public void RecordRead(bool failed)
+ {
+ Interlocked.Increment(ref _readCalls);
+ if (failed) Interlocked.Increment(ref _readFailures);
+ }
+
+ /// 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 cc9a3f70..559d9177 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
@@ -144,7 +144,24 @@ 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;
+
+ private NotificationEventHandler? _notificationHandler;
+ private PublishErrorEventHandler? _publishErrorHandler;
private bool _disposed;
/// URL of the endpoint the driver actually connected to. Exposed via .
private string? _connectedEndpointUrl;
@@ -208,9 +225,31 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// §8 "Namespace Assignment" — a misconfigured remote fails draft validation here,
// not as a runtime surprise.
ValidateNamespaceKind(_options);
+ ValidateTuning(_options);
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
- var candidates = ResolveEndpointCandidates(_options);
+
+ // #278: when a DiscoveryUrl is configured, FindServers/GetEndpoints on it first and try the
+ // discovered URLs ahead of the static list. Non-fatal — an LDS outage must not block init.
+ IReadOnlyList discovered = [];
+ if (!string.IsNullOrWhiteSpace(_options.DiscoveryUrl))
+ {
+ try
+ {
+ discovered = await DiscoverEndpointsAsync(
+ appConfig, _options.DiscoveryUrl!, _options.SecurityPolicy, _options.SecurityMode,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "OpcUaClientDriver '{DriverInstanceId}': discovery against '{DiscoveryUrl}' failed; " +
+ "falling back to the configured endpoint list.", _driverInstanceId, _options.DiscoveryUrl);
+ }
+ }
+
+ var candidates = ResolveEndpointCandidates(_options, discovered);
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
// shared secret store ONCE, here in the async connect flow, just before the credentials
@@ -262,6 +301,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// via TransferSubscriptions so local MonitoredItem handles stay valid.
_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
@@ -327,7 +370,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
- StorePath = Path.Combine(pkiRoot, "issuers"),
+ // #277: the issuer store's crl/ sub-folder is where the SDK reads issuer CRLs, so an
+ // operator-supplied path is the CRL knob. Unset = the historical default.
+ StorePath = string.IsNullOrWhiteSpace(_options.CertificateValidation.IssuerStorePath)
+ ? Path.Combine(pkiRoot, "issuers")
+ : _options.CertificateValidation.IssuerStorePath,
},
RejectedCertificateStore = new CertificateTrustList
{
@@ -335,6 +382,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
StorePath = Path.Combine(pkiRoot, "rejected"),
},
AutoAcceptUntrustedCertificates = _options.AutoAcceptCertificates,
+ RejectUnknownRevocationStatus = _options.CertificateValidation.RejectUnknownRevocationStatus,
},
TransportQuotas = new TransportQuotas { OperationTimeout = (int)_options.Timeout.TotalMilliseconds },
ClientConfiguration = new ClientConfiguration
@@ -360,8 +408,22 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Accept the full set of certificate-validation error codes: a real dev cert can
// fail with BadCertificateChainIncomplete, BadCertificateTimeInvalid, or
// BadCertificateHostNameInvalid, not only BadCertificateUntrusted. Only accepting
- // the latter would silently fail for those certs.
- CertificateValidationEventHandler handler = (_, e) => e.Accept = true;
+ // the latter would silently fail for those certs. A REVOKED certificate is the one
+ // exception (#277): auto-accept is for dev self-signed certs, never for a cert its issuer
+ // has explicitly withdrawn.
+ CertificateValidationEventHandler handler = (_, e) =>
+ {
+ if (IsRevocationFailure(e.Error.StatusCode))
+ {
+ _logger.LogWarning(
+ "OpcUaClientDriver '{DriverInstanceId}': REVOKED server certificate rejected " +
+ "(subject={Subject}, status=0x{Status:X8}) — AutoAcceptCertificates does not apply to revoked certs.",
+ _driverInstanceId, e.Certificate?.Subject, e.Error.StatusCode.Code);
+ e.Accept = false;
+ return;
+ }
+ e.Accept = true;
+ };
config.CertificateValidator.CertificateValidation += handler;
// Store refs so ShutdownAsync + Dispose can detach the delegate and avoid
// leaking a closure on a potentially process-shared validator.
@@ -377,6 +439,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
return config;
}
+ /// True for the two hard "this certificate (or its issuer) is revoked" results (#277).
+ /// The SDK's certificate-validation status.
+ /// true when the status is Revoked / IssuerRevoked.
+ internal static bool IsRevocationFailure(StatusCode status) =>
+ status.Code == StatusCodes.BadCertificateRevoked || status.Code == StatusCodes.BadCertificateIssuerRevoked;
+
///
/// Resolve the ordered failover candidate list. EndpointUrls wins when
/// non-empty; otherwise fall back to EndpointUrl as a single-URL shortcut so
@@ -384,10 +452,136 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
///
/// Driver options containing endpoint configuration.
/// The ordered list of endpoint URLs to try.
- internal static IReadOnlyList ResolveEndpointCandidates(OpcUaClientDriverOptions opts)
+ internal static IReadOnlyList ResolveEndpointCandidates(OpcUaClientDriverOptions opts) =>
+ 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.
+ ///
+ /// Driver options containing endpoint configuration.
+ /// URLs resolved via (may be empty).
+ /// The ordered list of endpoint URLs to try.
+ internal static IReadOnlyList ResolveEndpointCandidates(
+ OpcUaClientDriverOptions opts, IReadOnlyList discovered)
{
- if (opts.EndpointUrls is { Count: > 0 }) return opts.EndpointUrls;
- return [opts.EndpointUrl];
+ IReadOnlyList staticList = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls : [opts.EndpointUrl];
+ if (discovered.Count == 0) return staticList;
+
+ 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);
+ return merged;
+ }
+
+ ///
+ /// FindServers on (an LDS, or the server itself), then
+ /// GetEndpoints on each returned server's discovery URL, keeping the endpoints whose security
+ /// policy + mode match. One unreachable server in the list is skipped, not fatal. The discovery
+ /// channel is unsecured by design (OPC UA Part 4 §5.4) regardless of the session's security mode.
+ ///
+ /// Application configuration (client identity for the discovery channel).
+ /// The LDS / server discovery URL.
+ /// Required security policy.
+ /// Required security mode.
+ /// Cancellation token.
+ /// Matching endpoint URLs in discovery order, de-duplicated.
+ internal static async Task> DiscoverEndpointsAsync(
+ ApplicationConfiguration appConfig, string discoveryUrl,
+ OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
+ {
+ var wantedPolicyUri = MapSecurityPolicy(policy);
+ var wantedMode = ToMessageSecurityMode(mode);
+
+ ApplicationDescriptionCollection servers;
+ using (var lds = await DiscoveryClient.CreateAsync(
+ appConfig, new Uri(discoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false))
+ {
+ servers = await lds.FindServersAsync(null, ct).ConfigureAwait(false);
+ }
+
+ var urls = new List();
+ foreach (var server in servers)
+ {
+ if (server.DiscoveryUrls is null) continue;
+ foreach (var serverDiscoveryUrl in server.DiscoveryUrls)
+ {
+ if (string.IsNullOrWhiteSpace(serverDiscoveryUrl)) continue;
+ EndpointDescriptionCollection endpoints;
+ try
+ {
+ using var ep = await DiscoveryClient.CreateAsync(
+ appConfig, new Uri(serverDiscoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
+ endpoints = await ep.GetEndpointsAsync(null, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch { continue; }
+
+ urls.AddRange(FilterEndpointUrls(endpoints, wantedPolicyUri, wantedMode));
+ }
+ }
+ return urls.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ /// The endpoint URLs in matching the wanted policy URI + mode.
+ /// Endpoints advertised by a server.
+ /// Required SecurityPolicyUri.
+ /// Required message security mode.
+ /// Matching EndpointUrls, in advertised order.
+ internal static IEnumerable FilterEndpointUrls(
+ IEnumerable endpoints, string wantedPolicyUri, MessageSecurityMode wantedMode) =>
+ endpoints
+ .Where(e => e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode
+ && !string.IsNullOrWhiteSpace(e.EndpointUrl))
+ .Select(e => e.EndpointUrl);
+
+ private static MessageSecurityMode ToMessageSecurityMode(OpcUaSecurityMode mode) => mode switch
+ {
+ OpcUaSecurityMode.None => MessageSecurityMode.None,
+ OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
+ OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
+ _ => throw new ArgumentOutOfRangeException(nameof(mode)),
+ };
+
+ ///
+ /// Range checks for the subscription-tuning and discovery options (#273/#278). Throws at
+ /// so a bad config fails like the namespace-kind check does rather
+ /// than surfacing later as a server-side BadInvalidArgument on every subscribe.
+ ///
+ /// The options to validate.
+ internal static void ValidateTuning(OpcUaClientDriverOptions opts)
+ {
+ if (opts.SubscriptionKeepAliveCount < 1)
+ throw new InvalidOperationException("OpcUaClient SubscriptionKeepAliveCount must be >= 1.");
+ if (opts.SubscriptionLifetimeCount < 3L * opts.SubscriptionKeepAliveCount)
+ throw new InvalidOperationException(
+ "OpcUaClient SubscriptionLifetimeCount must be at least 3x SubscriptionKeepAliveCount " +
+ $"(got lifetime={opts.SubscriptionLifetimeCount}, keep-alive={opts.SubscriptionKeepAliveCount}).");
+ if (opts.SubscriptionPublishingInterval is { } pi && pi <= TimeSpan.Zero)
+ throw new InvalidOperationException("OpcUaClient SubscriptionPublishingInterval must be positive when set.");
+ if (!string.IsNullOrWhiteSpace(opts.DiscoveryUrl)
+ && !Uri.TryCreate(opts.DiscoveryUrl, UriKind.Absolute, out _))
+ throw new InvalidOperationException(
+ $"OpcUaClient DiscoveryUrl '{opts.DiscoveryUrl}' is not an absolute URI.");
+ }
+
+ ///
+ /// Publishing interval (ms) a new subscription gets: the configured override if set, else the
+ /// interval the runtime requested, floored at 50 ms either way (servers negotiate up anyway, and
+ /// sub-50 ms just wastes negotiation).
+ ///
+ /// Driver options.
+ /// The per-call interval from SubscribeAsync.
+ /// The publishing interval in milliseconds.
+ internal static int ResolvePublishingIntervalMs(OpcUaClientDriverOptions opts, TimeSpan requested)
+ {
+ var effective = opts.SubscriptionPublishingInterval ?? requested;
+ return effective < TimeSpan.FromMilliseconds(50) ? 50 : (int)effective.TotalMilliseconds;
}
///
@@ -506,13 +700,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
var wantedPolicyUri = MapSecurityPolicy(policy);
- var wantedMode = mode switch
- {
- OpcUaSecurityMode.None => MessageSecurityMode.None,
- OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
- OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
- _ => throw new ArgumentOutOfRangeException(nameof(mode)),
- };
+ var wantedMode = ToMessageSecurityMode(mode);
var match = all.FirstOrDefault(e =>
e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
@@ -639,8 +827,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
try { sessionToClose.KeepAlive -= _keepAliveHandler; } catch { }
}
_keepAliveHandler = null;
+ 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 */ }
@@ -662,7 +852,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
///
- public DriverHealth GetHealth() => _health;
+ public DriverHealth GetHealth() => _health with { Diagnostics = _diag.Snapshot() };
// ---- IReadable ----
@@ -712,44 +902,59 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
if (toSend.Count == 0) return results;
- try
+ // #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)
{
- var resp = await session.ReadAsync(
- requestHeader: null,
- maxAge: 0,
- timestampsToReturn: TimestampsToReturn.Both,
- nodesToRead: toSend,
- ct: cancellationToken).ConfigureAwait(false);
+ 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 values = resp.Results;
- for (var w = 0; w < values.Count; w++)
- {
- 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);
+ 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);
+ }
}
- _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++)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
- var r = indexMap[w];
- results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
+ 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;
}
- _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;
@@ -799,40 +1004,55 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
if (toSend.Count == 0) return results;
- try
+ // #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)
{
- 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++)
+ var slice = new WriteValueCollection(count);
+ for (var w = start; w < start + count; w++) slice.Add(toSend[w]);
+ try
{
- 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);
+ var resp = await session.WriteAsync(
+ requestHeader: null,
+ nodesToWrite: slice,
+ 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)
+ {
+ // 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);
}
}
- 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);
- }
+ _diag.RecordWrite(anyFailed);
}
finally { _gate.Release(); }
return results;
@@ -1099,16 +1319,27 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.Historizing });
}
- DataValueCollection values;
+ // #275: split the attribute reads to the server's MaxNodesPerRead (each variable contributes 4).
+ var values = new DataValueCollection(nodesToRead.Count);
try
{
- var resp = await session.ReadAsync(
- requestHeader: null,
- maxAge: 0,
- timestampsToReturn: TimestampsToReturn.Neither,
- nodesToRead: nodesToRead,
- ct: ct).ConfigureAwait(false);
- values = resp.Results;
+ 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;
}
catch
{
@@ -1230,20 +1461,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Floor the publishing interval at 50ms — OPC UA servers routinely negotiate
// minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation
- // bandwidth on every subscription create.
- var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50)
- ? 50
- : (int)publishingInterval.TotalMilliseconds;
+ // bandwidth on every subscription create. #273: the options may pin the interval.
+ var intervalMs = ResolvePublishingIntervalMs(_options, publishingInterval);
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
{
DisplayName = $"opcua-sub-{id}",
PublishingInterval = intervalMs,
- KeepAliveCount = 10,
- LifetimeCount = 1000,
- MaxNotificationsPerPublish = 0,
+ KeepAliveCount = _options.SubscriptionKeepAliveCount,
+ LifetimeCount = _options.SubscriptionLifetimeCount,
+ MaxNotificationsPerPublish = _options.SubscriptionMaxNotificationsPerPublish,
PublishingEnabled = true,
- Priority = 0,
+ Priority = _options.SubscriptionPriority,
TimestampsToReturn = TimestampsToReturn.Both,
});
@@ -1263,6 +1492,7 @@ 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;
@@ -1287,6 +1517,15 @@ 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);
@@ -1336,6 +1575,7 @@ 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));
var snapshot = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
@@ -1405,9 +1645,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
DisplayName = $"opcua-alarm-sub-{id}",
PublishingInterval = 500, // 500ms — alarms don't need fast polling; the server pushes
- KeepAliveCount = 10,
- LifetimeCount = 1000,
- MaxNotificationsPerPublish = 0,
+ KeepAliveCount = _options.SubscriptionKeepAliveCount,
+ LifetimeCount = _options.SubscriptionLifetimeCount,
+ MaxNotificationsPerPublish = _options.SubscriptionMaxNotificationsPerPublish,
PublishingEnabled = true,
Priority = 0,
TimestampsToReturn = TimestampsToReturn.Both,
@@ -1988,6 +2228,49 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
+ /// Hook the SDK's publish-side events into the diagnostics counters (#276).
+ /// The session to observe.
+ private void AttachDiagnostics(ISession session)
+ {
+ _notificationHandler ??= (_, e) =>
+ {
+ _diag.RecordPublishResponse();
+ var msg = e.NotificationMessage;
+ if (msg?.NotificationData is { } data)
+ foreach (var nd in data)
+ _diag.RecordNotifications(CountNotifications(nd));
+ };
+ _publishErrorHandler ??= (_, _) => _diag.RecordPublishError();
+ session.Notification -= _notificationHandler;
+ session.Notification += _notificationHandler;
+ session.PublishError -= _publishErrorHandler;
+ session.PublishError += _publishErrorHandler;
+ }
+
+ private void DetachDiagnostics(ISession session)
+ {
+ try
+ {
+ if (_notificationHandler is not null) session.Notification -= _notificationHandler;
+ if (_publishErrorHandler is not null) session.PublishError -= _publishErrorHandler;
+ }
+ catch { /* best-effort — SDK may have already cleared its invocation lists on session loss */ }
+ }
+
+ private static int CountNotifications(ExtensionObject nd) => nd.Body switch
+ {
+ DataChangeNotification dc => dc.MonitoredItems?.Count ?? 0,
+ EventNotificationList ev => ev.Events?.Count ?? 0,
+ StatusChangeNotification => 1,
+ _ => 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
@@ -2003,6 +2286,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
TransitionTo(HostState.Stopped);
+ _diag.RecordKeepAliveMiss();
// 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
@@ -2022,6 +2306,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();
handler.BeginReconnect(
sender,
(int)_options.ReconnectPeriod.TotalMilliseconds,
@@ -2062,6 +2347,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
newSession.KeepAlive += _keepAliveHandler;
}
+ // Detach-then-attach: the SDK may hand back the SAME session object, and -= on a handler that
+ // is not attached is a no-op, so this never double-counts.
+ if (oldSession is not null) DetachDiagnostics(oldSession);
+ if (newSession is not null) AttachDiagnostics(newSession);
Session = newSession;
@@ -2084,6 +2373,8 @@ 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);
return;
}
@@ -2096,6 +2387,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();
_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/OpcUaClientOperationLimits.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientOperationLimits.cs
new file mode 100644
index 00000000..7f38ac5c
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientOperationLimits.cs
@@ -0,0 +1,94 @@
+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 1dace2bb..ba66a78e 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
@@ -185,6 +185,63 @@
+@* Subscriptions, limits, revocation, discovery (#273/#275/#277/#278) *@
+
+
Subscriptions, limits & discovery
+
+
+
+
+
+
Blank = use the interval the runtime requests per subscription.