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:
Joseph Doherty
2026-10-08 14:40:20 -04:00
parent 55eac5f68b
commit ba62022e88
12 changed files with 1308 additions and 100 deletions
+60 -3
View File
@@ -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