Files
lmxopcua/docs/drivers/TwinCAT.md
T
Joseph Doherty 1982af8155 docs(twincat): native array notifications, sub-range / size-cap rules, live TC2 gate record
Removes the 'use UseNativeNotifications=false for arrays' workaround and
documents the raw-bytes array model shared by poll and notification: element
checks, STRING arrays, multi-dim flattening, 0-based publication, leading
sub-range for a shorter arrayLength, refusals (struct/UDT arrays, too long,
scalar over array), the 128 KiB native cap (BadOutOfRange), the bit-path fix,
and the 2026-10-07 read-only live gate on Z33856 / Z33733.
2026-10-07 14:56:03 -04:00

24 KiB
Raw Blame History

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, for hands-on CLI testing read Driver.TwinCAT.Cli.md, and for the test-harness map read 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

"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, #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, #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.

Strings, arrays and bit-indexed BOOLs are registered as raw bytes at the PLC's resolved width and decoded by the driver, with the same rules the poll read uses (see Strings and Array tags). A tag the driver refuses to register — a declared-type mismatch (BadTypeMismatch) or an array over the 128 KiB notification cap (BadOutOfRange) — is isolated: it publishes that status once, logs one WARN, and the rest of the subscription registers. An ADS error at registration (e.g. a symbol that does not exist) still fails the whole subscribe call.

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 for the harness map.
  • CLI — Driver.TwinCAT.Cli.md documents the standalone read/write/browse/probe CLI for manual checks.

Array tags

A TwinCAT tag becomes a 1-D OPC UA array node (ValueRank 1, ArrayDimensions = [N]) 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. DataType is the element type.

One decode for poll and native notifications

An array is read — by a poll and by a native notification — as raw bytes at a layout planned from the PLC's resolved array type (TwinCATTypeGuard.CheckArray), and decoded by one codec (TwinCATArrayCodec). Both paths therefore publish the identical value: the element-typed CLR array (bool[], sbyte[], byte[], short[], ushort[], int[], uint[], long[], ulong[], float[], double[], string[]; TIME / DATE / DT / TOD elements as uint[] — the same CLR shape their scalars publish), N elements long.

  • A native notification registers the whole array (element stride × N) as raw bytes, so the PLC pushes the array on change and every notification decodes to the full array. Before this the notification path registered only the declared scalar — publishing element 0 at Good — and the declared-type check then refused every array tag. UseNativeNotifications=false is no longer needed for arrays.
  • Elements are checked like a scalar notification: the declared element type must match the PLC element's width, float/integer kind and string kind (DInt over ARRAY OF REAL → refused; Int over ARRAY OF UINT — same width, same kind — tolerated). BYTE elements map to SInt.
  • STRING(n) / WSTRING(n) arrays are laid out at the PLC's own element width (n+1 / 2(n+1)); each element ends at its own first NUL and decodes with the same Windows-1252 / UTF-8 rules as a scalar string (see above).
  • Multi-dimensional arrays (ARRAY [1..3, 1..2] OF REAL) are published flattened in PLC memory order (the last index varies fastest); arrayLength is the total element count (6). Lower bounds play no part: a PLC ARRAY [1..20] is published 0-based, element 0 = PLC index 1.
  • Sub-range: an arrayLength shorter than the PLC array reads the leading N elements (N × stride bytes from the array start) — a smaller read and a smaller notification, and the value always matches the node's advertised ArrayDimensions. A longer arrayLength is refused (BadTypeMismatch) rather than reading past the array.
  • Refused with BadTypeMismatch (one WARN, that tag only — the rest of the subscription registers and stays Good): an element-type mismatch, a too-long arrayLength, an array of structs / UDTs / FBs, an array of pointers, a nested (jagged) array, an array tag over a non-array symbol, a VAR_IN_OUT array reference, and — on the read path too now — a scalar tag over a PLC array (author it as an array tag, or address one element). Before, a scalar poll over an array made the Beckhoff read throw and surfaced as BadCommunicationError with the driver Degraded.
  • Element and bit paths stay scalars. Arr[3] is a scalar tag on one element (native and poll). A bit-indexed BOOL (MAIN.Flags.3, .ADSSTATE_RUN.0) registers its notification on the parent word as raw bytes and reads the bit — ADS has no symbol for WORD.N, and registering one used to fail with DeviceSymbolNotFound and take the whole subscribe call down (found live, below). A bit outside the parent's width, or a bit of a non-integer parent, is refused with BadTypeMismatch.

Notification size cap

ADS documents no notification size limit, and a live TwinCAT 2.11 target registered and delivered a 131,072-byte notification (P_ETHERCATDIAGNOSIS.SLAVEADDRESS, ARRAY [0..65535] OF UINT) without complaint. The driver still caps a native array at 128 KiB (TwinCATTypeGuard.MaxNotificationArrayBytes, the largest size proven live): the PLC compares the whole buffer every cycle for an on-change notification, and every change is copied through the router and the driver's notification queue. A bigger array is refused for native subscription with BadOutOfRange (one WARN, that tag only) — author a shorter arrayLength (sub-range) or subscribe with UseNativeNotifications=false; poll reads have no cap.

Why raw bytes, not the Beckhoff typed array read

The poll read used to ask the Beckhoff library for elementType[]. Probed live on Z33856, that failed for most real arrays: the CLR element type had to match the PLC's exactly (an ARRAY OF TIME as uint[] threw ArgumentException), a multi-dimensional array demanded a rank-N CLR array (Single[,]), and a dotted member array was sliced by its under-reported TwinCAT 2 symbol size (P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE, ARRAY [1..20] OF REAL: symbol 10 B vs type 80 B → ArgumentOutOfRangeException). Each surfaced as BadCommunicationError. The raw-bytes model sizes everything from the resolved type, as the string path already does.

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) through an in-process Beckhoff.TwinCAT.Ads.TcpRouter scratch harness; one connection at a time, no writes. Browse found 594 / 583 array symbols (509 / 500 of primitives, 15 / 14 of strings, 17 / 11 multi-dimensional, ~400 with a non-zero lower bound). Each tag was subscribed natively for 10 s alongside a 1 s poll read; native and poll matched in value, CLR element type and length for every array, and matched an independent Beckhoff typed read wherever that read works:

Symbol PLC type Tag Published
.ARRFAULTCODE ARRAY [0..12] OF UINT UInt × 13 UInt16[13] (element 3 = 32, matching .ARRFAULTCODE[3] scalar)
.ARRUSPOWER ARRAY [1..3, 1..2] OF REAL / [1..2, 1..2] Real × 6 / 4 Single[6] / Single[4] — changing (~1060 W on Z33733); 23/23 notifications identical to an immediate poll
P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE ARRAY [1..20] OF REAL (member, symbol 10 B) Real × 20, and × 5 (sub-range) Single[20] changing, 14/14 identical; Single[5] = its first 5
.THROTTLE_MODE_DEFAULT ARRAY [0..12] OF TIME Time × 13 UInt32[13] [0, 10, 20, 40, …] ms
.ARGTYPE_IS_BIT_TYPE ARRAY [0..18] OF BOOL Bool × 19 Boolean[19]
.FORMAT_DEFAULT_PRECISION / .DWORD_DECADE_DIVIDER / .DIXTIMEALARM INT / DWORD / UDINT arrays Int / UDInt Int16[12] / UInt32[10] / UInt32[101]
.IMQN_TABLE / .AMSNETID / .ARRMODULENO LREAL / USINT / BYTE arrays LReal / USInt / SInt Double[32] / Byte[6] / SByte[13] (changing on Z33856)
FAST.COMPORTCONTROL.COMPORTDEBUGBUFFER.BUFFER ARRAY [0..300] OF INT (member) Int × 301 Int16[301], changing every cycle; 18–19 of ~59 notifications identical to an immediate poll (the rest changed in between)
P_ALARMS.ARRSTATE / .ARRHEADER ARRAY [1..n] OF STRING(80) String ["RUN", "RUN", "RUN", "RUN", "MON_ON", "", …] / ["Time1", "UsTime1 bottom", …]
.ARRDATA ARRAY [0..5, 0..17] OF STRING(80) (8,748 B) String × 108 String[108]
P_ETHERCATDIAGNOSIS.SLAVEADDRESS ARRAY [0..65535] OF UINT (131,072 B) UInt × 65536 UInt16[65536] [1001, 1002, …] — at the cap

Refused per tag with BadTypeMismatch, on both paths, while every sibling stayed Good: DInt over .ARRUSPOWER (REAL elements), arrayLength 14 over the 13-element .ARRFAULTCODE, DInt × 13 over .ARRCARRIERDATA_V (ARRAY OF ST_CARRIERDATA), scalar UInt over .ARRFAULTCODE, and an array of 2 over the scalar .ADSSTATE_RUN. The bit tag .ADSSTATE_RUN.0 = TRUE natively (it previously failed to register at all).

Coverage and deferrals

Unit tests — TwinCATArrayCodecTests (layout planning, element checks, sub-range, size cap, decode of every element type), TwinCATNativeArraySubscribeTests (the driver passes the authored length, replays it after a reconnect, isolates a refused tag with the refusal's own status), TwinCATBitAccessTests. The Beckhoff AdsClient cannot be faked, so the adapter stays a thin shell over these pure classes.

Deferrals — array writes (ADS WriteValueAsync for an array), per-element historization, and controller browse of multi-dimensional arrays (discovery still drops them; author such a tag by hand with arrayLength = the total element count). 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 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