From f21883d607f62d09ad1d4b2b1c782e4f698c79b9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 3 Jun 2026 16:14:40 -0400 Subject: [PATCH] =?UTF-8?q?docs(audit):=20G2=20completeness=20=E2=80=94=20?= =?UTF-8?q?S7/TwinCAT/OpcUaClient/Historian=20overview=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/drivers/Historian.Wonderware.md | 119 +++++++++++++++++++++ docs/drivers/OpcUaClient.md | 129 +++++++++++++++++++++++ docs/drivers/S7.md | 148 +++++++++++++++++++++++++++ docs/drivers/TwinCAT.md | 129 +++++++++++++++++++++++ 4 files changed, 525 insertions(+) create mode 100644 docs/drivers/Historian.Wonderware.md create mode 100644 docs/drivers/OpcUaClient.md create mode 100644 docs/drivers/S7.md create mode 100644 docs/drivers/TwinCAT.md diff --git a/docs/drivers/Historian.Wonderware.md b/docs/drivers/Historian.Wonderware.md new file mode 100644 index 00000000..dc2dbbc8 --- /dev/null +++ b/docs/drivers/Historian.Wonderware.md @@ -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 diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md new file mode 100644 index 00000000..50bcb24d --- /dev/null +++ b/docs/drivers/OpcUaClient.md @@ -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 diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md new file mode 100644 index 00000000..fa163ac8 --- /dev/null +++ b/docs/drivers/S7.md @@ -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 diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md new file mode 100644 index 00000000..68933e01 --- /dev/null +++ b/docs/drivers/TwinCAT.md @@ -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