# OpcUaClient Driver — Implementation Plan > Source of gap analysis: [featuregaps.md → OpcUaClient](../featuregaps.md#opcuaclient-opc-ua-aggregation-client) > > Covers Build = Yes items only. Numbering matches the featuregaps Recommendations table. ## Summary The OpcUaClient driver already ships 8/8 capability interfaces and a working end-to-end Session/Subscription/MonitoredItem/HistoryRead pipeline backed by the OPC Foundation `OPCFoundation.NetStandard.Opc.Ua.Client` SDK. Most of the 14 Build = Yes gaps are operability or curation knobs — config surface + plumbing into existing SDK calls — rather than new protocol implementation. A small number need genuinely new SDK plumbing (Reverse Connect, ModelChangeEvent subscribe) and one (`ReadEventsAsync`) needs a coordinated cross-driver interface change. The plan groups the work into five phases, ordered to deliver per-tag / per-subscription operability first (highest-frequency operator pain), then curation, then change tracking, then connectivity, then historical+HA. Each PR sticks to one feature-gap row so reviews stay narrow. ## Phased delivery | Phase | Theme | Gaps | PRs | Notes | | :---: | --- | --- | :---: | --- | | 1 | Operability knobs | #5, #6, #15, #17, #20 | 5 | Pure SDK config surface; no new wire flows | | 2 | Discovery & curation | #2, #7, #8, #9 | 4 | Touches `ITagDiscovery` + adds method invoke | | 3 | Change tracking | #10 | 1 | New session-level subscription on `Server` node | | 4 | Connectivity | #1 | 1 | Reverse Connect — new listener path | | 5 | Historical & redundancy | #12, #13, #14 | 3 | Includes the cross-driver `IHistoryProvider` change | **Total: 14 PRs across 5 phases.** Phases 1-3 land independently against the existing single-session model. Phase 4 ships in parallel with phases 2-3 since it doesn't touch `OpcUaClientDriver` proper. Phase 5's first PR is a prerequisite for the `ReadEventsAsync` work in every other history-capable driver and must coordinate with them. ## Per-PR detail ### Phase 1 — Operability knobs #### PR-1: Per-subscription tuning (gap #6) **Goal**: lift the hard-coded `KeepAliveCount=10`, `LifetimeCount=1000`, `MaxNotificationsPerPublish=0`, `Priority=0`, `PublishingInterval` floor of 50 ms into `OpcUaClientDriverOptions` so high-event-rate servers can be defended against (`MaxNotificationsPerPublish=0` is unlimited — the documented DoS surface) and high-tag-count deployments can split by priority. **SDK API**: - `Subscription.SetPublishingMode(bool, ct)` for runtime enable/disable - `SubscriptionOptions.PublishingInterval / KeepAliveCount / LifetimeCount / MaxNotificationsPerPublish / Priority` set at create-time - New options class `OpcUaSubscriptionDefaults` (publish interval floor, keep-alive count, lifetime count, max notifications, priority) **Files**: - `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add `Subscriptions` sub-section - `src/.../OpcUaClient/OpcUaClientDriver.cs` — `SubscribeAsync` reads from options - `src/.../OpcUaClient/OpcUaClientDriver.cs` — `SubscribeAlarmsAsync` reuses same defaults but with `Priority=1` higher than data subscriptions so alarms aren't starved during data bursts **Tests**: `OpcUaClientSubscribeAndProbeTests` — assert options propagate; add a stress unit test (mocked `Subscription`) that asserts custom `MaxNotificationsPerPublish` is forwarded so a value > 0 actually reaches the SDK. **Risks**: Setting `LifetimeCount` too low against a server with publish- throttling can drop subscriptions; doc the formula (`LifetimeCount >= 3 * KeepAliveCount`). **Docs / fixture / e2e**: new "Subscription tuning" subsection in `docs/drivers/OpcUaClient.md` (create if missing) documenting the `Subscriptions` options block with the `LifetimeCount >= 3 * KeepAliveCount` formula; cross-link from the "Advanced options" section of `docs/Client.CLI.md` so CLI users discover the knobs. Fixture: opc-plc already publishes fast tickers (`FastUInt1` @ 100 ms) sufficient for coverage — no fixture-side change. Integration test in `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` asserting custom `KeepAliveCount` / `Priority` reach the wire (capture via `OpcPlcFixture` keepalive count). E2E: extend `scripts/e2e/test-opcuaclient.ps1` with a stage that sets a non-default publish interval and confirms the local subscription honours it. --- #### PR-2: Per-tag advanced subscription tuning incl. deadband (gap #5) **Goal**: surface `SamplingInterval`, `QueueSize`, `DiscardOldest`, `MonitoringMode`, and `DataChangeFilter` (DeadbandType=Absolute/Percent + Trigger=Status/StatusValue/StatusValueTimestamp) per-tag. Deadband is the baseline analog noise filter every commercial UA aggregator ships and the single feature most likely to cut bandwidth on busy plants. **SDK API**: - `MonitoredItem.Filter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValue, DeadbandType = (uint)DeadbandType.Absolute, DeadbandValue = 0.5 }` - `MonitoredItemOptions.QueueSize / DiscardOldest / SamplingInterval / MonitoringMode` - Per-tag override structure: extend the `SubscribeAsync` parameter shape (or add an overload accepting a `IReadOnlyList`) — note this requires coordinating with `ISubscribable` so the per-tag carrier reaches the driver. **Files**: - `src/.../Core.Abstractions/ISubscribable.cs` — add overload `SubscribeAsync(IReadOnlyList, ...)` keeping old API for source compat - `src/.../OpcUaClient/OpcUaClientDriver.cs` — translate spec → SDK filter **Tests**: assert `DataChangeFilter` lands on the `MonitoredItem.Filter` for each kind of trigger; assert PercentDeadband requires server-side EURange (server returns `BadFilterNotAllowed` if not configured) — capture the StatusCode and surface as a usable error. **Risks**: cross-cutting `ISubscribable` change. Mitigation: ship the overload as additive — existing single-arg path still exists. **Docs / fixture / e2e**: new "Per-tag deadband and monitoring filters" section in `docs/drivers/OpcUaClient.md` (create if missing) with worked examples of Absolute vs Percent deadband + the EURange prerequisite; update `docs/Client.CLI.md` `subscribe` command page with the new tag- config syntax for `--deadband` / `--queue-size` / `--discard-oldest`; update `docs/Client.UI.md` Subscriptions tab section to mirror. Fixture: `OpcPlcFixture` / `OpcPlcProfile` seeds an analog (`StepUp` already oscillates) and confirms `EURange` is published — extend the profile to flag noisy nodes. Integration test in `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` asserts publish suppression below the deadband threshold. E2E: add a `-DeadbandValue` stage to `scripts/e2e/test-opcuaclient.ps1` (and a `deadband` knob to `scripts/e2e/e2e-config.sample.json`) that subscribes, asserts no spurious updates within the band. --- #### PR-3: Honor server `OperationLimits` (gap #15) **Goal**: read `Server.ServerCapabilities.OperationLimits.MaxNodesPerRead / Write / Browse / HistoryReadData` once after Session activation, cache, and chunk batch operations to those caps client-side. Today the SDK chunks on its internal default; against an undersized embedded UA server this results in `BadTooManyOperations`. **SDK API**: - After session open: `Session.ReadAsync` of `VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead` + sibling NodeIds. The SDK exposes `Session.OperationLimits` after `FetchOperationLimits` is called — prefer that path. - `Session.FetchOperationLimitsAsync(ct)` (1.5+); fallback: explicit Read. **Files**: - `src/.../OpcUaClient/OpcUaClientDriver.cs` — call `FetchOperationLimitsAsync` post-`OpenSessionOnEndpointAsync`; honour caps in `ReadAsync`, `WriteAsync`, `BrowseRecursiveAsync`, `EnrichAndRegisterVariablesAsync`, `ExecuteHistoryReadAsync`. **Tests**: mock `Session.OperationLimits` to a value below the test batch size and assert the driver issues N wire calls instead of one. **Risks**: a zero on the server means "no limit" per Part 5 — don't divide by zero. **Docs / fixture / e2e**: new "Server OperationLimits handling" subsection in `docs/drivers/OpcUaClient.md` documenting the auto-fetch behaviour, the zero-means-unlimited semantics, and how to override via options if the server reports an under-truthful value. Fixture: opc-plc publishes the standard ServerCapabilities tree out of the box — no container-side change; the `OpcPlcFixture` seed validates the IDs at collection init. Integration test asserts batch reads chunk to the fetched cap. No e2e change needed (the script's batch sizes are already small). --- #### PR-4: Diagnostics counters (gap #17) **Goal**: expose per-driver counters on `DriverHealth` (or a sibling `DriverDiagnostics` surface): publish-request count, notifications-per- second EWMA, missing-publish-request count, dropped-notification rate, session resets count. Operators currently see only `LastSuccessfulRead` + last error. **SDK API**: - `Subscription.Notification` event fires per published notification — bump a counter - `Subscription.PublishStateChanged` event for missed-publish detection - `Session.PublishError` event for channel-level errors - `Session.SessionClosing`/`SessionConfigurationChanged` for session-reset attribution **Files**: - `src/.../OpcUaClient/OpcUaClientDriver.cs` — instrument hooks; expose via `IDriver.GetDiagnostics()` or extend `DriverHealth` - `src/.../Core.Abstractions/IDriver.cs` — confirm where the counter shape lives; if `DriverHealth` is too rigid, add `IDriverDiagnostics` (mirrors the Modbus `driver-diagnostics` RPC pattern from #154) **Tests**: synthetic notification fan-out → assert counters increment; session close → assert reset count bumps. **Risks**: counters need to be lock-free hot-path safe; use `Interlocked.Increment` and a single sliding-window clock per counter. **Docs / fixture / e2e**: new "Driver diagnostics" section in `docs/drivers/OpcUaClient.md` enumerating each counter and the event that bumps it; cross-link to the `driver-diagnostics` Admin RPC documented for Modbus (#154 pattern). Fixture: no opc-plc change required. Integration test exercises `IDriverDiagnostics` after forcing a session close. E2E: extend `scripts/e2e/test-opcuaclient.ps1` with a "diagnostics snapshot" stage that asserts publish/notification counters are non-zero after the subscribe stage. --- #### PR-5: CRL / revocation handling (gap #20) **Goal**: explicit revoked-cert handling in `CertificateValidator` plus a `RejectSHA1SignedCertificates` knob. Today the validator hooks `BadCertificateUntrusted` only — a revoked cert silently fails as "untrusted" with no operator-visible distinction. **SDK API**: - `CertificateValidator.CertificateValidation` event — inspect `e.Error.StatusCode` for `BadCertificateRevoked`, `BadCertificateRevocationUnknown`, `BadCertificateIssuerRevocationUnknown`, `BadCertificatePolicyCheckFailed` - `SecurityConfiguration.RejectSHA1SignedCertificates`, `SecurityConfiguration.RejectUnknownRevocationStatus`, `SecurityConfiguration.MinimumCertificateKeySize` — direct config bool/int knobs already on the SDK type - `CertificateTrustList.AddCRL` / per-store CRL directories under `%LocalAppData%\OtOpcUa\pki\{trusted,issuers}\crl\` **Files**: - `src/.../OpcUaClient/OpcUaClientDriver.cs` — `BuildApplicationConfigurationAsync` honours new options, validator handler distinguishes revoked vs untrusted in the surfaced error message - `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add `RejectSHA1SignedCertificates`, `RejectUnknownRevocationStatus`, `MinimumCertificateKeySize` **Tests**: feed a SHA1-signed test cert and a revoked cert through the validator with the new knobs on/off. **Risks**: PKI directory layout changes — existing deployments need a migration note. **Docs / fixture / e2e**: new "Certificate revocation and SHA1 rejection" subsection in `docs/drivers/OpcUaClient.md` documenting the CRL directory layout under `%LocalAppData%\OtOpcUa\pki\{trusted,issuers}\crl\` and the new options (with a migration note for existing PKI stores); cross-link from `docs/security.md`. Fixture: extend `OpcPlcFixture` / `Docker/docker-compose.yml` with an optional secured endpoint variant and a SHA1-signed test cert checked into the test project's resources for the validator unit test. Integration test exercises a revoked cert via a local CRL drop. E2E: add a `-Insecure:$false` smoke stage to `scripts/e2e/test-opcuaclient.ps1` that asserts a revoked cert produces a distinguishable error message. --- ### Phase 2 — Discovery & curation #### PR-6: Discovery URL `FindServers` (gap #2) **Goal**: accept a discovery URL (`opc.tcp://host:4840` pointing at the LDS or the server's own discovery endpoint) and surface advertised servers + endpoints to the operator without manual policy/mode tuple copy. **SDK API**: - `DiscoveryClient.CreateAsync(appConfig, new Uri(url), DiagnosticsMasks.None, ct)` - `DiscoveryClient.FindServersAsync(null, ct)` → `ApplicationDescription[]` - `DiscoveryClient.GetEndpointsAsync(null, ct)` per advertised `DiscoveryUrl` **Files**: - `src/.../OpcUaClient/OpcUaClientDriver.cs` — new internal `DiscoverServersAsync` helper; extend the Admin-side discovery RPC to invoke it (driver-diagnostics pattern from #154) - `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add `DiscoveryUrl` knob (alternative to explicit `EndpointUrls` — when set the driver runs `FindServers` at init and feeds the result into the failover candidate list) **Tests**: mock `DiscoveryClient` returning two advertised servers each with three endpoints; assert the candidate list reflects the policy/mode filter applied client-side. **Risks**: `FindServers` itself usually requires `SecurityMode=None` — spec out in the doc that the discovery channel is unsecured even when the data channel will be encrypted. **Docs / fixture / e2e**: new "Discovery URL (`FindServers`)" section in `docs/drivers/OpcUaClient.md` with the unsecured-discovery-vs-secured- data caveat called out; cross-link from `docs/Client.CLI.md` if a `discover` CLI command surfaces. Fixture: opc-plc already responds to `FindServers` on the same endpoint — `OpcPlcFixture` adds a discovery probe at collection init. Integration test exercises the helper against the live opc-plc container and asserts at least one `ApplicationDescription` returned. E2E: replace the hard-coded `-RemoteUrl` stage in `scripts/e2e/test-opcuaclient.ps1` with an optional `-DiscoveryUrl` mode that picks the first advertised endpoint. --- #### PR-7: Selective import / namespace remap (gap #7) **Goal**: per-branch include/exclude rules, namespace-URI remapping, and re-keyed BrowseNames — the curation surface every commercial aggregator ships. **Approach**: extend `OpcUaClientDriverOptions` with a `Curation` section: - `IncludePaths: string[]` — glob or NodeId-rooted prefix list; only paths matching are imported - `ExcludePaths: string[]` — wins over Include (Include is allow-list, Exclude is block-list) - `NamespaceRemap: Dictionary` — upstream NS URI → local-side alias for BrowseName generation - `RootAlias: string` — default `"Remote"`; replaces the hardcoded folder name today **SDK API** — none new; this is pure local filtering inside `BrowseRecursiveAsync` and `EnrichAndRegisterVariablesAsync`. **Files**: - `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` - `src/.../OpcUaClient/OpcUaClientDriver.cs` — `BrowseRecursiveAsync` consults the rule set; helper `MapNamespaceForBrowseName` handles NS remap **Tests**: synthetic browse tree, exercise include/exclude/remap each independently and combined; verify the cap accounting in `MaxDiscoveredNodes` excludes filtered nodes. **Risks**: glob semantics — pin to a small subset (`*`, `?` only — no character classes or `**`) to keep the doc + behaviour simple. **Docs / fixture / e2e**: new "Curation: include/exclude and namespace remap" section in `docs/drivers/OpcUaClient.md` with worked examples of each rule kind and the supported glob subset; update `docs/drivers/OpcUaClient-Test-Fixture.md` "Coverage map" with the new filtering rows. Fixture: extend `OpcPlcProfile` to enumerate which upstream namespaces are exercised so curation tests can target them. Integration test seeds an Include + Exclude + Remap rule and asserts the local tree reflects the filter. E2E: add a `-IncludePath` / `-NamespaceRemap` set of params to `scripts/e2e/test-opcuaclient.ps1` that asserts the local browse depth matches the rule. --- #### PR-8: Type definition mirroring (gap #8) **Goal**: walk the upstream `Types` folder (`ObjectTypes`, `VariableTypes`, `DataTypes`, `ReferenceTypes`) and project them into the local address space so downstream UI clients keep type-aware rendering and structured DataTypes decode correctly. **SDK API**: - `Session.NodeCache.FetchNode(typeNodeId)` for type metadata - `Session.LoadDataTypeSystem` — for structured DataType encoding - `Session.FetchTypeTree(NodeIdCollection)` — populates the session's type cache from the server **Files**: - `src/.../OpcUaClient/OpcUaClientDriver.cs` — new pass-3 in `DiscoverAsync` that walks `i=86` (Types folder) under the curation rules, registers a parallel type subtree, and links variables to their TypeDefinition via HasTypeDefinition references on the address-space builder - `src/.../Core.Abstractions/IAddressSpaceBuilder.cs` — confirm whether the builder accepts type nodes; if not, extend it (this likely is a prerequisite — if so, it gets its own preceding PR-8a) **Tests**: mock browse returning `BaseObjectType -> DerivedThing`; assert local builder receives the type node + the HasTypeDefinition link. **Risks**: significant. Type mirroring touches `IAddressSpaceBuilder` which is a cross-cutting interface every driver depends on. If `IAddressSpaceBuilder` already supports type nodes (Galaxy has type-like templates), reuse that surface; otherwise this PR splits. **Docs / fixture / e2e**: new "Type mirroring" section in `docs/drivers/OpcUaClient.md` documenting which type nodes get walked and how downstream UA clients see the HasTypeDefinition references; also note in `docs/Client.UI.md` that the Browse tree now shows mirrored types. Fixture: opc-plc already exposes the standard `Types` folder; extend `OpcPlcProfile` to assert at least one custom ObjectType is present. Integration test browses the local Types folder post-discovery and asserts the upstream type chain landed. No e2e change needed beyond extending the existing browse stage to walk under `Types`. --- #### PR-9: Method node mirroring + `Call` passthrough (gap #9) **Goal**: discover `NodeClass.Method` nodes in the browse pass, expose them on the local address space, and forward `Call` invocations as `Session.CallAsync` against the upstream node. The driver already calls `AcknowledgeableConditionType.Acknowledge` for A&C — generalize that path. **SDK API**: - `Session.CallAsync(requestHeader, methodsToCall: CallMethodRequestCollection, ct)` returning `CallMethodResultCollection` - Browse already covers Method nodes by lifting the `NodeClassMask`; need to additionally browse `HasProperty` to discover `InputArguments` / `OutputArguments` for argument translation **Files**: - `src/.../Core.Abstractions/IDriver.cs` — add `IMethodInvoker` capability interface (this is a NEW capability, not a tweak to an existing one) - `src/.../OpcUaClient/OpcUaClientDriver.cs` — implement `IMethodInvoker.InvokeAsync(string objectId, string methodId, IReadOnlyList inputs, ct)`; refactor `AcknowledgeAsync` to reuse the common path - `src/.../Server/...` node-manager — wire `IMethodInvoker` to the OPC UA server's `MethodNode.OnCallMethod` hook so downstream Call requests reach the driver **Tests**: mock `Session.CallAsync` returning Good + an output collection; assert pass-through fidelity. Also assert per-argument `BadInvalidArgument` codes pass through. **Risks**: high — adds a new capability interface. Other drivers that *could* support methods (Galaxy via `OnExecute` scripts, FOCAS via FOCAS commands) gain a clean extension point but each is its own follow-up. **Docs / fixture / e2e**: new "Method nodes and Call passthrough" section in `docs/drivers/OpcUaClient.md` explaining how method calls flow through the aggregator (input/output argument translation, error- code passthrough); add a `call` command page to `docs/Client.CLI.md` covering the new path; mirror in `docs/Client.UI.md` if a UI surface ships. Fixture: opc-plc already exposes the standard `Server.GetMonitoredItems` method — `OpcPlcFixture` registers it as the canonical method-call target. Integration test in `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` invokes `Server.GetMonitoredItems` through the aggregator. E2E: add a `-MethodNodeId` stage to `scripts/e2e/test-opcuaclient.ps1` that calls the method through the local server and asserts the output matches the direct upstream call. --- ### Phase 3 — Change tracking #### PR-10: Auto re-import on `ModelChangeEvent` (gap #10) **Goal**: subscribe to `BaseModelChangeEventType` / `GeneralModelChangeEventType` on the upstream server's `i=2253` Server node so when the upstream topology changes (new tag added, type modified) the driver triggers a `ReinitializeAsync`-style re-import without operator action. **SDK API**: - A second `Subscription` on the Session, monitoring `Server` node (`ObjectIds.Server`) with an `EventFilter` whose SelectClauses reference `BaseModelChangeEventType` and (optionally) `GeneralModelChangeEventType` Changes property - On notification: enqueue a debounced re-discover (don't react to every event during a bulk topology edit — coalesce 2-5s window) **Files**: - `src/.../OpcUaClient/OpcUaClientDriver.cs` — add `_modelChangeSubscription` field; new `SubscribeModelChangesAsync` invoked at the end of `InitializeAsync`; debounce timer that calls `ReinitializeAsync` on the driver host - `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add `WatchModelChanges: bool` (default true) + `ModelChangeDebounce: TimeSpan` (default 5s) **Tests**: synthetic event injection on the mock Session's notification stream; assert one debounced re-import call regardless of N events arriving in the window. **Risks**: re-import while a downstream client is mid-browse — needs serialization on `_gate` like the rest of the driver; document that clients see a brief gap in the address space during reload. **Docs / fixture / e2e**: new "Auto re-import on ModelChangeEvent" section in `docs/drivers/OpcUaClient.md` documenting the debounce window, the `_gate` serialization, and the brief browse-gap during reload. Fixture: opc-plc supports runtime topology mutation via the `addnode`/`addtag` HTTP control endpoint — extend `OpcPlcFixture` with a helper that triggers a model change. Integration test asserts a single re-import call after a burst of synthetic model change events. E2E: add a "topology change" stage to `scripts/e2e/test-opcuaclient.ps1` that calls the opc-plc control endpoint, then asserts the local server reflects the new node within the debounce window. --- ### Phase 4 — Connectivity #### PR-11: Reverse Connect (gap #1) **Goal**: support server-initiated client connect for OT-DMZ outbound-only firewalls. The upstream server connects *to* us on a TCP listener; we respond as the client. Hard requirement for many regulated plant networks. **SDK API**: - `Opc.Ua.Client.ReverseConnectManager` — manages a TCP listener on the configured port and dispatches incoming reverse-connect requests - `ReverseConnectManager.AddEndpoint(Uri reverseEndpoint)` — listener URI e.g. `opc.tcp://0.0.0.0:4844` - `ReverseConnectManager.WaitForConnection(serverUri, serverUri, ct)` — blocks until the configured server initiates a reverse connect - `Session.Create(appConfig, reverseConnection, endpoint, ...)` — alternative session-create overload accepting the `ITransportWaitingConnection` returned by the manager **Files**: - `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add `ReverseConnect: { Enabled, ListenerUrl, ExpectedServerUri }` section - `src/.../OpcUaClient/OpcUaClientDriver.cs` — when reverse-connect is enabled, replace the failover sweep with `WaitForConnection` and fall through into the same session-create path - New helper `ReverseConnectListener` — owns the manager lifecycle, one listener per driver-host process (singleton across instances if multiple reverse-connect drivers are configured) **Tests**: spin up a `ReverseConnectClient` test against an opc-plc container started with `--rc opc.tcp://host:4844` to verify end-to-end. Unit tests mock `ITransportWaitingConnection`. **Risks**: highest of the plan. Reverse Connect changes the listen-vs-dial direction; if multiple OpcUaClient driver instances both listen on the same port the manager must multiplex. opc-plc supports reverse connect (`--rc` flag) so the integration test pattern from `docs/drivers/OpcUaClient-Test-Fixture.md` extends cleanly. **Docs / fixture / e2e**: new "Reverse Connect" section in `docs/drivers/OpcUaClient.md` (create if missing) documenting the listener URL config, the OT-DMZ outbound-only use case, and the shared- listener singleton model; update `docs/drivers/OpcUaClient-Test-Fixture.md` with the new "Reverse Connect coverage" row. Fixture: extend `Docker/docker-compose.yml` with an `opc-plc-rc` service variant that adds `--rc opc.tcp://host.docker.internal:4844`; `OpcPlcFixture` gains a `[CollectionDefinition]` that wires up the reverse-connect listener on the test side. Integration test asserts a session opens via the reverse path. E2E: add a `-ReverseConnect` switch to `scripts/e2e/test-opcuaclient.ps1` that flips the driver to listener mode and verifies the bridge stage still passes. --- ### Phase 5 — Historical & redundancy #### PR-12: `IHistoryProvider.ReadEventsAsync` interface fix + driver impl (gap #12) **Goal**: extend `IHistoryProvider.ReadEventsAsync` to carry an `EventFilter SelectClauses` parameter so HistoryRead Events can return the right field projection, and implement the OPC UA Client passthrough. **This is a cross-driver concern.** `IHistoryProvider` lives in `Core.Abstractions` and every driver that opts into history (Galaxy, OpcUaClient, plus any future historian-backed Tier-A driver) inherits the default. Changing the signature is source-breaking — coordinate as one PR that: 1. Adds the `IReadOnlyList` (or equivalent abstract `EventFilterSpec`) parameter 2. Updates Galaxy's existing override (currently the only override) to honour the projection (best-effort — the Galaxy A&E log has a fixed field set so most projections degrade to the default columns) 3. Lands the OpcUaClient passthrough using `Session.HistoryReadAsync` with `ReadEventDetails` **SDK API**: - `ReadEventDetails { StartTime, EndTime, NumValuesPerNode, Filter }` - `Session.HistoryReadAsync` is already the call we use for Raw — pass `new ExtensionObject(new ReadEventDetails { ... })` for events - `HistoryEvent.Events: HistoryEventFieldList[]` — unwrap into `HistoricalEvent` records **Files**: - `src/.../Core.Abstractions/IHistoryProvider.cs` — interface change - `src/.../Driver.Galaxy.../*HistoryProvider*.cs` — adjust signature - `src/.../OpcUaClient/OpcUaClientDriver.cs` — implement `ReadEventsAsync`; reuse `ExecuteHistoryReadAsync` shape - Server-side history facade — propagate the new parameter **Tests**: integration test against opc-plc with `--alm` (alarm sim already enabled per the fixture doc) — verify the SelectClause projection comes back correctly. **Risks**: the cross-driver interface change is the riskiest single ergonomic call in this plan. If we can't fit the new parameter without breaking every driver's `IHistoryProvider` impl, fall back to a sibling `IEventHistoryProvider` interface and only the OPC UA Client + Galaxy implement it. **Decide this in the PR review.** **Docs / fixture / e2e**: new "HistoryRead Events" section in `docs/drivers/OpcUaClient.md` documenting the `EventFilter`-aware passthrough; update `docs/Client.CLI.md` `historyread` page to cover event-mode reads. **Cross-driver doc updates** (this PR adds an "`IHistoryProvider.ReadEventsAsync` signature change — see `docs/plans/opcuaclient-plan.md` PR-12" note to every other driver plan that has a history surface): `docs/plans/abcip-plan.md`, `docs/plans/ablegacy-plan.md`, `docs/plans/focas-plan.md`, `docs/plans/s7-plan.md`, `docs/plans/twincat-plan.md`, the Galaxy plan family (`docs/plans/galaxy-*.md` if/when present, and the LMX equivalent if it lands), and any Modbus plan. Galaxy is the only existing implementor and gets a real signature update in this PR; the others get a heads-up note so future work tracks the new shape. Fixture: opc- plc runs with `--alm` already (per existing fixture doc) — no compose change. Integration test issues a HistoryRead Events with a non-default SelectClause and asserts the projected fields. E2E: extend `scripts/e2e/test-opcuaclient.ps1` with a "history events" stage gated on the `--alm` simulator producing at least one event. --- #### PR-13: Full Aggregate function set (gap #13) **Goal**: extend `HistoryAggregateType` from the 5 enum values today (Average/Minimum/Maximum/Total/Count) to the OPC UA Part 13 standard catalog of 30+ aggregates that historian-class clients expect. **SDK API**: `ObjectIds.AggregateFunction_*` constants — one per aggregate. The SDK already exposes them; this is pure mapping work. Aggregates to add (Part 13 §5): - `TimeAverage`, `TimeAverage2` - `Interpolative` - `MinimumActualTime`, `MaximumActualTime`, `Range`, `Range2` - `AnnotationCount`, `DurationGood`, `DurationBad`, `PercentGood`, `PercentBad` - `WorstQuality`, `WorstQuality2` - `StandardDeviationSample`, `StandardDeviationPopulation`, `VarianceSample`, `VariancePopulation` - `NumberOfTransitions` - `Start`, `End`, `Delta`, `StartBound`, `EndBound` - `DurationInStateZero`, `DurationInStateNonZero` **Files**: - `src/.../Core.Abstractions/IHistoryProvider.cs` — extend `HistoryAggregateType` enum (additive — existing values keep their ordinal) - `src/.../OpcUaClient/OpcUaClientDriver.cs` — `MapAggregateToNodeId` switch grows; default arm rejects `out of range` **Tests**: parametrized unit test sweeping every enum value — assert each maps to a non-null `NodeId` in the SDK's well-known set. **Risks**: low — this is mapping work. Drivers without a real historian (everything except Galaxy + OpcUaClient) keep throwing `NotSupported`. **Docs / fixture / e2e**: extend the "HistoryRead aggregates" section in `docs/drivers/OpcUaClient.md` with the full Part 13 catalog and which aggregates require server-side support; update `docs/Client.CLI.md` `historyread` page enumerating the new `--aggregate` values. Fixture: opc-plc historian support is limited — flag in `docs/drivers/OpcUaClient-Test-Fixture.md` that the new aggregates are unit-tested via the SDK's well-known NodeId set, not exercised wire-side. Integration test sweeps every enum value and asserts the mapping; gated-skip for aggregates the live opc-plc image doesn't honour. No e2e change. --- #### PR-14: `ServerUriArray` redundant failover (gap #14) **Goal**: read upstream `Server.ServerArray` / `ServerStatus.ServerArray` and `ServerRedundancyType.RedundancySupport` at session activation; when the upstream server advertises non-`None` redundancy, fail over mid-session on `ServiceLevel` drop without losing client subscriptions. Today our `EndpointUrls` is a one-shot connect- attempt list, not a live redundancy group. **SDK API**: - `Session.ReadValueAsync(VariableIds.Server_ServerStatus_ServerArray, ct)` → URI list - `Session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct)` polled or subscribed via MonitoredItem - Subscribe `Server_ServiceLevel` on the existing alarm subscription so drops propagate via the publish channel - On low-`ServiceLevel`: open a parallel session against the next URI in `ServerArray`, `Session.TransferSubscriptionsAsync(otherSession, ...)` the live subscriptions, swap `Session` reference **Files**: - `src/.../OpcUaClient/OpcUaClientDriver.cs` — new `MonitorServerRedundancyAsync` method; integrate with the existing `OnKeepAlive` / `SessionReconnectHandler` machinery so reconnect and redundancy-failover share the subscription-transfer code path - `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add `Redundancy: { Enabled, ServiceLevelThreshold (default 200) }` **Tests**: with two opc-plc containers behind the driver, artificially drop ServiceLevel on the active one and assert the secondary takes over; assert subscription handles stay valid. **Risks**: redundancy is the second-riskiest item after Reverse Connect. The SDK's `TransferSubscriptions` has known edge cases when the secondary's `SecureChannel` rejects the source-channel's authentication token; doc that the secondary must trust the same client cert as the primary. **Docs / fixture / e2e**: new "Upstream redundancy (`ServerArray`)" section in `docs/drivers/OpcUaClient.md` with the ServiceLevel threshold, the shared-cert prerequisite for `TransferSubscriptions`, and the ops runbook for forcing a failover; cross-link from `docs/Redundancy.md` (which today covers OUR server's redundancy — add a "vs upstream-side redundancy" note). Fixture: extend `Docker/docker-compose.yml` with a second `opc-plc-secondary` service on a different port; `OpcPlcFixture` gains a multi-endpoint variant. Integration test drops the active server's ServiceLevel and asserts the secondary takes over with subscription handles intact. E2E: add a `-PrimaryUrl` / `-SecondaryUrl` pair to `scripts/e2e/test-opcuaclient.ps1` (and matching keys to `scripts/e2e/e2e-config.sample.json`) that scripts a primary stop + asserts the bridge stage continues to pass. --- ## Documentation, fixture, and e2e impact Consolidated index of every doc page, fixture asset, and e2e script touched by the plan above. Authoritative for review — if a PR's `Docs / fixture / e2e` line references a path not listed here, that's a checklist gap. ### Driver user docs - `docs/drivers/OpcUaClient.md` — **create on first PR that needs it (PR-1)** if not present, then extend with one section per PR-1 through PR-14 covering: subscription tuning, per-tag deadband, OperationLimits handling, diagnostics counters, CRL/SHA1, FindServers, curation, type mirroring, methods, ModelChangeEvent, Reverse Connect, history events, aggregates, upstream redundancy. - `docs/drivers/OpcUaClient-Test-Fixture.md` — coverage map updated for curation (PR-7), Reverse Connect (PR-11), aggregates note (PR-13), redundancy multi-endpoint variant (PR-14). - `docs/Client.CLI.md` — extended for subscribe deadband syntax (PR-2), any `discover` command (PR-6), `call` command (PR-9), `historyread` event mode (PR-12), `--aggregate` enum expansion (PR-13). - `docs/Client.UI.md` — extended for Subscriptions tab deadband fields (PR-2), Browse-tree type rendering note (PR-8), Method-call surface (PR-9) if it ships. - `docs/security.md` — cross-link from PR-5 (CRL/SHA1 knobs). - `docs/Redundancy.md` — cross-link from PR-14 (note distinguishing server-side redundancy from upstream-side redundancy). ### Fixture assets - `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml` — add `opc-plc-rc` (PR-11) and `opc-plc-secondary` (PR-14) service variants; optional secured endpoint (PR-5). - `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs` — discovery probe at collection init (PR-6), reverse-connect listener (PR-11), multi-endpoint variant (PR-14), model-change helper (PR-10). - `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs` — flag noisy analogs for deadband (PR-2), enumerate exercised namespaces for curation (PR-7), record at least one custom ObjectType (PR-8). - New integration tests added per PR; all live under the existing `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` collection. - Test certs (PR-5): SHA1-signed + revoked test fixtures checked into the unit-test project's resources. ### E2E scripts - `scripts/e2e/test-opcuaclient.ps1` — new stages added per PR (subscription tuning PR-1, deadband PR-2, diagnostics PR-4, CRL PR-5, discovery PR-6, curation PR-7, method call PR-9, topology change PR-10, reverse connect PR-11, history events PR-12, redundancy failover PR-14). The script is the single integration point for every driver-level e2e — keep the stages ordered top-down by phase. - `scripts/e2e/e2e-config.sample.json` — new keys: `deadband`, `discoveryUrl`, `includePath`, `namespaceRemap`, `methodNodeId`, `reverseConnect`, `primaryUrl`, `secondaryUrl`. - `scripts/e2e/test-all.ps1` — no structural change; the existing `opcuaclient` block forwards new params after wiring them through `e2e-config.sample.json`. ### Cross-driver impact (PR-12 — `IHistoryProvider.ReadEventsAsync`) PR-12 changes the `IHistoryProvider.ReadEventsAsync` signature in `Core.Abstractions` (or introduces a sibling `IEventHistoryProvider` — pinned in PR-12 review per Open Question 2). That decision is source-breaking for every driver that opts into history. PR-12 must add an explicit "interface change — adopt new signature when this driver implements `ReadEventsAsync`" note to: - `docs/plans/abcip-plan.md` - `docs/plans/ablegacy-plan.md` - `docs/plans/focas-plan.md` - `docs/plans/s7-plan.md` - `docs/plans/twincat-plan.md` - The Galaxy plan family — `docs/plans/galaxy-*.md` if/when those pages exist; Galaxy is the only current implementor and gets the real signature update in PR-12, not just a note. - The LMX plan — `docs/plans/lmx-*.md` if/when it lands (current state: the LMX driver's history surface is implicit through Galaxy; revisit during PR-12 review). - A Modbus plan page if/when one exists; Modbus does not implement history today but the heads-up note tracks the cross-driver shape. The cross-driver note text should be a one-paragraph "Heads up: the `IHistoryProvider.ReadEventsAsync` interface gained an `EventFilterSpec` parameter in OpcUaClient PR-12 (`docs/plans/opcuaclient-plan.md`). If/when this driver implements event-history, adopt the new signature." This pattern keeps each driver plan stable while the cross-cutting breakage is owned by one PR. --- ## Skip-rated items (for context) These featuregaps rows are **Build = No** and intentionally omitted from the plan above: | # | Gap | Why we're skipping | | :---: | --- | --- | | 3 | Multicast / LDS-ME registration | Server-side responsibility, not aggregator's. | | 4 | GDS push management (Part 12) | Significant infra; rare for our deployment scale. | | 11 | HistoryUpdate / Modified / Annotation passthrough | MES backfill scope; defer. | | 16 | Connection / session pooling for multi-instance scale-out | Premature; current per-instance model is simple and adequate. | | 18 | Kerberos / OAuth2 / JWT identity | Significant security work; defer until AD integration drives it (separate workstream). | | 19 | Write attribute scope beyond `Value` | Niche; rarely used in OPC UA practice. | If any of these get prioritized later they slot cleanly between the phases above — none have prerequisites among the Build = Yes items. ## Open questions 1. **`ISubscribable` overload vs new method (PR-2)**: per-tag spec carrier is needed for deadband; do we extend the existing `SubscribeAsync` overload or add `SubscribeWithSpecsAsync`? The former is source-breaking but cleaner; the latter is additive but leaves two parallel paths. 2. **`IHistoryProvider.ReadEventsAsync` shape (PR-12)**: does the `EventFilterSpec` parameter live on `IHistoryProvider` (one interface, every driver gets it) or on a sibling `IEventHistoryProvider` (two interfaces, only event-history drivers implement)? Memory entry suggests the former; preference depends on whether non-OPC-UA drivers ever expect to project arbitrary event fields. **Pin this in PR-12 review.** 3. **`IMethodInvoker` capability (PR-9)**: does this become the 9th capability interface (currently 8/8) or is it folded into `IWritable` as a method-invoke variant? Adding a 9th interface is the cleaner model and matches the spec layering. 4. **Type mirroring address-space surface (PR-8)**: does `IAddressSpaceBuilder` already accept type nodes? If yes, PR-8 is straightforward; if no, it splits into a prerequisite PR-8a that extends the builder, then PR-8b for the OPC UA Client wire-up. The answer determines whether PR-8 ships in Phase 2 or slips to a later phase. 5. **Reverse Connect listener ownership (PR-11)**: one listener per driver instance (port collision when multiple reverse-connect drivers run in the same process) vs one shared listener with a `expectedServerUri` dispatcher. Shared is the right answer; pin the singleton lifetime to the driver-host. 6. **Phase 1 ship order**: PR-1, PR-3, PR-4, PR-5 are independent and can land in parallel. PR-2 depends on the `ISubscribable` interface decision (Q1) — recommend landing PR-1 first to validate the `OpcUaSubscriptionDefaults` shape, then PR-2.