Merge TwinCAT fixes ported from ignitionoee + live-validated on the Elma PLCs
v2-ci / build (push) Successful in 4m41s
v2-ci / unit-tests (push) Failing after 16m28s

This commit is contained in:
Joseph Doherty
2026-10-07 14:28:01 -04:00
16 changed files with 959 additions and 26 deletions
+91 -3
View File
@@ -51,7 +51,13 @@ an AMS Net ID + port.
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`).
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
@@ -62,6 +68,15 @@ mirrors IEC 61131-3 structured-text identifiers: global-variable-list
(`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
@@ -79,6 +94,74 @@ 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`
@@ -145,8 +228,13 @@ A TwinCAT tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON carr
**Read mechanism** — the driver performs an **ADS native array symbol read** against the
declared `SymbolPath`. ADS returns the full array value buffer for a symbol that maps to a
TwinCAT array type (e.g. `ARRAY [0..9] OF REAL`); the driver reads `N` elements from the
response. When `UseNativeNotifications` is enabled, ADS also pushes array-value changes in
a single notification payload — no per-element polling.
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