Files
lmxopcua/docs/drivers/OpcUaClient.md
T
Joseph Doherty dd05baedd6 refactor(opcuaclient): remove vestigial TargetNamespaceKind + UnsMappingTable (#648)
Nothing read the mapping table beyond the startup validator; v3 UNS projection is
explicit via UnsTagReference. Legacy keys in stored DriverConfig are ignored (pinned).
Reverts the docker-dev opc-plc seed workaround from fdee129f.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:14:37 -04:00

212 lines
13 KiB
Markdown

# 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`](../v2/driver-specs.md),
and for the test-harness map read [OpcUaClient-Test-Fixture.md](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](../Client.CLI.md) 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
```jsonc
"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 init
and on session drop, 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,
reconnect period, browse root, node/depth caps).
### Session lifetime
A single `Session` per driver instance; subscriptions multiplex onto it. The
SDK reconnect handler takes the session down and brings it back on remote-server
restart, re-sending subscriptions on reconnect so monitored-item handles don't
dangle. 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.
### 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, IHistoryProvider`
(`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 `StatusCode`s 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 |
| `IHistoryProvider` | `ReadRawAsync` / `ReadProcessedAsync` / `ReadAtTimeAsync` → `Session.HistoryReadAsync` | **Unique to this driver** — passthrough history read against the upstream server |
> **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). Condition **branch** events (non-null `BranchId`) are ignored.
> **`ConditionRefresh`** is called after the event item is created, after every in-place reconnect, 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 reconnect 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.
> `IHistoryProvider` is implemented by no other driver; history reads for every
> other source route server-side through `IHistoryRouter`.
### History passthrough
`IHistoryProvider` forwards `HistoryRead` to the upstream server's own historian.
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.
### 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`, `reconnect_attempt`, `reconnect_success`, `reconnect_exhausted`. A climbing `keepalive_miss` /
`reconnect_attempt` rate is the signature of a flapping upstream. They are not part of `DriverHealth` or the
`/hosts` panel.
## Testing
- **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](OpcUaClient-Test-Fixture.md) for the coverage map.
## Further reading
- [`docs/v2/driver-specs.md §8`](../v2/driver-specs.md) — full per-field spec,
namespace-assignment rules, and cascading-quality detail
- [OpcUaClient-Test-Fixture.md](OpcUaClient-Test-Fixture.md) — test-harness map
- [Client.CLI.md](../Client.CLI.md) — general-purpose OPC UA client CLI for
ad-hoc browsing of any endpoint