docs(audit): G2 completeness — S7/TwinCAT/OpcUaClient/Historian overview pages

This commit is contained in:
Joseph Doherty
2026-06-03 16:14:40 -04:00
parent 974d835d08
commit f21883d607
4 changed files with 525 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
# Wonderware Historian Backend
The Wonderware Historian backend is **not a tag driver** — it has no address
space, no `IDriver` lifecycle, and exposes no PLC. It is a **server-side
historian sink**: an optional sidecar that gives OtOpcUa read access to AVEVA
System Platform (Wonderware) Historian history and a write-back path for alarm
events. It runs only when `Historian:Wonderware:Enabled=true`.
For the sidecar's place in a deployment, see
[ServiceHosting.md](../ServiceHosting.md). For the alarm-history store-and-forward
flow that drains into it, see [AlarmHistorian.md](../AlarmHistorian.md).
## Architecture
```
+-------------------------------------------+
| OtOpcUa Host (.NET 10 AnyCPU) |
| Server.History.IHistoryRouter --read--+--+
| Core.AlarmHistorian.SqliteStore | |
| AndForwardSink --write----+--+
| WonderwareHistorianClient (.NET 10) | |
+-------------------------------------------+ |
| named pipe
MessagePack frames | (shared secret + allowed-SID)
v
+-------------------------------------------+
| OtOpcUaWonderwareHistorian (sidecar) |
| net48 / x64 |
| PipeServer + HistorianFrameHandler |
| HistorianDataSource (reads) |
| SdkAlarmHistorianWriteBackend (writes) |
| aahClientManaged / HistorianAccess |
+-------------------------------------------+
```
The split exists because the AVEVA Historian SDK (`aahClientManaged` +
native `aahClient.dll`) is .NET Framework 4.8 / x64 — so it lives out-of-process
in the sidecar, and everything in the OtOpcUa host stays .NET 10 AnyCPU. The
host never references the SDK; it speaks the pipe contract only.
## Project split
| Project | Target | Role |
|---------|--------|------|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/` | net48 / x64 | The **sidecar** (`OutputType=Exe`). Hosts the named-pipe server, the historian reader, and the alarm-write backend bound to the AVEVA SDK |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/` | net10.0 | `WonderwareHistorianClient` — the in-host pipe client consumed by the history router and the alarm sink |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/` | net10.0 | `WonderwareHistorianClientOptions` (pipe name, shared secret, timeouts) |
> The csproj targets **net48 / x64** (`PlatformTarget=x64`) — the AVEVA Historian
> 2020 SDK ships an x64 `aahClientManaged` build; the earlier x86 default was an
> inherited v1 artifact, not a constraint of the Historian SDK.
## What it does
The sidecar exposes two surfaces, both over the same named pipe:
### Read path — `IHistorianDataSource`
`HistorianDataSource` (in the sidecar) reads history through the
`aahClientManaged` SDK; `WonderwareHistorianClient` (in the host) implements
`IHistorianDataSource` and maps returned samples back to OPC UA `DataValue`s for
`Server.History.IHistoryRouter`. The read surface is:
| Call | Maps to |
|------|---------|
| `ReadRawAsync` | Raw historical samples for a tag over a time range |
| `ReadProcessedAsync` / `ReadAggregateAsync` | Aggregated samples at an interval |
| `ReadAtTimeAsync` | Samples at specific timestamps |
| `ReadEventsAsync` | Historical events for a source |
| `GetHealthSnapshot` | Connection health for the host-side health surface |
### Write path — alarm-historian write-back
`WonderwareHistorianClient` also implements `IAlarmHistorianWriter`. Alarm events
are drained into the sidecar from `Core.AlarmHistorian.SqliteStoreAndForwardSink`
and persisted by `SdkAlarmHistorianWriteBackend` via
`HistorianAccess.AddStreamedValue(HistorianEvent, out HistorianAccessError)`. The
production writer is wrapped by `AahClientManagedAlarmEventWriter`, which handles
batch orchestration and per-event `HistorianAccessError` outcome classification
(connection-class errors are retryable; malformed-argument errors are not).
The alarm write path can be disabled independently of reads by setting
`OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false` — the sidecar then rejects
`WriteAlarmEvents` frames while still serving history reads.
## Hosting and IPC
- **Process**: `OtOpcUaWonderwareHistorian`, installed/managed by
`scripts/install/` (`Install-Services.ps1 -InstallWonderwareHistorian`).
- **Spawn config**: the supervisor passes the pipe name, the allowed server
principal SID, and a per-process shared secret via environment
(`OTOPCUA_HISTORIAN_PIPE`, `OTOPCUA_ALLOWED_SID`, `OTOPCUA_HISTORIAN_SECRET`);
Historian connection settings come from `OTOPCUA_HISTORIAN_SERVER` /
`_PORT` / `_INTEGRATED` / `_USER` / `_PASS` etc. (see
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs`).
- **Pipe-only mode**: with `OTOPCUA_HISTORIAN_ENABLED!=true` the sidecar boots
without loading the SDK at all — used for smoke and IPC tests.
- **Wire**: MessagePack-framed request/reply; the named-pipe ACL restricts the
pipe to the allowed SID and the client proves the shared secret in a Hello
frame. The client owns a single channel with one in-flight call at a time and
retries a transport failure once before propagating — broader backoff is the
caller's responsibility.
## Testing
- **Sidecar unit tests** —
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/` cover the
reader, the alarm-write backend outcome classification, and the pipe-frame
handler with a faked SDK seam.
- **Client unit tests** —
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/`
cover the pipe client + framing against an in-process duplex pipe pair.
## Further reading
- [ServiceHosting.md](../ServiceHosting.md) — where the sidecar fits in a
deployment and how it's installed
- [AlarmHistorian.md](../AlarmHistorian.md) — the alarm store-and-forward flow
that feeds the write-back path
+129
View File
@@ -0,0 +1,129 @@
# 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",
"TargetNamespaceKind": "Equipment"
}
}
}
```
`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
This is the only driver that gateways into **either** namespace kind, decided
per instance via `TargetNamespaceKind`:
- `Equipment` — the remote server exposes raw equipment data; remote browse
paths are remapped to UNS via a required `UnsMappingTable`.
- `SystemPlatform` — the remote server exposes processed/derived data; the
remote hierarchy is preserved with no UNS conversion (and the mapping table
must be empty).
The choice is enforced at startup so a misconfiguration fails draft validation
rather than surfacing as a runtime surprise.
## 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 |
> 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 (Average / Minimum / Maximum / Total / Count aggregates mapped to
OPC UA Part 13 standard aggregate NodeIds), 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.
## 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
+148
View File
@@ -0,0 +1,148 @@
# Siemens S7 Driver
Getting-started guide for the Siemens S7 driver. This is the short path — for
the full per-field spec read [`docs/v2/driver-specs.md §5`](../v2/driver-specs.md),
for hands-on CLI testing read [Driver.S7.Cli.md](../Driver.S7.Cli.md), and for
the test-harness map read [S7-Test-Fixture.md](S7-Test-Fixture.md).
## What it talks to
Siemens S7 PLCs — S7-300, S7-400, S7-1200, S7-1500, plus S7-200 / S7-200 Smart
/ LOGO! 0BA8 — over the native **S7comm** protocol on **ISO-on-TCP, TCP port
102**. The wire is spoken by the pure-managed [S7netplus](https://github.com/S7NetPlus/s7netplus)
(`S7.Net`) library: no native DLL, no P/Invoke, no out-of-process isolation. The
driver runs in-process in the OtOpcUa server's .NET 10 AnyCPU host on every OS
the server runs on.
This is the **leanest** OtOpcUa driver — read/write/subscribe/discover plus a
connectivity probe, and nothing else. It implements no alarm source and no
per-call host resolver (a single S7 instance targets a single CPU).
## Project split
| Project | Target | Role |
|---------|--------|------|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/` | net10.0 | In-process driver — hosts the `S7.Net.Plc` connection and the address parser |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/` | net10.0 | Dependency-free config records + enums (`S7DriverOptions`, `S7CpuType`, `S7DataType`) bound from `DriverConfig` JSON |
## Minimum deployment
Register the driver instance in the central config DB (or `appsettings.json`).
No separate service, no DLL deployment:
```jsonc
"Drivers": {
"s7-line-1": {
"Type": "S7",
"Config": {
"Host": "10.20.30.40",
"CpuType": "S71500",
"Rack": 0,
"Slot": 0,
"Tags": [
{ "Name": "Running", "Address": "DB1.DBX0.0", "DataType": "Bool", "Writable": false },
{ "Name": "Speed", "Address": "DB1.DBD4", "DataType": "Float32", "Writable": true }
]
}
}
}
```
S7 exposes a symbol table, but `S7.Net` does not surface it — so the driver
operates off a **static, per-site tag list**, not live symbol discovery.
### Rack / slot / CPU family
`CpuType` selects the ISO-TSAP slot byte used during the connection handshake;
pick the family that matches the PLC exactly. `Rack` is almost always `0`
(relevant only for distributed S7-400 racks). `Slot` conventions per family:
S7-300 = slot 2, S7-400 = slot 2 or 3, S7-1200 / S7-1500 = slot 0 (onboard PN).
A wrong slot causes a connection refusal during the handshake. See
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs` for the
per-field defaults.
## Address forms
Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs`:
| Area | Example | Meaning |
|------|---------|---------|
| Data block | `DB1.DBX0.0` / `DB1.DBW0` / `DB1.DBD4` | DB number + size suffix `X`(bit) / `B`(byte) / `W`(word) / `D`(dword), optional `.bit` for `DBX` |
| Merker (M) | `MB0` / `MW0` / `MD4` / `M0.0` | Marker byte; size prefix `B`/`W`/`D`, or bare offset `.bit` for bit access |
| Input (I) | `IB0` / `IW0` / `I0.0` | Process-image input |
| Output (Q) | `QB0` / `QW0` / `Q0.0` | Process-image output |
Parsing is strict and runs once at `InitializeAsync` so a config typo fails fast
at load instead of surfacing as `BadInternalError` on every read. Bit offsets
must be 0-7, byte offsets non-negative, DB numbers >= 1.
> **Timer (`T{n}`) and Counter (`C{n}`)** addresses parse cleanly but the read
> path has no decode case for them yet — the driver rejects them at init with an
> explicit error rather than letting them surface a misleading type-mismatch.
## Data types
`S7DataType` declares the **semantic** type; `S7.Net` returns an unsigned boxed
value (bool / byte / ushort / uint) that the driver reinterprets without an
extra PLC round-trip. Wired through today: `Bool`, `Byte`, `Int16`, `UInt16`,
`Int32`, `UInt32`, `Float32`. `Int64`, `UInt64`, `Float64`, `String`, and
`DateTime` are declared in the enum but **rejected at init** — half-implemented
types must not create OPC UA nodes that then return `BadNotSupported` on every
access.
## Capability surface
`S7Driver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs`).
| Capability | Path | Notes |
|------------|------|-------|
| `IReadable` | `ReadAsync``S7.Net.Plc.ReadAsync` | One request/response per tag, serialized on a per-PLC semaphore |
| `IWritable` | `WriteAsync``S7.Net.Plc.WriteAsync` | Read-only tags (`Writable=false`) return `BadNotWritable` |
| `ITagDiscovery` | `DiscoverAsync` | Emits a flat `S7/` folder of the configured tags — no live browse |
| `ISubscribable` | per-tag poll loop with capped exponential backoff | S7 has no push model; floor is 100 ms (the CPU services the comms mailbox once per scan) |
| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
### Single-connection policy
One `S7.Net.Plc` instance per PLC, serialized with a `SemaphoreSlim`.
Parallelising reads against a single CPU doesn't help — the CPU scans its
comms mailbox at most once per cycle and queues concurrent requests wire-side
anyway, while wasting the CPU's 8-64 connection-resource budget.
## PUT/GET communication
S7-1200 / S7-1500 ship with **PUT/GET access disabled** by default. A driver
pointed at a freshly-flashed CPU sees a hard access-denied fault. The driver
maps it specifically to `BadNotSupported`, flags the instance `Faulted` (a
configuration alert, not a transient fault), and does **not** blind-retry —
because the CPU will keep refusing. Fix: enable PUT/GET communication in TIA
Portal under *Protection & Security* for the CPU.
## Error mapping
| Condition | StatusCode | Health |
|-----------|------------|--------|
| Tag not in config | `BadNodeIdUnknown` | unchanged |
| Read-only tag written | `BadNotWritable` | unchanged |
| Unimplemented data type | `BadNotSupported` | unchanged |
| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) |
| CPU / hardware fault | `BadDeviceFailure` | `Degraded` |
| Socket / timeout | `BadCommunicationError` | `Degraded` |
## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` cover the
address parser, the reinterpret/box conversions, and the driver lifecycle.
- **Integration fixture** — a Docker S7 simulator on the shared docker host; see
[S7-Test-Fixture.md](S7-Test-Fixture.md) for the coverage map and endpoint.
- **CLI** — [Driver.S7.Cli.md](../Driver.S7.Cli.md) documents the standalone
read/write/probe CLI for manual checks against a real or simulated CPU.
## Further reading
- [`docs/v2/driver-specs.md §5`](../v2/driver-specs.md) — full per-field spec,
DriverConfig JSON shape, and operational stability notes
- [Driver.S7.Cli.md](../Driver.S7.Cli.md) — standalone S7 driver CLI
- [S7-Test-Fixture.md](S7-Test-Fixture.md) — simulator + test-harness map
+129
View File
@@ -0,0 +1,129 @@
# Beckhoff TwinCAT (ADS) Driver
Getting-started guide for the Beckhoff TwinCAT driver. This is the short path —
for the full per-field spec read [`docs/v2/driver-specs.md §6`](../v2/driver-specs.md),
for hands-on CLI testing read [Driver.TwinCAT.Cli.md](../Driver.TwinCAT.Cli.md),
and for the test-harness map read [TwinCAT-Test-Fixture.md](TwinCAT-Test-Fixture.md).
## What it talks to
Beckhoff PLC runtimes — **TwinCAT 2 and TwinCAT 3** — over the Beckhoff **ADS**
protocol carried by **AMS** routing. The driver runs in-process in the OtOpcUa
server's .NET 10 AnyCPU host. It compiles and runs without a local AMS router,
but every wire call returns `BadCommunicationError` until a router is reachable
(the router translates an AMS Net ID to an IP route).
Addressing is **symbol-based**: tags are referenced by their TwinCAT symbolic
name (e.g. `MAIN.bStart`, `GVL.Counter`, `Motor1.Status.Running`) rather than by
raw memory offset. One driver instance fans out to N targets, each identified by
an AMS Net ID + port.
## Project split
| Project | Target | Role |
|---------|--------|------|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/` | net10.0 | In-process driver — hosts the ADS client, symbol-path parser, and per-device probe loops |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/` | net10.0 | Config records + the `TwinCATDataType` enum bound from `DriverConfig` JSON |
## Minimum deployment
```jsonc
"Drivers": {
"twincat-cell-1": {
"Type": "TwinCAT",
"Config": {
"Devices": [ { "HostAddress": "ads://5.23.91.23.1.1:851", "DeviceName": "Cell1" } ],
"Tags": [
{ "Name": "Start", "DeviceHostAddress": "ads://5.23.91.23.1.1:851",
"SymbolPath": "MAIN.bStart", "DataType": "Bool", "Writable": true },
{ "Name": "Count", "DeviceHostAddress": "ads://5.23.91.23.1.1:851",
"SymbolPath": "GVL.Counter", "DataType": "Int32", "Writable": false }
]
}
}
}
```
### AMS address form
`HostAddress` is an `ads://{netId}:{port}` URI parsed by
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs`. The Net ID
is six dot-separated octets (NOT an IP — a Beckhoff-specific identifier the
router maps to a route); the port is the AMS service port (851 = TC3 PLC runtime
1, 852 = runtime 2, 801 / 811 / 821 = TC2 PLC runtimes). Port defaults to 851
when omitted (`ads://5.23.91.23.1.1`).
### Symbol path form
Symbol paths are parsed by
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs`, which
mirrors IEC 61131-3 structured-text identifiers: global-variable-list
(`GVL.Counter`), program variable (`MAIN.bStart`), struct member access
(`Motor1.Status.Running`), array subscripts (`Data[5]`, `Matrix[1,2]`), and
bit-access (`Flags.0`).
## Tag discovery
`DiscoverAsync` always emits the pre-declared `Tags` as the authoritative config
path, under `TwinCAT/{device}/`. When `EnableControllerBrowse` is set, the
driver also walks each device's symbol table and surfaces controller-resident
globals / program locals under a `Discovered/` sub-folder; any symbol-loader
error falls back to pre-declared-only so a flaky symbol download never blocks
discovery.
## Capability surface
`TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs`).
| Capability | Path | Notes |
|------------|------|-------|
| `IReadable` | `ReadAsync` → ADS `ReadValueAsync` | Per-device client, lazily connected and serialized per device |
| `IWritable` | `WriteAsync` → ADS `WriteValueAsync` | Read-only tags return `BadNotWritable` |
| `ITagDiscovery` | `DiscoverAsync` | Pre-declared tags + opt-in controller symbol browse |
| `ISubscribable` | native ADS notifications (default), poll fallback | `UseNativeNotifications=true` registers device notifications so the PLC pushes changes; `false` uses the shared `PollGroupEngine` |
| `IHostConnectivityProbe` | per-device probe loop | One `HostConnectivityStatus` per configured device; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
| `IPerCallHostResolver` | `ResolveHost` lookup in the tag map | Routes each call to the device of the referenced tag; returns an empty-string sentinel when unresolved |
| `IRediscoverable` | symbol-version-changed callback | A PLC re-download fires `OnRediscoveryNeeded` so the address space is rebuilt |
### Rediscovery on PLC re-download
`IRediscoverable` is the distinguishing capability. When the ADS client detects
`DeviceSymbolVersionInvalid` (1809 / 0x0711) — the documented TwinCAT
symbol-version-changed signal, raised when a PLC program is re-downloaded —
every symbol and notification handle is invalidated. The driver raises
`OnRediscoveryNeeded` with a `TwinCAT` scope hint so Core rebuilds the address
space rather than treating it as a transient connection error.
### Native notifications
By default the driver registers native ADS device notifications: the PLC pushes
value changes on its own cycle, which is strictly better for latency and CPU
than polling. `NotificationMaxDelayMs` lets TwinCAT coalesce notifications up to
a batching delay for high-churn signals. Set `UseNativeNotifications=false` for
deployments where the AMS router has notification limits you can't raise — then
the driver falls through to the shared poll engine.
## Single-connection-per-device
Each device's ADS client is lazily connected and serialized by a per-device
connect gate, so a concurrent read / write / probe can't race a client
create-or-dispose. Probe-initiated connects use the probe timeout; reads and
writes use the driver-wide `Timeout`.
## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` cover
the AMS / symbol-path parsers, the status mapper, and the driver lifecycle via
a fake ADS client factory.
- **Integration fixture** — see
[TwinCAT-Test-Fixture.md](TwinCAT-Test-Fixture.md) for the harness map.
- **CLI** — [Driver.TwinCAT.Cli.md](../Driver.TwinCAT.Cli.md) documents the
standalone read/write/browse/probe CLI for manual checks.
## Further reading
- [`docs/v2/driver-specs.md §6`](../v2/driver-specs.md) — full per-field spec and
DriverConfig JSON shape
- [Driver.TwinCAT.Cli.md](../Driver.TwinCAT.Cli.md) — standalone TwinCAT driver CLI
- [TwinCAT-Test-Fixture.md](TwinCAT-Test-Fixture.md) — test-harness map