diff --git a/docs/Historian.md b/docs/Historian.md index 02b0919a..1307f197 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -595,7 +595,7 @@ unknown keys are preserved byte-stable on round-trip by all typed editors. | **S7** | `ReadBytesAsync` block + per-element decode loop | Unit-proven (fixture down) | | **AB CIP** | libplctag native array read (atomic + UDT member arrays) | Unit-proven (fixture down) | | **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (fixture down) | -| **TwinCAT** | ADS native array symbol read | Unit-proven (fixture down) | +| **TwinCAT** | Raw-bytes array read + per-element decode (native notifications carry the whole array) | Live-verified on TwinCAT 2.11 (2026-10-07) | Array writes, multi-dimensional arrays (ValueRank>1), and per-element historization are out of scope — see [Uns.md §Array tags](Uns.md#array-tags-1-d). diff --git a/docs/Uns.md b/docs/Uns.md index 89651195..b900f3d9 100644 --- a/docs/Uns.md +++ b/docs/Uns.md @@ -310,7 +310,7 @@ Combined with historization (values are arrays — history of the whole array sn | **S7** | Contiguous `ReadBytesAsync` block over the declared address span + per-element decode loop | Unit-proven (sim fixture down) | | **AB CIP** | libplctag native array read (atomic and UDT member arrays) | Unit-proven (sim fixture down) | | **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (sim fixture down) | -| **TwinCAT** | ADS native array symbol read against the declared `SymbolPath` | Unit-proven (sim fixture down) | +| **TwinCAT** | Raw-bytes read of the declared `SymbolPath` at the resolved array type's layout + per-element decode; native ADS notifications carry the whole array (same decode). Multi-dim PLC arrays publish flattened; a shorter `arrayLength` reads the leading sub-range | Live-verified 2026-10-07 on two TwinCAT 2.11 PLCs (see [TwinCAT.md §Array tags](drivers/TwinCAT.md#array-tags)) | ### Out of scope (named deferrals) diff --git a/docs/drivers/TwinCAT-Test-Fixture.md b/docs/drivers/TwinCAT-Test-Fixture.md index 6cd5fd98..8b4d0ff9 100644 --- a/docs/drivers/TwinCAT-Test-Fixture.md +++ b/docs/drivers/TwinCAT-Test-Fixture.md @@ -93,6 +93,11 @@ All tests gate on `TWINCAT_TARGET_NETID` (required) via `[TwinCATFact]` / `BrowseSymbolsAsync` + system-symbol filtering - `TwinCATNativeNotificationTests` — `AddDeviceNotification` registration, callback-delivery-to-`OnDataChange` wiring, unregister on unsubscribe +- `TwinCATNativeArraySubscribeTests` — array tags register with their authored + length, replay after reconnect, and a refused tag is isolated with its status +- `TwinCATArrayCodecTests` — array layout planning / validation + (`TwinCATTypeGuard.CheckArray`, size cap) + raw-bytes decode of every element type +- `TwinCATBitAccessTests` — bit-indexed BOOL notifications served from the parent word - `TwinCATDriverTests` — `IDriver` lifecycle Capability surfaces whose contract is verified at the unit layer: `IDriver`, diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md index f4ac2c7c..3a883607 100644 --- a/docs/drivers/TwinCAT.md +++ b/docs/drivers/TwinCAT.md @@ -202,6 +202,15 @@ 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 @@ -219,32 +228,121 @@ writes use the driver-wide `Timeout`. - **CLI** — [Driver.TwinCAT.Cli.md](../Driver.TwinCAT.Cli.md) documents the standalone read/write/browse/probe CLI for manual checks. -## 1-D array support +## Array tags -A TwinCAT tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON carries -`"isArray": true` and `"arrayLength": N` (N ≥ 1). The canonical rule: -`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar. +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. -**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. +### One decode for poll and native notifications -⚠️ **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. +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. -**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` covers -the array symbol read path via the fake ADS client factory. The TwinCAT integration -fixture is down during normal dev. +- **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`. -**Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router). +### Notification size cap -**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional -arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518) -on PLC re-download (unchanged semantics from scalar nodes). +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](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix and the UI authoring flow. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index c5d53591..486f06b2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -263,10 +263,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; - // An array-typed tag (def.ArrayLength != null) drives a native 1-D ADS array read; - // the boxed result is an element-typed CLR array. Scalar tags pass null - // (scalar path) — bit-indexed BOOLs are inherently scalar so the client ignores - // arrayCount when a bitIndex is present (Phase 4c). + // An array-typed tag (def.ArrayLength != null) reads the array as raw bytes and decodes + // it with TwinCATArrayCodec — the same decode a native array notification runs — into an + // element-typed CLR array. Scalar tags pass null (scalar path) — bit-indexed BOOLs are + // inherently scalar so the client ignores arrayCount when a bitIndex is present. var (value, status) = await client.ReadValueAsync( symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken) .ConfigureAwait(false);