feat(opcuaclient): subscription tuning, OperationLimits chunking, diagnostics counters, revocation options, DiscoveryUrl (#273 #275 #276 #277 #278)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
/// </param>
|
||||
/// <param name="Diagnostics">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public sealed record DriverHealth(
|
||||
DriverState State,
|
||||
DateTime? LastSuccessfulRead,
|
||||
string? LastError);
|
||||
string? LastError,
|
||||
IReadOnlyDictionary<string, double>? Diagnostics = null);
|
||||
|
||||
/// <summary>Driver-instance lifecycle state.</summary>
|
||||
public enum DriverState
|
||||
|
||||
+80
@@ -189,6 +189,86 @@ public sealed record OpcUaClientDriverOptions
|
||||
/// </summary>
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = 15;
|
||||
|
||||
// ---- #273 per-subscription tuning (defaults are the values that used to be hardcoded) ----
|
||||
|
||||
/// <summary>
|
||||
/// Fixed publishing interval for every data-change subscription this driver creates. <c>null</c>
|
||||
/// (default) keeps the historical behaviour: the interval the runtime asks for per
|
||||
/// <c>SubscribeAsync</c> 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.
|
||||
/// </summary>
|
||||
public TimeSpan? SubscriptionPublishingInterval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Publish cycles with no data before the server sends a keep-alive (default 10). Must be >= 1.
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public uint SubscriptionKeepAliveCount { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Publish cycles with no Publish request before the server drops the subscription (default 1000).
|
||||
/// Must be at least 3x <see cref="SubscriptionKeepAliveCount"/> (OPC UA Part 4 §5.13.2).
|
||||
/// </summary>
|
||||
[Range(3, int.MaxValue)]
|
||||
public uint SubscriptionLifetimeCount { get; init; } = 1000;
|
||||
|
||||
/// <summary>Max notifications per Publish response; <c>0</c> (default) = server decides.</summary>
|
||||
public uint SubscriptionMaxNotificationsPerPublish { get; init; }
|
||||
|
||||
/// <summary>Relative subscription priority 0-255 (default 0).</summary>
|
||||
[Range(0, 255)]
|
||||
public byte SubscriptionPriority { get; init; }
|
||||
|
||||
// ---- #275 ----
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c> (default) the driver reads the remote server's
|
||||
/// <c>ServerCapabilities/OperationLimits</c> after connect and splits Read / Write / CreateMonitoredItems
|
||||
/// requests that exceed them (a declared limit of 0 = unlimited). Set <c>false</c> to send every request
|
||||
/// whole and let the server answer <c>BadTooManyOperations</c> itself.
|
||||
/// </summary>
|
||||
public bool HonorServerOperationLimits { get; init; } = true;
|
||||
|
||||
// ---- #277 ----
|
||||
|
||||
/// <summary>Certificate-revocation / issuer-store settings. Defaults preserve the SDK behaviour.</summary>
|
||||
public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
|
||||
|
||||
// ---- #278 ----
|
||||
|
||||
/// <summary>
|
||||
/// Optional discovery endpoint (a Local Discovery Server or the server itself), e.g.
|
||||
/// <c>opc.tcp://lds.internal:4840</c>. When set, <c>InitializeAsync</c> calls <c>FindServers</c> and
|
||||
/// <c>GetEndpoints</c> on it and tries the discovered URLs that match <see cref="SecurityPolicy"/> +
|
||||
/// <see cref="SecurityMode"/> <i>before</i> the statically configured <see cref="EndpointUrls"/> /
|
||||
/// <see cref="EndpointUrl"/>. A discovery failure is non-fatal: the static list is used. Discovery
|
||||
/// itself always runs over an unsecured channel, as the spec requires. <c>null</c> (default) = off.
|
||||
/// </summary>
|
||||
public string? DiscoveryUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-certificate revocation handling for the OPC UA Client driver (#277). The SDK reads CRLs from
|
||||
/// the <c>crl</c> 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.
|
||||
/// </summary>
|
||||
public sealed record OpcUaCertificateValidationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// When <c>true</c>, a server certificate whose revocation status is unknown (no CRL for its issuer, or a
|
||||
/// stale one) is rejected. Default <c>false</c> keeps brownfield deployments without CRL infrastructure
|
||||
/// connecting. A certificate that IS revoked is always rejected — even with
|
||||
/// <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/> set.
|
||||
/// </summary>
|
||||
public bool RejectUnknownRevocationStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory used as the trusted-issuer certificate store. Its <c>crl/</c> sub-folder is where the SDK
|
||||
/// looks for issuer CRLs (<c>.crl</c> files), so this is the knob for pointing the driver at an
|
||||
/// operator-managed CRL set. <c>null</c>/empty (default) = <c>%LocalAppData%/OtOpcUa/pki/issuers</c>.
|
||||
/// </summary>
|
||||
public string? IssuerStorePath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe counters for the OPC UA Client driver (#276), surfaced through
|
||||
/// <c>DriverHealth.Diagnostics</c>. Writers are the SDK's notification / keep-alive threads and
|
||||
/// the request paths, so every field is touched only through <see cref="Interlocked"/>.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>A NotificationMessage arrived (one per Publish response that carried data).</summary>
|
||||
public void RecordPublishResponse() => Interlocked.Increment(ref _publishResponses);
|
||||
|
||||
/// <summary>A Publish failed (missing publish requests, subscription errors).</summary>
|
||||
public void RecordPublishError() => Interlocked.Increment(ref _publishErrors);
|
||||
|
||||
/// <summary>One monitored-item data change was delivered.</summary>
|
||||
/// <param name="bad">Whether the value's status code was Bad.</param>
|
||||
public void RecordDataChange(bool bad)
|
||||
{
|
||||
Interlocked.Increment(ref _dataChanges);
|
||||
if (bad) Interlocked.Increment(ref _badStatusValues);
|
||||
}
|
||||
|
||||
/// <summary>Count raw notification items carried by a message.</summary>
|
||||
/// <param name="count">Number of items in the message.</param>
|
||||
public void RecordNotifications(int count) => Interlocked.Add(ref _notifications, count);
|
||||
|
||||
/// <summary>A keep-alive came back Bad.</summary>
|
||||
public void RecordKeepAliveMiss() => Interlocked.Increment(ref _keepAliveMisses);
|
||||
|
||||
/// <summary>The reconnect handler was armed.</summary>
|
||||
public void RecordReconnectAttempt() => Interlocked.Increment(ref _reconnectAttempts);
|
||||
|
||||
/// <summary>A reconnect landed a new session.</summary>
|
||||
public void RecordReconnectSucceeded()
|
||||
{
|
||||
Interlocked.Increment(ref _reconnectsSucceeded);
|
||||
Interlocked.Exchange(ref _lastReconnectUtcTicks, DateTime.UtcNow.Ticks);
|
||||
}
|
||||
|
||||
/// <summary>A reconnect handler exhausted its retry window.</summary>
|
||||
public void RecordReconnectExhausted() => Interlocked.Increment(ref _reconnectsExhausted);
|
||||
|
||||
/// <summary>A Read request (all chunks) finished.</summary>
|
||||
/// <param name="failed">Whether any chunk failed on the wire.</param>
|
||||
public void RecordRead(bool failed)
|
||||
{
|
||||
Interlocked.Increment(ref _readCalls);
|
||||
if (failed) Interlocked.Increment(ref _readFailures);
|
||||
}
|
||||
|
||||
/// <summary>A Write request (all chunks) finished.</summary>
|
||||
/// <param name="failed">Whether any chunk failed on the wire.</param>
|
||||
public void RecordWrite(bool failed)
|
||||
{
|
||||
Interlocked.Increment(ref _writeCalls);
|
||||
if (failed) Interlocked.Increment(ref _writeFailures);
|
||||
}
|
||||
|
||||
/// <summary>A logical request was split into more than one wire call to honour a server OperationLimit.</summary>
|
||||
public void RecordChunkedRequest() => Interlocked.Increment(ref _chunkedRequests);
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time copy for <c>DriverHealth.Diagnostics</c>. Keys are stable and
|
||||
/// <c>PascalCase</c>; <c>LastReconnectUtcTicks</c> is 0 until a reconnect succeeds.
|
||||
/// </summary>
|
||||
/// <returns>A fresh dictionary the caller may keep.</returns>
|
||||
public IReadOnlyDictionary<string, double> Snapshot() => new Dictionary<string, double>
|
||||
{
|
||||
["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),
|
||||
};
|
||||
}
|
||||
@@ -144,7 +144,24 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <param name="map">The namespace map to install, or <c>null</c> to clear it.</param>
|
||||
internal void SetNamespaceMapForTest(NamespaceMap? map) => _namespaceMap = map;
|
||||
|
||||
/// <summary>Test-only seam installing the OperationLimits that connect would have read (#275).</summary>
|
||||
/// <param name="limits">The limits to apply to subsequent Read / Write / subscribe calls.</param>
|
||||
internal void SetOperationLimitsForTest(OpcUaClientOperationLimits limits) => _limits = limits;
|
||||
|
||||
/// <summary>Test-only view of the diagnostics counters (#276).</summary>
|
||||
internal OpcUaClientDiagnostics DiagnosticsForTest => _diag;
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Counters surfaced through <see cref="GetHealth"/> (#276).</summary>
|
||||
private readonly OpcUaClientDiagnostics _diag = new();
|
||||
|
||||
/// <summary>Server OperationLimits read at connect (#275); <c>None</c> when unread / disabled.
|
||||
/// Replaced wholesale (never mutated), so readers just snapshot the reference.</summary>
|
||||
private OpcUaClientOperationLimits _limits = OpcUaClientOperationLimits.None;
|
||||
|
||||
private NotificationEventHandler? _notificationHandler;
|
||||
private PublishErrorEventHandler? _publishErrorHandler;
|
||||
private bool _disposed;
|
||||
/// <summary>URL of the endpoint the driver actually connected to. Exposed via <see cref="HostName"/>.</summary>
|
||||
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<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>True for the two hard "this certificate (or its issuer) is revoked" results (#277).</summary>
|
||||
/// <param name="status">The SDK's certificate-validation status.</param>
|
||||
/// <returns><c>true</c> when the status is Revoked / IssuerRevoked.</returns>
|
||||
internal static bool IsRevocationFailure(StatusCode status) =>
|
||||
status.Code == StatusCodes.BadCertificateRevoked || status.Code == StatusCodes.BadCertificateIssuerRevoked;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the ordered failover candidate list. <c>EndpointUrls</c> wins when
|
||||
/// non-empty; otherwise fall back to <c>EndpointUrl</c> as a single-URL shortcut so
|
||||
@@ -384,10 +452,136 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// </summary>
|
||||
/// <param name="opts">Driver options containing endpoint configuration.</param>
|
||||
/// <returns>The ordered list of endpoint URLs to try.</returns>
|
||||
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts)
|
||||
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts) =>
|
||||
ResolveEndpointCandidates(opts, []);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the ordered failover list with optional discovery results (#278). Discovered URLs are
|
||||
/// <b>prepended</b> 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.
|
||||
/// </summary>
|
||||
/// <param name="opts">Driver options containing endpoint configuration.</param>
|
||||
/// <param name="discovered">URLs resolved via <see cref="DiscoverEndpointsAsync"/> (may be empty).</param>
|
||||
/// <returns>The ordered list of endpoint URLs to try.</returns>
|
||||
internal static IReadOnlyList<string> ResolveEndpointCandidates(
|
||||
OpcUaClientDriverOptions opts, IReadOnlyList<string> discovered)
|
||||
{
|
||||
if (opts.EndpointUrls is { Count: > 0 }) return opts.EndpointUrls;
|
||||
return [opts.EndpointUrl];
|
||||
IReadOnlyList<string> staticList = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls : [opts.EndpointUrl];
|
||||
if (discovered.Count == 0) return staticList;
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var merged = new List<string>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>FindServers</c> on <paramref name="discoveryUrl"/> (an LDS, or the server itself), then
|
||||
/// <c>GetEndpoints</c> 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.
|
||||
/// </summary>
|
||||
/// <param name="appConfig">Application configuration (client identity for the discovery channel).</param>
|
||||
/// <param name="discoveryUrl">The LDS / server discovery URL.</param>
|
||||
/// <param name="policy">Required security policy.</param>
|
||||
/// <param name="mode">Required security mode.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Matching endpoint URLs in discovery order, de-duplicated.</returns>
|
||||
internal static async Task<IReadOnlyList<string>> 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<string>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>The endpoint URLs in <paramref name="endpoints"/> matching the wanted policy URI + mode.</summary>
|
||||
/// <param name="endpoints">Endpoints advertised by a server.</param>
|
||||
/// <param name="wantedPolicyUri">Required <c>SecurityPolicyUri</c>.</param>
|
||||
/// <param name="wantedMode">Required message security mode.</param>
|
||||
/// <returns>Matching <c>EndpointUrl</c>s, in advertised order.</returns>
|
||||
internal static IEnumerable<string> FilterEndpointUrls(
|
||||
IEnumerable<EndpointDescription> 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)),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Range checks for the subscription-tuning and discovery options (#273/#278). Throws at
|
||||
/// <see cref="InitializeAsync"/> so a bad config fails like the namespace-kind check does rather
|
||||
/// than surfacing later as a server-side <c>BadInvalidArgument</c> on every subscribe.
|
||||
/// </summary>
|
||||
/// <param name="opts">The options to validate.</param>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="opts">Driver options.</param>
|
||||
/// <param name="requested">The per-call interval from <c>SubscribeAsync</c>.</param>
|
||||
/// <returns>The publishing interval in milliseconds.</returns>
|
||||
internal static int ResolvePublishingIntervalMs(OpcUaClientDriverOptions opts, TimeSpan requested)
|
||||
{
|
||||
var effective = opts.SubscriptionPublishingInterval ?? requested;
|
||||
return effective < TimeSpan.FromMilliseconds(50) ? 50 : (int)effective.TotalMilliseconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<MonitoredItemNotificationHandle>();
|
||||
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)];
|
||||
}
|
||||
|
||||
/// <summary>Hook the SDK's publish-side events into the diagnostics counters (#276).</summary>
|
||||
/// <param name="session">The session to observe.</param>
|
||||
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 */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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. " +
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// The remote server's <c>Server/ServerCapabilities/OperationLimits</c> that bound how many
|
||||
/// items one service call may carry (#275). Every field is <c>0</c> = "no limit declared" (the
|
||||
/// OPC UA convention), so <see cref="None"/> is the safe value when the server publishes nothing
|
||||
/// or the read fails.
|
||||
/// </summary>
|
||||
/// <param name="MaxNodesPerRead">Items per Read.</param>
|
||||
/// <param name="MaxNodesPerWrite">Items per Write.</param>
|
||||
/// <param name="MaxNodesPerBrowse">Nodes per Browse.</param>
|
||||
/// <param name="MaxMonitoredItemsPerCall">Items per CreateMonitoredItems / Modify / SetMonitoringMode.</param>
|
||||
/// <param name="MaxNodesPerHistoryReadData">Nodes per HistoryRead (raw / processed / at-time).</param>
|
||||
/// <param name="MaxNodesPerHistoryReadEvents">Nodes per HistoryRead (events).</param>
|
||||
internal sealed record OpcUaClientOperationLimits(
|
||||
uint MaxNodesPerRead,
|
||||
uint MaxNodesPerWrite,
|
||||
uint MaxNodesPerBrowse,
|
||||
uint MaxMonitoredItemsPerCall,
|
||||
uint MaxNodesPerHistoryReadData,
|
||||
uint MaxNodesPerHistoryReadEvents)
|
||||
{
|
||||
/// <summary>No limits declared — every call goes out in one request.</summary>
|
||||
public static OpcUaClientOperationLimits None { get; } = new(0, 0, 0, 0, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Split <paramref name="total"/> items into consecutive <c>(Start, Count)</c> chunks of at most
|
||||
/// <paramref name="limit"/> each. <c>limit == 0</c> (no limit) or a total that already fits yields a
|
||||
/// single chunk; <c>total == 0</c> yields none.
|
||||
/// </summary>
|
||||
/// <param name="total">Number of items to send.</param>
|
||||
/// <param name="limit">The server's per-call cap; 0 = unlimited.</param>
|
||||
/// <returns>The chunk ranges, in order, covering <c>[0, total)</c> exactly once.</returns>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>The standard variables read to populate the limits, in <see cref="FromValues"/> order.</summary>
|
||||
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,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Build limits from the values read for <see cref="LimitNodeIds"/>. A Bad or non-<c>uint</c> value
|
||||
/// (server doesn't publish that limit) is treated as 0 = unlimited.
|
||||
/// </summary>
|
||||
/// <param name="values">One value per <see cref="LimitNodeIds"/> entry, in order.</param>
|
||||
/// <returns>The parsed limits.</returns>
|
||||
public static OpcUaClientOperationLimits FromValues(IReadOnlyList<DataValue> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the server's declared limits. Best-effort: any failure (older server without the
|
||||
/// capability object, transport error) yields <see cref="None"/> so connect never fails over this.
|
||||
/// </summary>
|
||||
/// <param name="session">The freshly connected session.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The declared limits, or <see cref="None"/>.</returns>
|
||||
public static async Task<OpcUaClientOperationLimits> 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; }
|
||||
}
|
||||
}
|
||||
+91
@@ -185,6 +185,63 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Subscriptions, limits, revocation, discovery (#273/#275/#277/#278) *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.13s">
|
||||
<div class="panel-head">Subscriptions, limits & discovery</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Publishing interval override (ms)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SubscriptionPublishingIntervalMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Blank = use the interval the runtime requests per subscription.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Keep-alive count</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SubscriptionKeepAliveCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Lifetime count</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SubscriptionLifetimeCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 1000; at least 3x keep-alive count.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max notifications per publish</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SubscriptionMaxNotificationsPerPublish" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = server decides.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Subscription priority (0-255)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SubscriptionPriority" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.OpcUa.HonorServerOperationLimits" @bind-Value:after="EmitAsync" class="form-check-input" id="honorOpLimits" />
|
||||
<label class="form-check-label" for="honorOpLimits">Honor server OperationLimits</label>
|
||||
</div>
|
||||
<div class="form-text">Split oversized Read / Write / monitored-item calls.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.OpcUa.RejectUnknownRevocationStatus" @bind-Value:after="EmitAsync" class="form-check-input" id="rejectUnkRevocation" />
|
||||
<label class="form-check-label" for="rejectUnkRevocation">Reject unknown revocation status</label>
|
||||
</div>
|
||||
<div class="form-text">Revoked certificates are always rejected.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Issuer store / CRL directory</label>
|
||||
<InputText @bind-Value="_form.OpcUa.IssuerStorePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" />
|
||||
<div class="form-text">CRLs are read from its <code>crl/</code> sub-folder. Blank = default PKI store.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Discovery URL (FindServers)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.DiscoveryUrl" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="opc.tcp://lds:4840" />
|
||||
<div class="form-text">Optional. Discovered endpoints are tried before the failover list.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Diagnostics *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Diagnostics</div>
|
||||
@@ -318,6 +375,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<byte> 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 HonorServerOperationLimits { get; set; } = true;
|
||||
public bool RejectUnknownRevocationStatus { get; set; }
|
||||
public string? IssuerStorePath { get; set; }
|
||||
public string? DiscoveryUrl { get; set; }
|
||||
|
||||
internal IReadOnlyDictionary<string, string> _unsMappingTable = new Dictionary<string, string>();
|
||||
|
||||
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
|
||||
@@ -343,6 +412,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,
|
||||
HonorServerOperationLimits = r.HonorServerOperationLimits,
|
||||
RejectUnknownRevocationStatus = r.CertificateValidation.RejectUnknownRevocationStatus,
|
||||
IssuerStorePath = r.CertificateValidation.IssuerStorePath,
|
||||
DiscoveryUrl = r.DiscoveryUrl,
|
||||
_unsMappingTable = r.UnsMappingTable,
|
||||
};
|
||||
|
||||
@@ -371,6 +449,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),
|
||||
HonorServerOperationLimits = HonorServerOperationLimits,
|
||||
CertificateValidation = new OpcUaCertificateValidationOptions
|
||||
{
|
||||
RejectUnknownRevocationStatus = RejectUnknownRevocationStatus,
|
||||
IssuerStorePath = string.IsNullOrWhiteSpace(IssuerStorePath) ? null : IssuerStorePath,
|
||||
},
|
||||
DiscoveryUrl = string.IsNullOrWhiteSpace(DiscoveryUrl) ? null : DiscoveryUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>#276 — counters are thread-safe and surface through <c>DriverHealth.Diagnostics</c>.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Fresh_snapshot_is_all_zero_with_the_full_key_set()
|
||||
{
|
||||
var snap = new OpcUaClientDiagnostics().Snapshot();
|
||||
|
||||
snap.Keys.ShouldBe(
|
||||
[
|
||||
"PublishResponseCount", "NotificationCount", "DataChangeCount", "BadStatusValueCount",
|
||||
"PublishErrorCount", "KeepAliveMissCount", "ReconnectAttemptCount", "ReconnectSuccessCount",
|
||||
"ReconnectExhaustedCount", "LastReconnectUtcTicks", "ReadCallCount", "ReadFailureCount",
|
||||
"WriteCallCount", "WriteFailureCount", "ChunkedRequestCount",
|
||||
], ignoreOrder: true);
|
||||
snap.Values.ShouldAllBe(v => v == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_recorder_moves_exactly_its_own_counter()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.RecordPublishResponse();
|
||||
d.RecordNotifications(3);
|
||||
d.RecordDataChange(bad: false);
|
||||
d.RecordDataChange(bad: true);
|
||||
d.RecordPublishError();
|
||||
d.RecordKeepAliveMiss();
|
||||
d.RecordReconnectAttempt();
|
||||
d.RecordReconnectSucceeded();
|
||||
d.RecordReconnectExhausted();
|
||||
d.RecordRead(failed: true);
|
||||
d.RecordWrite(failed: false);
|
||||
d.RecordChunkedRequest();
|
||||
|
||||
var s = d.Snapshot();
|
||||
s["PublishResponseCount"].ShouldBe(1);
|
||||
s["NotificationCount"].ShouldBe(3);
|
||||
s["DataChangeCount"].ShouldBe(2);
|
||||
s["BadStatusValueCount"].ShouldBe(1);
|
||||
s["PublishErrorCount"].ShouldBe(1);
|
||||
s["KeepAliveMissCount"].ShouldBe(1);
|
||||
s["ReconnectAttemptCount"].ShouldBe(1);
|
||||
s["ReconnectSuccessCount"].ShouldBe(1);
|
||||
s["ReconnectExhaustedCount"].ShouldBe(1);
|
||||
s["LastReconnectUtcTicks"].ShouldBeGreaterThan(0);
|
||||
s["ReadCallCount"].ShouldBe(1);
|
||||
s["ReadFailureCount"].ShouldBe(1);
|
||||
s["WriteCallCount"].ShouldBe(1);
|
||||
s["WriteFailureCount"].ShouldBe(0);
|
||||
s["ChunkedRequestCount"].ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concurrent_increments_do_not_lose_updates()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
const int threads = 8, each = 5_000;
|
||||
|
||||
Parallel.For(0, threads, _ =>
|
||||
{
|
||||
for (var i = 0; i < each; i++) { d.RecordDataChange(bad: i % 2 == 0); d.RecordNotifications(2); }
|
||||
});
|
||||
|
||||
var s = d.Snapshot();
|
||||
s["DataChangeCount"].ShouldBe(threads * each);
|
||||
s["BadStatusValueCount"].ShouldBe(threads * each / 2);
|
||||
s["NotificationCount"].ShouldBe(threads * each * 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_is_a_point_in_time_copy()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
var before = d.Snapshot();
|
||||
d.RecordKeepAliveMiss();
|
||||
|
||||
before["KeepAliveMissCount"].ShouldBe(0);
|
||||
d.Snapshot()["KeepAliveMissCount"].ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealth_carries_the_diagnostics_and_keeps_state_fields()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-diag");
|
||||
drv.DiagnosticsForTest.RecordKeepAliveMiss();
|
||||
drv.DiagnosticsForTest.RecordKeepAliveMiss();
|
||||
|
||||
var h = drv.GetHealth();
|
||||
|
||||
h.State.ShouldBe(DriverState.Unknown);
|
||||
h.Diagnostics.ShouldNotBeNull();
|
||||
h.Diagnostics!["KeepAliveMissCount"].ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriverHealth_without_diagnostics_stays_back_compatible()
|
||||
{
|
||||
var h = new DriverHealth(DriverState.Healthy, null, null);
|
||||
|
||||
h.Diagnostics.ShouldBeNull();
|
||||
h.ShouldBe(new DriverHealth(DriverState.Healthy, null, null));
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>#275 — the driver splits Read / Write calls to the server's declared OperationLimits.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientOperationLimitsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, 0u, new int[0])] // nothing to send => no call
|
||||
[InlineData(5, 0u, new[] { 5 })] // 0 = unlimited
|
||||
[InlineData(5, 5u, new[] { 5 })] // exactly fits
|
||||
[InlineData(5, 100u, new[] { 5 })] // limit larger than the batch
|
||||
[InlineData(10, 4u, new[] { 4, 4, 2 })] // remainder chunk
|
||||
[InlineData(9, 3u, new[] { 3, 3, 3 })]
|
||||
[InlineData(3, 1u, new[] { 1, 1, 1 })]
|
||||
public void Chunk_splits_to_the_limit(int total, uint limit, int[] expectedSizes)
|
||||
{
|
||||
var chunks = OpcUaClientOperationLimits.Chunk(total, limit).ToList();
|
||||
|
||||
chunks.Select(c => c.Count).ToArray().ShouldBe(expectedSizes);
|
||||
// Contiguous cover of [0, total): every item exactly once, in order.
|
||||
var next = 0;
|
||||
foreach (var (start, count) in chunks) { start.ShouldBe(next); next += count; }
|
||||
next.ShouldBe(total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromValues_reads_good_uints_and_treats_bad_or_missing_as_unlimited()
|
||||
{
|
||||
var values = new List<DataValue>
|
||||
{
|
||||
new(new Variant(100u)),
|
||||
new(new Variant(50u)),
|
||||
new(StatusCodes.BadNodeIdUnknown), // server doesn't publish MaxNodesPerBrowse
|
||||
new(new Variant(7u)),
|
||||
};
|
||||
|
||||
var limits = OpcUaClientOperationLimits.FromValues(values);
|
||||
|
||||
limits.MaxNodesPerRead.ShouldBe(100u);
|
||||
limits.MaxNodesPerWrite.ShouldBe(50u);
|
||||
limits.MaxNodesPerBrowse.ShouldBe(0u);
|
||||
limits.MaxMonitoredItemsPerCall.ShouldBe(7u);
|
||||
limits.MaxNodesPerHistoryReadData.ShouldBe(0u); // short list => 0
|
||||
limits.MaxNodesPerHistoryReadEvents.ShouldBe(0u);
|
||||
}
|
||||
|
||||
private static (OpcUaClientDriver Drv, Mock<ISession> Session, List<int> ReadSizes, List<int> WriteSizes) Rig(
|
||||
uint readLimit, uint writeLimit, int tagCount)
|
||||
{
|
||||
var tags = Enumerable.Range(0, tagCount)
|
||||
.Select(i => new RawTagEntry($"raw/t{i}", $$"""{"nodeId":"ns=2;s=T{{i}}"}""", WriteIdempotent: false))
|
||||
.ToList();
|
||||
var options = new OpcUaClientDriverOptions { RawTags = tags };
|
||||
var drv = new OpcUaClientDriver(options, "opcua-limits");
|
||||
drv.SetNamespaceMapForTest(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.NamespaceMap.FromTable(new NamespaceTable(), tags));
|
||||
drv.SetOperationLimitsForTest(OpcUaClientOperationLimits.None with
|
||||
{
|
||||
MaxNodesPerRead = readLimit, MaxNodesPerWrite = writeLimit,
|
||||
});
|
||||
|
||||
var readSizes = new List<int>();
|
||||
var writeSizes = new List<int>();
|
||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||
mock.SetupGet(s => s.MessageContext)
|
||||
.Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
mock.Setup(s => s.ReadAsync(It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
{
|
||||
readSizes.Add(nodes.Count);
|
||||
// Echo each node's identifier number back as the value so ordering across chunks is checkable.
|
||||
var results = new DataValueCollection();
|
||||
foreach (var n in nodes) results.Add(new DataValue(new Variant((string)n.NodeId.Identifier)));
|
||||
return new ReadResponse { ResponseHeader = new ResponseHeader(), Results = results };
|
||||
});
|
||||
mock.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, WriteValueCollection nodes, CancellationToken _) =>
|
||||
{
|
||||
writeSizes.Add(nodes.Count);
|
||||
var codes = new StatusCodeCollection();
|
||||
foreach (var _ in nodes) codes.Add(StatusCodes.Good);
|
||||
return new WriteResponse { ResponseHeader = new ResponseHeader(), Results = codes };
|
||||
});
|
||||
drv.SetSessionForTest(mock.Object);
|
||||
return (drv, mock, readSizes, writeSizes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_splits_to_MaxNodesPerRead_and_keeps_result_order()
|
||||
{
|
||||
var (drv, _, readSizes, _) = Rig(readLimit: 4, writeLimit: 0, tagCount: 10);
|
||||
using var _ = drv;
|
||||
|
||||
var refs = Enumerable.Range(0, 10).Select(i => $"raw/t{i}").ToList();
|
||||
var res = await drv.ReadAsync(refs, TestContext.Current.CancellationToken);
|
||||
|
||||
readSizes.ShouldBe([4, 4, 2]);
|
||||
res.Count.ShouldBe(10);
|
||||
for (var i = 0; i < 10; i++) res[i].Value.ShouldBe($"T{i}"); // positional mapping survives chunking
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().Diagnostics!["ChunkedRequestCount"].ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_with_no_declared_limit_sends_one_request()
|
||||
{
|
||||
var (drv, _, readSizes, _) = Rig(readLimit: 0, writeLimit: 0, tagCount: 10);
|
||||
using var _ = drv;
|
||||
|
||||
await drv.ReadAsync(Enumerable.Range(0, 10).Select(i => $"raw/t{i}").ToList(), TestContext.Current.CancellationToken);
|
||||
|
||||
readSizes.ShouldBe([10]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_a_failing_chunk_only_fails_its_own_tags()
|
||||
{
|
||||
var (drv, session, _, _) = Rig(readLimit: 2, writeLimit: 0, tagCount: 4);
|
||||
using var _ = drv;
|
||||
var call = 0;
|
||||
session.Setup(s => s.ReadAsync(It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
{
|
||||
if (Interlocked.Increment(ref call) == 2) throw new ServiceResultException(StatusCodes.BadConnectionClosed);
|
||||
var results = new DataValueCollection();
|
||||
foreach (var n in nodes) results.Add(new DataValue(new Variant((string)n.NodeId.Identifier)));
|
||||
return Task.FromResult(new ReadResponse { ResponseHeader = new ResponseHeader(), Results = results });
|
||||
});
|
||||
|
||||
var res = await drv.ReadAsync(["raw/t0", "raw/t1", "raw/t2", "raw/t3"], TestContext.Current.CancellationToken);
|
||||
|
||||
res[0].Value.ShouldBe("T0");
|
||||
res[1].Value.ShouldBe("T1");
|
||||
res[2].StatusCode.ShouldBe(0x80050000u); // BadCommunicationError, chunk 2 only
|
||||
res[3].StatusCode.ShouldBe(0x80050000u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().Diagnostics!["ReadFailureCount"].ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_splits_to_MaxNodesPerWrite()
|
||||
{
|
||||
var (drv, _, _, writeSizes) = Rig(readLimit: 0, writeLimit: 3, tagCount: 7);
|
||||
using var _ = drv;
|
||||
|
||||
var res = await drv.WriteAsync(
|
||||
Enumerable.Range(0, 7).Select(i => new Core.Abstractions.WriteRequest($"raw/t{i}", i)).ToList(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
writeSizes.ShouldBe([3, 3, 1]);
|
||||
res.Count.ShouldBe(7);
|
||||
res.ShouldAllBe(r => r.StatusCode == 0u);
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>#273 subscription tuning, #277 revocation options, #278 discovery URL.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientTuningAndDiscoveryTests
|
||||
{
|
||||
// ---- #273 ----
|
||||
|
||||
[Fact]
|
||||
public void Defaults_equal_the_values_that_used_to_be_hardcoded()
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions();
|
||||
|
||||
o.SubscriptionKeepAliveCount.ShouldBe(10u);
|
||||
o.SubscriptionLifetimeCount.ShouldBe(1000u);
|
||||
o.SubscriptionMaxNotificationsPerPublish.ShouldBe(0u);
|
||||
o.SubscriptionPriority.ShouldBe((byte)0);
|
||||
o.SubscriptionPublishingInterval.ShouldBeNull();
|
||||
o.HonorServerOperationLimits.ShouldBeTrue();
|
||||
o.DiscoveryUrl.ShouldBeNull();
|
||||
o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeFalse();
|
||||
o.CertificateValidation.IssuerStorePath.ShouldBeNull();
|
||||
Should.NotThrow(() => OpcUaClientDriver.ValidateTuning(o));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_json_binds_the_new_keys_and_old_json_keeps_defaults()
|
||||
{
|
||||
var o = OpcUaClientDriverFactoryExtensions.ParseOptions("d", """
|
||||
{"subscriptionPublishingInterval":"00:00:02","subscriptionKeepAliveCount":20,
|
||||
"subscriptionLifetimeCount":600,"subscriptionMaxNotificationsPerPublish":500,
|
||||
"subscriptionPriority":9,"honorServerOperationLimits":false,
|
||||
"discoveryUrl":"opc.tcp://lds:4840",
|
||||
"certificateValidation":{"rejectUnknownRevocationStatus":true,"issuerStorePath":"/pki/issuers"}}
|
||||
""");
|
||||
|
||||
o.SubscriptionPublishingInterval.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
o.SubscriptionKeepAliveCount.ShouldBe(20u);
|
||||
o.SubscriptionLifetimeCount.ShouldBe(600u);
|
||||
o.SubscriptionMaxNotificationsPerPublish.ShouldBe(500u);
|
||||
o.SubscriptionPriority.ShouldBe((byte)9);
|
||||
o.HonorServerOperationLimits.ShouldBeFalse();
|
||||
o.DiscoveryUrl.ShouldBe("opc.tcp://lds:4840");
|
||||
o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeTrue();
|
||||
o.CertificateValidation.IssuerStorePath.ShouldBe("/pki/issuers");
|
||||
|
||||
var legacy = OpcUaClientDriverFactoryExtensions.ParseOptions("d", """{"endpointUrl":"opc.tcp://x:1"}""");
|
||||
legacy.SubscriptionKeepAliveCount.ShouldBe(10u);
|
||||
legacy.SubscriptionLifetimeCount.ShouldBe(1000u);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, 1000u)] // keep-alive must be >= 1
|
||||
[InlineData(10u, 29u)] // lifetime < 3x keep-alive
|
||||
[InlineData(500u, 1000u)]
|
||||
public void ValidateTuning_rejects_out_of_range_counts(uint keepAlive, uint lifetime)
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions { SubscriptionKeepAliveCount = keepAlive, SubscriptionLifetimeCount = lifetime };
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateTuning(o));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTuning_accepts_the_3x_boundary_and_rejects_bad_interval_or_discovery_url()
|
||||
{
|
||||
Should.NotThrow(() => OpcUaClientDriver.ValidateTuning(
|
||||
new OpcUaClientDriverOptions { SubscriptionKeepAliveCount = 10, SubscriptionLifetimeCount = 30 }));
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateTuning(
|
||||
new OpcUaClientDriverOptions { SubscriptionPublishingInterval = TimeSpan.Zero }));
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateTuning(
|
||||
new OpcUaClientDriverOptions { DiscoveryUrl = "not a uri" }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 1000, 1000)] // no override: the per-call value
|
||||
[InlineData(null, 10, 50)] // floor still applies to the per-call value
|
||||
[InlineData(2000, 250, 2000)] // override wins over the per-call value
|
||||
[InlineData(5, 250, 50)] // ... and is floored too
|
||||
public void ResolvePublishingIntervalMs_prefers_the_override_and_floors_at_50(int? overrideMs, int requestedMs, int expected)
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions
|
||||
{
|
||||
SubscriptionPublishingInterval = overrideMs is { } ms ? TimeSpan.FromMilliseconds(ms) : null,
|
||||
};
|
||||
|
||||
OpcUaClientDriver.ResolvePublishingIntervalMs(o, TimeSpan.FromMilliseconds(requestedMs)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- #277 ----
|
||||
|
||||
[Fact]
|
||||
public void Revocation_failures_are_exactly_Revoked_and_IssuerRevoked()
|
||||
{
|
||||
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateRevoked).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateIssuerRevoked).ShouldBeTrue();
|
||||
// "unknown" is governed by RejectUnknownRevocationStatus, not auto-accept's hard exception;
|
||||
// untrusted is what auto-accept exists to wave through.
|
||||
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateRevocationUnknown).ShouldBeFalse();
|
||||
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateUntrusted).ShouldBeFalse();
|
||||
OpcUaClientDriver.IsRevocationFailure(StatusCodes.Good).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- #278 ----
|
||||
|
||||
private static EndpointDescription Ep(string url, string policyUri, MessageSecurityMode mode) =>
|
||||
new() { EndpointUrl = url, SecurityPolicyUri = policyUri, SecurityMode = mode };
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_without_discovery_is_unchanged()
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions { EndpointUrls = ["opc.tcp://a:1", "opc.tcp://b:2"] };
|
||||
|
||||
OpcUaClientDriver.ResolveEndpointCandidates(o, []).ShouldBe(["opc.tcp://a:1", "opc.tcp://b:2"]);
|
||||
OpcUaClientDriver.ResolveEndpointCandidates(o).ShouldBe(["opc.tcp://a:1", "opc.tcp://b:2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_prepends_discovered_urls_and_dedups_case_insensitively()
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions { EndpointUrls = ["opc.tcp://Static:1", "opc.tcp://found:2"] };
|
||||
|
||||
var merged = OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://found:2", "opc.tcp://new:3"]);
|
||||
|
||||
merged.ShouldBe(["opc.tcp://found:2", "opc.tcp://new:3", "opc.tcp://Static:1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_uses_the_single_EndpointUrl_after_discovered_when_no_list()
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:9" };
|
||||
|
||||
OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://d:1"]).ShouldBe(["opc.tcp://d:1", "opc.tcp://only:9"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEndpointUrls_keeps_only_the_wanted_policy_and_mode()
|
||||
{
|
||||
const string b256 = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256";
|
||||
const string none = "http://opcfoundation.org/UA/SecurityPolicy#None";
|
||||
var endpoints = new[]
|
||||
{
|
||||
Ep("opc.tcp://h:1/none", none, MessageSecurityMode.None),
|
||||
Ep("opc.tcp://h:1/sign", b256, MessageSecurityMode.Sign),
|
||||
Ep("opc.tcp://h:1/enc", b256, MessageSecurityMode.SignAndEncrypt),
|
||||
Ep("", b256, MessageSecurityMode.SignAndEncrypt),
|
||||
};
|
||||
|
||||
OpcUaClientDriver.FilterEndpointUrls(endpoints, b256, MessageSecurityMode.SignAndEncrypt)
|
||||
.ShouldBe(["opc.tcp://h:1/enc"]);
|
||||
OpcUaClientDriver.FilterEndpointUrls(endpoints, none, MessageSecurityMode.None)
|
||||
.ShouldBe(["opc.tcp://h:1/none"]);
|
||||
}
|
||||
}
|
||||
+44
@@ -156,6 +156,50 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
result.ProbeTimeoutSeconds.ShouldBe(25);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #273/#275/#277/#278: the typed form rewrites the whole record on save, so a key it does not model
|
||||
/// is silently dropped (#576). Every new option must survive FromRecord -> ToRecord.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FormModel_RoundTrip_PreservesTuningLimitsRevocationAndDiscovery()
|
||||
{
|
||||
var original = new OpcUaClientDriverOptions
|
||||
{
|
||||
SubscriptionPublishingInterval = TimeSpan.FromMilliseconds(2500),
|
||||
SubscriptionKeepAliveCount = 20,
|
||||
SubscriptionLifetimeCount = 600,
|
||||
SubscriptionMaxNotificationsPerPublish = 500,
|
||||
SubscriptionPriority = 9,
|
||||
HonorServerOperationLimits = false,
|
||||
CertificateValidation = new OpcUaCertificateValidationOptions
|
||||
{
|
||||
RejectUnknownRevocationStatus = true,
|
||||
IssuerStorePath = "/pki/issuers",
|
||||
},
|
||||
DiscoveryUrl = "opc.tcp://lds:4840",
|
||||
};
|
||||
|
||||
var back = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original).ToRecord([]);
|
||||
|
||||
back.SubscriptionPublishingInterval.ShouldBe(TimeSpan.FromMilliseconds(2500));
|
||||
back.SubscriptionKeepAliveCount.ShouldBe(20u);
|
||||
back.SubscriptionLifetimeCount.ShouldBe(600u);
|
||||
back.SubscriptionMaxNotificationsPerPublish.ShouldBe(500u);
|
||||
back.SubscriptionPriority.ShouldBe((byte)9);
|
||||
back.HonorServerOperationLimits.ShouldBeFalse();
|
||||
back.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeTrue();
|
||||
back.CertificateValidation.IssuerStorePath.ShouldBe("/pki/issuers");
|
||||
back.DiscoveryUrl.ShouldBe("opc.tcp://lds:4840");
|
||||
|
||||
// Defaults round-trip as defaults (blank override stays null, not 0 ms).
|
||||
var def = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions()).ToRecord([]);
|
||||
def.SubscriptionPublishingInterval.ShouldBeNull();
|
||||
def.SubscriptionKeepAliveCount.ShouldBe(10u);
|
||||
def.SubscriptionLifetimeCount.ShouldBe(1000u);
|
||||
def.HonorServerOperationLimits.ShouldBeTrue();
|
||||
def.DiscoveryUrl.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointUrlRow_FromUrl_ToUrl_Trims()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user