48a80b5962
After an OpcUaClient respawn an upstream inactive+unacked condition went out as Retrigger+Clear (duplicate Activated+Cleared rows, Active flicker), and a first-sight inactive+acked one as Unspecified (an Activated row for an inactive alarm; a retained-Active host condition never cleared). - New AlarmTransitionKind.InactiveUnacknowledged: one projection, rows only for what changed (Activated+Cleared from acked, Cleared from active+unacked, none when already inactive+unacked). - OpcUaClient uses it for moves into inactive+unacked; first sight of an inactive condition is InactiveUnacknowledged or Clear+Acknowledge, flagged. - Projector: a flagged Acknowledge on an already-acked condition writes no row. - E2E tests through DriverHostActor.ForwardNativeAlarm (red-proven). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
215 lines
14 KiB
Markdown
215 lines
14 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`
|
|
(`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 |
|
|
|
|
> **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 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.
|
|
|
|
### 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`, `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
|