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/AdsTwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs index 07057e61..bc10d2c4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -99,7 +99,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient // is inherently scalar — arrayCount does not apply. if (bitIndex is int bit && type == TwinCATDataType.Bool) { - var parent = StripBitSuffix(symbolPath); + var parent = TwinCATBitAccess.ParentPath(symbolPath); var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken) .ConfigureAwait(false); if (parentResult.ErrorCode != AdsErrorCode.NoError) @@ -107,31 +107,29 @@ internal sealed class AdsTwinCATClient : ITwinCATClient return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good); } - // Array read — ADS reads array symbols natively into a typed managed array. We ask - // the SDK for the element-CLR-type's 1-D array (e.g. int[]); the .NET binding - // marshals the whole array in one read. The returned value is already the boxed - // element-typed array (int[] / float[] / bool[] / string[] / …), so the runtime's - // OPC UA layer sees a proper 1-D array value. ASSUMPTION (no live ADS here): - // AdsClient.ReadValueAsync(symbol, elementType.MakeArrayType(), ct) returns the - // typed array — this matches Beckhoff's documented generic ReadValue surface - // (InfoSys tcadsnetref / SumRead samples). Verified only against the fake client. - if (arrayCount is int count && count > 0) - { - var elementType = MapToClrType(type); - var arrayType = elementType.MakeArrayType(); - var arrResult = await _client.ReadValueAsync(symbolPath, arrayType, cancellationToken) - .ConfigureAwait(false); - if (arrResult.ErrorCode != AdsErrorCode.NoError) - return (null, MapAndSignal((uint)arrResult.ErrorCode)); - return (arrResult.Value, TwinCATStatusMapper.Good); - } - - // Scalar read: check the declared type against the PLC symbol first. The symbol is + // Resolve the PLC symbol first so the declared type can be checked against it. The symbol is // resolved through the Beckhoff symbol cache (the same lookup ReadValueAsync makes // internally), so this costs no extra round trip after the first read. - var (shape, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken) + var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken) .ConfigureAwait(false); if (symbolStatus != TwinCATStatusMapper.Good) return (null, symbolStatus); + + // Array read: raw bytes at the layout TwinCATTypeGuard.CheckArray plans from the RESOLVED + // array type, decoded by TwinCATArrayCodec — the same decode a native array notification + // runs, so poll and notification publish identical arrays. The Beckhoff typed array read this + // replaced failed for most arrays on a live TC 2.11 target: the CLR element type had to match + // exactly (ARRAY OF TIME as uint[] threw), a multi-dimensional array needed a rank-N CLR + // array, and a member array was sliced by its under-reported symbol size. + if (arrayCount is int count) + { + if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is not null) + return (null, TwinCATStatusMapper.BadTypeMismatch); + var (arrayBytes, arrayStatus) = await ReadRawAsync(symbolPath, symbol!, layout.ByteLength, cancellationToken) + .ConfigureAwait(false); + return arrayStatus == TwinCATStatusMapper.Good + ? (TwinCATArrayCodec.Decode(type, arrayBytes, layout, StringsAreUtf8()), TwinCATStatusMapper.Good) + : (null, arrayStatus); + } if (TwinCATTypeGuard.CheckRead(type, shape) is not null) return (null, TwinCATStatusMapper.BadTypeMismatch); @@ -170,21 +168,34 @@ internal sealed class AdsTwinCATClient : ITwinCATClient /// compares a declared type against. Uses the Beckhoff symbol /// cache (lookup on), so repeat calls are local. /// - private async Task<(TwinCATPlcSymbolShape shape, IAdsSymbol? symbol, uint status)> DescribeSymbolAsync( - string symbolPath, CancellationToken cancellationToken) + private async Task<(TwinCATPlcSymbolShape shape, TwinCATPlcArrayShape? array, IAdsSymbol? symbol, uint status)> + DescribeSymbolAsync(string symbolPath, CancellationToken cancellationToken) { var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false); if (result.ErrorCode != AdsErrorCode.NoError) - return (default, null, MapAndSignal((uint)result.ErrorCode)); + return (default, null, null, MapAndSignal((uint)result.ErrorCode)); if (result.Value is not { } symbol) - return (default, null, TwinCATStatusMapper.BadNodeIdUnknown); + return (default, null, null, TwinCATStatusMapper.BadNodeIdUnknown); // Resolve aliases (T_MaxString → STRING(255)) so the guard sees the base type, and take the // resolved type's byte size — a TwinCAT 2 member symbol's own ByteSize is under-reported. var dataType = symbol.DataType?.Resolve(); var shape = TwinCATPlcSymbolShape.Classify( dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize, dataType?.ByteSize, symbol.TypeName); - return (shape, symbol, TwinCATStatusMapper.Good); + return (shape, DescribeArray(dataType), symbol, TwinCATStatusMapper.Good); + } + + /// + /// The array facts plans from: every dimension's element + /// count, the resolved array size, and the alias-resolved element type. Null for a non-array. + /// + private static TwinCATPlcArrayShape? DescribeArray(IDataType? dataType) + { + if (dataType is not IArrayType arrayType || arrayType.Dimensions is not { } dims) return null; + var element = arrayType.ElementType?.Resolve(); + var elementShape = TwinCATPlcSymbolShape.Classify( + element?.Name ?? arrayType.ElementTypeName, element?.Category, element?.ByteSize ?? 0); + return new TwinCATPlcArrayShape([.. dims.Select(d => d.ElementCount)], dataType.ByteSize, elementShape); } /// @@ -246,15 +257,6 @@ internal sealed class AdsTwinCATClient : ITwinCATClient ? TwinCATStringCodec.DecodeWString(bytes) : TwinCATStringCodec.DecodeString(bytes, utf8); - private static string StripBitSuffix(string symbolPath) - { - var lastDot = symbolPath.LastIndexOf('.'); - if (lastDot < 0) return symbolPath; - return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _) - ? symbolPath[..lastDot] - : symbolPath; - } - /// public async Task WriteValueAsync( string symbolPath, @@ -297,6 +299,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient string symbolPath, TwinCATDataType type, int? bitIndex, + int? arrayCount, TimeSpan cycleTime, int maxDelayMs, Action onChange, @@ -305,24 +308,59 @@ internal sealed class AdsTwinCATClient : ITwinCATClient var clrType = MapToClrType(type); int[]? anyTypeArgs = null; var stringsUtf8 = false; - if (bitIndex is null || type != TwinCATDataType.Bool) + TwinCATArrayLayout? arrayLayout = null; + var registerPath = symbolPath; + if (bitIndex is int bit && type == TwinCATDataType.Bool) + { + // ADS has no symbol for WORD.N (live on TC 2.11: DeviceSymbolNotFound for .ADSSTATE_RUN.0, + // which took the whole subscribe call down). Register the PARENT word as raw bytes at its + // resolved width and read the bit in OnAdsNotificationEx — the read path's model. + registerPath = TwinCATBitAccess.ParentPath(symbolPath); + var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken) + .ConfigureAwait(false); + if (parentStatus != TwinCATStatusMapper.Good) + throw new InvalidOperationException( + $"Resolving {registerPath} for a notification failed with status 0x{parentStatus:X8}"); + if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason) + throw new TwinCATTypeMismatchException(symbolPath, bitReason); + clrType = typeof(byte[]); + anyTypeArgs = [parent.ByteSize]; + } + else { // A notification carries raw bytes decoded as the DECLARED type, so a declared type that // cannot decode the PLC symbol (width, float/integer or string kind differ) would publish // wrong values at Good. Refuse it here; the driver isolates the one tag to Bad. - var (shape, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken) + var (shape, array, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken) .ConfigureAwait(false); if (symbolStatus != TwinCATStatusMapper.Good) throw new InvalidOperationException( $"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}"); - if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason) + + if (arrayCount is int count) + { + // An array registers the whole planned layout (element stride × authored length, from the + // RESOLVED array type) as raw bytes and decodes it in OnAdsNotificationEx with the same + // TwinCATArrayCodec a poll read uses — so both paths publish the identical array. + if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is { } arrayReason) + throw new TwinCATTypeMismatchException(symbolPath, arrayReason); + if (TwinCATTypeGuard.CheckNotificationSize(layout) is { } sizeReason) + throw new TwinCATNotificationRefusedException(symbolPath, sizeReason, TwinCATStatusMapper.BadOutOfRange); + clrType = typeof(byte[]); + anyTypeArgs = [layout.ByteLength]; + stringsUtf8 = StringsAreUtf8(); + arrayLayout = layout; + } + else if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason) + { throw new TwinCATTypeMismatchException(symbolPath, reason); + } // A string has no fixed width: the "any" type string needs an explicit length (the // Beckhoff marshaller throws ArgumentNullException without one), and a STRING(n) is n+1 // bytes with n chosen per declaration. Register the PLC's own byte width as raw bytes // and decode them in OnAdsNotificationEx with the same rules as a read. - if (type is TwinCATDataType.String or TwinCATDataType.WString) + if (arrayLayout is null && type is TwinCATDataType.String or TwinCATDataType.WString) { clrType = typeof(byte[]); anyTypeArgs = [shape.ByteSize]; @@ -337,23 +375,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient // with the handle as part of the event args so we use the handle as the correlation // key into _notifications. var result = await _client.AddDeviceNotificationExAsync( - symbolPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken) + registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken) .ConfigureAwait(false); if (result.ErrorCode != AdsErrorCode.NoError) throw new InvalidOperationException( - $"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}"); + $"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {registerPath}"); var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle) { StringsUtf8 = stringsUtf8, + ArrayLayout = arrayLayout, }; _notifications[result.Handle] = reg; return reg; } /// - /// Runs on the AMS router thread. Does the cheap bit-extraction - /// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer + /// Runs on the AMS router thread. Does the cheap decode (bit + /// extraction, string, array) then enqueues — no driver logic, no consumer callbacks — so a slow consumer /// can never stall ADS notification delivery for the rest of the process. /// Drops the notification (DropWrite) if the queue is saturated. /// @@ -361,8 +400,16 @@ internal sealed class AdsTwinCATClient : ITwinCATClient { if (!_notifications.TryGetValue(args.Handle, out var reg)) return; var value = args.Value; - if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool) - value = ExtractBit(value, bit); + if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool) + { + if (value is not byte[] word || bit / 8 >= word.Length) return; + value = TwinCATBitAccess.ReadBit(word, bit); + } + else if (reg.ArrayLayout is { } layout && value is byte[] arrayBytes) + { + try { value = TwinCATArrayCodec.Decode(reg.Type, arrayBytes, layout, reg.StringsUtf8); } + catch (ArgumentException) { return; } // a short buffer is never published as a short array + } else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString) value = DecodeString(reg.Type, raw, reg.StringsUtf8); _notificationQueue.Writer.TryWrite(new PendingNotification(reg, value)); @@ -615,6 +662,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient /// public bool StringsUtf8 { get; init; } + /// + /// Gets the raw-bytes layout of an array notification, decoded by ; null for a scalar. + /// + public TwinCATArrayLayout? ArrayLayout { get; init; } + /// /// Disposes the notification subscription. /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs index 76c8665d..6267aa1c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs @@ -41,9 +41,11 @@ public interface ITwinCATClient : IDisposable /// The ADS symbol path. /// The target data type. /// Optional bit index for bit extraction within a word. - /// When non-null, read a 1-D array of this many - /// elements; the boxed value is the element-typed CLR array (int[] / float[] / - /// bool[] / string[] / …). When null, read a scalar (Phase 4c). + /// When non-null, read an array of this many + /// elements (a multi-dimensional PLC array flattened in memory order; a count shorter than the PLC + /// array reads its leading sub-range); the boxed value is the element-typed CLR array + /// (int[] / float[] / bool[] / string[] / …) decoded by + /// . When null, read a scalar. /// Cancellation token for the read operation. /// The boxed value read (or null) together with the mapped OPC UA status. Task<(object? value, uint status)> ReadValueAsync( @@ -87,6 +89,10 @@ public interface ITwinCATClient : IDisposable /// ADS symbol path (e.g. MAIN.bStart). /// Declared type; drives the native layout + callback value boxing. /// For BOOL-within-word tags — the bit to extract from the parent word. + /// When non-null (and no ), register the whole + /// array: elements of as raw bytes, decoded per + /// notification into the same element-typed CLR array returns + /// (). Null = a scalar. /// Minimum interval between change notifications (native-floor depends on target). /// Maximum batching delay in milliseconds — TwinCAT may coalesce /// notifications up to this delay before pushing them. 0 = no batching, push @@ -94,13 +100,18 @@ public interface ITwinCATClient : IDisposable /// Invoked with (symbolPath, boxedValue) per notification. /// Cancels the initial registration; does not tear down an established notification. /// A handle whose disposal tears the registered notification down. - /// The declared cannot decode - /// the PLC symbol (width, float/integer or string kind differ) — the caller isolates that one tag - /// to Bad rather than registering a notification that would publish wrong bytes at Good. + /// The notification is refused for this one tag + /// — the declared / cannot decode the PLC + /// symbol (: width, float/integer or string kind differ, + /// not an array, too long), or the array exceeds + /// . The caller isolates that one tag to the + /// exception's status rather than registering a notification that would publish wrong bytes at + /// Good. Task AddNotificationAsync( string symbolPath, TwinCATDataType type, int? bitIndex, + int? arrayCount, TimeSpan cycleTime, int maxDelayMs, Action onChange, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs new file mode 100644 index 00000000..7d471bd1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs @@ -0,0 +1,125 @@ +using System.Buffers.Binary; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// Decodes a PLC array's raw bytes — laid out by — into the +/// element-typed CLR array an array tag publishes (bool[], short[], float[], +/// string[], …). Both the poll read and the native notification decode here, so the two paths +/// publish identical values, element type and length by construction. +/// +/// +/// The driver reads arrays as raw bytes rather than through the Beckhoff typed array read, which a live +/// TwinCAT 2.11 target showed failing for most real arrays (2026-10-07, Elma cleaner Z33856): the CLR +/// element type had to match the PLC's exactly (ARRAY OF TIME as uint[] threw), a +/// multi-dimensional array demanded a rank-N CLR array, and a dotted MEMBER array was sliced by the +/// under-reported symbol size (ArgumentOutOfRangeException). Elements are little-endian (every +/// TwinCAT target is x86/x64/ARM-LE); a STRING / WSTRING element is decoded by +/// within its fixed stride, so each ends at its own first NUL. +/// +internal static class TwinCATArrayCodec +{ + /// Decodes .ElementCount elements of . + /// The tag's declared element type. + /// The PLC bytes from the array's first element (at least ). + /// The planned layout. + /// True when the target reports UTF-8 STRING data (else Windows-1252). + /// The element-typed CLR array. + /// The buffer is shorter than the layout. + public static Array Decode(TwinCATDataType declared, ReadOnlySpan bytes, TwinCATArrayLayout layout, bool utf8) + { + if (layout.ElementCount < 0 || layout.Stride <= 0 || bytes.Length < layout.ByteLength) + throw new ArgumentException( + $"An array buffer of {bytes.Length} B cannot hold {layout.ElementCount} × {layout.Stride} B elements", + nameof(bytes)); + + var n = layout.ElementCount; + var stride = layout.Stride; + switch (declared) + { + case TwinCATDataType.Bool: + { + var a = new bool[n]; + for (var i = 0; i < n; i++) a[i] = bytes[i * stride] != 0; + return a; + } + case TwinCATDataType.SInt: + { + var a = new sbyte[n]; + for (var i = 0; i < n; i++) a[i] = unchecked((sbyte)bytes[i * stride]); + return a; + } + case TwinCATDataType.USInt: + { + var a = new byte[n]; + for (var i = 0; i < n; i++) a[i] = bytes[i * stride]; + return a; + } + case TwinCATDataType.Int: + { + var a = new short[n]; + for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt16LittleEndian(bytes[(i * stride)..]); + return a; + } + case TwinCATDataType.UInt: + { + var a = new ushort[n]; + for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt16LittleEndian(bytes[(i * stride)..]); + return a; + } + case TwinCATDataType.DInt: + { + var a = new int[n]; + for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32LittleEndian(bytes[(i * stride)..]); + return a; + } + case TwinCATDataType.UDInt or TwinCATDataType.Time or TwinCATDataType.Date + or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay: + { + // TIME / DATE / DT / TOD publish their raw 32-bit count — the same CLR shape (uint) a + // scalar of those types publishes. + var a = new uint[n]; + for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]); + return a; + } + case TwinCATDataType.LInt: + { + var a = new long[n]; + for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt64LittleEndian(bytes[(i * stride)..]); + return a; + } + case TwinCATDataType.ULInt: + { + var a = new ulong[n]; + for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt64LittleEndian(bytes[(i * stride)..]); + return a; + } + case TwinCATDataType.Real: + { + var a = new float[n]; + for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadSingleLittleEndian(bytes[(i * stride)..]); + return a; + } + case TwinCATDataType.LReal: + { + var a = new double[n]; + for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadDoubleLittleEndian(bytes[(i * stride)..]); + return a; + } + case TwinCATDataType.String: + { + var a = new string[n]; + for (var i = 0; i < n; i++) a[i] = TwinCATStringCodec.DecodeString(bytes.Slice(i * stride, stride), utf8); + return a; + } + case TwinCATDataType.WString: + { + var a = new string[n]; + for (var i = 0; i < n; i++) a[i] = TwinCATStringCodec.DecodeWString(bytes.Slice(i * stride, stride)); + return a; + } + default: + throw new NotSupportedException($"TwinCATDataType {declared} has no array element decoding"); + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs new file mode 100644 index 00000000..186e1cee --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs @@ -0,0 +1,41 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// Serves a bit-indexed BOOL tag (MAIN.Flags.3, .ADSSTATE_RUN.0) from its PARENT word. +/// ADS has no symbol for WORD.N — live on a TwinCAT 2.11 target a notification registered on +/// .ADSSTATE_RUN.0 failed with DeviceSymbolNotFound — so a native notification registers +/// the parent as raw bytes at its resolved width and reads the bit here, exactly as a read already +/// reads the parent word and extracts the bit. +/// +internal static class TwinCATBitAccess +{ + /// The parent word's path: the bit path without its trailing .N. + /// The bit path (MAIN.Flags.3). + /// The parent path, or when it has no numeric last segment. + public static string ParentPath(string symbolPath) + { + var lastDot = symbolPath.LastIndexOf('.'); + if (lastDot < 0) return symbolPath; + return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _) ? symbolPath[..lastDot] : symbolPath; + } + + /// Checks that addresses a bit of an integer parent word. + /// The bit index. + /// The parent symbol's shape. + /// null when addressable; otherwise the reason. + public static string? CheckParent(int bit, TwinCATPlcSymbolShape parent) + { + if (parent.Kind != TwinCATPlcKind.Scalar || parent.IsFloat) + return $"bit {bit} of {parent.TypeName} ({parent.ByteSize} B) — only an integer word has addressable bits"; + return bit >= 0 && bit < parent.ByteSize * 8 + ? null + : $"bit {bit} is outside the {parent.ByteSize * 8}-bit parent {parent.TypeName}"; + } + + /// Reads bit of a little-endian parent word. + /// The parent word's raw bytes. + /// The bit index (0 = least significant bit of the first byte). + /// The bit's value. + public static bool ReadBit(ReadOnlySpan parent, int bit) => + (parent[bit / 8] & (1 << (bit % 8))) != 0; +} 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 c2288c90..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); @@ -279,7 +279,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery if (status == TwinCATStatusMapper.BadTypeMismatch) _logger.LogWarning( "TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " + - "cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch or a reference/container)", + "cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " + + "or length mismatch, or a reference/container)", _driverInstanceId, reference, def.DataType, symbolName); else _logger.LogWarning( @@ -531,7 +532,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery // client swap unchanged — replay re-registers the SAME intent onto the new client. var reg = new NativeRegistration( Interlocked.Increment(ref _nextRegId), device, reference, symbolName, - def.DataType, bitIndex, publishingInterval, + def.DataType, bitIndex, def.ArrayLength, publishingInterval, onChange: (_, value) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot( value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))), @@ -545,17 +546,19 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery { await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false); } - catch (TwinCATTypeMismatchException mismatch) + catch (TwinCATNotificationRefusedException refused) { - // The declared type cannot decode this PLC symbol (ignitionoee TwinCAT-002/-008): - // isolate THIS tag to Bad — one WARN, one BadTypeMismatch snapshot — and keep the - // rest of the subscription. Its intent stays on the device with no live handle, - // so the probe-tick retry re-checks it after a PLC program change. + // The declared type / array length cannot decode this PLC symbol (ignitionoee + // TwinCAT-002/-008), or the array is over the native-notification size cap: isolate + // THIS tag to Bad — one WARN, one snapshot carrying the refusal's status + // (BadTypeMismatch / BadOutOfRange) — and keep the rest of the subscription. Its + // intent stays on the device with no live handle, so the probe-tick retry re-checks + // it after a PLC program change. _logger.LogWarning( "TwinCAT driver '{DriverInstanceId}' refused native notification for '{Reference}': {Reason}", - _driverInstanceId, reference, mismatch.Message); + _driverInstanceId, reference, refused.Message); OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot( - null, TwinCATStatusMapper.BadTypeMismatch, null, DateTime.UtcNow))); + null, refused.StatusCode, null, DateTime.UtcNow))); } registrations.Add(reg); } @@ -594,10 +597,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery try { newHandle = await client.AddNotificationAsync( - reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval, + reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval, _options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false); } - catch (TwinCATTypeMismatchException) + catch (TwinCATNotificationRefusedException) { // Keep the intent with no live handle (a "dead" registration) so the probe-tick // retry re-checks it and UnsubscribeAsync still removes it; the caller isolates it. @@ -659,6 +662,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery public TwinCATDataType DataType { get; } /// Bit index for a BOOL-within-word symbol; null otherwise. public int? BitIndex { get; } + /// Authored array length — the whole array is registered and published; null for a scalar. + public int? ArrayLength { get; } /// Requested notification interval. public TimeSpan Interval { get; } /// Value-change callback (closes over the owning subscription handle + reference). @@ -687,11 +692,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery /// ADS symbol name (resolved from the symbol path). /// Declared data type. /// Bit index for a BOOL-within-word symbol; null otherwise. + /// Authored array length for an array tag; null for a scalar. /// Requested notification interval. /// Value-change callback (closes over the owning subscription handle + reference). /// Bad-quality callback fired when the handle goes dead (05/STAB-16). public NativeRegistration(long id, DeviceState device, string reference, string symbolName, - TwinCATDataType dataType, int? bitIndex, TimeSpan interval, Action onChange, + TwinCATDataType dataType, int? bitIndex, int? arrayLength, TimeSpan interval, Action onChange, Action onUnavailable) { Id = id; @@ -700,6 +706,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery SymbolName = symbolName; DataType = dataType; BitIndex = bitIndex; + ArrayLength = arrayLength; Interval = interval; OnChange = onChange; OnUnavailable = onUnavailable; @@ -969,7 +976,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery try { var newHandle = await client.AddNotificationAsync( - reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval, + reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval, _options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false); var old = reg.SwapHandle(newHandle); try { old?.Dispose(); } catch { /* dead handle from the disposed client */ } @@ -1034,7 +1041,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery try { var newHandle = await client.AddNotificationAsync( - reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval, + reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval, _options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false); reg.SwapHandle(newHandle); // STAB-17 ownership re-check — see ReplayNativeRegistrationsAsync. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATNotificationRefusedException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATNotificationRefusedException.cs new file mode 100644 index 00000000..74eb494a --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATNotificationRefusedException.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// Thrown by when the client refuses to register a +/// native notification for one tag — its declared type cannot decode the PLC symbol +/// (), or an array is too large for a native notification. +/// The driver isolates that one tag to (one WARN) and keeps the rest of the +/// subscription; the intent stays registered so the probe-tick retry re-checks it. +/// +internal class TwinCATNotificationRefusedException : Exception +{ + /// Initializes a new instance of the class. + /// The ADS symbol the tag names. + /// The one-line reason. + /// The OPC UA status the refused tag publishes. + public TwinCATNotificationRefusedException(string symbolPath, string reason, uint statusCode) + : base($"TwinCAT symbol '{symbolPath}': {reason}") + { + SymbolPath = symbolPath; + StatusCode = statusCode; + } + + /// Gets the ADS symbol the refused tag names. + public string SymbolPath { get; } + + /// Gets the OPC UA status the refused tag publishes. + public uint StatusCode { get; } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs index 5fa6d4cf..15bb1e8c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs @@ -24,7 +24,9 @@ internal enum TwinCATPlcKind /// The symbol's byte size as the PLC reports it. /// True for a REAL / LREAL scalar. /// The (alias-resolved) PLC type name, for log messages. -internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName) +/// True for an ARRAY (a with array category or name). +internal readonly record struct TwinCATPlcSymbolShape( + TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName, bool IsArray = false) { private static readonly Regex StringTypeName = new(@"^(W?)STRING(\s*\(\s*\d+\s*\))?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); @@ -71,13 +73,39 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B || name.StartsWith("POINTER TO", StringComparison.OrdinalIgnoreCase) || name.StartsWith("REFERENCE TO", StringComparison.OrdinalIgnoreCase) || name.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase)) - return new(TwinCATPlcKind.NonScalar, byteSize, false, name); + return new(TwinCATPlcKind.NonScalar, byteSize, false, name, + IsArray: category == DataTypeCategory.Array || name.StartsWith("ARRAY", StringComparison.OrdinalIgnoreCase)); var mapped = AdsTwinCATClient.MapSymbolTypeName(name); return new(TwinCATPlcKind.Scalar, byteSize, mapped is TwinCATDataType.Real or TwinCATDataType.LReal, name); } } +/// The facts about a PLC ARRAY symbol plans a layout from. +/// The element count of each dimension (lower bounds play no part: the +/// driver publishes a 0-based, flattened array in PLC memory order). +/// The array's byte size, from its RESOLVED data type (a TwinCAT 2 member symbol +/// under-reports its own size). +/// The (alias-resolved) element type, classified like a scalar symbol. +internal readonly record struct TwinCATPlcArrayShape(IReadOnlyList DimensionLengths, int ByteSize, TwinCATPlcSymbolShape Element) +{ + /// Gets the total element count across every dimension (0 when any dimension is empty). + public long ElementCount => DimensionLengths.Aggregate(1L, (total, length) => total * Math.Max(0, length)); +} + +/// +/// How an array tag is read from the PLC's raw bytes: elements at a +/// fixed , starting at the array's first byte. +/// +/// The number of elements published (the authored ArrayLength). +/// The bytes per element: the element type's width, or n+1 / 2(n+1) for a +/// STRING(n) / WSTRING(n). +internal readonly record struct TwinCATArrayLayout(int ElementCount, int Stride) +{ + /// Gets the number of bytes the read or notification covers. + public int ByteLength => ElementCount * Stride; +} + /// /// Decides whether a tag's DECLARED may decode the PLC symbol it names — /// the port of the ignitionoee TwinCAT driver's extra-type reconciliation (TwinCAT-002, -008). A @@ -90,11 +118,14 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B /// /// Read () goes through the Beckhoff symbol cache, which /// decodes by the PLC's OWN type and converts the value to the declared CLR type — a numeric -/// pair is value-converted, never reinterpreted. Only string-ness can go wrong there: a -/// numeric tag over a PLC string got a string object back at Good. +/// pair is value-converted, never reinterpreted. Only string-ness can go wrong there (a +/// numeric tag over a PLC string got a string object back at Good), plus a scalar over +/// an ARRAY, which the read cannot convert at all. /// Notification () registers raw bytes decoded as the /// DECLARED type, so a width mismatch truncates/over-reads and a same-width float/integer clash /// reinterprets the bit pattern — both at Good. +/// Array tags () are read AND notified as raw bytes, so the +/// element gets the notification rules on both paths. /// /// String-ness is a KIND (TwinCAT-008): a STRING/WSTRING tag matches only a PLC string of the SAME /// kind, at any declared length (0.19.2). A same-width, same-kind numeric divergence @@ -117,6 +148,9 @@ internal static class TwinCATTypeGuard : $"declared {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — a string tag " + $"matches only a PLC {declaredKind.Value.ToString().ToUpperInvariant()} of any length"; } + if (plc.IsArray) + return $"declared scalar {declared} but the PLC symbol is the array {plc.TypeName} ({plc.ByteSize} B) — " + + "author it as an array tag (isArray + arrayLength) or address one element (Arr[i])"; return plc.Kind is TwinCATPlcKind.String or TwinCATPlcKind.WString ? $"declared {declared} but the PLC symbol is the string {plc.TypeName} — declare it String/WString" : null; @@ -144,6 +178,72 @@ internal static class TwinCATTypeGuard : $"declared {declared} but the PLC symbol is {plc.TypeName} — float/integer kinds differ"; } + /// + /// The largest array a native notification registers, in bytes: 128 KiB — the largest registration + /// proven on a real target (a TwinCAT 2.11 ARRAY [0..65535] OF UINT, live 2026-10-07; ADS + /// itself documents no notification size limit). 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, so a bigger array is refused rather than registered blind. + /// + public const int MaxNotificationArrayBytes = 128 * 1024; + + /// + /// Checks an ARRAY tag — its declared element type and authored ArrayLength — against a PLC + /// array, and plans the raw-bytes layout both the poll read and the native notification decode + /// with . + /// + /// + /// + /// The element is checked like a scalar notification (): + /// the bytes are decoded as the DECLARED element type, so width, float/integer and string kind + /// must agree. Struct / FB / pointer / nested-array (jagged) elements are refused. + /// A multi-dimensional array is published flattened in PLC memory order (the last index + /// varies fastest); its ArrayLength is the total element count. Lower bounds play no + /// part — the published index is 0-based. + /// An authored ArrayLength SHORTER than the PLC array reads the leading sub-range + /// (the first N elements, N × stride bytes from the array start); a LONGER one is refused, since + /// it would read past the array. + /// + /// + /// The tag's declared element type. + /// The tag's authored ArrayLength. + /// The PLC symbol's shape (decides a non-array symbol; names it in messages). + /// The PLC array's shape, or null when the symbol is not an array. + /// The planned layout when compatible; otherwise default. + /// null when compatible; otherwise the reason. + public static string? CheckArray(TwinCATDataType declared, int declaredLength, TwinCATPlcSymbolShape symbol, + TwinCATPlcArrayShape? array, out TwinCATArrayLayout layout) + { + layout = default; + if (declaredLength <= 0) + return $"declared an array of {declaredLength} {declared} elements — an array tag needs arrayLength >= 1"; + if (array is not { } plc || symbol.TypeName.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase)) + return $"declared an array of {declaredLength} {declared} but the PLC symbol is {symbol.TypeName} " + + $"({symbol.ByteSize} B) — not an array"; + + if (CheckNotification(declared, plc.Element) is { } elementReason) + return $"{symbol.TypeName}: element {elementReason}"; + + var stride = plc.Element.ByteSize; + var count = plc.ElementCount; + if (stride <= 0 || count <= 0 || count * stride != plc.ByteSize) + return $"{symbol.TypeName} is {plc.ByteSize} B, not {count} × {stride} B elements — not a packed array"; + if (declaredLength > count) + return $"declared arrayLength {declaredLength} but the PLC array {symbol.TypeName} has {count} elements"; + + layout = new TwinCATArrayLayout(declaredLength, stride); + return null; + } + + /// Checks a planned array layout against . + /// The planned layout. + /// null when it fits; otherwise the reason. + public static string? CheckNotificationSize(TwinCATArrayLayout layout) => + layout.ByteLength <= MaxNotificationArrayBytes + ? null + : $"{layout.ElementCount} elements ({layout.ByteLength} B) exceed the {MaxNotificationArrayBytes} B " + + "native-notification cap — author a shorter arrayLength or subscribe with UseNativeNotifications=false"; + /// The byte width the declared type decodes from a notification buffer. /// The declared type. /// The width in bytes. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeMismatchException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeMismatchException.cs index 2dc981c3..ad847996 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeMismatchException.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeMismatchException.cs @@ -2,21 +2,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; /// /// Thrown by when a tag's declared -/// cannot decode the PLC symbol it names (see -/// ). The driver isolates the one tag to -/// and keeps the rest of the subscription. +/// (or, for an array tag, its element type / ArrayLength) cannot +/// decode the PLC symbol it names (see ). The driver isolates the one +/// tag to and keeps the rest of the subscription. /// -internal sealed class TwinCATTypeMismatchException : Exception +internal sealed class TwinCATTypeMismatchException : TwinCATNotificationRefusedException { /// Initializes a new instance of the class. /// The ADS symbol the tag names. /// The guard's one-line reason. public TwinCATTypeMismatchException(string symbolPath, string reason) - : base($"TwinCAT symbol '{symbolPath}': {reason}") + : base(symbolPath, reason, TwinCATStatusMapper.BadTypeMismatch) { - SymbolPath = symbolPath; } - - /// Gets the ADS symbol the refused tag names. - public string SymbolPath { get; } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs index 40c4c70c..ba5d8023 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs @@ -129,13 +129,14 @@ internal class FakeTwinCATClient : ITwinCATClient /// The path to the symbol to watch. /// The data type of the symbol. /// The optional bit index for bit-level notifications. + /// The authored array length for an array tag; null for a scalar. /// The sampling cycle time. /// The maximum delay in milliseconds. /// The callback to invoke on value change. /// The cancellation token. /// A task that returns a notification handle. public virtual async Task AddNotificationAsync( - string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime, + string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime, int maxDelayMs, Action onChange, CancellationToken cancellationToken) { if (ThrowOnAddNotification) @@ -147,7 +148,7 @@ internal class FakeTwinCATClient : ITwinCATClient await gate.Task.ConfigureAwait(false); LastMaxDelayMs = maxDelayMs; - var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this); + var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this) { ArrayCount = arrayCount }; Notifications.Add(reg); return reg; } @@ -195,6 +196,8 @@ internal class FakeTwinCATClient : ITwinCATClient public TwinCATDataType Type { get; } = type; /// Gets the optional bit index. public int? BitIndex { get; } = bitIndex; + /// Gets the array count the driver registered with (null = scalar). + public int? ArrayCount { get; init; } /// Gets the callback to invoke on value change. public Action OnChange { get; } = onChange; /// Gets a value indicating whether this notification has been disposed. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayCodecTests.cs new file mode 100644 index 00000000..d032d4a8 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayCodecTests.cs @@ -0,0 +1,268 @@ +using System.Buffers.Binary; +using System.Text; +using Shouldly; +using TwinCAT.TypeSystem; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +/// +/// Pins the array half of the raw-bytes model: decides +/// whether an authored array tag (element type + ArrayLength) may decode a PLC array and +/// plans the byte layout, and turns the PLC's raw bytes into +/// the element-typed CLR array both the poll read and the native notification publish. +/// +/// +/// The raw-bytes model replaced the Beckhoff typed array read after the 2026-10-07 live gate on the +/// TwinCAT 2.11 Elma cleaner Z33856, where that read failed for most real arrays: the CLR element +/// type had to match exactly (ARRAY OF TIME as uint[] threw), a multi-dimensional +/// array demanded a rank-2 CLR array, and a dotted MEMBER array was sliced by the under-reported +/// symbol size (P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE, ARRAY [1..20] OF REAL, symbol +/// 10 B vs type 80 B → ArgumentOutOfRangeException). +/// +[Trait("Category", "Unit")] +public sealed class TwinCATArrayCodecTests +{ + private static TwinCATPlcSymbolShape Elem(string typeName, int byteSize, DataTypeCategory? category = DataTypeCategory.Primitive) + => TwinCATPlcSymbolShape.Classify(typeName, category, byteSize); + + private static TwinCATPlcArrayShape Arr(TwinCATPlcSymbolShape element, params int[] dims) + => new(dims, dims.Aggregate(1, (a, b) => a * b) * element.ByteSize, element); + + private static TwinCATPlcSymbolShape ArraySymbol(string typeName, int byteSize) + => TwinCATPlcSymbolShape.Classify(typeName, DataTypeCategory.Array, byteSize); + + private static string? Check(TwinCATDataType declared, int declaredLength, TwinCATPlcArrayShape? array, + out TwinCATArrayLayout layout, string typeName = "ARRAY") + => TwinCATTypeGuard.CheckArray(declared, declaredLength, + ArraySymbol(typeName, array?.ByteSize ?? 4), array, out layout); + + // ---- guard: plan + validate ---- + + /// A matching element type and length plans the whole array at element-size stride. + [Fact] + public void CheckArray_plans_a_matching_numeric_array_at_its_element_stride() + { + Check(TwinCATDataType.UInt, 13, Arr(Elem("UINT", 2), 13), out var layout).ShouldBeNull(); + layout.ShouldBe(new TwinCATArrayLayout(13, 2)); + layout.ByteLength.ShouldBe(26); + } + + /// + /// A multi-dimensional array (ARRAY [1..3, 1..2] OF REAL, live on Z33856) is published + /// flattened in PLC memory order; its ArrayLength is the TOTAL element count. Lower bounds play + /// no part — the published index is 0-based. + /// + [Fact] + public void CheckArray_flattens_a_multi_dimensional_array_to_its_total_count() + { + Check(TwinCATDataType.Real, 6, Arr(Elem("REAL", 4), 3, 2), out var layout).ShouldBeNull(); + layout.ShouldBe(new TwinCATArrayLayout(6, 4)); + } + + /// + /// An authored ArrayLength SHORTER than the PLC array reads the leading sub-range only: the first + /// N elements in PLC memory order, at N × stride bytes from the array start — cheap (a smaller + /// notification) and well-defined, and it matches the node's advertised ArrayDimensions. + /// + [Fact] + public void CheckArray_plans_a_leading_sub_range_when_the_authored_length_is_shorter() + { + Check(TwinCATDataType.Real, 5, Arr(Elem("REAL", 4), 20), out var layout).ShouldBeNull(); + layout.ShouldBe(new TwinCATArrayLayout(5, 4)); + layout.ByteLength.ShouldBe(20); + } + + /// An authored ArrayLength LONGER than the PLC array would read past it — refused. + [Fact] + public void CheckArray_refuses_an_authored_length_longer_than_the_plc_array() + => Check(TwinCATDataType.UInt, 14, Arr(Elem("UINT", 2), 13), out _)!.ShouldContain("13"); + + /// A zero or negative authored length is not an array. + [Fact] + public void CheckArray_refuses_a_non_positive_authored_length() + => Check(TwinCATDataType.UInt, 0, Arr(Elem("UINT", 2), 13), out _).ShouldNotBeNull(); + + /// + /// The element is checked with the same rules as a scalar notification (TwinCAT-002/-008): width, + /// float/integer kind and string kind must agree; a same-width, same-kind divergence is tolerated. + /// + [Fact] + public void CheckArray_checks_the_element_like_a_scalar_notification() + { + Check(TwinCATDataType.DInt, 4, Arr(Elem("REAL", 4), 4), out _).ShouldNotBeNull(); // float kind + Check(TwinCATDataType.Int, 4, Arr(Elem("DINT", 4), 4), out _).ShouldNotBeNull(); // width + Check(TwinCATDataType.DInt, 4, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 4), out _).ShouldNotBeNull(); + Check(TwinCATDataType.String, 4, Arr(Elem("INT", 2), 4), out _).ShouldNotBeNull(); + Check(TwinCATDataType.WString, 4, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 4), out _).ShouldNotBeNull(); + + Check(TwinCATDataType.Int, 4, Arr(Elem("UINT", 2), 4), out _).ShouldBeNull(); // same width, same kind + Check(TwinCATDataType.Time, 13, Arr(Elem("TIME", 4), 13), out _).ShouldBeNull(); + Check(TwinCATDataType.USInt, 6, Arr(Elem("USINT", 1), 6), out _).ShouldBeNull(); + Check(TwinCATDataType.SInt, 13, Arr(Elem("BYTE", 1), 13), out _).ShouldBeNull(); + Check(TwinCATDataType.Bool, 19, Arr(Elem("BOOL", 1), 19), out _).ShouldBeNull(); + Check(TwinCATDataType.LReal, 32, Arr(Elem("LREAL", 8), 32), out _).ShouldBeNull(); + } + + /// A STRING(n) / WSTRING(n) element is laid out at the PLC's own width: n+1 / 2(n+1). + [Fact] + public void CheckArray_strides_a_string_array_by_the_plc_element_width() + { + Check(TwinCATDataType.String, 10, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 10), out var s).ShouldBeNull(); + s.ShouldBe(new TwinCATArrayLayout(10, 81)); + Check(TwinCATDataType.WString, 3, Arr(Elem("WSTRING(20)", 42, DataTypeCategory.String), 3), out var w).ShouldBeNull(); + w.ShouldBe(new TwinCATArrayLayout(3, 42)); + } + + /// + /// Arrays of structs / UDTs / FBs, of pointers, and arrays of arrays (jagged) have no scalar element + /// the driver can decode — refused with a reason that says so. + /// + [Fact] + public void CheckArray_refuses_struct_pointer_and_nested_array_elements() + { + Check(TwinCATDataType.DInt, 13, Arr(Elem("ST_CARRIERDATA", 120, DataTypeCategory.Struct), 13), out _).ShouldNotBeNull(); + Check(TwinCATDataType.DInt, 4, Arr(Elem("POINTER TO BYTE", 4, DataTypeCategory.Pointer), 4), out _).ShouldNotBeNull(); + Check(TwinCATDataType.Int, 2, Arr(Elem("ARRAY [0..2] OF INT", 6, DataTypeCategory.Array), 2), out _).ShouldNotBeNull(); + } + + /// + /// An array tag over a PLC symbol that is not an array (a scalar, a string, an element path such + /// as Arr[3]) is refused: there is no array to read. + /// + [Fact] + public void CheckArray_refuses_a_plc_symbol_that_is_not_an_array() + { + TwinCATTypeGuard.CheckArray(TwinCATDataType.DInt, 4, Elem("DINT", 4), null, out _).ShouldNotBeNull(); + TwinCATTypeGuard.CheckArray(TwinCATDataType.String, 4, + Elem("STRING(80)", 81, DataTypeCategory.String), null, out _).ShouldNotBeNull(); + } + + /// A VAR_IN_OUT array member is a reference, not the array — refused. + [Fact] + public void CheckArray_refuses_a_var_in_out_reference() + { + var symbol = TwinCATPlcSymbolShape.Classify("ARRAY [1..3] OF INT", DataTypeCategory.Array, 4, + dataTypeByteSize: 6, symbolTypeName: "ARRAY [1..3] OF INT (VAR_IN_OUT)"); + TwinCATTypeGuard.CheckArray(TwinCATDataType.Int, 3, symbol, Arr(Elem("INT", 2), 3), out _).ShouldNotBeNull(); + } + + /// + /// A layout whose element count × element size disagrees with the array's own byte size is not + /// a packed array the driver understands (padding, a mis-resolved element) — refused rather than + /// decoded at the wrong offsets. + /// + [Fact] + public void CheckArray_refuses_an_array_whose_size_is_not_count_times_element_size() + { + var padded = new TwinCATPlcArrayShape([4], 32, Elem("INT", 2)); + Check(TwinCATDataType.Int, 4, padded, out _).ShouldNotBeNull(); + } + + // ---- guard: notification size ---- + + /// + /// A native notification is capped at + /// (128 KiB — the largest registration proven on a real target: a TC 2.11 + /// ARRAY [0..65535] OF UINT). Above it the tag is refused with a reason pointing at polling. + /// + [Fact] + public void CheckNotificationSize_caps_a_native_array_at_128_KiB() + { + TwinCATTypeGuard.MaxNotificationArrayBytes.ShouldBe(128 * 1024); + TwinCATTypeGuard.CheckNotificationSize(new TwinCATArrayLayout(65536, 2)).ShouldBeNull(); + TwinCATTypeGuard.CheckNotificationSize(new TwinCATArrayLayout(65537, 2))!.ShouldContain("UseNativeNotifications"); + } + + // ---- codec: decode ---- + + /// Each numeric element type decodes little-endian into its CLR array, in PLC memory order. + [Fact] + public void Decode_numeric_elements_little_endian_into_the_declared_clr_array() + { + Decode(TwinCATDataType.Int, 2, [-1, 300], (b, v) => BinaryPrimitives.WriteInt16LittleEndian(b, v)); + Decode(TwinCATDataType.UInt, 2, [65535, 7], (b, v) => BinaryPrimitives.WriteUInt16LittleEndian(b, v)); + Decode(TwinCATDataType.DInt, 4, [-123456, 42], (b, v) => BinaryPrimitives.WriteInt32LittleEndian(b, v)); + Decode(TwinCATDataType.UDInt, 4, [4_000_000_000, 1], (b, v) => BinaryPrimitives.WriteUInt32LittleEndian(b, v)); + Decode(TwinCATDataType.LInt, 8, [long.MinValue, 9], (b, v) => BinaryPrimitives.WriteInt64LittleEndian(b, v)); + Decode(TwinCATDataType.ULInt, 8, [ulong.MaxValue, 3], (b, v) => BinaryPrimitives.WriteUInt64LittleEndian(b, v)); + Decode(TwinCATDataType.Real, 4, [949.2f, -0.5f], (b, v) => BinaryPrimitives.WriteSingleLittleEndian(b, v)); + Decode(TwinCATDataType.LReal, 8, [Math.PI, -1e300], (b, v) => BinaryPrimitives.WriteDoubleLittleEndian(b, v)); + Decode(TwinCATDataType.SInt, 1, [-128, 127], (b, v) => b[0] = unchecked((byte)v)); + Decode(TwinCATDataType.USInt, 1, [0, 255], (b, v) => b[0] = v); + } + + /// + /// TIME / DATE / DT / TOD elements publish as their raw 32-bit count (uint[]) — the same + /// CLR shape a scalar of those types publishes. Live: .THROTTLE_MODE_DEFAULT + /// (ARRAY [0..12] OF TIME) begins 0, 10, 20 ms. + /// + [Theory] + [InlineData(TwinCATDataType.Time)] + [InlineData(TwinCATDataType.Date)] + [InlineData(TwinCATDataType.DateTime)] + [InlineData(TwinCATDataType.TimeOfDay)] + public void Decode_time_and_date_elements_as_uint(TwinCATDataType type) + => TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0], new TwinCATArrayLayout(3, 4), utf8: false) + .ShouldBe(new uint[] { 0, 10, 20 }); + + /// A BOOL element is one byte; any non-zero byte is TRUE (live: .ARGTYPE_IS_BIT_TYPE). + [Fact] + public void Decode_bool_elements_one_byte_each_non_zero_is_true() + => TwinCATArrayCodec.Decode(TwinCATDataType.Bool, [0, 1, 1, 0xFF, 0], new TwinCATArrayLayout(5, 1), utf8: false) + .ShouldBe(new[] { false, true, true, true, false }); + + /// + /// Each STRING(n) element sits at a fixed n+1 stride and ends at ITS first NUL — the stale tail a + /// longer earlier value left (live on Z33856) never leaks into the element, nor into the next one. + /// Windows-1252 unless the target reports UTF-8, exactly like a scalar string. + /// + [Fact] + public void Decode_string_elements_at_their_stride_each_ending_at_its_first_nul() + { + var bytes = new byte[3 * 6]; // STRING(5) + "RUN"u8.CopyTo(bytes.AsSpan(0)); + bytes[4] = (byte)'T'; // stale tail after the NUL + "MON_O"u8.CopyTo(bytes.AsSpan(6)); // full-length, no NUL before the stride end + bytes[12] = 0xB0; // '°' in Windows-1252 + TwinCATArrayCodec.Decode(TwinCATDataType.String, bytes, new TwinCATArrayLayout(3, 6), utf8: false) + .ShouldBe(new[] { "RUN", "MON_O", "°" }); + + var utf8 = new byte[2 * 4]; + "°C"u8.CopyTo(utf8.AsSpan(0)); + TwinCATArrayCodec.Decode(TwinCATDataType.String, utf8, new TwinCATArrayLayout(2, 4), utf8: true) + .ShouldBe(new[] { "°C", "" }); + } + + /// Each WSTRING(n) element is UTF-16LE at a 2(n+1) stride, ending at its first aligned U+0000. + [Fact] + public void Decode_wstring_elements_at_their_stride() + { + var bytes = new byte[2 * 8]; // WSTRING(3) + Encoding.Unicode.GetBytes("Ab").CopyTo(bytes, 0); + Encoding.Unicode.GetBytes("Zürn").CopyTo(bytes, 8); // 4 chars fill the whole stride + TwinCATArrayCodec.Decode(TwinCATDataType.WString, bytes, new TwinCATArrayLayout(2, 8), utf8: false) + .ShouldBe(new[] { "Ab", "Zürn" }); + } + + /// A sub-range layout decodes only its leading elements, even from a longer buffer. + [Fact] + public void Decode_honours_the_layout_count_not_the_buffer_length() + => TwinCATArrayCodec.Decode(TwinCATDataType.Int, [1, 0, 2, 0, 3, 0], new TwinCATArrayLayout(2, 2), utf8: false) + .ShouldBe(new short[] { 1, 2 }); + + /// A buffer shorter than the layout is a protocol fault, never a silently-short array. + [Fact] + public void Decode_refuses_a_buffer_shorter_than_the_layout() + => Should.Throw(() => + TwinCATArrayCodec.Decode(TwinCATDataType.Int, [1, 0, 2], new TwinCATArrayLayout(2, 2), utf8: false)); + + private static void Decode(TwinCATDataType type, int stride, T[] values, Action, T> write) + { + var bytes = new byte[values.Length * stride]; + for (var i = 0; i < values.Length; i++) write(bytes.AsSpan(i * stride, stride), values[i]); + var decoded = TwinCATArrayCodec.Decode(type, bytes, new TwinCATArrayLayout(values.Length, stride), utf8: false); + decoded.ShouldBeOfType().ShouldBe(values); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs new file mode 100644 index 00000000..3dc22507 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs @@ -0,0 +1,58 @@ +using Shouldly; +using TwinCAT.TypeSystem; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +/// +/// Pins — how a bit-indexed BOOL tag (.ADSSTATE_RUN.0) is served by a +/// native notification. ADS has no symbol for WORD.N: live on the TwinCAT 2.11 Elma cleaner Z33856 +/// (2026-10-07) registering the notification on .ADSSTATE_RUN.0 failed with +/// DeviceSymbolNotFound — and that registration error tore down the WHOLE subscribe call. The +/// notification now registers the PARENT word as raw bytes (its resolved width) and extracts the bit. +/// +[Trait("Category", "Unit")] +public sealed class TwinCATBitAccessTests +{ + /// The parent path is the bit path without its trailing .N; a non-bit path is unchanged. + [Fact] + public void ParentPath_strips_the_trailing_bit_index() + { + TwinCATBitAccess.ParentPath(".ADSSTATE_RUN.0").ShouldBe(".ADSSTATE_RUN"); + TwinCATBitAccess.ParentPath("MAIN.Status.Flags.15").ShouldBe("MAIN.Status.Flags"); + TwinCATBitAccess.ParentPath("MAIN.Flags").ShouldBe("MAIN.Flags"); + } + + /// Bits are numbered little-endian across the parent's bytes (bit 9 = byte 1, bit 1). + [Fact] + public void ReadBit_numbers_bits_little_endian_across_the_parent_bytes() + { + byte[] word = [0b0000_0101, 0b0000_0010]; // .ADSSTATE_RUN = 5 → bits 0 and 2; plus bit 9 + TwinCATBitAccess.ReadBit(word, 0).ShouldBeTrue(); + TwinCATBitAccess.ReadBit(word, 1).ShouldBeFalse(); + TwinCATBitAccess.ReadBit(word, 2).ShouldBeTrue(); + TwinCATBitAccess.ReadBit(word, 9).ShouldBeTrue(); + TwinCATBitAccess.ReadBit(word, 15).ShouldBeFalse(); + } + + /// A bit past the parent's width cannot be read — refused instead of publishing FALSE at Good. + [Fact] + public void CheckParent_refuses_a_bit_outside_the_parent_width() + { + var word = TwinCATPlcSymbolShape.Classify("UINT16", DataTypeCategory.Primitive, 2); + TwinCATBitAccess.CheckParent(15, word).ShouldBeNull(); + TwinCATBitAccess.CheckParent(16, word).ShouldNotBeNull(); + TwinCATBitAccess.CheckParent(-1, word).ShouldNotBeNull(); + } + + /// Only an integer word has addressable bits — a string, a float or a container is refused. + [Fact] + public void CheckParent_refuses_a_parent_that_is_not_an_integer_word() + { + TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("STRING(80)", DataTypeCategory.String, 81)).ShouldNotBeNull(); + TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("REAL", DataTypeCategory.Primitive, 4)).ShouldNotBeNull(); + TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("ST_X", DataTypeCategory.Struct, 8)).ShouldNotBeNull(); + TwinCATBitAccess.CheckParent(31, TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4)).ShouldBeNull(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeArraySubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeArraySubscribeTests.cs new file mode 100644 index 00000000..02376288 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeArraySubscribeTests.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +/// +/// Native ADS notifications for ARRAY tags. Before this, the notification path registered only the +/// declared scalar, so an array tag published its first element at Good; the declared-type guard +/// then refused every array tag with BadTypeMismatch. The driver now hands the authored +/// ArrayLength to the client, which registers the whole array as raw bytes and decodes it +/// () — these pin the driver's half of that contract. +/// +[Trait("Category", "Unit")] +public sealed class TwinCATNativeArraySubscribeTests +{ + private const string Host = "ads://5.23.91.23.1.1:851"; + + private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewNativeDriver(params TwinCATTagDefinition[] tags) + { + var factory = new FakeTwinCATClientFactory(); + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions(Host)], + RawTags = TwinCATRawTags.Entries(tags), + Probe = new TwinCATProbeOptions { Enabled = false }, + UseNativeNotifications = true, + }, "drv-1", factory); + return (drv, factory); + } + + /// + /// An array tag registers with its authored ArrayLength, and the element-typed array the client + /// decodes reaches OnDataChange whole, at Good — the same shape the poll read publishes. + /// + [Fact] + public async Task Native_subscribe_registers_an_array_tag_with_its_authored_length_and_publishes_the_array() + { + var (drv, factory) = NewNativeDriver( + new TwinCATTagDefinition("Samples", Host, "P_FILTER.SAMPLE", TwinCATDataType.Real, ArrayLength: 20)); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + var events = new ConcurrentQueue(); + drv.OnDataChange += (_, e) => events.Enqueue(e); + + _ = await drv.SubscribeAsync(["Samples"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); + + var reg = factory.Clients[0].Notifications.ShouldHaveSingleItem(); + reg.Type.ShouldBe(TwinCATDataType.Real); + reg.ArrayCount.ShouldBe(20); + + var samples = Enumerable.Range(1, 20).Select(i => i * 1.5f).ToArray(); + factory.Clients[0].FireNotification("P_FILTER.SAMPLE", samples); + var e = events.ShouldHaveSingleItem(); + e.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good); + e.Snapshot.Value.ShouldBeSameAs(samples); + } + + /// + /// A scalar tag, an element-indexed path (Arr[3]) and a bit-indexed BOOL stay scalars: no + /// array count reaches the client. + /// + [Fact] + public async Task Scalar_element_indexed_and_bit_indexed_tags_register_without_an_array_count() + { + var (drv, factory) = NewNativeDriver( + new TwinCATTagDefinition("Count", Host, "MAIN.Count", TwinCATDataType.DInt), + new TwinCATTagDefinition("Fault3", Host, "MAIN.FaultCodes[3]", TwinCATDataType.UInt), + new TwinCATTagDefinition("Run0", Host, "MAIN.State.0", TwinCATDataType.Bool)); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + _ = await drv.SubscribeAsync(["Count", "Fault3", "Run0"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); + + var regs = factory.Clients[0].Notifications; + regs.Count.ShouldBe(3); + regs.ShouldAllBe(n => n.ArrayCount == null); + regs.Single(n => n.SymbolPath == "MAIN.State.0").BitIndex.ShouldBe(0); + } + + /// A reconnect replays an array registration with the same array count. + [Fact] + public async Task Reconnect_replays_an_array_registration_with_its_array_count() + { + var (drv, factory) = NewNativeDriver( + new TwinCATTagDefinition("Faults", Host, "MAIN.FaultCodes", TwinCATDataType.UInt, ArrayLength: 13)); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + _ = await drv.SubscribeAsync(["Faults"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); + + factory.Clients[0].SimulateWireDrop(); + _ = await drv.ReadAsync(["Faults"], TestContext.Current.CancellationToken); + + factory.Clients.Count.ShouldBe(2); + factory.Clients[1].Notifications.ShouldHaveSingleItem().ArrayCount.ShouldBe(13); + } + + /// + /// A notification the client refuses for a reason other than a type mismatch (an array above the + /// native-notification size cap → BadOutOfRange) is isolated the same way: that one tag + /// publishes the refusal's own status and the rest of the subscription registers. + /// + [Fact] + public async Task A_refused_oversized_array_is_isolated_with_the_refusals_own_status() + { + var (drv, factory) = NewNativeDriver( + new TwinCATTagDefinition("Slaves", Host, "P_EC.SLAVEADDRESS", TwinCATDataType.UInt, ArrayLength: 70000), + new TwinCATTagDefinition("Count", Host, "MAIN.Count", TwinCATDataType.DInt)); + factory.Customise = () => + { + var c = new FakeTwinCATClient(); + c.AddNotificationErrors["P_EC.SLAVEADDRESS"] = new TwinCATNotificationRefusedException( + "P_EC.SLAVEADDRESS", "140000 B exceeds the 131072 B native-notification cap", + TwinCATStatusMapper.BadOutOfRange); + return c; + }; + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + var events = new ConcurrentQueue(); + drv.OnDataChange += (_, e) => events.Enqueue(e); + + _ = await drv.SubscribeAsync(["Slaves", "Count"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); + + factory.Clients[0].Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.Count"); + var refused = events.ShouldHaveSingleItem(); + refused.FullReference.ShouldBe("Slaves"); + refused.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange); + refused.Snapshot.Value.ShouldBeNull(); + } + + /// A type-mismatch refusal is a notification refusal carrying BadTypeMismatch. + [Fact] + public void A_type_mismatch_is_a_notification_refusal_carrying_BadTypeMismatch() + { + TwinCATNotificationRefusedException ex = new TwinCATTypeMismatchException("MAIN.X", "declared DInt over REAL"); + ex.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch); + ex.SymbolPath.ShouldBe("MAIN.X"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs index 3e2bb9ff..7b40cbc0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs @@ -191,13 +191,13 @@ public sealed class TwinCATNativeNotificationTests /// public override Task AddNotificationAsync( - string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime, + string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime, int maxDelayMs, Action onChange, CancellationToken cancellationToken) { AddCallCount++; if (AddCallCount > _succeedBefore) throw new InvalidOperationException($"fake fail on call #{AddCallCount}"); - return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, maxDelayMs, onChange, cancellationToken); + return base.AddNotificationAsync(symbolPath, type, bitIndex, arrayCount, cycleTime, maxDelayMs, onChange, cancellationToken); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs index e83ced13..eab1c1ad 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs @@ -105,6 +105,26 @@ public sealed class TwinCATTypeGuardTests TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("ST_LOGDATA", 81, DataTypeCategory.Struct)).ShouldNotBeNull(); } + /// + /// A SCALAR tag over a PLC array has no value the symbol-aware read can convert: live on Z33856 + /// (2026-10-07) .ARRFAULTCODE (ARRAY [0..12] OF UINT) read as UInt made the Beckhoff + /// read throw, surfacing as BadCommunicationError and a Degraded driver, while the native path + /// said BadTypeMismatch. Both paths now refuse it with BadTypeMismatch — by category, so an + /// alias of an array type is caught too. Other non-scalars (a pointer read as its address) are left + /// to the read, as before. + /// + [Fact] + public void Read_refuses_a_scalar_declared_over_a_plc_array() + { + TwinCATTypeGuard.CheckRead(TwinCATDataType.UInt, Plc("ARRAY [0..12] OF UINT", 26, DataTypeCategory.Array)) + .ShouldNotBeNull(); + TwinCATTypeGuard.CheckRead(TwinCATDataType.DInt, Plc("T_FAULTS", 52, DataTypeCategory.Array)).ShouldNotBeNull(); + TwinCATTypeGuard.CheckRead(TwinCATDataType.UDInt, Plc("POINTER TO BYTE", 4, DataTypeCategory.Pointer)).ShouldBeNull(); + Plc("ARRAY [0..12] OF UINT", 26, DataTypeCategory.Array).IsArray.ShouldBeTrue(); + Plc("ARRAY [0..12] OF UINT", 26).IsArray.ShouldBeTrue(); // by name when no category resolved + Plc("UINT", 2, DataTypeCategory.Primitive).IsArray.ShouldBeFalse(); + } + /// /// 0.19.2: a STRING / WSTRING tag matches a PLC STRING(n) / WSTRING(n) of ANY length. /// A numeric-over-numeric pair is left to the symbol-aware read, which converts the value.