Files
lmxopcua/docs/drivers/TwinCAT.md
T
Joseph Doherty e08855fb9d
v2-ci / build (push) Successful in 4m52s
v2-ci / unit-tests (push) Failing after 15m58s
docs: source-verified deferment register + correct 17 drifted docs
Adds deferment.md — a source-verified inventory of new-driver status, all 17
open issues, in-code deferrals, open live gates and plan bookkeeping. Every
claim was checked against src/ and git, not against documentation.

Headline finding: three subsystems are authored, persisted and shipped but
never executed —
  * Node ACLs: IPermissionEvaluator/TriePermissionEvaluator have zero
    production consumers and OtOpcUaNodeManager never references them, yet
    ClusterAcls.razor authors NodeAcl rows and ConfigComposer.cs:51 ships them
    in every artifact. An authored deny rule has no effect.
  * IRediscoverable / IHostConnectivityProbe raise into the void (#518/#507);
    nothing ever writes a DriverHostStatus row.
  * DriverTypeRegistry is vestigial, so no factory passes a tier and every
    driver runs Tier A with the Tier-C protections dormant.

Also records the Calculation driver as picker-visible but unauthorable
(DriverConfigModal has no case) — the "registered but unauthorable" class
recurring after the Sql picker defect — and notes that no parity test guards
DriverConfigModal/DeviceModal, which is why it survived review.

Documentation corrections (source-verified):
  * ReadWriteOperations.md claimed "a denied read never hits the driver" via
    four types that do not exist in src/. Bannered + struck through; a security
    review reading that page would have concluded a per-node ACL gate exists.
  * CLAUDE.md: the Change Detection sentence was false (DriverHost consumes
    nothing); the mesh Phase 4 and Phase 5 live gates and the auto-down 1-vs-1
    gate had all PASSED; the ScriptedAlarmState table was already dropped.
  * driver-expansion tracking: Modbus RTU and SQL poll are merged, not
    pending — its command table would have made someone rebuild two shipped
    drivers in a fresh worktree.
  * drivers/README.md: dead DriverTypeRegistry paragraph, retired
    SystemPlatform namespace kind, missing Sql + Calculation rows, missing
    Modbus RTU-over-TCP transport.
  * TwinCAT.md/Galaxy.md promised an address-space rebuild that never happens.
  * Historian.md gained the #491 unproven-value-capture pointer.
  * IncrementalSync.md and AddressSpace.md bannered as wholesale v2-era (the
    rewrite is recorded as still owed, not done here); four v2 status docs
    bannered as historical; three wrong-name-for-a-live-type fixes.

Left deliberately untouched: the secrets NoOpSecretReplicator line, which
makes a security claim and needs the real behaviour identified rather than a
rename.
2026-07-27 17:26:20 -04:00

212 lines
11 KiB
Markdown

# 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`).
**BOOL-within-word writes** — writing a bit-access path (e.g. `MAIN.Flags.3`)
is a driver-level parent-word read-modify-write: the driver reads the parent
word as `UDInt`, flips the target bit, and writes the word back, under a
per-parent lock. The lock serialises the driver's own concurrent bit-writers
against the same word, but cannot guard against the PLC program writing that
word between the driver's read and write — this is an inherent limitation of
any software-level RMW.
## 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` | BOOL-within-word writes (e.g. `MAIN.Flags.3`) use a driver-level parent-word read-modify-write (read as UDInt, flip bit, write back) under a per-parent lock. 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`. ⚠️ **No consumer wires it today** ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518), [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)) — the address space does **not** rebuild; a config redeploy is required. Driver-side behaviour is log-observable only |
### 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 that the signal is treated
as an address-space change rather than a transient connection error.
⚠️ **That is the intended design, not present behaviour.** No consumer subscribes
to `OnRediscoveryNeeded` anywhere in `src/`, and the scope hint is read by nothing
([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518),
[#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)). Today the
signal is observable only in the driver log; after a PLC re-download the served
address space changes only on a config redeploy.
### 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.
## 1-D array support
A TwinCAT tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON carries
`"isArray": true` and `"arrayLength": N` (N ≥ 1). The canonical rule:
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
**Read mechanism** — the driver performs an **ADS native array symbol read** against the
declared `SymbolPath`. ADS returns the full array value buffer for a symbol that maps to a
TwinCAT array type (e.g. `ARRAY [0..9] OF REAL`); the driver reads `N` elements from the
response. When `UseNativeNotifications` is enabled, ADS also pushes array-value changes in
a single notification payload — no per-element polling.
**Unit test coverage**`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` covers
the array symbol read path via the fake ADS client factory. The TwinCAT integration
fixture is down during normal dev.
**Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router).
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional
arrays, per-element historization. `IRediscoverable` still fires (but is unconsumed — #518)
on PLC re-download (unchanged semantics from scalar nodes).
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
and the UI authoring flow.
## Controller-discovered struct/UDT/FB member variables
When `EnableControllerBrowse` is set, `BrowseSymbolsAsync` walks the device's symbol tree
recursively and expands **Struct / UDT / Function-Block typed symbols** into their **atomic
member leaves** as individually addressable OPC UA Variables, surfaced under the `Discovered/`
sub-folder.
### What is discovered
- Each symbol of a struct, UDT, or FB type is recursively expanded. The walk follows the ADS
symbol tree's `SubSymbols` collection.
- **Full instance paths** are preserved for every leaf (e.g. `MAIN.Motor.Speed`,
`GVL.Drive1.Status.Running`).
- The recursion is **depth-capped at 8 levels** to guard against pathological or circular-like
layouts.
- **Unsupported leaves** (types the driver cannot map to an OPC UA data type) are silently
dropped rather than surfaced as error nodes.
### Pre-declared Structure-typed tags
Pre-declared `Tags` with a `DataType` of `Structure` are **still rejected** — the driver cannot
read an opaque struct blob without knowing its member layout. For structs that must be
individually addressed, use `EnableControllerBrowse` to let the driver discover the member
layout from the controller's symbol table, rather than pre-declaring the container tag.
### Member writes
Discovered struct/UDT/FB member **writes** are **deferred** in this release. Scalar atomic
members can be written when pre-declared with `Writable: true`; the controller-browse expansion
path does not yet wire the write channel.
### Live caveat — Flat-mode vs VirtualTree-mode browse
The ADS client's `BrowseSymbolsAsync` may return symbols in **Flat mode** (a flat list of all
instance paths, with no `SubSymbols` populated) on some TC3 runtime configurations, in which
case the recursive struct expansion produces no sub-symbols and the walk yields only top-level
symbols. If a live TC3 browse does not populate `SubSymbols` on struct-typed symbols, the
alternative is **VirtualTree mode** (which returns a hierarchical symbol tree with `SubSymbols`
populated). VirtualTree-mode browse is an unverified follow-up; if Flat-mode struct expansion
produces empty `Discovered/` sub-folders for struct symbols, switch to VirtualTree mode via the
driver configuration.
## 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