Files
lmxopcua/docs/drivers/TwinCAT.md
T
Joseph Doherty a92fb81f00 fix(twincat): review — a connect failure no longer burns the sum-chunk warning; doc precision
- ReadBucketAsync: the once-per-device "sum-read chunk failed" Warning fires only after a successful
  connect; a connect/backoff failure is the connect path's to report. TwinCATSumChunkWarningTests pins
  it (no warning across connect failures, exactly one across two later failing sum polls; red without
  the guard) plus a source guard that ReadGroupAsync builds its SumRead only via CreateSumRead.
- Docs: diagnostics live-gated via the driver harness (/hosts not exercised); TC2 costs 8 reads on the
  first tick and after each reconnect, then <= 4; read counts attributed to the unit tests.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 21:26:26 -04:00

482 lines
32 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.
- **IEC temporal types publish native values (#306, behaviour change).** `TIME` and
`TOD` (TIME_OF_DAY) publish **`Double` milliseconds** (`DriverDataType.Float64`);
`DATE` and `DT` (DATE_AND_TIME) publish a **UTC `DateTime`** decoded from the
UDINT seconds-since-1970 counter (`DriverDataType.DateTime`). They used to
publish the raw counter as `UInt32`. Reads, notifications, array elements and
browse/discovery all agree (`TwinCATTemporalCodec`). No new `DriverDataType`
member was added (`Duration` would touch every switch in Core, the node
manager, historian mapper and editors); a duration is a Double ms, which is
what OPC UA `Duration` is on the wire. `LTIME`/`LDT` are not in the driver's
type surface and are still unsupported. **Writes** accept a `Double` ms /
`TimeSpan` (TIME, TOD) or a `DateTime` (DATE — truncated to a whole day — and
DT); negative, NaN, pre-1970 or > UDINT values are refused `BadOutOfRange`, and
a duration written to a date (or vice versa) `BadTypeMismatch`.
**Existing tags:** a tag authored with `dataType: "Time"` (etc.) now surfaces
the decoded type — re-browse/redeploy to pick up the new node type. A tag
authored `UDInt` against a TIME/DATE symbol is unchanged: it still reads and
writes the raw 32-bit counter (the guard tolerates the same-width divergence).
**Historian:** a historized tag authored `Date`/`DateTime` now publishes
`DriverDataType.DateTime`, which continuous historization skips as non-numeric
(it was historized as `UInt32` before) — such tags stop recording. Author them
`UDInt` to keep historizing the raw counter. `Time`/`TimeOfDay` are now
`Float64` and still historize. A `TimeOfDay` write of 86 400 000 ms or more
is refused `BadOutOfRange`.
- **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.
## Sum-read and diagnostics (Gitea #645)
Two items ported from the stranded `auto/driver-gaps` branch (#310, #314), re-implemented against the
current raw-byte read path rather than cherry-picked (the branch predates the type guard, the string codec
and the array codec). Live-gated read-only on 2026-10-08 against Z35474 (TC3) and Z34834 (TC2 2.11) — see
`docs/v3/twincat-backlog.md` items 6–8 for the evidence.
⚠️ **Beckhoff's `SumRead` must be handed an ARRAY of entities.** Its `IList<IgIoSumReadEntity>` overload casts to
`IList<SumDataEntity>` internally, which only an array survives (covariance); a `List<>` throws
`InvalidCastException` before anything is sent. The shipped #645 code passed a `List<>`, so on real hardware every
sum chunk came back `BadCommunicationError` while all fake-client tests passed. `AdsTwinCATClient.CreateSumRead`
owns the construction; an unexpected chunk failure now logs one Warning per device.
### Sum-read (opt-in)
`useSumRead: true` (default **off**) batches a poll's reads for one device into ADS sum-read commands
(`IndexGroup 0xF080`) of at most `sumReadMaxItems` symbols (default 200, ceiling 500) and at most 32 KiB of
payload (read lengths + 4 bytes each) instead of one round-trip per tag. TC2 CE 2.11 support for the sum
command is unverified, hence opt-in.
- **Eligibility is strict.** The sum path reads raw bytes at the symbol's index group / offset and decodes them
as the DECLARED type (the notification rules), so a scalar rides the batch only when it passes
`TwinCATTypeGuard.CheckNotification` — same width, same float/integer kind. A tag the lenient single read
accepts by value conversion (`DInt` over a PLC `INT`, `Real` over `LREAL` or `DINT`) is read **individually**
inside the call, keeping the Beckhoff conversion; the batch never publishes reinterpreted bytes at Good.
Arrays use the same raw-bytes rule as on the single path. A type-mismatched item gets `BadTypeMismatch`
without joining the batch; a bit-indexed BOOL and a handle-addressed symbol (`0xF005`) are read singly.
- Per-item ADS error codes map to that tag only (`MapAdsError`); a response shorter than asked is a size error
for that item, not a failed chunk. The overall code is the command's.
- **Latch vs read-singly.** Only overall ADS **1793 / 1794** (service / index group not supported) throws
`TwinCATSumReadRefusedException`; the driver logs once, marks the device `SumReadUnsupported` and reads
singly until re-initialise. **Any other non-comms overall code** (1792 general error, 1795 offset, 1797 size, 1798 data, ...) means
*this request* was rejected — typically too big — so that chunk is read singly for this poll, nothing is
latched, and a Warning is logged once per device so the live gate can see sum-read is not working there. A
comms-level failure (router / port / timeout) is neither: that chunk goes `BadCommunicationError` and the next poll tries again.
- A one-item chunk is just a single read. Sum-**write** is not implemented (writes stay per-tag).
### `/hosts` diagnostics (`IDriverDiagnostics`, opt-in)
Behind `diagnosticsEnabled` (default **off**). Live-gated read-only 2026-10-08 on Z35474 (TC3) and Z34834 (TC2) by
driving `TwinCATDriver` from a scratch harness with an in-process AMS router; the `/hosts` page end-to-end was
**not** exercised (a stock host has no AMS router). The driver implements the optional
`IDriverDiagnostics` bag (the S7 `NegotiatedPduSize` precedent, #640), deliberately not synthetic raw tags.
When enabled, after each healthy probe tick **and after the host-state transition** it reads up to four
`TwinCAT_SystemInfoVarList` symbols per device — `_AppInfo.AppName`, `_AppInfo.OnlineChangeCnt`,
`_TaskInfo[1].CycleTime`, `_TaskInfo[1].LastExecTime` (100 ns ticks) — so at most **4 extra read requests per
device per probe interval** (default 5 s) once settled. On **TwinCAT 2** (fallback below) the first tick, and the
first tick after every reconnect, costs **8** (the four TC3 names answer "not found", then the four TC2 names);
after that ≤ 4. The ADS state is the one `ProbeAsync` already read; no second
ReadState. One device: flat keys `TwinCAT.AdsState`, `.AppName`, `.OnlineChangeCnt`, `.CycleTimeMs`,
`.LastExecTimeMs`, `.HeadroomMs` (cycle − last exec; negative = the task overran; this is spare time, not
jitter). Several devices: one line per device, `TwinCAT.<DeviceName or host>` =
`Run; Plc30 App; online-changes 7; cycle 10.0 ms; exec 2.5 ms; headroom 7.5 ms`, so the host's 16-entry cap
covers 16 devices. Times round to 0.1 ms (the bag's digest is in the publish fingerprint). Each symbol is
best-effort: a runtime that does not export one keeps the others, and a symbol answering "not
found" is not re-asked until a new client is installed (reconnect). **TwinCAT 2** has no
`TwinCAT_SystemInfoVarList`; once a TC3 name answers "not found" the driver reads the TC2 globals instead —
`.SYSTEMINFO.PROJECTNAME` (AppName), `.SYSTEMINFO.ONLINECHANGECOUNT` (UINT), `.SYSTEMTASKINFOARR[1].CYCLETIME` /
`.LASTEXECTIME` (same 100 ns units). The read counts — 8 on the first tick and after each reconnect, 4 after, and
none of the TC2 names on a TC3 target — are pinned by `TwinCATDiagnosticsTests`; the live run proved the values
(all six keys on TC2 2.11), not the counts.
## Capability surface
`TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, ISkippedTagReporter, IDriverDiagnostics`
(`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.
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](#strings-and-declared-type-checking) and [Array tags](#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](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.
## 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 | `Double[13]` `[0, 10, 20, 40, …]` ms (was `UInt32[13]` before #306; declare `UDInt` for the raw counter) |
| `.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](../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.
### Flat-mode vs VirtualTree-mode browse — resolved, Flat stays (#612)
The driver loads symbols with `SymbolsLoadMode.Flat`. The worry was that Flat might leave `SubSymbols`
empty on some TC3 runtimes and drop every struct member. Live on the only TC3 press, Z35474 (TwinCAT
3.1.4024, 2026-10-08, read-only): Flat returned 3,384 roots, every one of the 573 struct roots carried its
`SubSymbols`, and the expander produced 10,770 leaves (9,921 of them struct members; `DiscoverAsync` 10,628
variables). VirtualTree returned 1,027 roots and 1,459 leaves — a strict subset (0 tree-only leaves) that
misses array-of-struct element members such as `.L_Axis[10].IN._AT.DriveStatusWord`. There is no load-mode
option and none is needed. TC2 Flat was already proven (45,083 / 42,666 variables, 2026-10-07).
## 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