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..f8a1d6de 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,67 @@ 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. 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. **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)
+
+The per-subscription parameters that used to be hardcoded are driver options; the defaults are the old values.
+
+| Key | Default | Notes |
+|---|---|---|
+| `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 (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)
+
+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, 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)
+
+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/IHistoryProvider.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs
index 684b8b7c..5b8be190 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs
@@ -132,6 +132,14 @@ public sealed record HistoryReadResult(
byte[]? ContinuationPoint);
/// Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.
+///
+/// The first five members are the original set every historian backend serves. The remainder (#285) are
+/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
+/// supports them (the OPC UA Client driver forwards all of them upstream). A backend that cannot serve a
+/// member must throw — the server maps that to
+/// BadAggregateNotSupported rather than a generic failure. Append only: the numeric values
+/// are not persisted, but every switch over this enum must keep a total default.
+///
public enum HistoryAggregateType
{
Average,
@@ -139,6 +147,42 @@ public enum HistoryAggregateType
Maximum,
Total,
Count,
+ Interpolative,
+ TimeAverage,
+ Start,
+ End,
+ Delta,
+ Range,
+ MinimumActualTime,
+ MaximumActualTime,
+ StartBound,
+ EndBound,
+ DeltaBounds,
+ DurationGood,
+ DurationBad,
+ PercentGood,
+ PercentBad,
+ WorstQuality,
+ NumberOfTransitions,
+ AnnotationCount,
+ StandardDeviationPopulation,
+ VariancePopulation,
+}
+
+///
+/// Thrown by a history backend when it cannot serve the requested .
+/// The Server HistoryRead path translates it to BadAggregateNotSupported for that node only.
+///
+public sealed class HistoryAggregateNotSupportedException : NotSupportedException
+{
+ /// The aggregate the backend could not serve.
+ public HistoryAggregateType Aggregate { get; }
+
+ /// Creates the exception for .
+ /// The unsupported aggregate.
+ public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate)
+ : base($"The historian backend does not support the {aggregate} aggregate.")
+ => Aggregate = aggregate;
}
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs
index 033f5403..556bbde2 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs
@@ -79,11 +79,13 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
+ // Mapped OUTSIDE the failure-recording try: an aggregate the gateway has no mode for is a client
+ // request the server answers BadAggregateNotSupported, not a backend fault to count (#285).
+ var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
try
{
// Total/Count are now native gateway retrieval modes — no client-side scaling
// (unlike the Wonderware path that derived Total as Average × interval-seconds).
- var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
var buckets = new List();
await foreach (var bucket in _client
.ReadAggregateAsync(fullReference, startUtc, endUtc, mode, interval, cancellationToken)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs
index f3d8b3a0..09eb3921 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs
@@ -25,7 +25,8 @@ internal static class AggregateModeMapper
/// Maps an aggregate function to the gateway retrieval mode.
/// The driver-agnostic aggregate function.
/// The matching gateway .
- /// A future, unmapped enum member (fails the matrix guard).
+ /// A defined member the gateway has no retrieval mode for.
+ /// An undefined enum value.
public static RetrievalMode ToRetrievalMode(HistoryAggregateType aggregate) => aggregate switch
{
HistoryAggregateType.Average => RetrievalMode.TimeWeightedAverage,
@@ -33,6 +34,10 @@ internal static class AggregateModeMapper
HistoryAggregateType.Maximum => RetrievalMode.MaximumWithTime,
HistoryAggregateType.Total => RetrievalMode.Integral,
HistoryAggregateType.Count => RetrievalMode.Counter,
+ // Part 13 aggregates the gateway has no retrieval mode for (#285) — an UNDEFINED enum value is a
+ // programming error, but a defined-yet-unserved member is a normal client request the server
+ // answers with BadAggregateNotSupported.
+ _ when Enum.IsDefined(aggregate) => throw new HistoryAggregateNotSupportedException(aggregate),
_ => throw new ArgumentOutOfRangeException(
nameof(aggregate), aggregate, "Unmapped HistoryAggregateType — add a RetrievalMode mapping."),
};
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..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
@@ -189,6 +189,81 @@ 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.
+ ///
+ 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).
+ ///
+ 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).
+ public byte SubscriptionPriority { get; init; }
+
+ // ---- #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 +
+ /// 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; }
+}
+
+///
+/// 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..2d379a63
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDiagnostics.cs
@@ -0,0 +1,48 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Observability;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
+
+///
+/// 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
+{
+ /// A NotificationMessage arrived (one per Publish response that carried data).
+ public const string PublishResponse = "publish_response";
+ /// Items carried by notification messages.
+ public const string Notification = "notification";
+ /// One monitored-item data change was delivered.
+ 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 const string KeepAliveMiss = "keepalive_miss";
+ /// The reconnect handler was armed.
+ public const string ReconnectAttempt = "reconnect_attempt";
+ /// A reconnect landed a new session.
+ public const string ReconnectSuccess = "reconnect_success";
+ /// A reconnect handler exhausted its retry window.
+ public const string ReconnectExhausted = "reconnect_exhausted";
+
+ 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)
+ {
+ if (count <= 0) return;
+ OtOpcUaTelemetry.OpcUaClientEvents.Add(count,
+ new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, _driverInstanceId),
+ new KeyValuePair("event", evt));
+ }
+}
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 a5a3c25f..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,7 +145,16 @@ 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 view of the diagnostics counters (#276).
+ internal OpcUaClientDiagnostics DiagnosticsForTest => _diag;
+
private DriverHealth _health = new(DriverState.Unknown, null, null);
+
+ /// Per-instance event counters published as OTel metrics (#276).
+ private readonly OpcUaClientDiagnostics _diag;
+
+ private NotificationEventHandler? _notificationHandler;
+ private PublishErrorEventHandler? _publishErrorHandler;
private bool _disposed;
/// URL of the endpoint the driver actually connected to. Exposed via .
private string? _connectedEndpointUrl;
@@ -212,9 +222,36 @@ 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))
+ {
+ IReadOnlyList found = [];
+ try
+ {
+ found = 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; " +
+ "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);
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
// shared secret store ONCE, here in the async connect flow, just before the credentials
@@ -266,6 +303,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// via TransferSubscriptions so local MonitoredItem handles stay valid.
_keepAliveHandler = OnKeepAlive;
session.KeepAlive += _keepAliveHandler;
+ AttachDiagnostics(session);
// Build the bidirectional namespace map from the freshly negotiated session's
// NamespaceUris (driver-specs.md §8 "Namespace Remapping"). Stored NodeIds carry
@@ -331,7 +369,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
{
@@ -339,6 +381,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
@@ -364,8 +407,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.
@@ -381,6 +438,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
@@ -388,10 +451,173 @@ 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). 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).
+ /// 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 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;
+ }
+
+ ///
+ /// 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 found = 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; }
+
+ found.AddRange(FilterEndpointUrls(endpoints, wantedPolicyUri, wantedMode)
+ .Select(u => new DiscoveredEndpoint(server.ApplicationUri ?? string.Empty, u)));
+ }
+ }
+ 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.
+ /// 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;
}
///
@@ -510,13 +736,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);
@@ -625,6 +845,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
try { sessionToClose.KeepAlive -= _keepAliveHandler; } catch { }
}
_keepAliveHandler = null;
+ if (sessionToClose is not null) DetachDiagnostics(sessionToClose);
Session = null;
}
@@ -1245,20 +1466,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,
});
@@ -1351,6 +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.Record(OpcUaClientDiagnostics.DataChange);
+ if (StatusCode.IsBad(dv.StatusCode)) _diag.Record(OpcUaClientDiagnostics.BadStatusValue);
var snapshot = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
@@ -1420,9 +1641,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,
@@ -1794,6 +2015,26 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum,
HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total,
HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count,
+ HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
+ HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
+ HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start,
+ HistoryAggregateType.End => ObjectIds.AggregateFunction_End,
+ HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta,
+ HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range,
+ HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
+ HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
+ HistoryAggregateType.StartBound => ObjectIds.AggregateFunction_StartBound,
+ HistoryAggregateType.EndBound => ObjectIds.AggregateFunction_EndBound,
+ HistoryAggregateType.DeltaBounds => ObjectIds.AggregateFunction_DeltaBounds,
+ HistoryAggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood,
+ HistoryAggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad,
+ HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
+ HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
+ HistoryAggregateType.WorstQuality => ObjectIds.AggregateFunction_WorstQuality,
+ HistoryAggregateType.NumberOfTransitions => ObjectIds.AggregateFunction_NumberOfTransitions,
+ HistoryAggregateType.AnnotationCount => ObjectIds.AggregateFunction_AnnotationCount,
+ HistoryAggregateType.StandardDeviationPopulation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
+ HistoryAggregateType.VariancePopulation => ObjectIds.AggregateFunction_VariancePopulation,
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null),
};
@@ -1983,6 +2224,43 @@ 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.Record(OpcUaClientDiagnostics.PublishResponse);
+ var msg = e.NotificationMessage;
+ if (msg?.NotificationData is { } data)
+ foreach (var nd in data)
+ _diag.Record(OpcUaClientDiagnostics.Notification, CountNotifications(nd));
+ };
+ _publishErrorHandler ??= (_, _) => _diag.Record(OpcUaClientDiagnostics.PublishError);
+ 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,
+ };
+
///
/// 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
@@ -1998,6 +2276,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
TransitionTo(HostState.Stopped);
+ _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
@@ -2017,6 +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.Record(OpcUaClientDiagnostics.ReconnectAttempt);
handler.BeginReconnect(
sender,
(int)_options.ReconnectPeriod.TotalMilliseconds,
@@ -2057,6 +2337,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;
@@ -2079,6 +2363,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
RecordSkippedTags(_namespaceMap);
TransitionTo(HostState.Running);
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
+ _diag.Record(OpcUaClientDiagnostics.ReconnectSuccess);
return;
}
@@ -2091,6 +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.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/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..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
@@ -185,6 +185,61 @@
+@* Subscriptions, limits, revocation, discovery (#273/#275/#277/#278) *@
+
+
Subscriptions, limits & discovery
+
+
+
+
+
+
Blank = use the interval the runtime requests per subscription.
+
+
+
+
+
Default 10.
+
+
+
+
+
Default 1000; at least 3x keep-alive count.
+
+
+
+
+
0 = server decides.
+
+
+
+
+
+
+
+
+
+
+
Revoked certificates are always rejected.
+
+
+
+
+
CRLs are read from its crl/ sub-folder. Blank = default PKI store.
+
+
+
+
+
Optional. Discovered endpoints are tried after the failover list.
+
+
+
+
+
Required when the discovery server lists more than one server.
+
+
+
+
+
@* Diagnostics *@
Diagnostics
@@ -318,6 +373,18 @@
public int ProbeTimeoutSeconds { get; set; } = 15;
+ // #273 / #275 / #277 / #278 — without these the typed form silently drops the keys on save (#576).
+ public int? SubscriptionPublishingIntervalMs { get; set; }
+ // int, not uint/byte: InputNumber 500s the page (#505).
+ public int SubscriptionKeepAliveCount { get; set; } = 10;
+ public int SubscriptionLifetimeCount { get; set; } = 1000;
+ public int SubscriptionMaxNotificationsPerPublish { get; set; }
+ public int SubscriptionPriority { get; set; }
+ public bool RejectUnknownRevocationStatus { get; set; }
+ public string? IssuerStorePath { get; set; }
+ public string? DiscoveryUrl { get; set; }
+ public string? DiscoveryServerUri { get; set; }
+
internal IReadOnlyDictionary _unsMappingTable = new Dictionary();
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
@@ -343,6 +410,15 @@
UserCertificatePassword = r.UserCertificatePassword,
TargetNamespaceKind = r.TargetNamespaceKind,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
+ SubscriptionPublishingIntervalMs = r.SubscriptionPublishingInterval is { } spi ? (int)spi.TotalMilliseconds : null,
+ SubscriptionKeepAliveCount = (int)Math.Min(r.SubscriptionKeepAliveCount, int.MaxValue),
+ SubscriptionLifetimeCount = (int)Math.Min(r.SubscriptionLifetimeCount, int.MaxValue),
+ SubscriptionMaxNotificationsPerPublish = (int)Math.Min(r.SubscriptionMaxNotificationsPerPublish, int.MaxValue),
+ SubscriptionPriority = r.SubscriptionPriority,
+ RejectUnknownRevocationStatus = r.CertificateValidation.RejectUnknownRevocationStatus,
+ IssuerStorePath = r.CertificateValidation.IssuerStorePath,
+ DiscoveryUrl = r.DiscoveryUrl,
+ DiscoveryServerUri = r.DiscoveryServerUri,
_unsMappingTable = r.UnsMappingTable,
};
@@ -371,6 +447,19 @@
TargetNamespaceKind = TargetNamespaceKind,
UnsMappingTable = _unsMappingTable,
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
+ SubscriptionPublishingInterval = SubscriptionPublishingIntervalMs is { } ms and > 0
+ ? TimeSpan.FromMilliseconds(ms) : null,
+ SubscriptionKeepAliveCount = (uint)Math.Max(SubscriptionKeepAliveCount, 0),
+ SubscriptionLifetimeCount = (uint)Math.Max(SubscriptionLifetimeCount, 0),
+ SubscriptionMaxNotificationsPerPublish = (uint)Math.Max(SubscriptionMaxNotificationsPerPublish, 0),
+ SubscriptionPriority = (byte)Math.Clamp(SubscriptionPriority, 0, 255),
+ CertificateValidation = new OpcUaCertificateValidationOptions
+ {
+ RejectUnknownRevocationStatus = RejectUnknownRevocationStatus,
+ IssuerStorePath = string.IsNullOrWhiteSpace(IssuerStorePath) ? null : IssuerStorePath,
+ },
+ DiscoveryUrl = string.IsNullOrWhiteSpace(DiscoveryUrl) ? null : DiscoveryUrl,
+ DiscoveryServerUri = string.IsNullOrWhiteSpace(DiscoveryServerUri) ? null : DiscoveryServerUri,
};
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 91cca9b0..22db9ecd 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -5169,6 +5169,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
+ catch (HistoryAggregateNotSupportedException ex)
+ {
+ // The backend cannot serve this Part 13 aggregate (#285) — the spec status for THIS node,
+ // not a logged server fault.
+ _logger.LogDebug(ex, "OtOpcUaNodeManager: backend does not support aggregate for node {NodeId}", handle.NodeId);
+ errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
+ results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported };
+ }
catch (Exception ex)
{
// One node's backend failure (throw / timeout) must not throw out of the batch — surface a
@@ -5424,6 +5432,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_Interpolative) return HistoryAggregateType.Interpolative;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_TimeAverage) return HistoryAggregateType.TimeAverage;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_Start) return HistoryAggregateType.Start;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_End) return HistoryAggregateType.End;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_Delta) return HistoryAggregateType.Delta;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_Range) return HistoryAggregateType.Range;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_MinimumActualTime) return HistoryAggregateType.MinimumActualTime;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_MaximumActualTime) return HistoryAggregateType.MaximumActualTime;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_StartBound) return HistoryAggregateType.StartBound;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_EndBound) return HistoryAggregateType.EndBound;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_DeltaBounds) return HistoryAggregateType.DeltaBounds;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_DurationGood) return HistoryAggregateType.DurationGood;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_DurationBad) return HistoryAggregateType.DurationBad;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_PercentGood) return HistoryAggregateType.PercentGood;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_PercentBad) return HistoryAggregateType.PercentBad;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_WorstQuality) return HistoryAggregateType.WorstQuality;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_NumberOfTransitions) return HistoryAggregateType.NumberOfTransitions;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_AnnotationCount) return HistoryAggregateType.AnnotationCount;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_StandardDeviationPopulation) return HistoryAggregateType.StandardDeviationPopulation;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_VariancePopulation) return HistoryAggregateType.VariancePopulation;
return null;
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs
index 871b513f..a608b500 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs
@@ -16,10 +16,24 @@ public sealed class AggregateModeMapperTests
public void Maps_each_aggregate(HistoryAggregateType a, RetrievalMode expected)
=> Assert.Equal(expected, AggregateModeMapper.ToRetrievalMode(a));
- [Fact] // matrix guard: a new HistoryAggregateType member must fail here
- public void Every_aggregate_member_is_mapped()
+ [Fact] // matrix guard: every member is either mapped or explicitly reported as not supported
+ public void Every_aggregate_member_is_mapped_or_reports_not_supported()
{
+ var original = new[]
+ {
+ HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
+ HistoryAggregateType.Total, HistoryAggregateType.Count,
+ };
foreach (var a in Enum.GetValues())
- _ = AggregateModeMapper.ToRetrievalMode(a); // must not throw for any defined member
+ {
+ if (original.Contains(a))
+ _ = AggregateModeMapper.ToRetrievalMode(a);
+ else
+ Assert.Throws(() => AggregateModeMapper.ToRetrievalMode(a));
+ }
}
+
+ [Fact]
+ public void Undefined_value_is_a_programming_error_not_a_not_supported()
+ => Assert.Throws(() => AggregateModeMapper.ToRetrievalMode((HistoryAggregateType)9999));
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiagnosticsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiagnosticsTests.cs
new file mode 100644
index 00000000..acb3497b
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiagnosticsTests.cs
@@ -0,0 +1,131 @@
+using System.Diagnostics.Metrics;
+using Opc.Ua;
+using Opc.Ua.Client;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
+
+///
+/// #276 — session/publish events are published on the otopcua.driver.opcuaclient.events OTel counter.
+/// The listener filters on a UNIQUE driver_instance tag: the Meter is process-static, so a
+/// name-only filter would also collect parallel tests' measurements.
+///
+[Trait("Category", "Unit")]
+public sealed class OpcUaClientDiagnosticsTests
+{
+ private sealed class Capture : IDisposable
+ {
+ private readonly MeterListener _listener = new();
+ private readonly object _gate = new();
+ private readonly Dictionary _byEvent = new();
+
+ public Capture(string instance)
+ {
+ _listener.InstrumentPublished = (inst, l) =>
+ {
+ if (inst.Name == "otopcua.driver.opcuaclient.events") l.EnableMeasurementEvents(inst);
+ };
+ _listener.SetMeasurementEventCallback((_, value, tags, _) =>
+ {
+ string? driver = null, evt = null;
+ foreach (var t in tags)
+ {
+ if (t.Key == "driver_instance") driver = t.Value as string;
+ else if (t.Key == "event") evt = t.Value as string;
+ }
+ if (driver != instance || evt is null) return;
+ lock (_gate) _byEvent[evt] = _byEvent.GetValueOrDefault(evt) + value;
+ });
+ _listener.Start();
+ }
+
+ public long this[string evt] { get { lock (_gate) return _byEvent.GetValueOrDefault(evt); } }
+ public void Dispose() => _listener.Dispose();
+ }
+
+ [Fact]
+ public void Each_event_lands_on_its_own_tagged_series()
+ {
+ var id = $"diag-{Guid.NewGuid():N}";
+ using var cap = new Capture(id);
+ var d = new OpcUaClientDiagnostics(id);
+
+ d.Record(OpcUaClientDiagnostics.PublishResponse);
+ d.Record(OpcUaClientDiagnostics.Notification, 3);
+ d.Record(OpcUaClientDiagnostics.DataChange);
+ d.Record(OpcUaClientDiagnostics.BadStatusValue);
+ d.Record(OpcUaClientDiagnostics.PublishError);
+ d.Record(OpcUaClientDiagnostics.KeepAliveMiss);
+ d.Record(OpcUaClientDiagnostics.ReconnectAttempt);
+ d.Record(OpcUaClientDiagnostics.ReconnectSuccess);
+ d.Record(OpcUaClientDiagnostics.ReconnectExhausted);
+
+ cap[OpcUaClientDiagnostics.PublishResponse].ShouldBe(1);
+ cap[OpcUaClientDiagnostics.Notification].ShouldBe(3);
+ cap[OpcUaClientDiagnostics.DataChange].ShouldBe(1);
+ cap[OpcUaClientDiagnostics.BadStatusValue].ShouldBe(1);
+ cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1);
+ cap[OpcUaClientDiagnostics.KeepAliveMiss].ShouldBe(1);
+ cap[OpcUaClientDiagnostics.ReconnectAttempt].ShouldBe(1);
+ cap[OpcUaClientDiagnostics.ReconnectSuccess].ShouldBe(1);
+ cap[OpcUaClientDiagnostics.ReconnectExhausted].ShouldBe(1);
+ }
+
+ [Fact]
+ public void Another_instances_events_are_not_attributed_to_this_one()
+ {
+ var mine = $"diag-{Guid.NewGuid():N}";
+ using var cap = new Capture(mine);
+
+ // Positive control: my own event IS seen; the other instance's identical event is not.
+ new OpcUaClientDiagnostics("someone-else").Record(OpcUaClientDiagnostics.KeepAliveMiss);
+ new OpcUaClientDiagnostics(mine).Record(OpcUaClientDiagnostics.PublishError);
+
+ cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1);
+ cap[OpcUaClientDiagnostics.KeepAliveMiss].ShouldBe(0);
+ }
+
+ [Fact]
+ public void Concurrent_records_do_not_lose_updates()
+ {
+ var id = $"diag-{Guid.NewGuid():N}";
+ using var cap = new Capture(id);
+ var d = new OpcUaClientDiagnostics(id);
+ const int threads = 8, each = 2_000;
+
+ Parallel.For(0, threads, _ =>
+ {
+ for (var i = 0; i < each; i++) d.Record(OpcUaClientDiagnostics.DataChange);
+ });
+
+ cap[OpcUaClientDiagnostics.DataChange].ShouldBe(threads * each);
+ }
+
+ [Fact]
+ public void Zero_or_negative_counts_are_ignored()
+ {
+ var id = $"diag-{Guid.NewGuid():N}";
+ using var cap = new Capture(id);
+ var d = new OpcUaClientDiagnostics(id);
+
+ d.Record(OpcUaClientDiagnostics.Notification, 0);
+ d.Record(OpcUaClientDiagnostics.Notification, -2);
+ d.Record(OpcUaClientDiagnostics.DataChange); // positive control
+
+ cap[OpcUaClientDiagnostics.Notification].ShouldBe(0);
+ cap[OpcUaClientDiagnostics.DataChange].ShouldBe(1);
+ }
+
+ ///
+ /// #275: the driver does not split Read/Write/CreateMonitoredItems itself because the SDK session already
+ /// does — Session derives from SessionClientBatched (which batches by the server's
+ /// OperationLimits, fetched on open). If an SDK bump ever drops that, this goes red and the driver needs
+ /// its own chunking again.
+ ///
+ [Fact]
+ public void The_sdk_session_is_the_batching_session_type()
+ {
+ typeof(SessionClientBatched).IsAssignableFrom(typeof(Session)).ShouldBeTrue();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
index a515dac5..7fe81eca 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
@@ -8,14 +8,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
[Trait("Category", "Unit")]
public sealed class OpcUaClientHistoryTests
{
+ /// Every member (#285: the full Part 13 set, not just five).
+ public static IEnumerable