Files
lmxopcua/docs/drivers/TwinCAT.md
T
Joseph Doherty 8060cfd2a5 fix(twincat): size strings by the resolved type and refuse VAR_IN_OUT references (live TC2 gate)
Found by the read-only live gate against the TwinCAT 2.11 Elma cleaners
Z33856 / Z33733:

- The Beckhoff library reports a TC2 dotted MEMBER symbol's ByteSize as if the
  PLC's byte count were a bit count (STRING(80) -> 11, INT -> 1, UDINT -> 1)
  while DataType.ByteSize is right (81/2/4, raw SYM_INFOBYNAMEEX says 81). The
  previous commit's typeof(byte[]) read sliced by the symbol size, so
  .OPC_MACHINEDATA.PROCESSSTATUS read "ready for o" at Good; the notification
  width check would also have refused every numeric member. The shape now takes
  the resolved type's size, and string reads go by index group/offset (or a
  fresh handle for 0xF005) at that width.
- P_INITINPUT_ACTION.SLOTID is "STRING(20) (VAR_IN_OUT)" - a 4-byte reference -
  but resolves to a plain STRING(20); a String tag read 21 bytes at the pointer
  as text at Good. The symbol's own VAR_IN_OUT marker now classifies it
  NonScalar -> BadTypeMismatch.
2026-10-07 14:27:22 -04:00

300 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`) — **a TwinCAT 2 target must name `:801`**.
The driver never registers a route on the PLC: it sends no UDP `AddRoute` and
writes nothing to the PLC's route table. The route from the PLC back to this
host's AMS Net ID (and the AMS router's route to the PLC) is provisioned
out-of-band — the same posture as the ignitionoee driver's
`Register Route on Connect = off` (TwinCAT-009).
### 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`).
**TwinCAT 2 globals keep their leading dot.** A TC2 global is named with ONE
leading dot (`.OPC_HEARTBEAT`, `.OPC_MACHINEDATA.PROCESSSTATUS`,
`.ARRCARRIERDATA_V[1].SWORKORDER`) and the PLC requires it — without it
`SYM_HNDBYNAME` answers 0x710 (symbol not found), observed live on a TC 2.11 CX.
The parser keeps the dot on the root segment, so bit access on a TC2 global
(`.OPC_STATUS.WORD.3`) still reads the parent word `.OPC_STATUS.WORD`. Exactly one
dot: `..x`, `.` and `.[1]` are rejected. (Before this, the dot made the parser
give up and the raw string went to ADS verbatim, losing bit-index handling.)
**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.
Discovered variables are named by their full instance path (flat, no dot
splitting), so a TC2 global appears as `.OPC_HEARTBEAT` — with its dot, never
as an empty-named folder. TwinCAT 2 reports many top-level integers by their
ADS base-type name (`INT16`, `UINT16` — 354 symbols on a real TC 2.11 Elma
cleaner) and pads some member type names (`"REAL "`); both map like their IEC
twins, so those symbols are no longer silently dropped. The CLI `browse
--prefix` filter is a case-sensitive `StartsWith` on that name, so filter TC2
globals with the dot (`--prefix .OPC_`; `OPC_` matches nothing).
## Strings and declared-type checking
Ported from the ignitionoee TwinCAT driver (0.19.2–0.19.4), where each was
found against a real TwinCAT 2 PLC:
- **A `String` / `WString` tag matches a PLC `STRING(n)` / `WSTRING(n)` of any
`n`** and is read at the PLC's own width — `STRING(n)` is n+1 bytes,
`WSTRING(n)` 2(n+1). This also applies to native notifications, which
previously could not register a string at all (the Beckhoff marshaller needs
an explicit length and threw), taking the rest of that subscribe call down
with it.
- **A string ends at its first NUL** (`0x00` for STRING, an aligned `U+0000`
for WSTRING). Bytes after it are stale content from a longer earlier value
(live: work order `67504440` read back with a stale `"\0" + "1"` tail).
- **A STRING decodes as Windows-1252** (umlauts, `°` in alarm texts), or UTF-8
when the target reports UTF-8 string data (TwinCAT 3.1 4026+ "UTF8 Encoding
for STRING"). It no longer depends on the server's culture — the Beckhoff
marshaller falls back to the host's ANSI code page, which is 1250 on a Polish
host. Writes still go through the Beckhoff marshaller.
- **String-ness is a kind.** A numeric tag over a PLC string, a string tag over
a numeric, `String` over a `WSTRING` (or vice versa), and a string over a
struct/array are refused with `BadTypeMismatch` and a WARN, instead of
publishing the PLC's bytes as the wrong type at Good.
- **Native notifications are additionally width- and float-checked.** A
notification delivers raw bytes decoded as the *declared* type, so `DInt`
over a PLC `REAL` (same width, bit pattern reinterpreted) or `DInt` over
`LINT` (truncated) is refused too. A same-width, same-kind divergence (`DInt`
vs `UDINT`, `Time` vs a 4-byte integer) is tolerated. A refused tag is
isolated: it publishes `BadTypeMismatch`, the rest of the subscription
registers, and the probe-tick retry re-checks it after a PLC program change.
Reads go through the Beckhoff symbol cache, which converts numeric values by
the PLC's own type, so only the string-kind rule applies there.
- **Sizes come from the resolved data type, not the symbol.** On a TwinCAT 2
target the Beckhoff library reports a dotted *member* symbol's `ByteSize` as if
the PLC's byte count were a bit count (`.OPC_MACHINEDATA.PROCESSSTATUS`
`STRING(80)` → 11 B, an `INT` member → 1, a `UDINT` → 1) while its resolved
`DataType.ByteSize` is correct (81 / 2 / 4, matching raw `SYM_INFOBYNAMEEX`).
String reads therefore address the symbol's index group/offset explicitly at
the type's width — sizing them by the symbol truncated `"ready for operation"`
to `"ready for o"` at Good.
- **A `VAR_IN_OUT` member is a reference, not a value.** TC2 names it
`STRING(20) (VAR_IN_OUT)` (4 bytes) but the library resolves its type to the
plain `STRING(20)`; the symbol's own name wins and such a tag is refused
(`BadTypeMismatch`) instead of publishing the memory at the pointer as text.
### Live validation (2026-10-07, TwinCAT 2.11 Elma cleaners)
Read-only, against Z33856 (`10.201.45.236.1.1:801`) and Z33733
(`5.25.53.46.1.1:801`) over the VPN, through an in-process
`Beckhoff.TwinCAT.Ads.TcpRouter` in a scratch harness: ReadState RUN; browse
discovered 45,083 / 42,666 variables incl. the `INT16`/`UINT16` globals
(`.ACTUALOPERATIONMODE`, `.ADSSTATE_RUN`) and 3,663 / 3,564 leading-dot names, no
empty names; `.ADSSTATE_RUN` = 5 with bits `.0`/`.1`/`.2` = true/false/true;
work-order and part-number strings whose PLC buffers carry a stale tail after
the NUL (`68261005\0` + `01`) read as `68261005` through both poll and native
notifications; `DInt` over a `STRING(80)` → `BadTypeMismatch` in both paths with
the rest of the subscription Good. No PLC value held a genuine non-ASCII string
to exercise Windows-1252 live — that rule is unit-tested only.
## 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.
⚠️ **Native notifications do not carry arrays.** The notification path registers the
declared *scalar* type, so before the declared-type check an array tag subscribed natively
published only its first element at Good. It is now refused with `BadTypeMismatch` (the PLC
symbol is an array, not a scalar). Subscribe array tags with `UseNativeNotifications=false`
(the poll engine uses the array read above) until native array notifications are added.
**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` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#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