Files
lmxopcua/docs/drivers/OpcUaClient.md
T
Joseph Doherty 87e8aff786 fix(opcuaclient): scope the #670 F2 late-replay drop to the post-recovery window
The global 'never older than the last Good source time' rule froze a node
whenever an upstream clock stepped backwards (NTP correction, PLC clock
reset) until the clock caught up. The check is now a replay guard armed
only by a same-session recovery (keep-alive bad->good): per ref it holds
the last Good upstream source time (raised by the recovery read), a ref
disarms on its first in-order notification, and the whole guard expires
after max(30 s, 2x the longest subscription keep-alive window). Outside
it every notification is accepted. The steady-state tracker records the
LAST accepted time, not a high-water mark.
2026-10-11 09:15:51 -04:00

18 KiB
Raw Blame History

OPC UA Client (Gateway) Driver

Getting-started guide for the OPC UA Client driver. This is the short path — for the full per-field spec read docs/v2/driver-specs.md §8, and for the test-harness map read OpcUaClient-Test-Fixture.md.

What it talks to

A remote OPC UA server. This driver runs the opposite direction from the usual "server exposes PLC data" flow: it acts as an OPC UA client, opens a Session against an upstream server, and re-exposes that server's address space through the local OtOpcUa server. Browse, read, write, subscribe, alarm, and history calls are passed through to the upstream endpoint.

It is built on the OPC Foundation UA .NET Standard reference SDK and runs in-process in the OtOpcUa server's .NET 10 AnyCPU host — pure managed, no out-of-process isolation.

There is no standalone driver CLI for the OPC UA Client driver. To exercise a remote OPC UA endpoint by hand, point the general-purpose Client CLI at it directly.

Project split

Project Target Role
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ net10.0 In-process driver — session lifetime, read / write / subscribe / alarm / history passthrough
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ net10.0 IDriverBrowser — live address-picker browse used by the AdminUI
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ net10.0 Config records + enums bound from DriverConfig JSON

Minimum deployment

"Drivers": {
  "upstream-1": {
    "Type": "OpcUaClient",
    "Config": {
      "EndpointUrl": "opc.tcp://plc.internal:4840",
      "SecurityPolicy": "None",
      "SecurityMode": "None",
      "AuthType": "Anonymous"
    }
  }
}

EndpointUrls (a list) takes precedence over the single-URL EndpointUrl and provides ordered failover — the driver tries each candidate in turn at every InitializeAsync (including the comms-loss watchdog's re-initialise), and the first to connect wins (e.g. a hot-standby pair on 4840 / 4841). See src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs for every field (security policy/mode, auth type, session timeout, keep-alive, browse root, node/depth caps). ReconnectPeriod is still parsed but no longer used (#670).

Session lifetime

A single Session per driver instance; subscriptions multiplex onto it. Stored NodeIds embed the server-stable namespace URI (not the session-relative ns=N index) so a remote namespace-table reorder across a restart doesn't silently re-point references at the wrong namespace.

Upstream outage and recovery (Gitea #670)

The session keep-alive (KeepAliveInterval, default 5 s) is the liveness signal:

  • Good keep-alive / any data or event notification advances DriverHealth.LastSuccessfulRead — a subscription-only driver never calls ReadAsync, so without this its read stamp froze at connect time.
  • Bad keep-alive ⇒ health Degraded (LastError = "keep-alive: <status>", read stamp preserved), host status Stopped, and — once per outage, not per keep-alive — every subscribed ref is published BadNoCommunication with its last value retained and source + server timestamp = the detection time (live-gate F1: stamped with the last Good's source time it collided with that row's (TagId, SourceTimeUtc) history key and was never stored), plus a quality-only BadNoCommunication annotation (#626 pattern, no alarm-state change, no /alerts row) on every subscribed alarm condition.
  • The session survives (a network blip; the keep-alive turns Good again) ⇒ recovered in place: health Healthy, the refs still Bad are re-read once and published Good (a server whose subscription survived does not resend an unchanged value, and a (value, status) dedup downstream lets the identical value through), alarm quality goes back Good and a ConditionRefresh re-reports anything missed. A notification that lands first wins over the read. For a bounded replay window after such a recovery — max(30 s, 2 x the longest subscription keep-alive window) — a notification whose source time is OLDER than the ref's last Good upstream source time (raised by the recovery read) is dropped: the SDK was seen delivering a missed one 4–5 s late, which regressed the node (live-gate F2). A ref disarms on its first in-order (equal or newer) notification. Outside that window every notification is accepted whatever its timestamp, so an upstream clock that steps backwards (NTP, a PLC clock reset) never freezes a node. The outage Bad never moves the comparison; a re-initialise resets it.
  • The session does not come back (an upstream restart: the server forgot the session) ⇒ the driver stays Degraded with its read stamp frozen, which is exactly the #527 comms-loss watchdog's trip condition. After 90 s DriverInstanceActor runs ShutdownAsync + InitializeAsync (retried every 10 s while the server is down) and re-subscribes data + alarms; the new subscription's initial values come back Good. Alarm last-told state survives the re-initialise (#619), so the post-subscribe ConditionRefresh raises no un-ack flicker; the host's reconnect annotation restores condition quality.
  • The subscription dies but the session lives (an outage longer than the subscription lifetime — LifetimeCount x publishing interval, often well under the session timeout — or a server that drops it) ⇒ keep-alives stay Good, so they cannot be the signal. A PublishError of BadNoSubscription (while subscriptions are tracked) or BadSubscriptionIdInvalid naming a tracked subscription, or a tracked subscription whose SDK PublishingStopped persists for one more subscription keep-alive window + 5 s across good keep-alives, declares it lost: Degraded ("subscription lost: …"), the outage published once, and — sticky until the session is released — nothing advances LastSuccessfulRead any more (not good keep-alives, not a sibling subscription's notifications), so the watchdog's re-initialise recreates it. Both detectors are wire-tested independently.
  • An in-place recovery is bound to the session whose keep-alive recovered: one overtaken by a re-initialise neither publishes, nor degrades, nor re-arms the outage latch of the new session.

There is no in-driver reconnect any more. The SDK's SessionReconnectHandler (removed) retried forever in SDK 1.5.378 (its "exhausted" path was unreachable), recreated sessions by cloning subscriptions the driver never re-tracked, and kept health Healthy throughout — so on docker-dev a stopped opc-plc left nodes Good on their last value indefinitely and they never recovered. A teardown against a lost session skips the wire deletes and CloseSession (bounded to 5 s otherwise). SDK warnings (keep-alive / publish / channel errors) now reach the driver's own logger, rate-limited to 20 per minute with a suppressed-count note; the per-driver SDK logger factory is disposed with the driver.

Namespace assignment (retired, Gitea #648)

Earlier versions required a per-instance TargetNamespaceKind (Equipment + a mandatory UnsMappingTable, or SystemPlatform + an empty table). Both options are removed: nothing ever read the mapping table beyond the startup check, and in v3 the raw-to-UNS projection is explicit (UnsTagReference), so the choice carried no behaviour — it only made a config without the key fail Initialize. Remote nodes are authored as /raw tags bound by their upstream nodeId; there is no namespace field on the driver, the AdminUI form or the seed.

Stored DriverConfig JSON that still carries targetNamespaceKind or unsMappingTable keeps working: the keys are unknown and ignored on parse and initialise (the AdminUI form drops them on the next save). Pinned by OpcUaClientReinitConfigAdoptionTests.Legacy_namespace_keys_are_ignored_by_parse_and_initialise.

Capability surface

OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource (src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs:31).

Capability Path Notes
ITagDiscovery DiscoverAsync (recursive browse) Mirrors the upstream tree from BrowseRoot (default ObjectsFolder i=85), bounded by MaxDiscoveredNodes / MaxBrowseDepth
IReadable ReadAsync → Session.ReadAsync Upstream StatusCodes pass through verbatim (cascading-quality rule)
IWritable WriteAsync → Session.WriteAsync Passthrough write
ISubscribable native OPC UA subscriptions / monitored items The remote server pushes data changes
IHostConnectivityProbe session keep-alive Host key is the endpoint URL actually connected to after the failover sweep
IAlarmSource SubscribeAlarmsAsync (EventFilter) + AcknowledgeAsync Subscribes to upstream alarm/condition events and forwards acks

Native alarms (#619). An alarm raw tag is TagConfig { "nodeId": "<upstream SourceNode>", "alarm": {...} }. The host subscribes with RawPaths and routes on (driver, RawPath), so the driver matches each event's ConditionId (then its SourceNode) against the tag's authored nodeId and reports the RawPath as ConditionId. Author nodeId as the upstream condition node for a 1:1 mapping (required when one source carries several conditions, e.g. Hi + HiHi); authoring the source node folds every condition of that source into one tag. Acknowledge is called on the condition node of the last event seen for the RawPath with that event's EventId (Part 9 §5.7.3). The event's ActiveState/Id + AckedState/Id are selected and turned into the Raise/Acknowledge/Clear/Retrigger kinds the host projector consumes (without a kind no upstream alarm could ever show active); the kinds are emitted from the state last told to the host per RawPath, so an identical re-delivery emits nothing (no re-raise, which would un-ack). An inactive → active+acked move is ONE ActiveAcknowledged (#656; it used to be Raise + Acknowledge, a client-visible unacked intermediate); a move into inactive+unacked is ONE InactiveUnacknowledged (#657; it used to be Raise/Retrigger + Clear, an Active flicker). The first event per condition after a start or deploy respawn is flagged IsRestatement and states the upstream condition so it lands from whatever the host retained: an inactive+acked one goes out as Clear + Acknowledge (#657; it used to be an Unspecified the host wrote as "Activated", and a retained-Active host condition stayed Active). So the host writes no duplicate row when its condition already shows that state (see docs/AlarmTracking.md). Condition branch events (non-null BranchId) are ignored. ConditionRefresh is called after the event item is created, after a keep-alive outage the session survived, and on a RefreshRequired event, so a condition already active upstream is reported at subscribe; RefreshStart/RefreshEnd markers are dropped. A server without it (BadNotSupported/BadMethodInvalid/…) is logged once and tolerated. Last-told state survives a re-initialise (the host re-subscribes the same set over nodes it did not rebuild) and is forgotten when the subscribed set changes (the host rebuilt, recreating the nodes inactive). Index keys are server-stable (nsu=), so a namespace reorder across a re-initialise keeps matching. docker-dev seeds an opc-plc driver (MAIN-opcua); the alarm tag is created only when @OpcPlcAlarmSourceNodeId is set in docker-dev/seed/seed-clusters.sql.

This driver does not implement IRediscoverable — there is no push-driven rediscovery signal from a remote OPC UA server in this driver.

History: not supported (Gitea #655)

This driver does not proxy the upstream server's HistoryRead. The server serves HistoryRead only through the server-wide IHistorianDataSource (see docs/Historian.md); drivers carry no history capability, and the former IHistoryProvider passthrough (Raw / Processed / AtTime / Events) was never dispatched to and has been deleted. isHistorized on an OpcUaClient tag means OtOpcUa records the value into the historian from now on (numeric only), not that upstream history is forwarded. A tag without isHistorized has no HistoryRead access bit and answers BadHistoryOperationUnsupported. If upstream-history proxying is wanted later, it needs a new intent key, a dispatch path through DriverHostActor, and a continuation-point rework — see #655.

Certificate trust

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. The SDK rejects a revoked certificate (BadCertificateRevoked / BadCertificateIssuerRevoked) before the auto-accept handler runs, and the handler also refuses them explicitly — defence in depth (#277), not a behaviour change.

CertificateValidation (defaults change nothing):

Key Default Meaning
rejectUnknownRevocationStatus false Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave false for brownfield sites without CRL infrastructure. Only effective with autoAcceptCertificates off — the auto-accept handler accepts every suppressible error, including revocation-unknown.
issuerStorePath null (= %LocalAppData%/OtOpcUa/pki/issuers) Directory used as the trusted-issuer store. The SDK reads issuer CRLs (*.crl) from its crl/ sub-folder, so this is how an operator points the driver at a managed CRL set.

Subscription tuning (#273)

The per-subscription parameters that used to be hardcoded are driver options; the defaults are the old values.

Key Default Notes
subscriptionPublishingInterval null A .NET TimeSpan string ("00:00:02"), not a number. null = the interval the runtime requests per subscribe (floored at 50 ms). When set it overrides that value (floor still applies); monitored-item sampling follows it.
subscriptionKeepAliveCount 10 >= 1.
subscriptionLifetimeCount 1000 Must be >= 3 x keep-alive count (Part 4 §5.13.2).
subscriptionMaxNotificationsPerPublish 0 0 = server decides.
subscriptionPriority 0 0-255.

Out-of-range values (and a non-absolute discoveryUrl) are rejected when the options are loaded — driver creation / InitializeAsync fails once with a clear message rather than the driver faulting and retrying forever. The alarm subscription uses the same counts. Per-tag tuning (deadband / sampling interval / queue size, #274) is not implemented — it needs an ISubscribable signature change on Core.

Server OperationLimits (#275)

Nothing driver-side: the SDK session already honours the remote server's ServerCapabilities/OperationLimits. Opc.Ua.Client.Session derives from Opc.Ua.SessionClientBatched, fetches the limits on open (FetchOperationLimitsAsync) and splits Read / Write / CreateMonitoredItems / Browse / HistoryRead requests that exceed them (a limit of 0 = unlimited). The driver issues one logical call per operation and a unit test pins the Session : SessionClientBatched relationship so an SDK change that dropped it is caught. (A driver-side splitter was tried and removed: it duplicated the SDK and, on a stalled link, held the driver gate for chunks x timeout.)

Discovery URL (#278)

discoveryUrl (default null, e.g. opc.tcp://lds:4840): at InitializeAsync the driver runs FindServers on it and GetEndpoints on every returned server, keeping the endpoints matching securityPolicy + securityMode. Discovered URLs are tried after endpointUrls/endpointUrl (case-insensitive de-dup) — discovery can add fallbacks but never outranks or replaces the configured server. Because an LDS commonly registers several servers (identical line PLCs, a gateway plus a historian) and the first to accept a connection would be bound silently:

  • discoveryServerUri set: only endpoints of the server with that ApplicationUri are used.
  • unset and exactly one distinct server discovered: that server is used.
  • unset and more than one: the connect attempt fails with an error naming the discovered ApplicationUris and telling the operator to set discoveryServerUri.

A discovery network failure (LDS down) is logged and non-fatal — the static list is used (note it is retried on every InitializeAsync, including comms-loss reconnects). The discovery channel itself is always unsecured (spec).

Diagnostics counters (#276)

Session/publish events are published on the shared OTel meter as the counter otopcua.driver.opcuaclient.events, tagged driver_instance (the driver instance id) and event = publish_response, notification (items), data_change, bad_status_value, publish_error, keepalive_miss, keepalive_recovered (a bad keep-alive followed by a good one on the same session), stale_notification_dropped (a late replay dropped inside the post-recovery window, see above). A climbing keepalive_miss / keepalive_recovered rate is the signature of a flapping upstream (reconnect_* were removed with the reconnect handler, #670). They are not part of DriverHealth or the /hosts panel.

Testing

  • Unit tests — tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ cover the session lifecycle, namespace remapping, alarm/history passthrough, and config binding against a faked SDK session.
  • Integration fixture — exercises the driver against a reference OPC UA server (opc-plc) on the shared docker host; see OpcUaClient-Test-Fixture.md for the coverage map.

Further reading