a92fb81f00
- 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
482 lines
32 KiB
Markdown
482 lines
32 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`) — **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
|